feat: ship Grand Exchange with durable Fly market storage

This commit is contained in:
max bittker committed 2026-09-07 23:28:51 -04:00
1 parent 0f761d1cf3
commit 69d5b709c8
80 files changed
+12651 -23

No files matched your search

+6
View File
@@ -35,3 +35,9 @@ bots/*_fleet_monitor.sh
bots/run_*_combat.sh
bots/combat_status.sh
bots/rs-sdk-remote.cron
# Durable Grand Exchange ledger and SQLite WAL files
server/engine/data/market.sqlite*
# Generated when engine utilities are invoked from the repository root
/data/
+11
View File
@@ -156,3 +156,14 @@ You cannot play Old School RuneScape here, buy RuneScape gold, or access any of
## License
This project is licensed under the [MIT License](https://opensource.org/licenses/MIT). See the [LICENSE](LICENSE) file for details.
### Grand Exchange
The server includes a player-funded Grand Exchange at Varrock west bank, with a
5% seller tax and a public price dashboard at `/market`. To run without GE, set
`GE_ENABLED=false` in the engine environment and restart the server (no content
rebuild needed). It defaults to `true`. Disabling GE restores the ordinary bank
table and hides the teller and market link,
and the SDK reports `ge_unavailable` when asked to use GE. See the
[market API and player guide](sdk/MARKET.md) and
[maintenance notes](docs/grand-exchange.md).
+142
View File
@@ -0,0 +1,142 @@
# Grand Exchange maintenance
The Grand Exchange is a custom backport for the Lost City 274 server.
For player instructions, trading rules and the public API, see
[the market guide](../sdk/MARKET.md). The dashboard is served by the game server
at `/market`.
## Implementation
| Part | Files |
|---|---|
| Matching, transactions, snapshots, history | `server/engine/src/engine/market/MarketStore.ts` |
| Tradeable catalogue, notes and shared fuzzy matching | `server/engine/src/engine/market/{MarketCatalog,ItemSearch}.ts` |
| Native search keyboard and result selection | `server/webclient/src/client/MarketSearchInput.ts`, `Client.ts`, lite component dispatch; `MarketSearch` packet/model/decoder/handler |
| Native UI and inventory adapter | `server/engine/src/engine/market/GrandExchange.ts` |
| Interface, reused bank table/teller and placements | `server/content/scripts/interface_bank/`, interface/loc/npc manifests, `maps/m49_53.jm2` |
| Engine hooks | `GE_OPEN(coord)` script opcode, native button/count handlers, World tick for offer updates, Player save/load |
| 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
`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.
Selections carry the displayed item ID and acknowledged query, and the server
checks both against its current result page, preventing stale-result selection.
The standard/bot clients handle typing; lite component clicks also resolve the
actual displayed item. Table and teller triggers both pass their coordinate to
`GE_OPEN`. Sessions accept only the table/NPC anchors, check the
two-tile footprint or teller adjacency, and require the ground-floor interior.
The west-wall bank table keeps its original model, rotation and collision footprint;
only that table gains Exchange. Normal bank booths keep their banking options.
Disabling GE restores the table’s original type and hides the teller. All state-changing UI clicks recheck the
active interface, session, floor, distance and player readiness on the server.
## Persistence and operation
The Bun server creates `data/market.sqlite` automatically (override with
`GE_DATABASE`). It uses SQLite WAL, `synchronous=FULL`, immediate transactions,
parameterized queries and indexes. Do not place this database on ephemeral
storage. This is a **single game-world process** design, not a shared multiworld
exchange; do not point multiple running game worlds at one ledger.
Fly sets `GE_DATABASE=/opt/server/data/market.sqlite` in `fly.toml`, placing the
ledger and its WAL files on the same persistent volume as player saves.
Placement and collection synchronously mutate the actor's real backpack inside
a ledger transaction. Their complete serialized player save is committed in
the same transaction as offer/claim changes. Failed transactions restore the
in-memory backpack. Counterparties' inventory is never remotely modified:
only their escrow claims change. Cancellation is an idempotent ledger-only
transaction.
Placement and collection also checkpoint online players. Normal saves update
existing checkpoints, and player loading prefers them over login-service saves.
This makes the ledger authoritative for bystanders as well as traders. Recovery
must account for temporary inventories and pending logout saves; checkpointing
online players alone is not sufficient. Transient NPC, ground-item and shop state
is not persisted by the exchange.
**Operational consequences:** back up the ledger and its player checkpoints
with the rest of the world. Use SQLite's backup API or stop the world before
copying/checkpointing WAL files. Never independently restore an old ledger or
old player saves. Offline tools that patch only `.sav` files will not override
a ledger checkpoint. Account deletion/renaming and rollback procedures must
include the ledger. Do not delete `market.sqlite` to disable the feature: that
would strand escrow and discard authoritative checkpoints.
## Build and validation
From the repository root:
```sh
# Required after changing custom content (upstream checksums describe vanilla data).
(cd server/engine && BUILD_VERIFY=false bun run build)
(cd server/webclient && bun run build)
bun run test:market:integration
bun run check
(cd server/engine && bunx tsc --noEmit)
bun run docs:api:check
```
The integration script creates a temporary ledger and disposable in-memory
players. It loads the real packed map and scripts, checks the actual table,
runs the table and both teller triggers, verifies collision/reachability and
SDK footprint agreement, tests live fill updates, exercises the search packet decoder/handler (including
stale/invalid selections), clicks through native server handlers, sells noted
logs, matches a second player, verifies collection/tax/save recovery, and checks
public API responses. Unit tests exercise matching priority, asset conservation,
partial fills and refunds, limits, ownership, full inventories, rollback,
restart persistence and quantity-weighted history. No test funds or offers are
inserted into the production market.
Visual checks use disposable fixtures and write generated images to the ignored
`screenshots/grand-exchange/` directory at the repository root:
```sh
(cd server/engine && bun test/market-preview.ts)
(cd server/engine && bun test/market-location-preview.ts)
```
Before a release, exercise normal browser clients through login, trading,
cancellation, full backpacks and restart recovery, and rehearse a coordinated
backup restore. Measure all-player checkpoint cost and public reads under load.
Automated handler tests do not cover the complete browser/socket/tick loop.
The protocol regression test checks missing interface components without losing
packet alignment. Optional SDK metadata is skipped when absent. Rebuild content
and clients together, and reload existing browser tabs after cache changes.
`file-stream.test.ts` covers same-build reads after packed-file writes so map
CRCs and `ondemand.zip` use the updated bytes.
## Research and historical choices
Inspected the supplied [2009scape repository](https://gitlab.com/2009scape/2009scape)
at commit `9f634148bab11fad821943864cb71d7c6af98da4`:
- [GrandExchange.kt](https://gitlab.com/2009scape/2009scape/-/blob/9f634148bab11fad821943864cb71d7c6af98da4/Server/src/main/core/game/ge/GrandExchange.kt): sorts counterparties by best
price, crosses compatible limits, uses the older offer's price, gives buyers
price-improvement refunds, and supports partial completion.
- [GrandExchangeOffer.kt](https://gitlab.com/2009scape/2009scape/-/blob/9f634148bab11fad821943864cb71d7c6af98da4/Server/src/main/core/game/ge/GrandExchangeOffer.kt): persists independent offers and
item/coin claims for later collection.
- [GrandExchangeInterface.java](https://gitlab.com/2009scape/2009scape/-/blob/9f634148bab11fad821943864cb71d7c6af98da4/Server/src/main/content/global/handlers/iface/ge/GrandExchangeInterface.java): offer-selection and collection flows.
These are behavioral references; this implementation is newly written in
TypeScript/SQL and does not copy the reference's Java/Kotlin source. The
reference repository's license is AGPL-3.0. It also has explicit artificial
market stock and bots: those are intentionally absent here.
This implementation follows the familiar GE offer lifecycle and matching
behavior, rather than claiming an exact recreation of any one historical year.
Deterministic price/time priority uses an increasing offer ID to break ties.
For example, a resting sell of ten logs at 10 gp matched by a new buy at 20 gp
consumes 100 gp, returns 100 gp to the buyer, and yields 95 gp to the seller.
A resting buy at 20 gp instead executes at 20 gp when a seller offers 10 gp.
Intentional departures: the user-requested 5% tax; collection only at this
physical location; six slots for every account; no historical guide-price
bands, membership slot tiers, four-hour buy limits, inactivity expiry or set
exchange. Public bid/ask depth totals are more transparent than the original
GE. The native interface is deliberately simpler than the 2007/2009 artwork.
+2
View File
@@ -8,6 +8,8 @@ ignorefile = "server/.dockerignore"
[env]
# SDK bug reports land on the mounted volume so they survive deploys/restarts.
BUG_REPORTS_FILE = "/opt/server/data/bug-reports.jsonl"
# Grand Exchange escrow and authoritative player checkpoints survive redeploys.
GE_DATABASE = "/opt/server/data/market.sqlite"
NODE_DEBUG = "true"
BUILD_STARTUP = "false"
BUILD_STARTUP_UPDATE = "false"
+17
View File
@@ -27,6 +27,22 @@ Claude Code auto-discovers the MCP server via `.mcp.json`. Just:
## Tools
### `get_market`
Public Grand Exchange research without a bot connection or controller takeover:
Use `get_market` with `operation: "status"` to check whether the server enables GE.
Disabled servers return a clear `ge_unavailable` error for market data reads.
```json
{"origin":"http://localhost:8888","operation":"items","query":"logs","sort":"buyValue","days":7}
```
Operations: `status`, `items`, `item` (requires `item_id`), `history` (requires `item_id`).
Use the game HTTP origin, not the gateway port. This tool cannot trade or read
private offers. See [the GE guide](../sdk/MARKET.md) for typed options and trading
through `bot.openGE()`, `bot.placeGEOffer()`, `bot.cancelGEOffer()` and collection.
### `execute_code`
Execute TypeScript code on a bot. Auto-connects on first use using credentials from `bots/{name}/bot.env`.
@@ -66,6 +82,7 @@ disconnect_bot({ name: "mybot" });
The server exposes API documentation as a resource:
- `file://../sdk/MARKET.md` — GE workflow, market data, receipts and recovery.
- `file://../sdk/API.md` — Auto-generated reference for `bot.*` (high-level actions) and `sdk.*` (low-level SDK)
Read this to discover available methods.
+51
View File
@@ -0,0 +1,51 @@
import { MarketClient, MARKET_SORTS, type MarketItemsOptions, type MarketItemOptions, type MarketHistoryOptions } from '../sdk/market';
export const marketTool = {
name: 'get_market',
description:
'Read public Grand Exchange prices/history without connecting, launching or taking control of a bot. No trading or private offers. Use the game HTTP origin (local default http://localhost:8888), not the gateway port. See the Grand Exchange guide resource for trading.',
inputSchema: {
type: 'object' as const,
properties: {
origin: {
type: 'string',
description: 'Game HTTP(S) origin, e.g. http://localhost:8888.',
},
operation: { type: 'string', enum: ['status', 'items', 'item', 'history'] },
query: { type: 'string', maxLength: 80 },
item_id: { type: 'integer', minimum: 1 },
days: { type: 'integer', enum: [1, 7, 30, 90] },
sort: { type: 'string', enum: [...MARKET_SORTS] },
limit: { type: 'integer', minimum: 1, maximum: 100 },
offset: { type: 'integer', minimum: 0, maximum: 100000 },
from: { type: 'integer', minimum: 0 },
to: { type: 'integer', minimum: 1 },
interval: { type: 'integer', enum: [3600000, 86400000] },
},
required: ['origin', 'operation'],
additionalProperties: false,
},
};
export async function readMarket(args: Record<string, unknown>) {
if (typeof args.origin !== 'string') throw new Error('Provide the game HTTP origin.');
const market = new MarketClient(args.origin);
const { days, sort, limit, offset, from, to, interval } = args;
if (args.operation === 'status') return market.status();
if (args.operation === 'items') {
if (args.query !== undefined && typeof args.query !== 'string') throw new Error('query must be text.');
return market.items(args.query ?? '', {
days,
sort,
limit,
offset,
} as MarketItemsOptions);
}
if (typeof args.item_id !== 'number') throw new Error('item_id is required for item/history reads.');
if (args.operation === 'item') return market.item(args.item_id, { days } as MarketItemOptions);
if (args.operation === 'history')
return market.history(args.item_id, {
from,
to,
interval,
} as MarketHistoryOptions);
throw new Error('Use operation status, items, item or history.');
}
+6 -1
View File
@@ -20,6 +20,7 @@ import { botManager } from './api/index.js';
import { formatWorldState } from '../sdk/formatter.js';
import { initPathfinding } from '../sdk/pathfinding.js';
import { Spells } from '../sdk/spells.js';
import { marketTool, readMarket } from './market-tools.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -47,7 +48,8 @@ server.setRequestHandler(ListResourcesRequestSchema, async () => {
name: 'SDK API Reference',
description: 'Auto-generated reference for bot.* (high-level actions: chopTree, walkTo, attack, openBank, ...) and sdk.* (low-level: getState, sendWalk, findNearbyNpc, ...).',
mimeType: 'text/markdown'
}
},
{uri: 'file://../sdk/MARKET.md', name: 'Grand Exchange guide', description: 'Public market reads, physical trading, offer receipts and recovery after uncertain outcomes.', mimeType: 'text/markdown'}
]
};
});
@@ -85,6 +87,7 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
marketTool,
{
name: 'execute_code',
description: 'Execute TypeScript code on a bot. Auto-connects using credentials from bots/{name}/bot.env. The code runs in an async context with bot (BotActions), sdk (BotSDK), and Spells (spell component ids) available. WARNING: many MCP clients cap tool-call waits at ~60s; if the client gives up, the code KEEPS RUNNING on the bot until it finishes or hits the timeout - keep interactive snippets short and put long loops in a bots/{name}/*.ts script instead.',
@@ -139,6 +142,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
try {
switch (name) {
case 'get_market': return successResponse(await readMarket(args ?? {}));
case 'disconnect_bot': {
const botName = args?.name as string;
+3 -2
View File
@@ -6,9 +6,10 @@
"docs:api": "bun sdk/generate-api-docs.ts",
"docs:api:check": "bun sdk/generate-api-docs.ts --check",
"fetch-collision-data": "bun sdk/fetch-collision-data.ts",
"test": "bun test sdk/test server/webclient/src/bot server/webclient/src/util server/webclient/src/lite",
"test": "bun test sdk/test server/webclient/src/bot server/webclient/src/util server/webclient/src/lite server/engine/test/market.test.ts server/engine/test/market-web.test.ts server/engine/test/item-search.test.ts server/engine/test/file-stream.test.ts",
"typecheck": "tsc --noEmit",
"typecheck:webclient": "tsc --noEmit -p server/webclient/tsconfig.json"
"typecheck:webclient": "tsc --noEmit -p server/webclient/tsconfig.json",
"test:market:integration": "cd server/engine && bun test/market-integration.ts && bun test/market-protocol.ts && bun test/market-disabled.ts"
},
"devDependencies": {
"@types/bun": "latest",
+197
View File
@@ -12,6 +12,17 @@
## BotActions (high-level)
### Grand Exchange
| Signature | Description |
|---|---|
| `async openGE(timeout: number = 15_000): Promise<GEActionResult>` | Walk to the physical exchange and open it. Requires updated client/server GE state support. |
| `async searchGE(query: string, side: 'buy' \| 'sell' = 'buy', timeout: number = 15_000): Promise<GEActionResult>` | Search the in-game catalogue in a free slot; returns acknowledged results in state.search. |
| `async placeGEOffer(request: GEOfferRequest, timeout: number = 30_000): Promise<GEActionResult>` | Place an inventory-funded offer at an already open exchange and verify the server receipt. Never retries confirmation. |
| `async cancelGEOffer(offerId: number, timeout: number = 15_000): Promise<GEActionResult>` | Cancel your offer's unfilled remainder; returned assets remain in collection. Uses offer ID, not slot. |
| `async collectGEOffer(offerId: number, notes: boolean = true, timeout: number = 15_000): Promise<GEActionResult>` | Collect one offer, optionally as notes. Reports actual backpack additions; overflow stays in collection. |
| `async collectGE(timeout: number = 15_000): Promise<GEActionResult>` | Collect every available claim as notes and coins; requires an open physical exchange. |
### UI & Dialog
| Signature | Description |
@@ -218,6 +229,17 @@
| `async waitForTicks(ticks: number = 1): Promise<BotWorldState>` | Wait for a specific number of server ticks (~300ms each). |
| `async waitForStateUpdate(): Promise<BotWorldState>` | Wait for the next state update from the server. This is the most common waiting pattern - ensures fresh data after an action. State updates arrive once per server tick (~300ms) when PLAYER_INFO is received. |
### Grand Exchange
| Signature | Description |
|---|---|
| `getGEAvailability(): Promise<GEAvailability>` | Check whether this server enables GE. No connected bot or physical access needed. |
| `getMarketItems(query = '', options: MarketItemsOptions = {}): Promise<MarketItems>` | Read public Grand Exchange prices. Uses the game origin; no login or physical access needed. |
| `getMarketItem(itemId: number, options: MarketItemOptions = {}): Promise<MarketQuote>` | Read current offers, latest trade and selected-period volume for a canonical item ID. |
| `getMarketHistory(itemId: number, options: MarketHistoryOptions = {}): Promise<MarketHistory>` | Read historical trade prices and volumes in hour/day UTC buckets (milliseconds). |
| `getGEState(): GEState \| null` | Private GE state while at an open physical exchange. Null when closed or unsupported. |
| `async sendGESearch(query: string): Promise<ActionResult>` | Filter the open in-game GE catalogue. Success means dispatch; wait for the acknowledged query. |
### On-Demand Scanning
| Signature | Description |
@@ -529,6 +551,8 @@ interface BotWorldState {
bank: BankState;
/** Absent when the connected client predates trade support. */
trade?: TradeState;
/** Null outside the exchange; absent on older clients. */
ge?: GEState | null;
modalOpen: boolean;
modalInterface: number;
combatStyle?: CombatStyleState;
@@ -877,3 +901,176 @@ interface StringAmuletResult {
reason?: 'no_amulet' | 'no_string' | 'level_too_low' | 'timeout';
}
```
### GEOffer
Private exchange observations delivered only through an open in-game session.
```typescript
interface GEOffer {
id: number;
slot: number;
item: number;
name: string;
side: 'buy' | 'sell';
quantity: number;
price: number;
remaining: number;
filled: number;
state: 'open' | 'completed' | 'cancelled';
/** Collectible balances, not assets already in the backpack. */
items: number;
coins: number;
gross: number;
tax: number;
}
```
### GEState
```typescript
interface GEState {
isOpen: true;
session: number;
revision: number;
/** Advances for handled client commands, never for passive fill updates. */
actionRevision: number;
screen: 'home' | 'catalog' | 'draft' | 'offer';
offers: GEOffer[];
selectedOffer: number | null;
draft: { item: number; name: string; side: 'buy' | 'sell'; quantity: number; price: number; slot: number | null; } | null;
search: { query: string; side: 'buy' | 'sell'; page: number; total: number; results: { item: number; name: string; componentId: number }[]; } | null;
input: 'item' | 'quantity' | 'price' | null;
/** Named component IDs for the current screen; dispatched through ordinary UI handlers. */
controls: Record<string, number>;
notice: string;
error: string | null;
receipt: { token: number; kind: 'place' | 'cancel' | 'collect'; offerId?: number; items?: number; coins?: number; } | null;
}
```
### GEOfferRequest
```typescript
interface GEOfferRequest {
item: number;
side: 'buy' | 'sell';
quantity: number;
price: number;
/** Zero-based offer slot. Defaults to the first empty slot. */
slot?: number;
}
```
### GEActionResult
```typescript
interface GEActionResult {
success: boolean;
message: string;
reason?: string;
phase?: 'validation' | 'routing' | 'dispatch' | 'observation' | 'completion';
/** An observation timeout after a send may have applied the action: inspect state before retrying. */
outcome?: 'confirmed' | 'unknown';
offer?: GEOffer;
items?: number;
coins?: number;
state?: GEState;
}
```
### MarketItemOptions
```typescript
interface MarketItemOptions {
days?: MarketDays;
}
```
### MarketItemsOptions
```typescript
interface MarketItemsOptions {
limit?: number;
offset?: number;
sort?: MarketSort;
}
```
### MarketHistoryOptions
```typescript
interface MarketHistoryOptions {
from?: number;
to?: number;
interval?: 3600000 | 86400000;
}
```
### MarketQuote
```typescript
interface MarketQuote {
id: number;
name: string;
basePrice: number;
item: number;
bid: number | null;
ask: number | null;
buyQuantity: number;
/** Coins committed to unfilled bids, independent of the selected period. */
buyValue: number;
sellQuantity: number;
/** Completed item quantity in the selected period (24 hours by default). */
volume: number;
/** Gross coins exchanged in the selected period (24 hours by default). */
gross: number;
tax: number;
trades: number;
lastPrice: number | null;
lastTradeAt: number | null;
vwap: number | null;
referencePrice: number | null;
change: number | null;
changePercent: number | null;
/** Listed sell quantity × last price, not circulating supply. */
marketCap: number | null;
}
```
### MarketItems
```typescript
interface MarketItems {
items: MarketQuote[];
total: number;
offset: number;
limit: number;
days: MarketDays;
sort: MarketSort;
updatedAt: number;
summary: { volume: number; gross: number; activeItems: number; totalItems: number; };
featured: MarketQuote | null;
}
```
### MarketHistory
```typescript
interface MarketHistory {
item: number;
from: number;
to: number;
interval: number;
history: { time: number; volume: number; gross: number; tax: number; low: number; high: number; vwap: number; trades: number; }[];
}
```
### GEAvailability
```typescript
interface GEAvailability {
enabled: boolean;
message?: string;
}
```
+241
View File
@@ -0,0 +1,241 @@
# Grand Exchange market data
The game server serves the market dashboard at **`/market`** and the public,
read-only JSON API below. Use the **game HTTP origin**, not the gateway's port.
Local development normally uses `http://localhost:8888`.
Buying, selling, cancelling and collecting are in-game operations. Walk beside a
**Grand Exchange table** on the west (left) side of Varrock west bank
(the existing bank table at **3180,3443**, one tile wide and two tiles north–south; stand at **3181,3443**), or the
**Grand Exchange teller** beside it at **3181,3445**. Click **Exchange** on either.
Both operate on the ground floor. Ordinary bank booths only provide banking. Bring coins
or items in your backpack. Banks are never read or written by the exchange.
## Availability and server configuration
GE is enabled by default. Set `GE_ENABLED=false` in the **engine's** environment
and restart to disable it; set `GE_ENABLED=true` and restart to re-enable it.
No map or client rebuild is needed. Disabled worlds restore the original bank table, hide the teller and market link,
and reject trading and public market reads.
`GET /api/market/status` returns `{ "enabled": false, "message": "Grand Exchange is not available on this server." }`
(or `{ "enabled": true }`). It does not open the market database. Use
`await sdk.getGEAvailability()` or `await market.status()` to check without a bot
connection. MCP `read_market` supports `operation: "status"` too.
On a disabled server, `bot.openGE()` and other GE actions return
`{ success: false, reason: 'ge_unavailable', message: 'Grand Exchange is not available on this server.' }`
before walking or dispatching. Public reads throw `GEUnavailableError` with the
same `reason`; HTTP market routes return 503 with `reason: "ge_unavailable"`.
Network failures remain errors, not claims that GE is disabled.
`getGEState()` still returns `null` when no exchange is open; use the availability
method to distinguish a closed interface from a disabled exchange.
Existing offers, escrow and history are retained while GE is off and become
accessible again when re-enabled. Existing `GE_DATABASE` player checkpoints stay
in use for save/recovery, preventing rollback or duplicated carried assets across
restarts. Keep that database with the world's saves. A world that has never used
GE does not create a market database while disabled.
## SDK
```ts
import { MarketClient } from './sdk/market';
const market = new MarketClient('http://localhost:8888');
const search = await market.items('logs', { limit: 25, offset: 0 });
const quote = await market.item(1511);
const history = await market.history(1511, {
from: Date.now() - 7 * 24 * 60 * 60 * 1000,
to: Date.now(),
interval: 3600000,
});
```
On an existing `BotSDK`, the same calls are available as:
```ts
await sdk.getMarketItems('rscim', { sort: 'relevance' }); // Rune scimitar
await sdk.getMarketItems('logs', { sort: 'buyValue', days: 7 });
await sdk.getMarketItem(1511, { days: 30 });
await sdk.getMarketHistory(1511);
```
These methods need no connected bot and send no game actions. `BotSDK` uses its
configured `browserLaunchUrl` origin when present, otherwise its usual game URL
(remote gateway origin, or localhost:8888 for local development). For a different
local game port use `MarketClient` or set `browserLaunchUrl` explicitly.
Credentials and URL query strings are stripped before market requests.
## Agent trading API
Public market reads are separate from your private exchange session. Only
`openGE()` travels: it walks to Varrock west bank, then uses the normal **Exchange**
interaction on whichever of the table or teller is reachable, trying the other if the
first does not open. It never demands one exact tile, so a crowded stand tile does not
stop it, and it skips the walk entirely when a table or teller is already in reach.
The other actions still need an open exchange, but they will **reopen** one from a table
or teller already in reach — enough to survive a stray dialog closing the interface
mid-sequence. They never travel to do it: away from Varrock west bank they fail with
`reason: 'no_interface'` as before. Reopening starts a new session, so inspect
`getGEState().offers` rather than assuming a pre-close draft survived.
The game server rechecks proximity, ground floor, session and player readiness for every
action; raw SDK calls cannot bypass these checks. Banks are not used automatically.
```ts
const opened = await bot.openGE();
if (!opened.success) throw new Error(opened.message);
const placed = await bot.placeGEOffer({
item: 1511, side: 'buy', quantity: 10, price: 20,
// slot: 0, // optional, zero-based; defaults to first empty slot
});
if (!placed.success) {
console.log(placed.reason, placed.outcome, placed.message);
// If outcome is 'unknown', inspect sdk.getGEState() before retrying.
// Never blindly repeat a confirmation that may have been applied.
} else {
console.log(placed.offer); // accepted offer; it may still be unfilled
}
```
| Method | Result/evidence |
|---|---|
| `sdk.getGEState()` | Private offers, IDs/slots, fills, collectible balances, current screen/draft, search results, error and latest receipt; `null` outside an open exchange or on an older client |
| `bot.searchGE(query, side = 'buy')` | Opens a free slot's catalogue and waits for the acknowledged query; sell results are restricted to carried items |
| `bot.placeGEOffer({item, side, quantity, price, slot?})` | Verifies the exact draft, confirms once, and returns the server-accepted offer |
| `bot.cancelGEOffer(offerId)` | Cancels only the remainder; uses the persistent offer ID, not the slot |
| `bot.collectGEOffer(offerId, notes = true)` | Collects one offer and reports actual item/coin additions |
| `bot.collectGE()` | Collects all available claims as notes and coins |
| `sdk.sendGESearch(query)` | Low-level native search packet; dispatch only |
`GEActionResult.success` for placement means the offer was accepted, not that a
counterparty filled it. Completion and cancellation do not free a slot until all
claims are collected. A successful collection may report zero, and inventory
limits leave claims in the exchange. `state.offers` exposes what remains.
The actions do not retry confirmation, cancellation or collection automatically.
A timeout/disconnect after dispatch can return `outcome: 'unknown'`; reconnect,
reopen the exchange and inspect the offer list before deciding what to do next.
Use one controller/action sequence per bot. `closeInterface()` explicitly exits;
incidental-dialog dismissal preserves GE sessions.
Actions use the game's existing component/count handlers. Item selection by
canonical ID uses the retained numeric selector; typed catalogue search uses the
same `MARKET_SEARCH` packet as the browser. The server sends one complete private
snapshot after UI updates using a hidden `IF_SETTEXT` component (client code
30403), decoded by both clients. Its named controls avoid hardcoded pack IDs.
This requires rebuilding the content cache and clients together. Older clients
remain able to use the visual UI but do not support these high-level actions.
## MCP without taking control
Use `get_market` for public research. It requires `origin` and `operation`
(`items`, `item`, `history`), with `query`, `item_id`, `days`, `sort`, pagination
or history bounds as appropriate. It never connects, launches a browser or
pre-empts a controller. Example arguments:
```json
{"origin":"http://localhost:8888","operation":"items","sort":"buyValue","days":7,"limit":25}
```
The MCP resource list includes this guide. Trading still uses `execute_code`
with the `bot.*` methods above. `bot.trade()` is player-to-player trade, not GE.
## HTTP API
All endpoints accept **GET only**, return JSON, and allow cross-origin reads.
No authentication is required. Responses may be cached for five seconds.
| Endpoint | Parameters | Result |
|---|---|---|
| `/api/market/items` | `q` fuzzy name/alias query, max 80 chars; `limit` 1–100 (default 25); `offset` 0–100000 (default 0) | `{ items: MarketQuote[], total, offset, limit }` |
| `/api/market/items/{id}` | Canonical, unnoted tradeable item ID | `MarketQuote` |
| `/api/market/items/{id}/history` | `from`, `to`: Unix milliseconds; `interval`: 3600000 (hour) or 86400000 (day) | `{ item, from, to, interval, history }` |
The list and item endpoints also accept `days=1|7|30|90` (default `1`).
The list accepts `sort=relevance|name|name-desc|recent|rises|falls|marketCap|price|volume|value|buyValue`
(default `relevance` when searching, `name` otherwise). Rankings apply before pagination;
`rises`/`falls` include only positive/negative changes with trades in the period.
`price` ranks by last unit price, `volume` by units traded, and `value` by gross
trade value. `buyValue` ranks the current open buy book by the sum of remaining
quantity × bid price for each item, including items without completed trades.
This value is independent of `days` and excludes filled and cancelled quantities.
Search filters the chosen ranking. The response also includes
`days`, `sort`, `updatedAt`, a market-wide `summary` (`volume`, `gross`,
`activeItems`, `totalItems`), and the most-traded `featured` item, or `null`.
For these requests, `volume`, `gross`, `tax`, `trades` and `vwap` cover the chosen
period. Additional fields are `referencePrice` (last execution before the
period), `change` (last price minus reference), and `changePercent`; these are
`null` without a comparison price. `marketCap` is **current listed sell quantity
× last execution price**, not total circulating value; it is `null` without a
trade price. Open quotes and last execution price always describe current data.
The game and API share the same search matcher. It accepts partial words, small
typos and common aliases: `rscim` → Rune scimitar, `addy pl8` → Adamant platebody,
`mith scim` → Mithril scimitar, `blue dhide` → Blue dragonhide items, and `lobbies`
→ Lobster. Exact names rank first; every query word must match. Short queries
use prefix matching; longer words allow one or two edits, including adjacent
letter transpositions. Explicit name sorts preserve matching but order A–Z/Z–A.
Aliases only find eligible existing items; they never add later-era equipment.
History defaults to the last 30 days in daily buckets. Ranges are `[from, to)`,
up to 366 days and 1000 buckets. Buckets align to UTC epoch boundaries; boundary
buckets include only trades inside the requested range. Periods with no trades
are omitted, never filled with invented prices or volume.
### Quote fields
| Field | Meaning |
|---|---|
| `id`, `item`, `name` | Canonical item ID (both IDs match) and display name |
| `basePrice` | Existing item definition's value, used only to prefill a first offer; **not a market price** |
| `bid`, `ask` | Highest open buy limit / lowest open sell limit; `null` if absent |
| `buyQuantity`, `sellQuantity` | Remaining quantities in open offers |
| `buyValue` | Total gp committed to unfilled open buy offers, at each offer’s bid price |
| `lastPrice`, `lastTradeAt` | Most recent execution price and Unix milliseconds, or `null` |
| `volume` | Actual item units exchanged in the selected period (24 hours by default) |
| `gross`, `tax`, `trades` | Selected-period gross coins, coins burned, and number of matched fills |
| `vwap` | Selected-period quantity-weighted average price; `null` without trades |
Each history bucket has `time`, `volume`, `gross`, `tax`, `low`, `high`, `vwap`,
and `trades`. Prices are per item, before seller tax. Volume counts each item
once, not once for each side. No account names, offer owners or player saves
are exposed. Quotes can change before a player reaches the table.
Errors return `{ "error": "..." }`: `400` invalid parameters, `404` unknown or
ineligible item, `405` unsupported method, `503` temporary market failure.
## In-game workflow
1. Click **Exchange** on the Grand Exchange table or teller in Varrock west bank.
2. Click **Buy** or **Sell** in an empty offer slot. Type a name or alias (up to
48 characters); the thumbnail grid filters as you type. Use **Sort** to cycle
best match, A–Z and Z–A, **Clear search** to reset, or page controls to browse.
Click an item thumbnail or name to select it. Sell lists only items in your
backpack, including banknotes. Enter filters immediately; Escape closes.
3. The offer form shows the item model, description and total. Click the quantity
or price field to type an exact value, or use the ± buttons and quantity
shortcuts. **All** appears only on sell offers and selects all carried items.
Price shortcuts offer ±5% and **Guide**
(last trade price, or the item-definition value before the first trade).
4. Review the total and **Confirm offer**. Funds/items enter escrow immediately.
5. Use **Offers** to inspect the six-slot grid. Open offers update automatically
when trades fill or collection balances change; no refresh is required. Item
icons, quantities, unit prices and progress bars summarize each offer. Click a slot to collect
items from the visual collection panels using **Collect** or **Collect as notes**,
or **Cancel remainder** while the offer is open. The top
**Collect** button collects available claims from all slots as notes and coins.
The 5% seller fee is deducted automatically. The main form emphasizes the item,
quantity, price and total; a small proceeds estimate accounts for the fee.
Matching also works while an owner is offline. Unfilled offers persist without
expiry. Cancellation returns unfilled assets to collection, not directly to the
backpack. Partial fills and price-improvement refunds can be collected while an
offer remains open. Full backpacks/coin stacks leave the excess in collection.
Finished or cancelled slots are freed only after all claims are collected.
The SDK can drive these existing in-game component/count actions, but there is
intentionally no remote trade or bank-transfer HTTP endpoint.
+14
View File
@@ -227,3 +227,17 @@ bot with `bun bots/create-bot.ts <username>` and run its generated script:
```bash
bun bots/<username>/script.ts
```
## Grand Exchange
GE can be disabled by the server (`GE_ENABLED=false`). Check
`await sdk.getGEAvailability()`; GE actions return `reason: "ge_unavailable"`
with a clear message when disabled, and public market reads throw `GEUnavailableError`.
Use `sdk.getMarketItems`, `sdk.getMarketItem`, and `sdk.getMarketHistory` for public
prices and volume. See [MARKET.md](MARKET.md) for the HTTP API, standalone
`MarketClient`, and in-game trading instructions. All trading and collection
happen at Varrock west bank. Use `bot.openGE()`, `bot.placeGEOffer()`,
`bot.cancelGEOffer()`, `bot.collectGEOffer()` and `bot.collectGE()` for verified
trading, and `sdk.getGEState()` for your offers and collectible balances.
MCP agents can read prices with `get_market` without connecting a character.
+25 -2
View File
@@ -3,6 +3,8 @@
// Actions resolve when the EFFECT is complete (not just acknowledged)
import { BotSDK } from './index';
import { GEActions } from './ge-actions';
import type { GEOfferRequest, GEActionResult } from './ge-types';
import { ActionHelpers, ALREADY_FIGHTING_REFUSALS, NO_RUNES_REFUSALS } from './actions-helpers';
import { findDoorsAlongPath, isTileWalkable } from './pathfinding';
import type {
@@ -86,6 +88,7 @@ import {
// completed, not closed). Everything else is informational or re-openable.
// Ids from server/content/pack/interface.pack.
const NEVER_AUTO_CLOSE = new Set([
10984, // grand_exchange - deliberate offer/draft session
3323, // trademain - auto-close would silently decline a player trade
3443, // tradeconfirm - second trade screen, same risk
6412, // duel_confirm - same risk for duels
@@ -103,11 +106,31 @@ const PICKPOCKET_ACK_TICKS = 3;
export class BotActions {
private helpers: ActionHelpers;
private ge: GEActions;
constructor(private sdk: BotSDK) {
this.helpers = new ActionHelpers(sdk);
this.ge = new GEActions(sdk, this);
}
/** Walk to the physical exchange and open it. Requires updated client/server GE state support. */
async openGE(timeout: number = 15_000): Promise<GEActionResult> { return this.ge.open(timeout); }
/** Search the in-game catalogue in a free slot; returns acknowledged results in state.search. */
async searchGE(query: string, side: 'buy' | 'sell' = 'buy', timeout: number = 15_000): Promise<GEActionResult> { return this.ge.search(query, side, timeout); }
/** Place an inventory-funded offer at an already open exchange and verify the server receipt. Never retries confirmation. */
async placeGEOffer(request: GEOfferRequest, timeout: number = 30_000): Promise<GEActionResult> { return this.ge.place(request, timeout); }
/** Cancel your offer's unfilled remainder; returned assets remain in collection. Uses offer ID, not slot. */
async cancelGEOffer(offerId: number, timeout: number = 15_000): Promise<GEActionResult> { return this.ge.cancel(offerId, timeout); }
/** Collect one offer, optionally as notes. Reports actual backpack additions; overflow stays in collection. */
async collectGEOffer(offerId: number, notes: boolean = true, timeout: number = 15_000): Promise<GEActionResult> { return this.ge.collect(offerId, notes, timeout); }
/** Collect every available claim as notes and coins; requires an open physical exchange. */
async collectGE(timeout: number = 15_000): Promise<GEActionResult> { return this.ge.collect(undefined, true, timeout); }
// ============ Porcelain: UI Helpers ============
/**
@@ -268,7 +291,7 @@ export class BotActions {
// input. Skip deliberate sessions (shop/bank have their own close
// actions) and modals where auto-close is destructive — see
// NEVER_AUTO_CLOSE. Anything else is informational or re-openable.
if (state.interface?.isOpen && !state.shop?.isOpen && !state.bank?.isOpen
if (state.interface?.isOpen && !state.shop?.isOpen && !state.bank?.isOpen && !state.ge?.isOpen
&& !NEVER_AUTO_CLOSE.has(state.interface.interfaceId)) {
await this.sdk.sendCloseModal();
await this.sdk.waitForStateChange(2000).catch(() => {});
@@ -293,7 +316,7 @@ export class BotActions {
// "safe" to dismiss - see dismissBlockingUI.
const hasSafeBlockingUI = (state: NonNullable<ReturnType<BotSDK['getState']>>) =>
(state.dialog.isOpen && state.dialog.options.length <= 1) ||
(state.interface?.isOpen && !state.shop?.isOpen && !state.bank?.isOpen
(state.interface?.isOpen && !state.shop?.isOpen && !state.bank?.isOpen && !state.ge?.isOpen
&& !NEVER_AUTO_CLOSE.has(state.interface.interfaceId));
while (Date.now() < deadline) {
+13 -2
View File
@@ -223,7 +223,7 @@ export function formatWorldState(
}
// Interface state (crafting menus, etc.)
if (state.interface && state.interface.isOpen) {
if (state.interface && state.interface.isOpen && !state.ge?.isOpen) {
lines.push('');
lines.push(`## Interface (id: ${state.interface.interfaceId})`);
if (state.interface.options.length > 0) {
@@ -234,11 +234,22 @@ export function formatWorldState(
}
if (state.trade?.isOpen) {
lines.push('(This is the player-trade window - see the Trade section below. Drive it with bot.trade()/offerTradeItems()/acceptTrade(); closing it would DECLINE the trade.)');
} else if (!state.shop?.isOpen && !state.bank?.isOpen) {
} else if (!state.shop?.isOpen && !state.bank?.isOpen && !state.ge?.isOpen) {
lines.push('(This modal blocks most actions - close it with bot.closeInterface() or sdk.sendCloseModal())');
}
}
if (state.ge?.isOpen && state.modalOpen) {
const ge = state.ge;
lines.push('', `## Grand Exchange: ${ge.screen} (revision ${ge.revision})`);
for (const offer of ge.offers) lines.push(`Offer #${offer.id}, slot ${offer.slot}: ${offer.side} ${offer.quantity} ${offer.name} @ ${offer.price} gp; ${offer.filled} filled, ${offer.state}; collect ${offer.items} items / ${offer.coins} gp`);
if (ge.draft) lines.push(`Draft: ${ge.draft.side} ${ge.draft.quantity} ${ge.draft.name} @ ${ge.draft.price} gp (item ${ge.draft.item})`);
if (ge.search) lines.push(`Search "${ge.search.query}": ${ge.search.total} matches, page ${ge.search.page + 1}`, ...ge.search.results.map(i => ` ${i.item}: ${i.name}`));
if (ge.input) lines.push(`Waiting for numeric ${ge.input} input.`);
if (ge.error || ge.notice) lines.push(ge.error ? `GE error: ${ge.error}` : ge.notice);
lines.push('Use bot.placeGEOffer / cancelGEOffer / collectGEOffer / collectGE; sdk.getGEState() has full private state. Trading requires this physical exchange session.');
}
// Shop state
if (state.shop && state.shop.isOpen) {
lines.push('');
+335
View File
@@ -0,0 +1,335 @@
import type { BotSDK } from './index';
import type { BotActions } from './actions';
import type { GEState, GEOfferRequest, GEActionResult } from './ge-types';
import type { ActionResult } from './types';
const activeSDKs = new WeakSet<BotSDK>();
const MAX_GP = 2_147_483_647;
/** Ground floor tile beside the Varrock west bank exchange table; the teller stands two tiles north. */
const GE_STAND = { x: 3181, z: 3443 };
/**
* Land anywhere beside the table rather than on one exact tile. The stand tile is the
* preferred approach, but an exact-tile walk can fail when a player occupies it,
* even though the table and teller stay perfectly reachable.
*/
const GE_APPROACH_TOLERANCE = 3;
/** Floor for observing an interface open, so a slow first target still leaves the second a chance. */
const MIN_OBSERVE_MS = 3_000;
/** Budget for silently reopening an exchange that closed mid-sequence. */
const REOPEN_MS = 15_000;
class GEFailure extends Error {
constructor(
message: string,
readonly reason: string,
readonly unknown = false,
) {
super(message);
}
}
/** Serial, observation-driven UI transactions. Never automatically retry a dispatched mutation. */
export class GEActions {
constructor(
private sdk: BotSDK,
private bot: BotActions,
) {}
private state(): GEState {
if (!this.sdk.isConnected() || this.sdk.getStateAge() > 5000)
throw new GEFailure('No fresh game connection. Reconnect and inspect the GE before acting.', 'stale_state');
const s = this.sdk.getGEState();
if (!s) throw new GEFailure('Open the Grand Exchange at its table or teller first. Older clients need an update for GE state.', 'no_interface');
return s;
}
private async run(work: () => Promise<GEActionResult>, approach = false): Promise<GEActionResult> {
if (activeSDKs.has(this.sdk))
return {
success: false,
reason: 'busy',
message: 'Another GE action is still running.',
phase: 'validation',
};
activeSDKs.add(this.sdk);
try {
// An open, fresh GE session already proves availability. Otherwise ask
// before routing or giving misleading 'open the interface' guidance.
if (!this.sdk.getGEState()) {
const availability = await this.sdk.getGEAvailability();
if (!availability.enabled)
throw new GEFailure(availability.message ?? 'Grand Exchange is not available on this server.', 'ge_unavailable');
// Reopen from a table or teller already in reach - a stray dialog can close
// the interface mid-sequence. This never travels; only open() does. Reopening
// is best effort: whatever goes wrong here, work() reports the caller's own
// failure ('no_interface') rather than a confusing routing error.
if (approach) await this.openNearby(Date.now() + REOPEN_MS).catch(() => undefined);
}
return await work();
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : String(error),
reason: error instanceof GEFailure ? error.reason : 'error',
outcome: error instanceof GEFailure && error.unknown ? 'unknown' : undefined,
state: this.sdk.getGEState() ?? undefined,
};
} finally {
activeSDKs.delete(this.sdk);
}
}
private async step(send: () => Promise<ActionResult>, done: (s: GEState) => boolean, deadline: number): Promise<GEState> {
const before = this.state();
if (Date.now() >= deadline) throw new GEFailure('GE action deadline reached before dispatch.', 'timeout');
const result = await send();
if (!result.success)
throw new GEFailure(result.message, result.reason ?? 'dispatch_failed', result.reason === 'timeout' || result.reason === 'disconnected');
let observed;
try {
observed = await this.sdk.waitForCondition(
(w) =>
!w.modalOpen ||
!w.ge ||
w.ge.session !== before.session ||
(w.ge.revision > before.revision &&
w.ge.actionRevision > before.actionRevision &&
(!!w.ge.error || ((!w.ge.receipt || w.ge.receipt.token !== before.receipt?.token) && done(w.ge)))),
Math.max(1, deadline - Date.now()),
);
} catch {
throw new GEFailure('GE result was not observed. The action may have applied; inspect getGEState() before retrying.', 'timeout', true);
}
const state = observed.ge;
if (!observed.modalOpen || !state || state.session !== before.session)
throw new GEFailure('The exchange session closed or changed; inspect offers before retrying.', 'session_changed', true);
if (state.error)
throw new GEFailure(
state.error,
'server_rejected',
state.receipt?.kind === 'collect' && ((state.receipt.items ?? 0) > 0 || (state.receipt.coins ?? 0) > 0),
);
return state;
}
private async click(name: string, done: (s: GEState) => boolean, deadline: number) {
const id = this.state().controls[name];
if (id === undefined) throw new GEFailure(`GE control ${name} is unavailable on this screen.`, 'no_control');
return this.step(() => this.sdk.sendClickComponent(id), done, deadline);
}
private async home(deadline: number) {
await this.click('home', (s) => s.screen === 'home' && !s.input, deadline);
}
private async select(offerId: number, deadline: number) {
await this.home(deadline);
const offer = this.state().offers.find((o) => o.id === offerId);
if (!offer) throw new GEFailure('That offer is no longer in your occupied slots.', 'offer_not_found');
await this.click(`view${offer.slot}`, (s) => s.screen === 'offer' && s.selectedOffer === offerId, deadline);
}
private validNumber(n: number) {
return Number.isSafeInteger(n) && n > 0 && n <= MAX_GP;
}
/**
* Table first, then the teller: both open the same exchange from different tiles.
* An unusable scene means nothing is in reach, never a thrown lookup error - callers
* use this to decide whether to travel, and must still report their own failure.
*/
private targets(): Array<{ kind: 'table' | 'teller'; interact: () => Promise<ActionResult> }> {
const found: Array<{ kind: 'table' | 'teller'; interact: () => Promise<ActionResult> }> = [];
try {
const table = this.sdk.findNearbyLoc(/^Grand Exchange table$/i, { reachable: true });
if (table) found.push({ kind: 'table', interact: () => this.bot.interactLoc(table, 'Exchange') });
const teller = this.sdk.findNearbyNpc(/^Grand Exchange teller$/i, { reachable: true });
if (teller) found.push({ kind: 'teller', interact: () => this.bot.interactNpc(teller, 'Exchange') });
} catch {
return [];
}
return found;
}
/**
* Open the exchange from a table or teller that is already in reach, closing the last
* tiles with the interaction's own approach. Tries every target: one that refuses to
* open does not rule out the other. Returns undefined when none is in reach, and a
* failed result when one was tried and did not open.
*/
private async openNearby(deadline: number): Promise<GEActionResult | undefined> {
let failure: GEActionResult | undefined;
for (const target of this.targets()) {
const result = await target.interact();
if (!result.success) {
failure = {
success: false,
message: `Could not reach the Grand Exchange ${target.kind}: ${result.message}`,
reason: result.reason ?? 'cant_reach',
phase: 'routing',
};
continue;
}
try {
await this.sdk.waitForCondition((w) => w.modalOpen && !!w.ge, Math.max(MIN_OBSERVE_MS, deadline - Date.now()));
} catch {
failure = {
success: false,
message: `The ${target.kind} did not open a structured exchange. Update the client/server and inspect the interface.`,
reason: 'timeout',
phase: 'observation',
};
continue;
}
return {
success: true,
message: `Grand Exchange opened at the Varrock west bank ${target.kind}.`,
state: this.state(),
phase: 'observation',
};
}
return failure;
}
async open(timeout: number): Promise<GEActionResult> {
return this.run(async () => {
if (this.sdk.getGEState())
return {
success: true,
message: 'Grand Exchange is open.',
state: this.state(),
phase: 'observation',
};
// Interactions walk the final tiles themselves, so only travel when neither
// target is in reach yet.
const inReach = await this.openNearby(Date.now() + timeout);
if (inReach?.success) return inReach;
const walk = await this.bot.walkTo(GE_STAND.x, GE_STAND.z, GE_APPROACH_TOLERANCE);
const arrived = await this.openNearby(Date.now() + timeout);
if (arrived) return arrived;
throw new GEFailure(
walk.success
? 'No reachable Grand Exchange table or teller at Varrock west bank. Check the server content version.'
: `Could not walk to the Grand Exchange: ${walk.message}`,
walk.success ? 'no_target' : 'cant_reach',
);
});
}
async search(query: string, side: 'buy' | 'sell', timeout: number): Promise<GEActionResult> {
return this.run(async () => {
if (!['buy', 'sell'].includes(side) || !/^[a-zA-Z0-9 '()*-]{0,48}$/.test(query))
throw new GEFailure('Invalid GE side or search query (maximum 48 ASCII characters).', 'invalid_argument');
const deadline = Date.now() + timeout;
await this.home(deadline);
const slot = Array.from({ length: 6 }, (_, i) => i).find((i) => !this.state().offers.some((o) => o.slot === i));
if (slot === undefined) throw new GEFailure('All six slots are occupied. Collect finished offers to free a slot.', 'no_slot');
await this.click(`${side}${slot}`, (s) => s.screen === 'catalog' && s.search?.side === side, deadline);
const state = await this.step(
() => this.sdk.sendGESearch(query),
(s) => s.search?.query === query,
deadline,
);
return {
success: true,
message: `${state.search?.total ?? 0} matching items.`,
state,
phase: 'observation',
};
}, true);
}
async place(request: GEOfferRequest, timeout: number): Promise<GEActionResult> {
return this.run(async () => {
const { item, side, quantity, price } = request;
if (
!this.validNumber(item) ||
!this.validNumber(quantity) ||
!this.validNumber(price) ||
quantity * price > MAX_GP ||
!['buy', 'sell'].includes(side) ||
(request.slot !== undefined && (!Number.isInteger(request.slot) || request.slot < 0 || request.slot > 5))
)
throw new GEFailure(
'Use a canonical item ID, buy/sell, positive integer quantity/price, total at most 2,147,483,647 gp and slot 0–5.',
'invalid_argument',
);
const deadline = Date.now() + timeout;
await this.home(deadline);
const slot = request.slot ?? Array.from({ length: 6 }, (_, i) => i).find((i) => !this.state().offers.some((o) => o.slot === i));
if (slot === undefined || this.state().offers.some((o) => o.slot === slot))
throw new GEFailure('Requested slot is occupied, or all six slots are full. Collect finished offers first.', 'no_slot');
await this.click(`${side}${slot}`, (s) => s.screen === 'catalog' && s.search?.side === side, deadline);
// Retained numeric selector: server validates canonical eligibility, and placement validates carried assets.
await this.click('find', (s) => s.input === 'item', deadline);
await this.step(
() => this.sdk.sendCountDialog(item),
(s) => s.draft?.item === item && !s.input,
deadline,
);
for (const [field, value] of [
['quantity', quantity],
['price', price],
] as const) {
if (this.state().draft?.[field] === value) continue;
await this.click(field, (s) => s.input === field, deadline);
await this.step(
() => this.sdk.sendCountDialog(value),
(s) => s.draft?.[field] === value && !s.input,
deadline,
);
}
const draft = this.state().draft;
if (!draft || draft.item !== item || draft.side !== side || draft.quantity !== quantity || draft.price !== price || draft.slot !== slot)
throw new GEFailure('GE draft changed before confirmation. No confirmation sent.', 'draft_changed');
const previous = new Set(this.state().offers.map((o) => o.id));
const state = await this.click('confirm', (s) => s.receipt?.kind === 'place' && !!s.receipt.offerId && !previous.has(s.receipt.offerId), deadline);
const offer = state.offers.find((o) => o.id === state.receipt?.offerId);
if (!offer || offer.item !== item || offer.side !== side || offer.quantity !== quantity || offer.price !== price || offer.slot !== slot)
throw new GEFailure('Placement receipt does not match the requested offer. Inspect offers before retrying.', 'unexpected_offer', true);
return {
success: true,
message: `Placed ${side} offer #${offer.id}; ${offer.filled}/${offer.quantity} filled.`,
offer,
state,
phase: 'completion',
outcome: 'confirmed',
};
}, true);
}
async cancel(offerId: number, timeout: number): Promise<GEActionResult> {
return this.run(async () => {
if (!this.validNumber(offerId)) throw new GEFailure('Invalid offer ID.', 'invalid_argument');
const deadline = Date.now() + timeout;
await this.select(offerId, deadline);
const offer = this.state().offers.find((o) => o.id === offerId)!;
if (offer.state !== 'open')
return {
success: true,
message: 'Offer has no open remainder. Collect its claims to free the slot.',
offer,
state: this.state(),
phase: 'observation',
outcome: 'confirmed',
};
const state = await this.click('cancel', (s) => s.receipt?.kind === 'cancel' && s.receipt.offerId === offerId, deadline);
return {
success: true,
message: 'Unfilled remainder cancelled; assets are ready for collection.',
offer: state.offers.find((o) => o.id === offerId),
state,
phase: 'completion',
outcome: 'confirmed',
};
}, true);
}
async collect(offerId: number | undefined, notes: boolean, timeout: number): Promise<GEActionResult> {
return this.run(async () => {
const deadline = Date.now() + timeout;
if (offerId !== undefined) {
if (!this.validNumber(offerId)) throw new GEFailure('Invalid offer ID.', 'invalid_argument');
await this.select(offerId, deadline);
}
const state = await this.click(
offerId === undefined ? 'collectAll' : notes ? 'collectNotes' : 'collect',
(s) => s.receipt?.kind === 'collect' && s.receipt.offerId === offerId,
deadline,
);
return {
success: true,
message: `Collected ${state.receipt!.items} items and ${state.receipt!.coins} gp. Any overflow remains in the exchange.`,
items: state.receipt!.items,
coins: state.receipt!.coins,
state,
phase: 'completion',
outcome: 'confirmed',
};
}, true);
}
}
+75
View File
@@ -0,0 +1,75 @@
/** Private exchange observations delivered only through an open in-game session. */
export interface GEOffer {
id: number;
slot: number;
item: number;
name: string;
side: 'buy' | 'sell';
quantity: number;
price: number;
remaining: number;
filled: number;
state: 'open' | 'completed' | 'cancelled';
/** Collectible balances, not assets already in the backpack. */
items: number;
coins: number;
gross: number;
tax: number;
}
export interface GEState {
isOpen: true;
session: number;
revision: number;
/** Advances for handled client commands, never for passive fill updates. */
actionRevision: number;
screen: 'home' | 'catalog' | 'draft' | 'offer';
offers: GEOffer[];
selectedOffer: number | null;
draft: {
item: number;
name: string;
side: 'buy' | 'sell';
quantity: number;
price: number;
slot: number | null;
} | null;
search: {
query: string;
side: 'buy' | 'sell';
page: number;
total: number;
results: { item: number; name: string; componentId: number }[];
} | null;
input: 'item' | 'quantity' | 'price' | null;
/** Named component IDs for the current screen; dispatched through ordinary UI handlers. */
controls: Record<string, number>;
notice: string;
error: string | null;
receipt: {
token: number;
kind: 'place' | 'cancel' | 'collect';
offerId?: number;
items?: number;
coins?: number;
} | null;
}
export interface GEOfferRequest {
item: number;
side: 'buy' | 'sell';
quantity: number;
price: number;
/** Zero-based offer slot. Defaults to the first empty slot. */
slot?: number;
}
export interface GEActionResult {
success: boolean;
message: string;
reason?: string;
phase?: 'validation' | 'routing' | 'dispatch' | 'observation' | 'completion';
/** An observation timeout after a send may have applied the action: inspect state before retrying. */
outcome?: 'confirmed' | 'unknown';
offer?: GEOffer;
items?: number;
coins?: number;
state?: GEState;
}
+9 -3
View File
@@ -108,7 +108,7 @@ function extractResultTypes(path: string, source: string): TypeDoc[] {
.filter((statement): statement is ts.InterfaceDeclaration =>
ts.isInterfaceDeclaration(statement) &&
hasModifier(statement, ts.SyntaxKind.ExportKeyword) &&
(statement.name.text.endsWith('Result') || statement.name.text.endsWith('State'))
(statement.name.text.endsWith('Result') || statement.name.text.endsWith('State') || statement.name.text.startsWith('GE') || statement.name.text.startsWith('Market'))
)
.map(statement => ({
name: statement.name.text,
@@ -133,6 +133,8 @@ function markdownCell(text: string): string {
function methodCategory(name: string, surface: 'actions' | 'sdk'): string {
const lower = name.toLowerCase();
if (name.includes('GE') || name.startsWith('getMarket')) return 'Grand Exchange';
if (surface === 'actions') {
if (lower.includes('dialog') || lower.includes('blocking') || lower.includes('tutorial')) return 'UI & Dialog';
if (lower.includes('walk')) return 'Movement';
@@ -232,16 +234,20 @@ export async function buildApiModel(sdkDir = import.meta.dir): Promise<ApiModel>
const actionsPath = join(sdkDir, 'actions.ts');
const indexPath = join(sdkDir, 'index.ts');
const typesPath = join(sdkDir, 'types.ts');
const [actionsSource, indexSource, typesSource] = await Promise.all([
const gePath = join(sdkDir, 'ge-types.d.ts');
const marketPath = join(sdkDir, 'market.ts');
const [actionsSource, indexSource, typesSource, geSource, marketSource] = await Promise.all([
readFile(actionsPath, 'utf8'),
readFile(indexPath, 'utf8'),
readFile(typesPath, 'utf8'),
readFile(gePath, 'utf8'),
readFile(marketPath, 'utf8'),
]);
return {
botActionsMethods: extractPublicClassMethods(actionsPath, actionsSource, 'BotActions'),
sdkMethods: extractPublicClassMethods(indexPath, indexSource, 'BotSDK'),
resultTypes: extractResultTypes(typesPath, typesSource),
resultTypes: [...extractResultTypes(typesPath, typesSource), ...extractResultTypes(gePath, geSource), ...extractResultTypes(marketPath, marketSource)],
};
}
+41
View File
@@ -1,3 +1,8 @@
import type { GEState } from './ge-types';
export type { GEState, GEOffer, GEOfferRequest, GEActionResult } from './ge-types';
import { MarketClient, type GEAvailability, type MarketItems, type MarketQuote, type MarketHistory, type MarketItemsOptions, type MarketItemOptions, type MarketHistoryOptions } from './market';
export { MarketClient, GEUnavailableError } from './market';
export type { GEAvailability, MarketItems, MarketQuote, MarketHistory, MarketSort, MarketDays, MarketItemsOptions, MarketItemOptions, MarketHistoryOptions } from './market';
// Bot SDK - Standalone client for remote bot control
// Low-level WebSocket API that maps 1:1 to the action protocol
// Raw actions resolve when the browser validates/routes and dispatches them.
@@ -560,6 +565,42 @@ export class BotSDK {
throw new Error(`Bot did not fully load within ${timeoutMs}ms`);
}
/** Check whether this server enables GE. No connected bot or physical access needed. */
getGEAvailability(): Promise<GEAvailability> {
return new MarketClient(this.buildClientUrl()).status();
}
/** Read public Grand Exchange prices. Uses the game origin; no login or physical access needed. */
getMarketItems(query = '', options: MarketItemsOptions = {}): Promise<MarketItems> {
return new MarketClient(this.buildClientUrl()).items(query, options);
}
/** Read current offers, latest trade and selected-period volume for a canonical item ID. */
getMarketItem(itemId: number, options: MarketItemOptions = {}): Promise<MarketQuote> {
return new MarketClient(this.buildClientUrl()).item(itemId, options);
}
/** Read historical trade prices and volumes in hour/day UTC buckets (milliseconds). */
getMarketHistory(itemId: number, options: MarketHistoryOptions = {}): Promise<MarketHistory> {
return new MarketClient(this.buildClientUrl()).history(itemId, options);
}
/** Private GE state while at an open physical exchange. Null when closed or unsupported. */
getGEState(): GEState | null {
return this.state?.modalOpen ? this.state.ge ?? null : null;
}
/** Filter the open in-game GE catalogue. Success means dispatch; wait for the acknowledged query. */
async sendGESearch(query: string): Promise<ActionResult> {
if (!/^[a-zA-Z0-9 '()*-]{0,48}$/.test(query)) return {success: false, message: 'GE search accepts up to 48 letters, digits, spaces and apostrophes/parentheses/*/hyphens.', reason: 'invalid_argument'};
if (!this.getGEState()) {
const availability = await this.getGEAvailability();
if (!availability.enabled) return { success: false, message: availability.message ?? 'Grand Exchange is not available on this server.', reason: 'ge_unavailable' };
}
if (this.getGEState()?.screen !== 'catalog') return {success: false, message: 'Open the GE catalogue at the exchange first.', reason: 'no_interface'};
return this.sendAction({type: 'searchGE', query, reason: 'SDK'});
}
private getStatusUrl(): string {
const gatewayUrl = this.config.gatewayUrl || `http://${this.config.host}:${this.config.port}`;
// Convert ws:// to http:// and wss:// to https://
+133
View File
@@ -0,0 +1,133 @@
/** Public, read-only Grand Exchange market data. Trading stays in the game. */
export const MARKET_SORTS = ['relevance', 'name', 'name-desc', 'recent', 'rises', 'falls', 'marketCap', 'price', 'volume', 'value', 'buyValue'] as const;
export type MarketSort = (typeof MARKET_SORTS)[number];
export type MarketDays = 1 | 7 | 30 | 90;
export interface MarketItemOptions {
days?: MarketDays;
}
export interface MarketItemsOptions extends MarketItemOptions {
limit?: number;
offset?: number;
sort?: MarketSort;
}
export interface MarketHistoryOptions {
from?: number;
to?: number;
interval?: 3600000 | 86400000;
}
export interface MarketQuote {
id: number;
name: string;
basePrice: number;
item: number;
bid: number | null;
ask: number | null;
buyQuantity: number;
/** Coins committed to unfilled bids, independent of the selected period. */
buyValue: number;
sellQuantity: number;
/** Completed item quantity in the selected period (24 hours by default). */
volume: number;
/** Gross coins exchanged in the selected period (24 hours by default). */
gross: number;
tax: number;
trades: number;
lastPrice: number | null;
lastTradeAt: number | null;
vwap: number | null;
referencePrice: number | null;
change: number | null;
changePercent: number | null;
/** Listed sell quantity × last price, not circulating supply. */
marketCap: number | null;
}
export interface MarketItems {
items: MarketQuote[];
total: number;
offset: number;
limit: number;
days: MarketDays;
sort: MarketSort;
updatedAt: number;
summary: {
volume: number;
gross: number;
activeItems: number;
totalItems: number;
};
featured: MarketQuote | null;
}
export interface MarketHistory {
item: number;
from: number;
to: number;
interval: number;
history: {
time: number;
volume: number;
gross: number;
tax: number;
low: number;
high: number;
vwap: number;
trades: number;
}[];
}
export interface GEAvailability {
enabled: boolean;
message?: string;
}
/** The server explicitly disabled GE (distinct from a transient HTTP failure). */
export class GEUnavailableError extends Error {
readonly reason = 'ge_unavailable';
constructor(message = 'Grand Exchange is not available on this server.') {
super(message);
this.name = 'GEUnavailableError';
}
}
/** Use the game's HTTP origin, e.g. http://localhost:8888, not the gateway port. */
export class MarketClient {
private readonly base: URL;
constructor(origin: string) {
this.base = new URL(origin);
if (!['http:', 'https:'].includes(this.base.protocol)) throw new Error('Market origin must use HTTP or HTTPS.');
this.base = new URL('/api/market/', this.base.origin);
}
private async get<T>(suffix: string, params: Record<string, string | number | undefined> = {}): Promise<T> {
const url = new URL(suffix, this.base);
for (const [key, value] of Object.entries(params)) if (value !== undefined) url.searchParams.set(key, String(value));
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
let data;
try {
data = await response.json();
} catch {
throw new Error(`Market API ${response.status}: expected JSON from the game HTTP origin.`);
}
if (!response.ok && data?.reason === 'ge_unavailable') throw new GEUnavailableError(data.error);
if (!response.ok) throw new Error(`Market API ${response.status}: ${data?.error ?? response.statusText}`);
return data as T;
}
/** Discover server GE availability without connecting a bot or opening the exchange. */
async status(): Promise<GEAvailability> {
const data = await this.get<GEAvailability>('status');
if (typeof data?.enabled !== 'boolean') throw new Error('Market API: invalid GE availability response.');
return data;
}
/** Search tradeable canonical items, including current quotes and selected-period totals. */
items(query = '', options: MarketItemsOptions = {}): Promise<MarketItems> {
return this.get('items', { q: query, ...options });
}
/** Current best offers, most recent trade and selected-period volume for an item. */
item(id: number, options: MarketItemOptions = {}): Promise<MarketQuote> {
this.validateId(id);
return this.get('items/' + id, { ...options });
}
/** UTC hour/day buckets; timestamps and interval are in milliseconds. Empty periods are omitted. */
history(id: number, options: MarketHistoryOptions = {}): Promise<MarketHistory> {
this.validateId(id);
return this.get('items/' + id + '/history', { ...options });
}
private validateId(id: number) {
if (!Number.isInteger(id) || id < 1) throw new Error('Item ID must be a positive integer.');
}
}
+253
View File
@@ -0,0 +1,253 @@
import { expect, test } from 'bun:test';
import { BotActions } from '../actions';
import { BotSDK } from '../index';
import type { GEState } from '../ge-types';
const state = (extra: Partial<GEState> = {}): GEState => ({
isOpen: true,
session: 1,
revision: 1,
actionRevision: 0,
screen: 'home',
offers: [],
controls: { collectAll: 5 },
draft: null,
selectedOffer: null,
search: null,
input: null,
error: null,
notice: '',
receipt: null,
...extra,
});
function setup(ge: GEState | null = state()) {
const sdk = new BotSDK({ botUsername: 'unused', autoLaunchBrowser: false });
const world = {
ge,
modalOpen: !!ge,
interface: { isOpen: !!ge, interfaceId: 10984, options: [] },
dialog: { isOpen: false },
} as any;
(sdk as any).state = world;
sdk.getGEAvailability = async () => ({ enabled: true });
sdk.isConnected = () => true;
sdk.getStateAge = () => 0;
const sent: number[] = [];
sdk.sendClickComponent = async (id) => {
sent.push(id);
return { success: true, message: 'dispatched', phase: 'dispatch' };
};
return { sdk, world, sent, bot: new BotActions(sdk) };
}
test('closed and stale sessions fail before dispatch', async () => {
const closed = setup(null);
expect((await closed.bot.collectGE()).reason).toBe('no_interface');
expect(closed.sent).toEqual([]);
const stale = setup();
stale.sdk.getStateAge = () => 6000;
expect((await stale.bot.collectGE()).reason).toBe('stale_state');
expect(stale.sent).toEqual([]);
});
test('a missing receipt is an unknown outcome and never triggers an automatic retry', async () => {
const { sdk, bot, sent } = setup();
sdk.waitForCondition = async () => {
throw new Error('timeout');
};
const result = await bot.collectGE(10);
expect(result.success).toBe(false);
expect(result.outcome).toBe('unknown');
expect(sent).toEqual([5]);
});
test('a newer tick repeating a previous collection receipt cannot confirm another collection', async () => {
const { sdk, world, bot } = setup(state({ receipt: { token: 10, kind: 'collect', items: 3, coins: 4 } }));
sdk.waitForCondition = async (predicate) => {
world.ge = { ...world.ge, revision: 2 };
expect(predicate(world)).toBe(false);
world.ge = {
...world.ge,
revision: 3,
actionRevision: 1,
receipt: { token: 11, kind: 'collect', items: 0, coins: 0 },
};
expect(predicate(world)).toBe(true);
return world;
};
const result = await bot.collectGE();
expect(result.success).toBe(true);
expect(result.items).toBe(0);
expect(result.coins).toBe(0);
});
test('session changes after dispatch are uncertain and concurrent actions are rejected', async () => {
const { sdk, world, bot, sent } = setup();
let release!: () => void;
sdk.waitForCondition = async (predicate) => {
await new Promise<void>((resolve) => {
release = resolve;
});
world.ge = { ...world.ge, session: 2 };
expect(predicate(world)).toBe(true);
return world;
};
const first = bot.collectGE();
await Promise.resolve();
await Promise.resolve();
expect((await new BotActions(sdk).collectGE()).reason).toBe('busy');
release();
expect((await first).outcome).toBe('unknown');
expect(sent).toEqual([5]);
});
test('incidental UI dismissal preserves the exchange', async () => {
const { sdk, bot } = setup();
let closes = 0;
sdk.sendCloseModal = async () => {
closes++;
return { success: true, message: 'closed' };
};
await bot.dismissBlockingUI();
expect(closes).toBe(0);
});
test('passive updates cannot replay an earlier server error as a new command rejection', async () => {
const { sdk, world, bot } = setup(state({ error: 'Earlier failure' }));
sdk.waitForCondition = async (predicate) => {
world.ge = { ...world.ge, revision: 2 };
expect(predicate(world)).toBe(false);
world.ge = {
...world.ge,
revision: 3,
actionRevision: 1,
error: null,
receipt: { token: 1, kind: 'collect', items: 0, coins: 0 },
};
expect(predicate(world)).toBe(true);
return world;
};
expect((await bot.collectGE()).success).toBe(true);
});
test('disabled GE actions explain unavailability before walking or dispatching', async () => {
const { sdk, bot, sent } = setup(null);
sdk.getGEAvailability = async () => ({ enabled: false });
let walks = 0;
bot.walkTo = async () => { walks++; throw new Error('Must not walk'); };
for (const action of [
() => bot.openGE(),
() => bot.searchGE('logs'),
() => bot.placeGEOffer({ item: 1511, side: 'buy', quantity: 1, price: 1 }),
() => bot.cancelGEOffer(1),
() => bot.collectGE(),
]) {
const result = await action();
expect(result.success).toBe(false);
expect(result.reason).toBe('ge_unavailable');
expect(result.message).toBe('Grand Exchange is not available on this server.');
expect(result.outcome).toBeUndefined();
}
expect(walks).toBe(0);
expect(sent).toEqual([]);
});
const table = { id: 1, name: 'Grand Exchange table', x: 3180, z: 3443 } as any;
const teller = { id: 2, name: 'Grand Exchange teller', x: 3181, z: 3445 } as any;
test('open() tries every reachable target and skips travel when one is already in reach', async () => {
const { sdk, world, bot } = setup(null);
let walks = 0;
bot.walkTo = async () => {
walks++;
return { success: true, message: 'Arrived' };
};
sdk.findNearbyLoc = (() => table) as any;
sdk.findNearbyNpc = (() => teller) as any;
// A table that will not open must not rule out the teller: both open the same exchange.
bot.interactLoc = async () => ({ success: false, message: "I can't reach that!", reason: 'cant_reach' });
bot.interactNpc = async () => {
world.ge = state();
world.modalOpen = true;
return { success: true, message: 'Interacted' };
};
sdk.waitForCondition = (async (predicate: any) => {
if (!predicate(world)) throw new Error('timeout');
return world;
}) as any;
const result = await bot.openGE();
expect(result.success).toBe(true);
expect(result.message).toContain('teller');
expect(walks).toBe(0);
});
test('a contested stand tile does not fail open()', async () => {
const { sdk, world, bot } = setup(null);
let walked = false;
// The exact-tile walk open() used to depend on: it fails whenever another player
// stands on the stand tile, even though the bot lands beside the table anyway.
bot.walkTo = async () => {
walked = true;
return { success: false, message: 'Stuck at (3182, 3438)' };
};
sdk.findNearbyLoc = (() => (walked ? table : null)) as any;
sdk.findNearbyNpc = (() => null) as any;
bot.interactLoc = async () => {
world.ge = state();
world.modalOpen = true;
return { success: true, message: 'Interacted' };
};
sdk.waitForCondition = (async (predicate: any) => {
if (!predicate(world)) throw new Error('timeout');
return world;
}) as any;
const result = await bot.openGE();
expect(result.success).toBe(true);
expect(result.message).toContain('table');
});
test('open() reports why it could not reach the exchange', async () => {
const { sdk, bot } = setup(null);
sdk.findNearbyLoc = (() => null) as any;
sdk.findNearbyNpc = (() => null) as any;
bot.walkTo = async () => ({ success: false, message: 'Stuck at (3191, 3436)' });
const result = await bot.openGE();
expect(result.success).toBe(false);
expect(result.reason).toBe('cant_reach');
expect(result.message).toContain('Stuck at (3191, 3436)');
});
test('other actions reopen a closed exchange from a target in reach, but never travel for it', async () => {
const { sdk, world, bot, sent } = setup(null);
let walks = 0;
bot.walkTo = async () => {
walks++;
throw new Error('Must not travel to reopen');
};
sdk.findNearbyLoc = (() => table) as any;
sdk.findNearbyNpc = (() => null) as any;
bot.interactLoc = async () => {
world.ge = state();
world.modalOpen = true;
return { success: true, message: 'Interacted' };
};
sdk.waitForCondition = (async (predicate: any) => {
if (predicate(world)) return world;
world.ge = {
...world.ge,
revision: world.ge.revision + 2,
actionRevision: world.ge.actionRevision + 1,
receipt: { token: 99, kind: 'collect', items: 0, coins: 0 },
};
if (!predicate(world)) throw new Error('timeout');
return world;
}) as any;
const result = await bot.collectGE();
expect(result.success).toBe(true);
expect(walks).toBe(0);
expect(sent).toEqual([5]);
});
test('reopening is best effort and never masks the caller own failure', async () => {
const { sdk, bot, sent } = setup(null);
sdk.findNearbyLoc = (() => {
throw new Error('scene not loaded');
}) as any;
const result = await bot.collectGE();
expect(result.reason).toBe('no_interface');
expect(sent).toEqual([]);
});
+150
View File
@@ -0,0 +1,150 @@
import { describe, expect, test } from 'bun:test';
import { MarketClient, type MarketItems } from '../market';
import { BotSDK } from '../index';
import { readMarket, marketTool } from '../../mcp/market-tools';
describe('read-only market SDK', () => {
test('HTTP origin, query encoding, credentials stripped, and BotSDK wrappers', async () => {
const seen: URL[] = [];
const methods: string[] = [];
const server = Bun.serve({
port: 0,
fetch(req) {
seen.push(new URL(req.url));
methods.push(req.method);
return Response.json({ items: [], history: [], id: 1511 });
},
});
try {
const origin = server.url.origin;
const market = new MarketClient(origin + '/bot?password=never-send&bot=private');
await market.items('logs & runes', { limit: 10, offset: 20 });
expect(seen[0]!.pathname).toBe('/api/market/items');
expect(seen[0]!.searchParams.get('q')).toBe('logs & runes');
expect(seen[0]!.searchParams.get('offset')).toBe('20');
const sdk = new BotSDK({
botUsername: 'private',
password: 'never-send',
browserLaunchUrl: origin + '/bot',
autoLaunchBrowser: false,
});
await sdk.getMarketItems('logs', { sort: 'name-desc' });
expect(seen[1]!.searchParams.get('sort')).toBe('name-desc');
await sdk.getMarketItem(1511);
await sdk.getMarketHistory(1511, {
from: 1,
to: 3600001,
interval: 3600000,
});
expect(seen[2]!.pathname).toBe('/api/market/items/1511');
expect(seen[3]!.pathname).toBe('/api/market/items/1511/history');
expect(seen[3]!.searchParams.get('interval')).toBe('3600000');
expect(seen.every((u) => !u.href.includes('private') && !u.href.includes('never-send'))).toBe(true);
expect(methods).toEqual(['GET', 'GET', 'GET', 'GET']);
} finally {
server.stop(true);
}
});
test('propagates API errors and validates item IDs', async () => {
const server = Bun.serve({
port: 0,
fetch: () => Response.json({ error: 'Tradeable item not found' }, { status: 404 }),
});
try {
const client = new MarketClient(server.url.origin);
await expect(client.item(99999)).rejects.toThrow('Market API 404');
expect(() => client.item(-1)).toThrow();
expect(() => client.history(NaN)).toThrow();
expect(() => new MarketClient('ws://localhost')).toThrow();
} finally {
server.stop(true);
}
});
});
test('typed market periods/rankings and MCP reads forward options without a bot', async () => {
const seen: URL[] = [];
const response: MarketItems = {
items: [],
total: 0,
offset: 0,
limit: 25,
days: 7,
sort: 'buyValue',
updatedAt: 123,
summary: { volume: 0, gross: 0, activeItems: 0, totalItems: 1 },
featured: null,
};
const server = Bun.serve({
port: 0,
fetch(req) {
seen.push(new URL(req.url));
return Response.json(response);
},
});
try {
const client = new MarketClient(server.url.origin);
const data = await client.items('', { sort: 'buyValue', days: 7 });
expect(data.summary.totalItems).toBe(1);
expect(data.updatedAt).toBe(123);
expect(seen[0]!.searchParams.get('days')).toBe('7');
expect(seen[0]!.searchParams.get('sort')).toBe('buyValue');
await client.item(1511, { days: 90 });
expect(seen[1]!.searchParams.get('days')).toBe('90');
const sdk = new BotSDK({
botUsername: 'never-connect',
password: 'secret',
browserLaunchUrl: server.url.origin,
});
await sdk.getMarketItem(1511, { days: 30 });
expect(sdk.isConnected()).toBe(false);
expect(seen[2]!.searchParams.get('days')).toBe('30');
const result = await readMarket({
origin: server.url.origin + '/bot?password=secret',
operation: 'items',
days: 7,
sort: 'volume',
});
expect(result).toEqual(response);
expect(seen[3]!.href).not.toContain('secret');
expect(marketTool.inputSchema.required).not.toContain('bot_name');
expect(seen[3]!.searchParams.get('sort')).toBe('volume');
} finally {
server.stop(true);
}
});
test('non-JSON server errors retain HTTP status and origin guidance', async () => {
const server = Bun.serve({
port: 0,
fetch: () => new Response('Bad gateway', { status: 502 }),
});
try {
await expect(new MarketClient(server.url.origin).items()).rejects.toThrow('Market API 502: expected JSON');
} finally {
server.stop(true);
}
});
test('disabled GE is discoverable and public reads report a typed unavailable error', async () => {
const { GEUnavailableError } = await import('../market');
const server = Bun.serve({
port: 0,
fetch(req) {
if (new URL(req.url).pathname === '/api/market/status') return Response.json({ enabled: false, message: 'Grand Exchange is not available on this server.' });
return Response.json({ error: 'Grand Exchange is not available on this server.', reason: 'ge_unavailable' }, { status: 503 });
},
});
try {
const market = new MarketClient(server.url.origin);
const sdk = new BotSDK({ botUsername: 'unused', browserLaunchUrl: server.url.origin, autoLaunchBrowser: false });
expect((await sdk.getGEAvailability()).enabled).toBe(false);
expect(await readMarket({ origin: server.url.origin, operation: 'status' })).toMatchObject({ enabled: false });
for (const read of [() => market.items(), () => market.item(1511), () => market.history(1511)]) {
await expect(read()).rejects.toBeInstanceOf(GEUnavailableError);
}
expect((await sdk.sendGESearch('logs')).reason).toBe('ge_unavailable');
expect(sdk.isConnected()).toBe(false);
} finally {
server.stop(true);
}
});
+32
View File
@@ -0,0 +1,32 @@
import { expect, test } from 'bun:test';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { join } from 'node:path';
test('public MCP market research lists its guide and never connects a bot', async () => {
let requests = 0;
const server = Bun.serve({
port: 0,
fetch(req) {
requests++;
expect(new URL(req.url).pathname).toBe('/api/market/items');
return Response.json({ items: [], total: 0 });
},
});
const client = new Client({ name: 'market-test', version: '1.0.0' });
const transport = new StdioClientTransport({ command: process.execPath, args: [join(import.meta.dir, '../../mcp/server.ts')], stderr: 'pipe' });
transport.stderr?.on('data', () => {});
try {
await client.connect(transport);
expect((await client.listTools()).tools.some((t) => t.name === 'get_market')).toBe(true);
expect((await client.listResources()).resources.some((r) => r.uri === 'file://../sdk/MARKET.md')).toBe(true);
const result = await client.callTool({ name: 'get_market', arguments: { origin: server.url.origin, operation: 'items', query: 'logs' } });
expect(result.isError).not.toBe(true);
expect(requests).toBe(1);
const bots = await client.callTool({ name: 'list_bots', arguments: {} });
expect(JSON.parse((bots.content as Array<{ text: string }>)[0]!.text).count).toBe(0);
} finally {
await client.close();
server.stop(true);
}
}, 30_000);
+4
View File
@@ -1,3 +1,4 @@
import type { GEState } from './ge-types';
// Re-export all types from the agent types module
// This file provides the full type definitions for remote SDK users
@@ -574,6 +575,8 @@ export interface BotWorldState {
bank: BankState;
/** Absent when the connected client predates trade support. */
trade?: TradeState;
/** Null outside the exchange; absent on older clients. */
ge?: GEState | null;
modalOpen: boolean;
modalInterface: number;
combatStyle?: CombatStyleState;
@@ -630,6 +633,7 @@ export type BotAction =
| { type: 'setTab'; tabIndex: number; reason: string }
| { type: 'bankDeposit'; slot: number; amount: number; reason: string }
| { type: 'bankWithdraw'; slot: number; amount: number; reason: string }
| { type: 'searchGE'; query: string; reason: string }
| { type: 'submitCountDialog'; value: number; reason: string }
| { type: 'scanNearbyLocs'; radius?: number; reason: string }
| { type: 'scanGroundItems'; radius?: number; reason: string }
+1
View File
@@ -37,6 +37,7 @@ sdk/test/
# rebuilt from server/content, same reasoning as server/webclient/out above).
server/engine/data/players/
server/engine/db.sqlite
server/engine/data/market.sqlite*
server/engine/data/pack/
# Config files at parent level
+1 -1
View File
@@ -54,7 +54,7 @@ RUN bun run build
# Environment.ts) / data/config/world.json, so no .env file is needed at build time.
WORKDIR /opt/server/engine
RUN sed -i 's/port: Environment.WEB_PORT,/port: Environment.WEB_PORT, hostname: "0.0.0.0",/' src/web/index.ts && \
bun run build
BUILD_VERIFY=false bun run build
# Patch gateway
WORKDIR /opt/server/gateway
+39
View File
@@ -109,7 +109,46 @@ survival) is described in the project memory; this file is the human-readable ch
`handler/IfButtonHandler.ts` clears `resumeButtons` when a registered option resumes
the script. Verify: `HEADLESS=true bun sdk/test/alkharid-gate-choice-resume.ts`.
### Grand Exchange (rs-sdk)
- [ ] **Physical market** — `src/engine/market/{MarketStore,MarketCatalog,GrandExchange}.ts`,
`GE_OPEN(coord)` opcode in `ScriptOpcode.ts` / `DebugOps.ts`, native `IfButtonHandler` and
`ResumePCountDialogHandler` hooks. Paired content: `engine.rs2` command, dedicated
`grand_exchange_booth`/`grand_exchange_teller` configs and triggers, `grand_exchange.if`,
append-only interface/loc/npc packs and `interface.order`. Map `m49_53.jm2`
reuses the west-wall bank table at 3180,3443 (original model, rotation and
collision) and places the teller at 3181,3445. Disabling GE restores the
original bank table. Ordinary bank booths remain banking-only.
`World.processPlayers` calls `tickExchange` to push changed offers; preserve this
with the visual offer form (there is no visible refresh button).
Repack with `BUILD_VERIFY=false` after custom content changes.
- [ ] **Thumbnail search** — shared `ItemSearch.ts` aliases/fuzzy ranking; native search
components with client codes 30400–30402, 30410–30421 and 30440–30451.
`MARKET_SEARCH` client opcode **243**, length -1, must match engine
`ClientGameProt`/repository/decoder/handler and webclient `ClientProt`.
Payload: `pjstr(query)` then `p2(item)` (0 filters; positive selects a shown
canonical ID). Max query 48 ASCII characters; server requires a physical
session and matching current query/page. `Client.ts` keyboard and click hooks
use `MarketSearchInput.ts`; lite component clicks resolve displayed IDs too.
Rebuild standard and bot clients. No new server-to-client packet.
Verify `item-search.test.ts`, `bot/MarketSearchInput.test.ts` and the integration.
- [ ] **Atomic recovery** — `Player.save()` checkpoints into the market ledger;
`PlayerLoading.load()` prefers that snapshot over stale login-service saves.
Placement/collection checkpoint all online players in the same SQLite transaction
to cover items handed between players since an autosave. Preserve both hooks.
`data/market.sqlite` (or `GE_DATABASE`) is authoritative durable data, including
player checkpoints; back it up with the world, never independently delete/restore it.
- [ ] **Market web and SDK** — `web/pages/market.ts` registered in `web/index.ts`,
`public/market.html`, `view/bot.ejs` Market link; `sdk/market.ts` and three
`BotSDK.getMarket*` methods. API is GET-only and exposes no player identities.
Verify: root `bun test server/engine/test/market.test.ts sdk/test/market-client.test.ts`,
then `bun run test:market:integration` with built content. Full design/operations:
`docs/grand-exchange.md`; API: `sdk/MARKET.md`.
### Assets
- [ ] **`FileStream.write`** — update `packed[archive][file]` after changed writes, so
same-pass version-list CRCs and `ondemand.zip` see the new map/model bytes.
Verify `bun test server/engine/test/file-stream.test.ts` from the root.
- [ ] `public/img/skill/*` (19 files), `public/img/*`, favicons, hiscores images —
restored after upstream website migrations deleted them. Verify pages render with images.
- [ ] `tools/pack/PackAll.ts` `packOnDemandZip()` — regenerates `data/pack/ondemand.zip`
+3 -1
View File
@@ -7451,7 +7451,7 @@
0 44 49: 1902 0
0 44 50: 1158 10 3
0 44 51: 184 22 2
0 44 51: 590 10 1
0 44 51: 4671 10 1
0 44 51: 1838 4
0 44 53: 1902 0
0 44 54: 1830 5
@@ -10223,6 +10223,8 @@
1 39 11: 9
1 39 22: 9
0 45 53: 1359
==== OBJ ====
0 7 60: 1973 1
0 15 7: 1573 1
+494
View File
@@ -10982,3 +10982,497 @@
10980
10981
10982
10984
10985
11031
11032
11033
11034
11035
11036
11037
11038
11039
10986
11040
11041
11042
11043
10987
11044
11045
11046
11047
10991
11048
11049
11050
11051
10988
11052
11053
11054
11055
11056
11057
11058
11059
11060
11061
11062
11063
11064
11065
11066
11067
11068
11069
11070
11071
11072
11073
11074
11075
11076
11077
11078
11079
11080
11081
11310
11082
11083
11084
11085
11298
11086
11087
11088
11089
11090
11091
11092
11311
11093
11094
11095
11096
11299
11097
11098
11099
11100
11101
11102
11103
11104
11105
11106
11107
11108
11109
11110
11111
11112
11113
11114
11115
11116
11117
11118
11119
11120
11312
11121
11122
11123
11124
11300
11125
11126
11127
11128
11129
11130
11131
11313
11132
11133
11134
11135
11301
11136
11137
11138
11139
11140
11141
11142
11143
11144
11145
11146
11147
11148
11149
11150
11151
11152
11153
11154
11155
11156
11157
11158
11159
11314
11160
11161
11162
11163
11302
11164
11165
11166
11167
11168
11169
11170
11315
11171
11172
11173
11174
11303
11175
11176
11177
11178
11179
11180
11181
11182
11183
11184
11185
11186
11187
11188
11189
11190
11191
11192
11193
11194
11195
11196
11197
11198
11316
11199
11200
11201
11202
11304
11203
11204
11205
11206
11207
11208
11209
11317
11210
11211
11212
11213
11305
11214
11215
11216
11217
11218
11219
11220
11221
11222
11223
11224
11225
11226
11227
11228
11229
11230
11231
11232
11233
11234
11235
11236
11237
11318
11238
11239
11240
11241
11306
11242
11243
11244
11245
11246
11247
11248
11319
11249
11250
11251
11252
11307
11253
11254
11255
11256
11257
11258
11259
11260
11261
11262
11263
11264
11265
11266
11267
11268
11269
11270
11271
11272
11273
11274
11275
11276
11320
11277
11278
11279
11280
11308
11281
11282
11283
11284
11285
11286
11287
11321
11288
11289
11290
11291
11309
11292
11293
11294
11295
11296
11297
10989
10990
10992
10993
10994
10995
10996
10997
10998
10999
11000
11001
11002
11003
11004
11005
11006
11007
11008
11009
11010
11011
11012
11013
11014
11015
11016
11017
11018
11019
11020
11021
11022
11023
11024
11025
11026
11027
11028
11029
11030
11322
11323
11324
11325
11326
11327
11328
11329
11330
11331
11332
11333
11334
11335
11336
11337
11338
11339
11340
11341
11342
11343
11344
11345
11346
11347
11348
11349
11350
11351
11352
11353
11354
11355
11356
11357
11358
11359
11360
11361
11362
11363
11364
11365
11366
11367
11368
11369
11370
11371
11372
11373
11374
11375
11376
11377
11378
11379
11380
11381
11382
11383
11384
11385
11386
11387
11388
11389
11390
11391
11392
11393
11394
11395
11396
11397
11398
11399
11400
11401
11402
11403
11404
11405
11406
11407
11408
11409
11410
11411
11412
11413
11414
11415
11416
11417
11418
11419
11420
11421
11422
11423
11424
11425
11426
11427
11428
11429
11430
11431
11432
11433
11434
11435
11436
11437
11438
11439
11440
11441
11442
11443
11444
11445
11446
11447
11448
11449
11450
11451
11452
11453
11454
11455
11456
11457
11458
11459
11460
11461
11462
11463
11464
11465
11466
11467
11468
11469
11470
11471
11472
11473
11474
11475
11476
11477
+494
View File
@@ -10982,3 +10982,497 @@
10981=boardgames_runelink_view:status1
10982=boardgames_runelink_view:status2
10983=music:competition
10984=grand_exchange
10985=grand_exchange:background
10986=grand_exchange:heading
10987=grand_exchange:close
10988=grand_exchange:home
10989=grand_exchange:buy
10990=grand_exchange:sell
10991=grand_exchange:refresh
10992=grand_exchange:find
10993=grand_exchange:letters
10994=grand_exchange:letter0
10995=grand_exchange:letter1
10996=grand_exchange:letter2
10997=grand_exchange:letter3
10998=grand_exchange:letter4
10999=grand_exchange:letter5
11000=grand_exchange:letter6
11001=grand_exchange:letter7
11002=grand_exchange:letter8
11003=grand_exchange:letter9
11004=grand_exchange:letter10
11005=grand_exchange:letter11
11006=grand_exchange:letter12
11007=grand_exchange:letter13
11008=grand_exchange:letter14
11009=grand_exchange:letter15
11010=grand_exchange:letter16
11011=grand_exchange:letter17
11012=grand_exchange:letter18
11013=grand_exchange:letter19
11014=grand_exchange:letter20
11015=grand_exchange:letter21
11016=grand_exchange:letter22
11017=grand_exchange:letter23
11018=grand_exchange:letter24
11019=grand_exchange:letter25
11020=grand_exchange:row0
11021=grand_exchange:row1
11022=grand_exchange:row2
11023=grand_exchange:row3
11024=grand_exchange:row4
11025=grand_exchange:row5
11026=grand_exchange:row6
11027=grand_exchange:row7
11028=grand_exchange:previous
11029=grand_exchange:next
11030=grand_exchange:status
11031=grand_exchange:outer_highlight
11032=grand_exchange:inner_frame
11033=grand_exchange:inner_shadow
11034=grand_exchange:body
11035=grand_exchange:body_edge
11036=grand_exchange:titlebarshadow
11037=grand_exchange:titlebaredge
11038=grand_exchange:titlebarinset
11039=grand_exchange:titlebarface
11040=grand_exchange:close_bgshadow
11041=grand_exchange:close_bgedge
11042=grand_exchange:close_bginset
11043=grand_exchange:close_bgface
11044=grand_exchange:refresh_bgshadow
11045=grand_exchange:refresh_bgedge
11046=grand_exchange:refresh_bginset
11047=grand_exchange:refresh_bgface
11048=grand_exchange:home_bgshadow
11049=grand_exchange:home_bgedge
11050=grand_exchange:home_bginset
11051=grand_exchange:home_bgface
11052=grand_exchange:subtitle
11053=grand_exchange:collect_bgshadow
11054=grand_exchange:collect_bgedge
11055=grand_exchange:collect_bginset
11056=grand_exchange:collect_bgface
11057=grand_exchange:collect
11058=grand_exchange:grid
11059=grand_exchange:slot0
11060=grand_exchange:slot0_cardshadow
11061=grand_exchange:slot0_cardedge
11062=grand_exchange:slot0_cardinset
11063=grand_exchange:slot0_cardface
11064=grand_exchange:slot0_divider
11065=grand_exchange:slot0_side
11066=grand_exchange:slot0_occupied
11067=grand_exchange:slot0_icon
11068=grand_exchange:slot0_name
11069=grand_exchange:slot0_bar_edge
11070=grand_exchange:slot0_bar
11071=grand_exchange:slot0_fill
11072=grand_exchange:slot0_mask
11073=grand_exchange:slot0_price
11074=grand_exchange:slot0_view
11075=grand_exchange:slot0_empty
11076=grand_exchange:slot0_buy_area
11077=grand_exchange:slot0_buy_area_buttonshadow
11078=grand_exchange:slot0_buy_area_buttonedge
11079=grand_exchange:slot0_buy_area_buttoninset
11080=grand_exchange:slot0_buy_area_buttonface
11081=grand_exchange:slot0_buy_coins
11082=grand_exchange:slot0_buy_area_arrow0
11083=grand_exchange:slot0_buy_area_arrow1
11084=grand_exchange:slot0_buy_area_arrow2
11085=grand_exchange:slot0_buy_area_arrow3
11086=grand_exchange:slot0_buy
11087=grand_exchange:slot0_sell_area
11088=grand_exchange:slot0_sell_area_buttonshadow
11089=grand_exchange:slot0_sell_area_buttonedge
11090=grand_exchange:slot0_sell_area_buttoninset
11091=grand_exchange:slot0_sell_area_buttonface
11092=grand_exchange:slot0_sell_coins
11093=grand_exchange:slot0_sell_area_arrow0
11094=grand_exchange:slot0_sell_area_arrow1
11095=grand_exchange:slot0_sell_area_arrow2
11096=grand_exchange:slot0_sell_area_arrow3
11097=grand_exchange:slot0_sell
11098=grand_exchange:slot1
11099=grand_exchange:slot1_cardshadow
11100=grand_exchange:slot1_cardedge
11101=grand_exchange:slot1_cardinset
11102=grand_exchange:slot1_cardface
11103=grand_exchange:slot1_divider
11104=grand_exchange:slot1_side
11105=grand_exchange:slot1_occupied
11106=grand_exchange:slot1_icon
11107=grand_exchange:slot1_name
11108=grand_exchange:slot1_bar_edge
11109=grand_exchange:slot1_bar
11110=grand_exchange:slot1_fill
11111=grand_exchange:slot1_mask
11112=grand_exchange:slot1_price
11113=grand_exchange:slot1_view
11114=grand_exchange:slot1_empty
11115=grand_exchange:slot1_buy_area
11116=grand_exchange:slot1_buy_area_buttonshadow
11117=grand_exchange:slot1_buy_area_buttonedge
11118=grand_exchange:slot1_buy_area_buttoninset
11119=grand_exchange:slot1_buy_area_buttonface
11120=grand_exchange:slot1_buy_coins
11121=grand_exchange:slot1_buy_area_arrow0
11122=grand_exchange:slot1_buy_area_arrow1
11123=grand_exchange:slot1_buy_area_arrow2
11124=grand_exchange:slot1_buy_area_arrow3
11125=grand_exchange:slot1_buy
11126=grand_exchange:slot1_sell_area
11127=grand_exchange:slot1_sell_area_buttonshadow
11128=grand_exchange:slot1_sell_area_buttonedge
11129=grand_exchange:slot1_sell_area_buttoninset
11130=grand_exchange:slot1_sell_area_buttonface
11131=grand_exchange:slot1_sell_coins
11132=grand_exchange:slot1_sell_area_arrow0
11133=grand_exchange:slot1_sell_area_arrow1
11134=grand_exchange:slot1_sell_area_arrow2
11135=grand_exchange:slot1_sell_area_arrow3
11136=grand_exchange:slot1_sell
11137=grand_exchange:slot2
11138=grand_exchange:slot2_cardshadow
11139=grand_exchange:slot2_cardedge
11140=grand_exchange:slot2_cardinset
11141=grand_exchange:slot2_cardface
11142=grand_exchange:slot2_divider
11143=grand_exchange:slot2_side
11144=grand_exchange:slot2_occupied
11145=grand_exchange:slot2_icon
11146=grand_exchange:slot2_name
11147=grand_exchange:slot2_bar_edge
11148=grand_exchange:slot2_bar
11149=grand_exchange:slot2_fill
11150=grand_exchange:slot2_mask
11151=grand_exchange:slot2_price
11152=grand_exchange:slot2_view
11153=grand_exchange:slot2_empty
11154=grand_exchange:slot2_buy_area
11155=grand_exchange:slot2_buy_area_buttonshadow
11156=grand_exchange:slot2_buy_area_buttonedge
11157=grand_exchange:slot2_buy_area_buttoninset
11158=grand_exchange:slot2_buy_area_buttonface
11159=grand_exchange:slot2_buy_coins
11160=grand_exchange:slot2_buy_area_arrow0
11161=grand_exchange:slot2_buy_area_arrow1
11162=grand_exchange:slot2_buy_area_arrow2
11163=grand_exchange:slot2_buy_area_arrow3
11164=grand_exchange:slot2_buy
11165=grand_exchange:slot2_sell_area
11166=grand_exchange:slot2_sell_area_buttonshadow
11167=grand_exchange:slot2_sell_area_buttonedge
11168=grand_exchange:slot2_sell_area_buttoninset
11169=grand_exchange:slot2_sell_area_buttonface
11170=grand_exchange:slot2_sell_coins
11171=grand_exchange:slot2_sell_area_arrow0
11172=grand_exchange:slot2_sell_area_arrow1
11173=grand_exchange:slot2_sell_area_arrow2
11174=grand_exchange:slot2_sell_area_arrow3
11175=grand_exchange:slot2_sell
11176=grand_exchange:slot3
11177=grand_exchange:slot3_cardshadow
11178=grand_exchange:slot3_cardedge
11179=grand_exchange:slot3_cardinset
11180=grand_exchange:slot3_cardface
11181=grand_exchange:slot3_divider
11182=grand_exchange:slot3_side
11183=grand_exchange:slot3_occupied
11184=grand_exchange:slot3_icon
11185=grand_exchange:slot3_name
11186=grand_exchange:slot3_bar_edge
11187=grand_exchange:slot3_bar
11188=grand_exchange:slot3_fill
11189=grand_exchange:slot3_mask
11190=grand_exchange:slot3_price
11191=grand_exchange:slot3_view
11192=grand_exchange:slot3_empty
11193=grand_exchange:slot3_buy_area
11194=grand_exchange:slot3_buy_area_buttonshadow
11195=grand_exchange:slot3_buy_area_buttonedge
11196=grand_exchange:slot3_buy_area_buttoninset
11197=grand_exchange:slot3_buy_area_buttonface
11198=grand_exchange:slot3_buy_coins
11199=grand_exchange:slot3_buy_area_arrow0
11200=grand_exchange:slot3_buy_area_arrow1
11201=grand_exchange:slot3_buy_area_arrow2
11202=grand_exchange:slot3_buy_area_arrow3
11203=grand_exchange:slot3_buy
11204=grand_exchange:slot3_sell_area
11205=grand_exchange:slot3_sell_area_buttonshadow
11206=grand_exchange:slot3_sell_area_buttonedge
11207=grand_exchange:slot3_sell_area_buttoninset
11208=grand_exchange:slot3_sell_area_buttonface
11209=grand_exchange:slot3_sell_coins
11210=grand_exchange:slot3_sell_area_arrow0
11211=grand_exchange:slot3_sell_area_arrow1
11212=grand_exchange:slot3_sell_area_arrow2
11213=grand_exchange:slot3_sell_area_arrow3
11214=grand_exchange:slot3_sell
11215=grand_exchange:slot4
11216=grand_exchange:slot4_cardshadow
11217=grand_exchange:slot4_cardedge
11218=grand_exchange:slot4_cardinset
11219=grand_exchange:slot4_cardface
11220=grand_exchange:slot4_divider
11221=grand_exchange:slot4_side
11222=grand_exchange:slot4_occupied
11223=grand_exchange:slot4_icon
11224=grand_exchange:slot4_name
11225=grand_exchange:slot4_bar_edge
11226=grand_exchange:slot4_bar
11227=grand_exchange:slot4_fill
11228=grand_exchange:slot4_mask
11229=grand_exchange:slot4_price
11230=grand_exchange:slot4_view
11231=grand_exchange:slot4_empty
11232=grand_exchange:slot4_buy_area
11233=grand_exchange:slot4_buy_area_buttonshadow
11234=grand_exchange:slot4_buy_area_buttonedge
11235=grand_exchange:slot4_buy_area_buttoninset
11236=grand_exchange:slot4_buy_area_buttonface
11237=grand_exchange:slot4_buy_coins
11238=grand_exchange:slot4_buy_area_arrow0
11239=grand_exchange:slot4_buy_area_arrow1
11240=grand_exchange:slot4_buy_area_arrow2
11241=grand_exchange:slot4_buy_area_arrow3
11242=grand_exchange:slot4_buy
11243=grand_exchange:slot4_sell_area
11244=grand_exchange:slot4_sell_area_buttonshadow
11245=grand_exchange:slot4_sell_area_buttonedge
11246=grand_exchange:slot4_sell_area_buttoninset
11247=grand_exchange:slot4_sell_area_buttonface
11248=grand_exchange:slot4_sell_coins
11249=grand_exchange:slot4_sell_area_arrow0
11250=grand_exchange:slot4_sell_area_arrow1
11251=grand_exchange:slot4_sell_area_arrow2
11252=grand_exchange:slot4_sell_area_arrow3
11253=grand_exchange:slot4_sell
11254=grand_exchange:slot5
11255=grand_exchange:slot5_cardshadow
11256=grand_exchange:slot5_cardedge
11257=grand_exchange:slot5_cardinset
11258=grand_exchange:slot5_cardface
11259=grand_exchange:slot5_divider
11260=grand_exchange:slot5_side
11261=grand_exchange:slot5_occupied
11262=grand_exchange:slot5_icon
11263=grand_exchange:slot5_name
11264=grand_exchange:slot5_bar_edge
11265=grand_exchange:slot5_bar
11266=grand_exchange:slot5_fill
11267=grand_exchange:slot5_mask
11268=grand_exchange:slot5_price
11269=grand_exchange:slot5_view
11270=grand_exchange:slot5_empty
11271=grand_exchange:slot5_buy_area
11272=grand_exchange:slot5_buy_area_buttonshadow
11273=grand_exchange:slot5_buy_area_buttonedge
11274=grand_exchange:slot5_buy_area_buttoninset
11275=grand_exchange:slot5_buy_area_buttonface
11276=grand_exchange:slot5_buy_coins
11277=grand_exchange:slot5_buy_area_arrow0
11278=grand_exchange:slot5_buy_area_arrow1
11279=grand_exchange:slot5_buy_area_arrow2
11280=grand_exchange:slot5_buy_area_arrow3
11281=grand_exchange:slot5_buy
11282=grand_exchange:slot5_sell_area
11283=grand_exchange:slot5_sell_area_buttonshadow
11284=grand_exchange:slot5_sell_area_buttonedge
11285=grand_exchange:slot5_sell_area_buttoninset
11286=grand_exchange:slot5_sell_area_buttonface
11287=grand_exchange:slot5_sell_coins
11288=grand_exchange:slot5_sell_area_arrow0
11289=grand_exchange:slot5_sell_area_arrow1
11290=grand_exchange:slot5_sell_area_arrow2
11291=grand_exchange:slot5_sell_area_arrow3
11292=grand_exchange:slot5_sell
11293=grand_exchange:detail
11294=grand_exchange:detail_panelshadow
11295=grand_exchange:detail_paneledge
11296=grand_exchange:detail_panelinset
11297=grand_exchange:detail_panelface
11298=grand_exchange:slot0_buy_label
11299=grand_exchange:slot0_sell_label
11300=grand_exchange:slot1_buy_label
11301=grand_exchange:slot1_sell_label
11302=grand_exchange:slot2_buy_label
11303=grand_exchange:slot2_sell_label
11304=grand_exchange:slot3_buy_label
11305=grand_exchange:slot3_sell_label
11306=grand_exchange:slot4_buy_label
11307=grand_exchange:slot4_sell_label
11308=grand_exchange:slot5_buy_label
11309=grand_exchange:slot5_sell_label
11310=grand_exchange:slot0_buy_area_count_cover
11311=grand_exchange:slot0_sell_area_count_cover
11312=grand_exchange:slot1_buy_area_count_cover
11313=grand_exchange:slot1_sell_area_count_cover
11314=grand_exchange:slot2_buy_area_count_cover
11315=grand_exchange:slot2_sell_area_count_cover
11316=grand_exchange:slot3_buy_area_count_cover
11317=grand_exchange:slot3_sell_area_count_cover
11318=grand_exchange:slot4_buy_area_count_cover
11319=grand_exchange:slot4_sell_area_count_cover
11320=grand_exchange:slot5_buy_area_count_cover
11321=grand_exchange:slot5_sell_area_count_cover
11322=grand_exchange:search
11323=grand_exchange:search_border
11324=grand_exchange:search_shadow
11325=grand_exchange:search_paper
11326=grand_exchange:search_prompt
11327=grand_exchange:query_border
11328=grand_exchange:query_paper
11329=grand_exchange:search_query
11330=grand_exchange:search_sort
11331=grand_exchange:result0
11332=grand_exchange:result0_icon
11333=grand_exchange:result0_name
11334=grand_exchange:result0_select
11335=grand_exchange:result1
11336=grand_exchange:result1_icon
11337=grand_exchange:result1_name
11338=grand_exchange:result1_select
11339=grand_exchange:result2
11340=grand_exchange:result2_icon
11341=grand_exchange:result2_name
11342=grand_exchange:result2_select
11343=grand_exchange:result3
11344=grand_exchange:result3_icon
11345=grand_exchange:result3_name
11346=grand_exchange:result3_select
11347=grand_exchange:result4
11348=grand_exchange:result4_icon
11349=grand_exchange:result4_name
11350=grand_exchange:result4_select
11351=grand_exchange:result5
11352=grand_exchange:result5_icon
11353=grand_exchange:result5_name
11354=grand_exchange:result5_select
11355=grand_exchange:result6
11356=grand_exchange:result6_icon
11357=grand_exchange:result6_name
11358=grand_exchange:result6_select
11359=grand_exchange:result7
11360=grand_exchange:result7_icon
11361=grand_exchange:result7_name
11362=grand_exchange:result7_select
11363=grand_exchange:result8
11364=grand_exchange:result8_icon
11365=grand_exchange:result8_name
11366=grand_exchange:result8_select
11367=grand_exchange:result9
11368=grand_exchange:result9_icon
11369=grand_exchange:result9_name
11370=grand_exchange:result9_select
11371=grand_exchange:result10
11372=grand_exchange:result10_icon
11373=grand_exchange:result10_name
11374=grand_exchange:result10_select
11375=grand_exchange:result11
11376=grand_exchange:result11_icon
11377=grand_exchange:result11_name
11378=grand_exchange:result11_select
11379=grand_exchange:search_clear
11380=grand_exchange:search_ack
11381=grand_exchange:offer_setup
11382=grand_exchange:offer_panel
11383=grand_exchange:offer_face
11384=grand_exchange:offer_icon_frame
11385=grand_exchange:offer_icon
11386=grand_exchange:offer_name
11387=grand_exchange:offer_description
11388=grand_exchange:offer_change
11389=grand_exchange:offer_divider
11390=grand_exchange:offer_quantity_label
11391=grand_exchange:offer_price_label
11392=grand_exchange:offer_quantity_minus_edge
11393=grand_exchange:offer_quantity_minus_face
11394=grand_exchange:offer_quantity_minus
11395=grand_exchange:offer_quantity_edge
11396=grand_exchange:offer_quantity_face
11397=grand_exchange:offer_quantity
11398=grand_exchange:offer_quantity_plus_edge
11399=grand_exchange:offer_quantity_plus_face
11400=grand_exchange:offer_quantity_plus
11401=grand_exchange:offer_price_minus_edge
11402=grand_exchange:offer_price_minus_face
11403=grand_exchange:offer_price_minus
11404=grand_exchange:offer_price_edge
11405=grand_exchange:offer_price_face
11406=grand_exchange:offer_price
11407=grand_exchange:offer_price_plus_edge
11408=grand_exchange:offer_price_plus_face
11409=grand_exchange:offer_price_plus
11410=grand_exchange:offer_qty_one_edge
11411=grand_exchange:offer_qty_one_face
11412=grand_exchange:offer_qty_one
11413=grand_exchange:offer_qty_ten_edge
11414=grand_exchange:offer_qty_ten_face
11415=grand_exchange:offer_qty_ten
11416=grand_exchange:offer_qty_hundred_edge
11417=grand_exchange:offer_qty_hundred_face
11418=grand_exchange:offer_qty_hundred
11419=grand_exchange:offer_qty_thousand_edge
11420=grand_exchange:offer_qty_thousand_face
11421=grand_exchange:offer_qty_thousand
11422=grand_exchange:offer_qty_all_edge
11423=grand_exchange:offer_qty_all_face
11424=grand_exchange:offer_qty_all
11425=grand_exchange:offer_price_lower_edge
11426=grand_exchange:offer_price_lower_face
11427=grand_exchange:offer_price_lower
11428=grand_exchange:offer_price_guide_edge
11429=grand_exchange:offer_price_guide_face
11430=grand_exchange:offer_price_guide
11431=grand_exchange:offer_price_higher_edge
11432=grand_exchange:offer_price_higher_face
11433=grand_exchange:offer_price_higher
11434=grand_exchange:offer_total_edge
11435=grand_exchange:offer_total_face
11436=grand_exchange:offer_total
11437=grand_exchange:offer_confirm_edge
11438=grand_exchange:offer_confirm_face
11439=grand_exchange:offer_confirm
11440=grand_exchange:offer_sell_quantity
11441=grand_exchange:offer_view
11442=grand_exchange:view_edge
11443=grand_exchange:view_face
11444=grand_exchange:view_icon_frame
11445=grand_exchange:view_icon
11446=grand_exchange:view_name
11447=grand_exchange:view_price
11448=grand_exchange:view_value
11449=grand_exchange:view_progress
11450=grand_exchange:view_bar_edge
11451=grand_exchange:view_bar
11452=grand_exchange:view_fill
11453=grand_exchange:view_mask
11454=grand_exchange:view_items_edge
11455=grand_exchange:view_items_face
11456=grand_exchange:view_items_icon
11457=grand_exchange:view_items_label
11458=grand_exchange:view_items
11459=grand_exchange:view_coins_edge
11460=grand_exchange:view_coins_face
11461=grand_exchange:view_coins_icon
11462=grand_exchange:view_coins_label
11463=grand_exchange:view_coins
11464=grand_exchange:view_waiting
11465=grand_exchange:view_claim_controls
11466=grand_exchange:view_collect_edge
11467=grand_exchange:view_collect_face
11468=grand_exchange:view_collect
11469=grand_exchange:view_notes_edge
11470=grand_exchange:view_notes_face
11471=grand_exchange:view_notes
11472=grand_exchange:view_cancel_controls
11473=grand_exchange:view_cancel_edge
11474=grand_exchange:view_cancel_face
11475=grand_exchange:view_cancel
11476=grand_exchange:agent_metadata
11477=grand_exchange:agent_state
+1
View File
@@ -4669,3 +4669,4 @@
4668=runeflash
4669=statue_herosguild
4670=statue_herosguild2
4671=grand_exchange_booth
+1
View File
@@ -1357,3 +1357,4 @@
1356=horror_dagganoth_melee
1357=boardgames_barmaid
1358=boardgames_barmaid2
1359=grand_exchange_teller
+3
View File
@@ -1061,3 +1061,6 @@
// info: Return true if the world has random events enabled
[command,map_random_events]()(boolean)
// rs-sdk: physical Grand Exchange booth interaction.
[command,ge_open](coord $counter)
@@ -0,0 +1,8 @@
[grand_exchange_booth]
name=Grand Exchange table
desc=Banking transactions and exchange offers are processed here.
model=model_loc_590
width=2
length=1
op1=Exchange
blockrange=no
@@ -0,0 +1,29 @@
[grand_exchange_teller]
vislevel=hide
name=Grand Exchange teller
desc=He handles offers at the Grand Exchange.
walkanim=human_walk_f,human_walk_b,human_walk_l,human_walk_r
readyanim=human_ready
op1=Exchange
op2=Talk-to
recol1s=2340
recol1d=6342
recol2s=14724
recol2d=6342
recol3s=15855
recol3d=24075
recol4s=6273
recol4d=10570
model1=model_215_idk
model2=model_246_idk
model3=model_292_idk
model4=model_162_idk
model5=model_176_idk
model6=model_254_idk
model7=model_181_idk
model8=model_320_idk
head1=model_53_idk_head
defaultmode=none
wanderrange=0
maxrange=2
moverestrict=nomove
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,8 @@
[oploc1,grand_exchange_booth]
ge_open(loc_coord);
[opnpc1,grand_exchange_teller]
ge_open(npc_coord);
[opnpc2,grand_exchange_teller]
ge_open(npc_coord);
File diff suppressed because it is too large. Load diff
+14
View File
@@ -143,6 +143,7 @@ export default class GameMap {
if (!this.members && !this.isFreeToPlay(absoluteX, absoluteZ)) {
continue;
}
if (!Environment.GE_ENABLED && id === NpcType.getId('grand_exchange_teller')) continue;
const npcType: NpcType = NpcType.get(id);
if (!npcType) {
printFatalError(`Invalid npc type ${id} in map m${mapsquareX >> 6}_${mapsquareZ >> 6}.jm2`);
@@ -277,6 +278,19 @@ export default class GameMap {
const shape: number = info >> 2;
const angle: number = info & 0x3;
if (!Environment.GE_ENABLED && locId === LocType.getId('grand_exchange_booth')) {
// Restore the original bank table, including its collision, when GE
// is disabled. Changed static locs also update the client's packed map.
const zone = this.getZone(absoluteX, absoluteZ, actualLevel);
const loc = new Loc(actualLevel, absoluteX, absoluteZ, width, length, EntityLifeCycle.RESPAWN, locId, shape, angle);
zone.addStaticLoc(loc);
loc.change(LocType.getId('banktable'), shape, angle);
if (type.blockwalk) {
changeLocCollision(shape, angle, type.blockrange, length, width, type.active, absoluteX, absoluteZ, actualLevel, true);
}
continue;
}
if (type.blockwalk) {
changeLocCollision(shape, angle, type.blockrange, length, width, type.active, absoluteX, absoluteZ, actualLevel, true);
}
+2
View File
@@ -1,3 +1,4 @@
import { tickExchange } from '#/engine/market/GrandExchange.js';
// stdlib
import fs from 'fs';
import { Worker } from 'worker_threads';
@@ -794,6 +795,7 @@ class World {
// - interactions
// - movement
player.processInteraction();
if (Environment.GE_ENABLED) tickExchange(player);
// - run energy
player.updateEnergy();
+4 -1
View File
@@ -1,3 +1,4 @@
import { playerSaveStore } from '#/engine/market/MarketStore.js';
import { PlayerInfoProt, Visibility } from '#/network/rsbuf/index.js';
import { CollisionFlag, CollisionType } from '#/engine/routefinder/index.js';
@@ -271,7 +272,9 @@ export default class Player extends PathingEntity {
sav.p8(this.lastLoginTime);
sav.p4(Packet.getcrc(sav.data, 0, sav.pos));
return sav.data.subarray(0, sav.pos);
const bytes = sav.data.subarray(0, sav.pos);
playerSaveStore()?.checkpoint(this.username, bytes);
return bytes;
}
username: string;
@@ -1,3 +1,4 @@
import { playerSaveStore } from '#/engine/market/MarketStore.js';
import InvType from '#/cache/config/InvType.js';
import { NetworkPlayer } from '#/engine/entity/NetworkPlayer.js';
import Player, { getExpByLevel, getLevelByExp } from '#/engine/entity/Player.js';
@@ -30,6 +31,9 @@ export class PlayerLoading {
const hash64 = toBase37(name); // username or email.
const name37 = toBase37(name); // always username.
const safeName = fromBase37(name37); // always safe username.
// Exchange participants use the atomic ledger checkpoint, including after a crash.
const checkpoint = playerSaveStore()?.saved(safeName);
if (checkpoint) sav = new Packet(checkpoint);
const player = client ? new NetworkPlayer(safeName, name37, hash64, client) : new Player(safeName, name37, hash64);
@@ -0,0 +1,576 @@
import Environment from '#/util/Environment.js';
import type { GEState } from '../../../../../sdk/ge-types.js';
import { MARKET_SORTS, type MarketSort } from './ItemSearch.js';
import { Inventory } from '#/engine/Inventory.js';
import IfSetHide from '#/network/game/server/model/IfSetHide.js';
import IfSetColour from '#/network/game/server/model/IfSetColour.js';
import IfSetPosition from '#/network/game/server/model/IfSetPosition.js';
import UpdateInvFull from '#/network/game/server/model/UpdateInvFull.js';
import World from '#/engine/World.js';
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 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';
import { marketStore, MAX_GP, positive, type MarketAccount, type Side } from './MarketStore.js';
import { canonicalItem, catalog, marketItem, noteFor } from './MarketCatalog.js';
let nextSession = 0;
let nextReceipt = 0;
type Session = {
serial: number;
revision: number;
actionRevision: number;
error: string | null;
receipt: GEState['receipt'];
screen: 'home' | 'catalog' | 'draft' | 'offer';
side: Side;
slot?: number;
query: string;
sort: MarketSort;
matches: number;
page: number;
offerSnapshot?: string;
rows: number[];
item: number;
quantity: number;
price: number;
offer: number;
input?: 'quantity' | 'price' | 'item';
x: number;
z: number;
};
const sessions = new WeakMap<Player, Session>();
const com = (name: string) => Component.getId(`grand_exchange:${name}`);
const root = () => Component.getId('grand_exchange');
const coins = () => ObjType.getId('coins');
const backpack = (p: Player) => p.getInventory(InvType.getId('inv'))!;
/** 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;
export function atExchange(p: Pick<Player, 'x' | 'z' | 'level'>, x: number, z: number): boolean {
const target = [EXCHANGE_TABLE, EXCHANGE_TELLER].find(t => t.x === x && t.z === z);
if (!target || p.level !== 0 || p.x < 3180 || p.x > 3185 || p.z < 3436 || p.z > 3446) return false;
const dx = Math.max(target.x - p.x, p.x - (target.x + target.width - 1), 0);
const dz = Math.max(target.z - p.z, p.z - (target.z + target.length - 1), 0);
return dx + dz <= 1;
}
export function openExchange(p: Player, x: number, z: number) {
if (!Environment.GE_ENABLED) {
p.messageGame('Grand Exchange is not available on this server.');
return;
}
if (!atExchange(p, x, z)) {
p.messageGame('Visit the Grand Exchange table or teller on the west side of Varrock west bank.');
return;
}
p.closeModal();
sessions.set(p, {
serial: ++nextSession,
revision: 0,
actionRevision: 0,
error: null,
receipt: null,
screen: 'home',
side: 'buy',
query: '',
sort: 'relevance',
matches: 0,
page: 0,
rows: [],
item: 0,
quantity: 1,
price: 1,
offer: 0,
x,
z
});
text(p, 'agent_state', '');
p.openMainModal(root());
render(p);
}
function active(p: Player): Session | undefined {
if (!Environment.GE_ENABLED) return;
const s = sessions.get(p);
if (!s || p.modalMain !== root()) return;
if (!atExchange(p, s.x, s.z) || p.delayed || p.loggingOut) {
p.closeModal();
sessions.delete(p);
return;
}
return s;
}
function account(p: Player): MarketAccount {
const inv = backpack(p);
const before = inv.items.map(i => (i ? { ...i } : null));
return {
owner: p.username,
take(id, count) {
const ids = id === coins() ? [id] : [id, noteFor(id)].filter((v, i, a) => a.indexOf(v) === i);
if (ids.reduce((n, id) => n + inv.getItemCount(id), 0) < count) throw new Error('Bring the required items or coins in your backpack.');
let remaining = count;
for (const id of ids) {
if (remaining) remaining -= inv.remove(id, remaining);
}
if (remaining) throw new Error('Inventory changed. Please try again.');
},
give: (id, count) => inv.add(id, count),
save: () => {
// One durable cut across carried player inventories prevents an item handed
// from A to B between autosaves reappearing in A after B deposits it here.
// This runs inside the exchange transaction, without yielding the world.
for (const other of World.playerLoop.all()) {
if (other !== p) marketStore().checkpoint(other.username, other.save(), true);
}
return p.save();
},
rollback: () => {
before.forEach((item, i) => inv.set(i, item));
}
};
}
function text(p: Player, name: string, value: string) {
const id = com(name);
// Optional SDK metadata may be absent while an older packed cache is loaded.
// Encoding -1 as an unsigned component ID would crash existing clients.
if (name === 'agent_state' && id < 0) return;
p.write(new IfSetText(id, value));
}
function hide(p: Player, name: string, hidden: boolean) {
p.write(new IfSetHide(com(name), hidden));
}
function icon(p: Player, name: string, item: number, count: number) {
// A display-only container. It is never attached to player/world inventory.
const display = new Inventory(-1, 1);
display.set(0, { id: item, count });
p.write(new UpdateInvFull(com(name), display));
}
function wrapItemName(name: string): string {
const words = name.split(' ');
const lines = [''];
for (const word of words) {
const last = lines.length - 1;
if (lines[last] && (lines[last] + ' ' + word).length > 17) lines.push(word);
else lines[last] += (lines[last] ? ' ' : '') + word;
}
return lines
.slice(0, 2)
.map(line => line.slice(0, 19))
.join('\\n');
}
function renderGrid(p: Player) {
const offers = marketStore().offers(p.username);
const s = sessions.get(p)!;
s.rows = [];
for (let i = 0; i < 6; i++) {
const o = offers.find(offer => offer.slot === i);
const name = `slot${i}`;
s.rows[i] = o?.id ?? 0;
hide(p, name + '_occupied', !o);
hide(p, name + '_empty', !!o);
text(p, name + '_side', o ? (o.side === 'buy' ? 'Buy' : 'Sell') : 'Empty');
if (!o) {
icon(p, name + '_buy_coins', ObjType.getId('coins_1000'), 1);
icon(p, name + '_sell_coins', ObjType.getId('coins_1000'), 1);
continue;
}
icon(p, name + '_icon', o.item, o.quantity);
text(p, name + '_name', wrapItemName(ObjType.get(o.item).name ?? 'Item'));
text(p, name + '_price', `${o.price.toLocaleString('en-US')} coins`);
// IF_SETCOLOUR uses RGB555, not a 24-bit RGB value.
const rgb = o.state === 'cancelled' ? 0xb33324 : o.state === 'completed' ? 0x299526 : 0xcc962c;
const colour = (((rgb >> 19) & 31) << 10) | (((rgb >> 11) & 31) << 5) | ((rgb >> 3) & 31);
p.write(new IfSetColour(com(name + '_fill'), colour));
p.write(new IfSetPosition(com(name + '_mask'), Math.floor((140 * o.filled) / o.quantity), 0));
}
}
/** One complete snapshot is sent after all visual updates, avoiding partially decoded UI state. */
function publishExchange(p: Player, notice = '') {
const s = sessions.get(p)!;
const offers = marketStore()
.offers(p.username)
.map(({ owner, created, ...o }) => ({ ...o, name: ObjType.get(o.item).name ?? 'Item' }));
const controls: Record<string, number> = { home: com('home'), close: com('close'), collectAll: com('collect') };
if (s.screen === 'home') {
for (let slot = 0; slot < 6; slot++) {
if (offers.some(o => o.slot === slot)) controls[`view${slot}`] = com(`slot${slot}_view`);
else {
controls[`buy${slot}`] = com(`slot${slot}_buy`);
controls[`sell${slot}`] = com(`slot${slot}_sell`);
}
}
}
if (s.screen === 'catalog') {
controls.find = com('find');
controls.sort = com('search_sort');
controls.clear = com('search_clear');
if (s.page > 0) controls.previous = com('previous');
if ((s.page + 1) * 12 < s.matches) controls.next = com('next');
}
if (s.screen === 'draft') {
controls.quantity = com('offer_quantity');
controls.price = com('offer_price');
controls.confirm = com('offer_confirm');
controls.change = com('offer_change');
}
if (s.screen === 'offer') {
controls.collect = com('row3');
controls.collectNotes = com('row4');
if (offers.find(o => o.id === s.offer)?.state === 'open') controls.cancel = com('row5');
}
const state: GEState = {
isOpen: true,
session: s.serial,
revision: ++s.revision,
actionRevision: s.actionRevision,
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,
search:
s.screen === 'catalog'
? {
query: s.query,
side: s.side,
page: s.page,
total: s.matches,
results: s.rows.map((item, i) => ({ item, name: ObjType.get(item).name ?? 'Item', componentId: com(`result${i}_select`) }))
}
: null,
input: s.input ?? null,
controls,
notice,
error: s.error,
receipt: s.receipt
};
text(p, 'agent_state', JSON.stringify(state));
}
function render(p: Player, notice = '') {
const s = sessions.get(p)!;
hide(p, 'grid', s.screen !== 'home');
hide(p, 'detail', true);
hide(p, 'offer_view', s.screen !== 'offer');
hide(p, 'offer_setup', s.screen !== 'draft');
// The 274 client reads only five packets per frame. Reveal a newly opened
// 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.');
for (let i = 0; i < 8; i++) text(p, `row${i}`, '');
text(p, 'previous', '');
text(p, 'next', '');
text(p, 'letters', '');
for (let i = 0; i < 26; i++) text(p, `letter${i}`, '');
if (s.screen === 'home') {
text(p, 'heading', 'Grand Exchange');
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, '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 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));
const page = items.slice(s.page * 12, s.page * 12 + 12);
s.rows = page.map(o => o.id);
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);
text(p, `result${i}_name`, wrapItemName(page[i].name));
}
if (!items.length) text(p, 'subtitle', 'No matching items. Try another search.');
text(p, 'search_ack', s.query);
hide(p, 'search', false);
text(p, 'previous', s.page > 0 ? 'Previous page' : '');
text(p, 'next', items.length > (s.page + 1) * 12 ? 'Next page' : '');
} else if (s.screen === 'draft') {
const item = marketItem(s.item);
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, 'offer_name', item.name!);
const description = item.desc ?? 'Choose a quantity and a price for your offer.';
const lines: string[] = [''];
for (const word of description.split(/\s+/)) {
if ((lines[lines.length - 1] + ' ' + word).length > 62) lines.push('');
lines[lines.length - 1] += (lines[lines.length - 1] ? ' ' : '') + word;
}
text(p, 'offer_description', lines.slice(0, 2).join('\\n'));
p.write(new IfSetObject(com('offer_icon'), s.item, 180));
text(p, 'offer_quantity', s.quantity.toLocaleString('en-US'));
text(p, 'offer_price', s.price.toLocaleString('en-US'));
text(p, 'offer_total', `${total.toLocaleString('en-US')} coins`);
} else {
const o = marketStore()
.offers(p.username)
.find(o => o.id === s.offer);
if (!o) {
s.screen = 'home';
render(p, notice);
return;
}
const amount = (n: number) => n.toLocaleString('en-US');
const state = o.state === 'open' ? 'In progress' : o.state === 'completed' ? 'Offer completed' : 'Offer cancelled';
text(p, 'heading', 'Grand Exchange: Offer details');
text(p, 'subtitle', `${o.side === 'buy' ? 'Buy' : 'Sell'} offer`);
text(p, 'view_name', ObjType.get(o.item).name ?? 'Item');
p.write(new IfSetObject(com('view_icon'), o.item, 140));
text(p, 'view_price', `Price per item: ${amount(o.price)} coins`);
text(p, 'view_value', `${amount(o.gross)} coins traded`);
text(p, 'view_progress', `${state} - ${amount(o.filled)} of ${amount(o.quantity)} traded`);
const rgb = o.state === 'cancelled' ? 0xb33324 : o.state === 'completed' ? 0x299526 : 0xcc962c;
p.write(new IfSetColour(com('view_fill'), (((rgb >> 19) & 31) << 10) | (((rgb >> 11) & 31) << 5) | ((rgb >> 3) & 31)));
p.write(new IfSetPosition(com('view_mask'), Math.floor((460 * o.filled) / o.quantity), 0));
p.write(new IfSetObject(com('view_items_icon'), o.item, 100));
p.write(new IfSetObject(com('view_coins_icon'), ObjType.getId('coins_1000'), 100));
text(p, 'view_items', amount(o.items));
text(p, 'view_coins', amount(o.coins));
const ready = o.items > 0 || o.coins > 0;
hide(p, 'view_claim_controls', !ready);
hide(p, 'view_cancel_controls', o.state !== 'open');
text(p, 'view_waiting', ready ? '' : 'Nothing ready to collect yet.');
}
if (s.screen === 'home' || s.screen === 'offer') s.offerSnapshot = JSON.stringify(marketStore().offers(p.username));
if (notice) p.messageGame(notice);
publishExchange(p, notice);
}
/** Push changed offers from the game tick; never disturb an input prompt or draft. */
export function tickExchange(p: Player): void {
const s = sessions.get(p);
if (!s || !active(p) || (s.screen !== 'home' && s.screen !== 'offer')) return;
if (s.offerSnapshot !== JSON.stringify(marketStore().offers(p.username))) render(p);
}
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()));
s.input = undefined;
s.error = null;
s.receipt = null;
try {
if (s.screen === 'draft') {
const aliases: Record<string, string> = { offer_quantity: 'row0', offer_price: 'row1', offer_confirm: 'row5', offer_change: 'row6' };
for (const [button, row] of Object.entries(aliases)) if (id === com(button)) id = com(row);
const quantitySteps: Record<string, number> = {
offer_quantity_minus: -1,
offer_quantity_plus: 1,
offer_qty_one: 1,
offer_qty_ten: 10,
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));
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);
}
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);
if (id === com('offer_price_lower')) id = com('row2');
if (id === com('offer_price_higher')) id = com('row3');
if (id === com('offer_price_guide')) s.price = marketStore().quote(s.item).lastPrice ?? Math.max(1, ObjType.get(s.item).cost);
}
if (s.screen === 'offer') {
const aliases: Record<string, string> = { view_collect: 'row3', view_notes: 'row4', view_cancel: 'row5' };
for (const [button, row] of Object.entries(aliases)) if (id === com(button)) id = com(row);
}
if (s.screen === 'home') {
for (let i = 0; i < 6; i++) {
const offer = marketStore()
.offers(p.username)
.find(o => o.slot === i);
if (id === com(`slot${i}_view`) && offer) {
s.offer = offer.id;
s.screen = 'offer';
render(p);
return true;
}
if ((id === com(`slot${i}_buy`) || id === com(`slot${i}_sell`)) && !offer) {
s.side = id === com(`slot${i}_buy`) ? 'buy' : 'sell';
s.slot = i;
s.screen = 'catalog';
s.page = 0;
render(p);
return true;
}
}
}
if (id === com('collect')) {
let items = 0,
gold = 0;
// Each offer is a separate atomic collection. Preserve already committed
// additions in the receipt if a later offer fails (e.g. a save error).
s.receipt = { token: ++nextReceipt, kind: 'collect', items: 0, coins: 0 };
for (const offer of marketStore().offers(p.username)) {
const result = marketStore().collect(account(p), offer.id, coins(), noteFor(offer.item));
items += result.items;
gold += result.coins;
s.receipt.items = items;
s.receipt.coins = gold;
}
s.screen = 'home';
render(p, `Collected ${items} items and ${gold} coins. Any overflow stays here.`);
return true;
}
if (id === com('close')) {
p.closeModal();
sessions.delete(p);
return true;
}
if (id === com('home') || id === com('refresh')) {
s.screen = 'home';
render(p);
return true;
}
if (id === com('buy') || id === com('sell')) {
s.side = id === com('buy') ? 'buy' : 'sell';
s.slot = undefined;
s.screen = 'catalog';
s.page = 0;
render(p);
return true;
}
if (id === com('find')) {
s.input = 'item';
p.write(new PCountDialog());
p.messageGame('Enter an item ID from the market website.');
publishExchange(p);
return true;
}
if (s.screen === 'catalog') {
if (id === com('search_sort')) {
s.sort = MARKET_SORTS[(MARKET_SORTS.indexOf(s.sort) + 1) % MARKET_SORTS.length];
s.page = 0;
}
if (id === com('search_clear')) {
s.query = '';
s.page = 0;
}
if (id === com('previous')) s.page = Math.max(0, s.page - 1);
if (id === com('next') && (s.page + 1) * 12 < s.matches) s.page++;
// Search results are selected by displayed item ID through MARKET_SEARCH,
// never by a row index that could change while a keystroke is in flight.
}
const row = Array.from({ length: 8 }, (_, i) => com(`row${i}`)).indexOf(id);
if (row >= 0) {
if (s.screen === 'home' && s.rows[row]) {
s.offer = s.rows[row];
s.screen = 'offer';
} else if (s.screen === 'draft') {
if (row === 0 || row === 1) {
s.input = row === 0 ? 'quantity' : 'price';
p.write(new PCountDialog());
p.messageGame(`Enter ${s.input} in the chat area below.`);
publishExchange(p);
return true;
}
if (row === 2) s.price = Math.max(1, s.price - Math.max(1, Math.floor(s.price * 0.05)));
if (row === 3) s.price = Math.min(MAX_GP, s.price + Math.max(1, Math.floor(s.price * 0.05)));
if (row === 5) {
marketItem(s.item);
const o = marketStore().place(account(p), s.item, s.side, s.quantity, s.price, coins(), s.slot);
s.receipt = { token: ++nextReceipt, kind: 'place', offerId: o.id };
s.offer = o.id;
s.screen = 'offer';
}
if (row === 6) s.screen = 'catalog';
} else if (s.screen === 'offer') {
if (row === 3 || row === 4) {
const offer = marketStore()
.offers(p.username)
.find(o => o.id === s.offer)!;
const result = marketStore().collect(account(p), s.offer, coins(), row === 4 ? noteFor(offer.item) : offer.item);
s.receipt = { token: ++nextReceipt, kind: 'collect', offerId: s.offer, items: result.items, coins: result.coins };
render(p, `Collected ${result.items} items and ${result.coins} gp. Any overflow stays here.`);
return true;
}
if (row === 5) {
marketStore().cancel(p.username, s.offer);
s.receipt = { token: ++nextReceipt, kind: 'cancel', offerId: s.offer };
}
if (row === 6) s.screen = 'home';
}
}
render(p);
} catch (error) {
s.error = error instanceof Error ? error.message : 'Exchange unavailable.';
render(p, s.error);
}
return true;
}
function draft(p: Player, id: number) {
const s = sessions.get(p)!;
marketItem(id);
s.item = id;
s.quantity = 1;
s.price = marketStore().quote(id).lastPrice ?? Math.max(1, ObjType.get(id).cost);
s.screen = 'draft';
}
export function exchangeCount(p: Player, value: number): boolean {
const s = active(p);
if (!s?.input) return false;
s.actionRevision++;
const input = s.input;
s.error = null;
s.receipt = null;
s.input = undefined;
try {
positive(value, input);
if (input === 'item') draft(p, value);
else s[input] = value;
render(p);
} catch (error) {
s.error = error instanceof Error ? error.message : 'Invalid number.';
render(p, s.error);
}
return true;
}
/** Bound to the logged-in player's physical exchange session, not a public mutation API. */
export function exchangeSearch(p: Player, query: string, selectedItem = 0): boolean {
const s = active(p);
if (!s || s.screen !== 'catalog' || s.input || query.length > 48 || !/^[a-zA-Z0-9 '()*-]*$/.test(query)) return false;
s.actionRevision++;
s.error = null;
s.receipt = null;
if (selectedItem) {
// Reject a stale result rather than silently selecting a different row/item.
if (query !== s.query || !s.rows.includes(selectedItem)) return true;
try {
draft(p, selectedItem);
render(p);
} catch (error) {
s.error = error instanceof Error ? error.message : 'Item unavailable.';
render(p, s.error);
}
} else {
s.query = query;
s.page = 0;
render(p);
}
return true;
}
@@ -0,0 +1,123 @@
export type MarketSort = 'relevance' | 'name' | 'name-desc';
export const MARKET_SORTS: readonly MarketSort[] = ['relevance', 'name', 'name-desc'];
// Query vocabulary only: aliases do not create alternate items or change IDs.
const PHRASES: Record<string, string> = {
rscim: 'rune scimitar',
dscim: 'dragon scimitar',
dlong: 'dragon longsword',
r2h: 'rune 2h sword',
dds: 'dragon dagger poison',
ddp: 'dragon dagger poison',
dhide: 'dragonhide',
'd hide': 'dragonhide',
'dragon hide': 'dragonhide',
'two handed': '2h',
'two hand': '2h',
'full helmet': 'full helm'
};
const SYNONYMS: Record<string, string[]> = {
addy: ['adamant'],
adamantite: ['adamant'],
mith: ['mithril'],
scim: ['scimitar'],
pick: ['pickaxe'],
picks: ['pickaxe'],
hatchet: ['axe'],
hatchets: ['axe'],
axes: ['axe'],
helm: ['helmet'],
helmet: ['helm'],
helms: ['helm'],
pl8: ['platebody'],
body: ['platebody', 'chainbody'],
legs: ['platelegs'],
armor: ['armour', 'platebody', 'chainbody', 'platelegs', 'plateskirt'],
armour: ['armor', 'platebody', 'chainbody', 'platelegs', 'plateskirt'],
dhide: ['dragonhide'],
pot: ['potion'],
pots: ['potion'],
arrows: ['arrow'],
runes: ['rune'],
logs: ['log'],
log: ['logs'],
lobs: ['lobster'],
lobbies: ['lobster'],
poison: ['p', 'poisoned']
};
export function normalizeSearch(value: string): string {
return value
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
/** Bounded Damerau-Levenshtein distance, including adjacent-key transpositions. */
function distance(a: string, b: string, limit: number): number {
if (Math.abs(a.length - b.length) > limit) return limit + 1;
let prior = Array.from({ length: b.length + 1 }, (_, i) => i),
before = prior;
for (let i = 1; i <= a.length; i++) {
const row = [i];
for (let j = 1; j <= b.length; j++) {
row[j] = Math.min(row[j - 1] + 1, prior[j] + 1, prior[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) row[j] = Math.min(row[j], before[j - 2] + 1);
}
if (Math.min(...row) > limit) return limit + 1;
before = prior;
prior = row;
}
return prior[b.length];
}
function wordScore(term: string, word: string): number {
if (term === word) return 0;
if (word.startsWith(term)) return 2;
if (term.length >= 3 && word.includes(term)) return 5;
if (term.length < 4) return Infinity;
const limit = term.length >= 7 ? 2 : 1;
const edits = distance(term, word, limit);
return edits <= limit ? 10 + edits * 4 : Infinity;
}
/** Build once per item-cache reload. Search is deterministic and never changes eligibility. */
export function createItemSearch<T extends { id: number; name: string }>(items: readonly T[]) {
const records = items.map(item => {
const name = normalizeSearch(item.name);
return { item, name, compact: name.replace(/ /g, ''), words: name.split(' ') };
});
return (input = '', sort: MarketSort = 'relevance'): T[] => {
const original = normalizeSearch(input.slice(0, 80));
let query = original;
for (const [alias, expansion] of Object.entries(PHRASES)) {
query = query.replace(new RegExp(`(^| )${alias}(?= |$)`, 'g'), (_match, prefix) => prefix + expansion);
}
const terms = query
.split(' ')
.filter(Boolean)
.map(term => [term, ...(SYNONYMS[term] ?? [])]);
const ranked = records.flatMap(record => {
if (!query) return [{ ...record, score: 0 }];
if (record.name === original) return [{ ...record, score: 0 }];
if (record.name === query || record.compact === original.replace(/ /g, '')) return [{ ...record, score: 1 }];
let score = 0;
for (const alternatives of terms) {
let best = Infinity;
for (let i = 0; i < alternatives.length; i++) for (const word of record.words) best = Math.min(best, wordScore(alternatives[i], word) + (i ? 1 : 0));
if (!Number.isFinite(best)) return [];
score += best;
}
score += 20;
if (record.name.startsWith(query)) score = 4;
else if (record.name.includes(query)) score = 8;
return [{ ...record, score }];
});
return ranked
.sort((a, b) => {
if (sort === 'relevance' && a.score !== b.score) return a.score - b.score;
const name = a.item.name.localeCompare(b.item.name, 'en');
return (sort === 'name-desc' ? -name : name) || a.item.id - b.item.id;
})
.map(r => r.item);
};
}
@@ -0,0 +1,37 @@
import { createItemSearch, type MarketSort } from './ItemSearch.js';
import ObjType from '#/cache/config/ObjType.js';
function eligible(item: ObjType | undefined): item is ObjType {
return !!item?.name && item.tradeable && item.dummyitem === 0 && item.certtemplate === -1 && item.id !== ObjType.getId('coins');
}
export function marketItem(id: number): ObjType {
const item = ObjType.get(id);
if (!eligible(item)) throw new Error('This item cannot be traded on the Grand Exchange.');
return item;
}
export function canonicalItem(id: number): number {
const item = ObjType.get(id);
return marketItem(item?.certtemplate !== -1 ? (item?.certlink ?? -1) : id).id;
}
let source: ObjType[] | undefined;
let items: { id: number; name: string; basePrice: number }[] = [];
let notes = new Map<number, number>();
let search = createItemSearch(items);
function refresh() {
if (source === ObjType.configs) return;
source = ObjType.configs;
notes = new Map();
for (const item of source) if (item.certtemplate !== -1) notes.set(item.certlink, item.id);
items = source
.filter(eligible)
.map(o => ({ id: o.id, name: o.name!, basePrice: o.cost }))
.sort((a, b) => a.name.localeCompare(b.name) || a.id - b.id);
search = createItemSearch(items);
}
export function noteFor(id: number): number {
refresh();
return notes.get(id) ?? id;
}
export function catalog(query = '', sort: MarketSort = 'relevance') {
refresh();
return search(query, sort);
}
@@ -0,0 +1,278 @@
import { Database } from 'bun:sqlite';
import { existsSync, mkdirSync } from 'node:fs';
import Environment from '#/util/Environment.js';
import { dirname } from 'node:path';
export const MAX_GP = 2_147_483_647;
export const OFFER_SLOTS = 6;
export type Side = 'buy' | 'sell';
export type MarketOverview = {
item: number;
bid: number | null;
ask: number | null;
buyQuantity: number;
buyValue: number;
sellQuantity: number;
volume: number;
gross: number;
tax: number;
trades: number;
lastPrice: number | null;
lastTradeAt: number | null;
referencePrice: number | null;
vwap: number | null;
change: number | null;
changePercent: number | null;
marketCap: number | null;
};
export type Offer = {
id: number;
owner: string;
slot: number;
item: number;
side: Side;
quantity: number;
price: number;
remaining: number;
filled: number;
state: 'open' | 'completed' | 'cancelled';
items: number;
coins: number;
gross: number;
tax: number;
created: number;
};
export interface MarketAccount {
owner: string;
/** Inventory only. Must remove all requested units or throw. */
take(item: number, quantity: number): void;
/** Return the number actually added (space/stack limits may prevent all). */
give(item: number, quantity: number): number;
save(): Uint8Array;
/** Restore in-memory inventory if SQLite or serialization fails. */
rollback(): void;
}
export function positive(value: number, label: string): number {
if (!Number.isInteger(value) || value < 1 || value > MAX_GP) throw new Error(`${label} must be a whole number from 1 to ${MAX_GP}.`);
return value;
}
/** Single-world synchronous ledger: no network or await inside a transaction. */
export class MarketStore {
readonly db: Database;
constructor(path: string) {
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
this.db = new Database(path, { create: true, strict: true });
this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000;
CREATE TABLE IF NOT EXISTS accounts(owner TEXT PRIMARY KEY, save BLOB NOT NULL);
CREATE TABLE IF NOT EXISTS offers(
id INTEGER PRIMARY KEY AUTOINCREMENT, owner TEXT NOT NULL, slot INTEGER NOT NULL,
item INTEGER NOT NULL, side TEXT NOT NULL CHECK(side IN ('buy','sell')),
quantity INTEGER NOT NULL, price INTEGER NOT NULL, remaining INTEGER NOT NULL,
filled INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'open', items INTEGER NOT NULL DEFAULT 0,
coins INTEGER NOT NULL DEFAULT 0, gross INTEGER NOT NULL DEFAULT 0,
tax INTEGER NOT NULL DEFAULT 0, created INTEGER NOT NULL,
CHECK(remaining>=0 AND items>=0 AND coins>=0));
CREATE UNIQUE INDEX IF NOT EXISTS occupied_slot ON offers(owner,slot) WHERE slot>=0;
CREATE INDEX IF NOT EXISTS book ON offers(item,side,state,price,id);
CREATE TABLE IF NOT EXISTS trades(
id INTEGER PRIMARY KEY AUTOINCREMENT, item INTEGER NOT NULL, buy_offer INTEGER NOT NULL,
sell_offer INTEGER NOT NULL, quantity INTEGER NOT NULL, price INTEGER NOT NULL,
gross INTEGER NOT NULL, tax INTEGER NOT NULL, time INTEGER NOT NULL);
CREATE INDEX IF NOT EXISTS trade_history ON trades(item,time,id);`);
}
offers(owner: string): Offer[] {
return this.db.query<Offer, [string]>('SELECT * FROM offers WHERE owner=? AND slot>=0 ORDER BY slot').all(owner);
}
private owned(owner: string, id: number): Offer {
const row = this.db.query<Offer, [string, number]>('SELECT * FROM offers WHERE owner=? AND id=? AND slot>=0').get(owner, id);
if (!row) throw new Error('Offer not found.');
return row;
}
private update(o: Offer) {
this.db.query('UPDATE offers SET remaining=?,state=?,items=?,coins=?,gross=?,tax=?,slot=?,filled=? WHERE id=?').run(o.remaining, o.state, o.items, o.coins, o.gross, o.tax, o.slot, o.filled, o.id);
}
checkpoint(owner: string, save: Uint8Array, enroll = false) {
if (enroll) this.db.query('INSERT INTO accounts VALUES(?,?) ON CONFLICT(owner) DO UPDATE SET save=excluded.save').run(owner, save);
else this.db.query('UPDATE accounts SET save=? WHERE owner=?').run(save, owner);
}
saved(owner: string): Uint8Array | undefined {
return this.db.query<{ save: Uint8Array }, [string]>('SELECT save FROM accounts WHERE owner=?').get(owner)?.save;
}
private transfer<T>(account: MarketAccount, action: () => T): T {
try {
let value!: T;
this.db
.transaction(() => {
const result = action();
this.checkpoint(account.owner, account.save(), true);
value = result;
})
.immediate();
return value;
} catch (error) {
account.rollback();
throw error;
}
}
place(account: MarketAccount, item: number, side: Side, quantity: number, price: number, coinsId: number, preferredSlot?: number): Offer {
positive(item, 'Item');
positive(quantity, 'Quantity');
positive(price, 'Price');
if (side !== 'buy' && side !== 'sell') throw new Error('Invalid offer side.');
// Also cap sells so price improvement never overflows a player's collection stack.
if (quantity * price > MAX_GP) throw new Error('Offer total exceeds the coin stack limit.');
return this.transfer(account, () => {
const slots = new Set(this.offers(account.owner).map(o => o.slot));
if (preferredSlot !== undefined && (!Number.isInteger(preferredSlot) || preferredSlot < 0 || preferredSlot >= OFFER_SLOTS || slots.has(preferredSlot))) {
throw new Error('That offer slot is no longer empty.');
}
const slot = preferredSlot ?? Array.from({ length: OFFER_SLOTS }, (_, i) => i).find(i => !slots.has(i));
if (slot === undefined) throw new Error('All six slots are occupied. Collect a finished offer first.');
account.take(side === 'buy' ? coinsId : item, side === 'buy' ? quantity * price : quantity);
const inserted = this.db.query('INSERT INTO offers(owner,slot,item,side,quantity,price,remaining,created) VALUES(?,?,?,?,?,?,?,?)').run(account.owner, slot, item, side, quantity, price, quantity, Date.now());
const incoming = this.owned(account.owner, Number(inserted.lastInsertRowid));
const matches = this.db
.query<Offer, [number, string, string, number]>(
`SELECT * FROM offers
WHERE item=? AND side!=? AND owner!=? AND state='open' AND price ${side === 'buy' ? '<=' : '>='} ?
ORDER BY price ${side === 'buy' ? 'ASC' : 'DESC'},id ASC`
)
.all(item, side, account.owner, price);
for (const resting of matches) {
if (!incoming.remaining) break;
const buy = side === 'buy' ? incoming : resting;
const sell = side === 'sell' ? incoming : resting;
const executionPrice = resting.price;
// Bound gross proceeds across the entire sell offer, even after collections.
const count = Math.min(buy.remaining, sell.remaining, Math.floor((MAX_GP - sell.gross) / executionPrice));
if (!count) continue;
const gross = count * executionPrice;
// Cumulative rounding per sell offer prevents partial-fill fragmentation avoiding tax.
const tax = Math.floor((sell.gross + gross) / 20) - sell.tax;
buy.remaining -= count;
sell.remaining -= count;
buy.filled += count;
sell.filled += count;
buy.items += count;
buy.coins += count * (buy.price - executionPrice);
sell.coins += gross - tax;
sell.gross += gross;
sell.tax += tax;
buy.gross += gross;
if (!buy.remaining) buy.state = 'completed';
if (!sell.remaining) sell.state = 'completed';
this.update(resting);
this.db.query('INSERT INTO trades(item,buy_offer,sell_offer,quantity,price,gross,tax,time) VALUES(?,?,?,?,?,?,?,?)').run(item, buy.id, sell.id, count, executionPrice, gross, tax, Date.now());
}
this.update(incoming);
return incoming;
});
}
cancel(owner: string, id: number): void {
this.db
.transaction(() => {
const o = this.owned(owner, id);
if (o.state !== 'open') return;
if (o.side === 'buy') o.coins += o.remaining * o.price;
else o.items += o.remaining;
o.remaining = 0;
o.state = 'cancelled';
this.update(o);
})
.immediate();
}
collect(account: MarketAccount, id: number, coinsId: number, itemId?: number): { items: number; coins: number } {
return this.transfer(account, () => {
const o = this.owned(account.owner, id);
const items = o.items ? account.give(itemId ?? o.item, o.items) : 0;
const coins = o.coins ? account.give(coinsId, o.coins) : 0;
if (!Number.isInteger(items) || items < 0 || items > o.items || !Number.isInteger(coins) || coins < 0 || coins > o.coins) throw new Error('Invalid inventory transfer.');
o.items -= items;
o.coins -= coins;
if (o.state !== 'open' && !o.items && !o.coins) o.slot = -1;
this.update(o);
return { items, coins };
});
}
quote(item: number, since = Date.now() - 86_400_000) {
const book = this.db
.query<{ bid: number | null; ask: number | null; buyQuantity: number; buyValue: number; sellQuantity: number }, [number]>(
`SELECT
MAX(CASE WHEN side='buy' THEN price END) bid, MIN(CASE WHEN side='sell' THEN price END) ask,
COALESCE(SUM(CASE WHEN side='buy' THEN remaining ELSE 0 END),0) buyQuantity,
COALESCE(SUM(CASE WHEN side='buy' THEN remaining * price ELSE 0 END),0) buyValue,
COALESCE(SUM(CASE WHEN side='sell' THEN remaining ELSE 0 END),0) sellQuantity
FROM offers WHERE item=? AND state='open'`
)
.get(item)!;
const stats = this.db
.query<{ volume: number; gross: number; tax: number; trades: number }, [number, number]>(
`SELECT COALESCE(SUM(quantity),0) volume,
COALESCE(SUM(gross),0) gross, COALESCE(SUM(tax),0) tax, COUNT(*) trades FROM trades WHERE item=? AND time>=?`
)
.get(item, since)!;
const last = this.db.query<{ price: number; time: number }, [number]>('SELECT price,time FROM trades WHERE item=? ORDER BY time DESC,id DESC LIMIT 1').get(item);
return { item, ...book, ...stats, lastPrice: last?.price ?? null, lastTradeAt: last?.time ?? null, vwap: stats.volume ? stats.gross / stats.volume : null };
}
history(item: number, from: number, to: number, interval: number) {
return this.db
.query<{ time: number; volume: number; gross: number; tax: number; low: number; high: number; vwap: number; trades: number }, [number, number, number, number, number]>(
`SELECT CAST(time / ? AS INTEGER) * ? time,
SUM(quantity) volume,SUM(gross) gross,SUM(tax) tax,MIN(price) low,MAX(price) high,
CAST(SUM(gross) AS REAL)/SUM(quantity) vwap,COUNT(*) trades
FROM trades WHERE item=? AND time>=? AND time<? GROUP BY 1 ORDER BY 1`
)
.all(interval, interval, item, from, to);
}
/** Read the whole market in one query, before ranking/pagination. No player identities leave the ledger. */
overview(since = Date.now() - 86_400_000, now = Date.now()): MarketOverview[] {
const rows = this.db
.query<Omit<MarketOverview, 'change' | 'changePercent' | 'marketCap' | 'vwap'>, [number, number, number, number, number]>(
`
WITH activity AS (SELECT DISTINCT item FROM trades UNION SELECT DISTINCT item FROM offers WHERE state='open'),
book AS (SELECT item,
MAX(CASE WHEN side='buy' THEN price END) bid, MIN(CASE WHEN side='sell' THEN price END) ask,
SUM(CASE WHEN side='buy' THEN remaining ELSE 0 END) buyQuantity,
SUM(CASE WHEN side='buy' THEN remaining * price ELSE 0 END) buyValue,
SUM(CASE WHEN side='sell' THEN remaining ELSE 0 END) sellQuantity
FROM offers WHERE state='open' GROUP BY item),
period AS (SELECT item,SUM(quantity) volume,SUM(gross) gross,SUM(tax) tax,COUNT(*) trades
FROM trades WHERE time>=? AND time<? GROUP BY item)
SELECT a.item,b.bid,b.ask,COALESCE(b.buyQuantity,0) buyQuantity,COALESCE(b.sellQuantity,0) sellQuantity,
COALESCE(b.buyValue,0) buyValue,
COALESCE(p.volume,0) volume,COALESCE(p.gross,0) gross,COALESCE(p.tax,0) tax,COALESCE(p.trades,0) trades,
(SELECT price FROM trades WHERE item=a.item AND time<? ORDER BY time DESC,id DESC LIMIT 1) lastPrice,
(SELECT time FROM trades WHERE item=a.item AND time<? ORDER BY time DESC,id DESC LIMIT 1) lastTradeAt,
(SELECT price FROM trades WHERE item=a.item AND time<? ORDER BY time DESC,id DESC LIMIT 1) referencePrice
FROM activity a LEFT JOIN book b ON b.item=a.item LEFT JOIN period p ON p.item=a.item
`
)
.all(since, now, now, now, since);
return rows.map(row => {
const change = row.lastPrice !== null && row.referencePrice !== null ? row.lastPrice - row.referencePrice : null;
return {
...row,
vwap: row.volume ? row.gross / row.volume : null,
change,
changePercent: change !== null && row.referencePrice ? (change / row.referencePrice) * 100 : null,
// Only listed sell supply is known; this is deliberately not total circulating supply.
marketCap: row.lastPrice !== null ? row.lastPrice * row.sellQuantity : null
};
});
}
}
let store: MarketStore | undefined;
export function marketStore(): MarketStore {
if (!Environment.GE_ENABLED) throw new Error('Grand Exchange is not available on this server.');
return (store ??= new MarketStore(process.env.GE_DATABASE ?? 'data/market.sqlite'));
}
/** Preserve crash recovery and later re-enabling for existing exchange participants.
* A world that has never used GE does not create or require a market database.
*/
export function playerSaveStore(): MarketStore | undefined {
const path = process.env.GE_DATABASE ?? 'data/market.sqlite';
if (!Environment.GE_ENABLED && !store && !existsSync(path)) return undefined;
return (store ??= new MarketStore(path));
}
@@ -447,7 +447,8 @@ export const enum ScriptOpcode {
ERROR,
MAP_RANDOM_EVENTS,
GETTIMESPENT, // custom: used to profile script execution (current duration)
TIMESPENT // custom: used to profile script execution (record start time)
TIMESPENT, // custom: used to profile script execution (record start time)
GE_OPEN = 10010 // rs-sdk: native physical Grand Exchange
}
export const ScriptOpcodeMap: Map<string, number> = new Map([
@@ -868,6 +869,7 @@ export const ScriptOpcodeMap: Map<string, number> = new Map([
['CONSOLE', ScriptOpcode.CONSOLE],
['ERROR', ScriptOpcode.ERROR],
['MAP_RANDOM_EVENTS', ScriptOpcode.MAP_RANDOM_EVENTS],
['GE_OPEN', ScriptOpcode.GE_OPEN],
['GETTIMESPENT', ScriptOpcode.GETTIMESPENT],
['TIMESPENT', ScriptOpcode.TIMESPENT]
]);
@@ -1,8 +1,14 @@
import { CoordGrid } from '#/engine/CoordGrid.js';
import { openExchange } from '#/engine/market/GrandExchange.js';
import { ScriptOpcode } from '#/engine/script/ScriptOpcode.js';
import { CommandHandlers } from '#/engine/script/ScriptRunner.js';
import Environment from '#/util/Environment.js';
const DebugOps: CommandHandlers = {
[ScriptOpcode.GE_OPEN]: state => {
const { level, x, z } = CoordGrid.unpackCoord(state.popInt());
if (level === 0) openExchange(state.activePlayer, x, z);
},
[ScriptOpcode.ERROR]: state => {
throw new Error(state.popString());
},
+3
View File
@@ -211,6 +211,9 @@ export default class FileStream {
sector = nextSector;
}
// Version-list CRCs and ondemand.zip are read in this same pack pass.
// Do not let them reuse the old bytes cached by write's comparison read.
this.packed[archive][file] = data;
return true;
}
@@ -1,6 +1,8 @@
export default class ClientGameProt {
static byId: ClientGameProt[] = [];
static readonly MARKET_SEARCH = new ClientGameProt(243, -1); // rs-sdk: bounded GE text/select
static readonly NO_TIMEOUT = new ClientGameProt(120, 0);
static readonly IDLE_TIMER = new ClientGameProt(209, 0);
@@ -1,3 +1,5 @@
import MarketSearchDecoder from '#/network/game/client/codec/MarketSearchDecoder.js';
import MarketSearchHandler from '#/network/game/client/handler/MarketSearchHandler.js';
import ClientGameProt from '#/network/game/client/ClientGameProt.js';
import ClientGameMessageDecoder from '#/network/game/client/ClientGameMessageDecoder.js';
import ClientGameMessageHandler from '#/network/game/client/ClientGameMessageHandler.js';
@@ -113,6 +115,7 @@ class ClientGameProtRepository {
this.bind(new IfButtonDecoder(), new IfButtonHandler());
this.bind(new IdkSaveDesignDecoder(), new IdkSaveDesignHandler());
this.bind(new IgnoreListAddDecoder(), new IgnoreListAddHandler());
this.bind(new MarketSearchDecoder(), new MarketSearchHandler());
this.bind(new IgnoreListDelDecoder(), new IgnoreListDelHandler());
this.bind(new InvButtonDecoder(ClientGameProt.INV_BUTTON1, 1), new InvButtonHandler());
this.bind(new InvButtonDecoder(ClientGameProt.INV_BUTTON2, 2), new InvButtonHandler());
@@ -0,0 +1,10 @@
import Packet from '#/io/Packet.js';
import ClientGameMessageDecoder from '#/network/game/client/ClientGameMessageDecoder.js';
import ClientGameProt from '#/network/game/client/ClientGameProt.js';
import MarketSearch from '#/network/game/client/model/MarketSearch.js';
export default class MarketSearchDecoder extends ClientGameMessageDecoder<MarketSearch> {
prot = ClientGameProt.MARKET_SEARCH;
decode(buf: Packet) {
return new MarketSearch(buf.gjstr(), buf.g2());
}
}
@@ -1,3 +1,4 @@
import { exchangeButton } 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';
@@ -21,6 +22,8 @@ export default class IfButtonHandler extends ClientGameMessageHandler<IfButton>
return false;
}
if (exchangeButton(player, comId)) return true;
player.lastCom = comId;
if (player.resumeButtons.indexOf(player.lastCom) !== -1) {
@@ -0,0 +1,9 @@
import type Player from '#/engine/entity/Player.js';
import { exchangeSearch } from '#/engine/market/GrandExchange.js';
import ClientGameMessageHandler from '#/network/game/client/ClientGameMessageHandler.js';
import MarketSearch from '#/network/game/client/model/MarketSearch.js';
export default class MarketSearchHandler extends ClientGameMessageHandler<MarketSearch> {
handle(message: MarketSearch, player: Player): boolean {
return exchangeSearch(player, message.query, message.item);
}
}
@@ -1,3 +1,4 @@
import { exchangeCount } from '#/engine/market/GrandExchange.js';
import Player from '#/engine/entity/Player.js';
import ScriptState from '#/engine/script/ScriptState.js';
import ClientGameMessageHandler from '#/network/game/client/ClientGameMessageHandler.js';
@@ -6,6 +7,7 @@ import ResumePCountDialog from '#/network/game/client/model/ResumePCountDialog.j
export default class ResumePCountDialogHandler extends ClientGameMessageHandler<ResumePCountDialog> {
handle(message: ResumePCountDialog, player: Player): boolean {
const { input } = message;
if (exchangeCount(player, input)) return true;
if (!player.activeScript || player.activeScript.execution !== ScriptState.COUNTDIALOG) {
return false;
@@ -0,0 +1,12 @@
import ClientGameMessage from '#/network/game/client/ClientGameMessage.js';
import ClientGameProtCategory from '#/network/game/client/ClientGameProtCategory.js';
/** Text filter or selection of the actual item displayed by the client. */
export default class MarketSearch extends ClientGameMessage {
category = ClientGameProtCategory.USER_EVENT;
constructor(
readonly query: string,
readonly item: number = 0
) {
super();
}
}
+3
View File
@@ -34,6 +34,9 @@ export default {
NODE_DEBUG: config.node.debug,
NODE_HOP_TIME: config.node.hopTime,
// Restart the engine after changing this flag; no cache rebuild is required.
GE_ENABLED: tryParseBoolean(process.env.GE_ENABLED, true),
// rs-sdk-only keys (not part of upstream WorldConfig)
WEB_SOCKET_TOKEN_PROTECTION: tryParseBoolean(process.env.WEB_SOCKET_TOKEN_PROTECTION, false),
NODE_DEBUG_SOCKET: tryParseBoolean(process.env.NODE_DEBUG_SOCKET, false),
+4
View File
@@ -1,3 +1,4 @@
import { handleMarket } from './pages/market.js';
import { register } from 'prom-client';
import Environment from '#/util/Environment.js';
import World from '#/engine/World.js';
@@ -94,6 +95,9 @@ async function handleRequest(req: Request, server: Bun.Server, url: URL): Promis
const bugReportResponse = await handleBugReport(req, url);
if (bugReportResponse) return bugReportResponse;
const marketResponse = handleMarket(req, url);
if (marketResponse) return marketResponse;
// Engine status endpoint
if (url.pathname === '/engine-status' || url.pathname === '/engine-status/') {
return new Response(JSON.stringify({
+1
View File
@@ -23,6 +23,7 @@ export async function handleClientPage(url: URL): Promise<Response | null> {
lowmem,
members: Environment.NODE_MEMBERS,
botUsername,
geEnabled: Environment.GE_ENABLED,
maxMessageLength: Environment.node.maxMessageLength,
profanityFilter: Environment.NODE_PROFANITY_FILTER,
cachebust: CLIENT_CACHEBUST,
+116
View File
@@ -0,0 +1,116 @@
import Environment from '#/util/Environment.js';
import { marketStore, positive } from '#/engine/market/MarketStore.js';
import { catalog, marketItem } from '#/engine/market/MarketCatalog.js';
const headers = { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=5' };
const snapshots = new Map<number, { at: number; rows: ReturnType<ReturnType<typeof marketStore>['overview']> }>();
function snapshot(days: number) {
const now = Date.now(),
cached = snapshots.get(days);
if (cached && now - cached.at < 5000) return cached;
const result = { at: now, rows: marketStore().overview(now - days * 86_400_000, now) };
snapshots.set(days, result);
return result;
}
function period(url: URL) {
const days = integer(url, 'days', 1, 1, 90);
if (![1, 7, 30, 90].includes(days)) throw new Error('Invalid days: use 1, 7, 30 or 90.');
return days;
}
function integer(url: URL, key: string, fallback: number, min: number, max: number) {
const raw = url.searchParams.get(key);
const value = raw === null ? fallback : Number(raw);
if (!Number.isSafeInteger(value) || value < min || value > max) throw new Error(`Invalid ${key}: expected an integer from ${min} to ${max}.`);
return value;
}
export function handleMarket(req: Request, url: URL): Response | null {
const page = url.pathname === '/market' || url.pathname === '/market/';
const status = url.pathname === '/api/market/status';
if (!page && !status && url.pathname !== '/api/market' && !url.pathname.startsWith('/api/market/')) return null;
const message = 'Grand Exchange is not available on this server.';
if (status && req.method === 'GET') return Response.json(
{ enabled: Environment.GE_ENABLED, ...(!Environment.GE_ENABLED ? { message } : {}) },
{ headers: { ...headers, 'Cache-Control': 'no-store' } }
);
if (!Environment.GE_ENABLED) return Response.json(
{ error: message, reason: 'ge_unavailable' },
{ status: 503, headers: { ...headers, 'Cache-Control': 'no-store' } }
);
if (req.method !== 'GET') return Response.json({ error: 'Market data is read-only. Trade at Varrock west bank.' }, { status: 405, headers: { ...headers, Allow: 'GET' } });
if (page) return new Response(Bun.file('public/market.html'), { headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache' } });
try {
if (url.pathname === '/api/market/items') {
const q = url.searchParams.get('q') ?? '';
if (q.length > 80) throw new Error('Search is limited to 80 characters.');
const limit = integer(url, 'limit', 25, 1, 100),
offset = integer(url, 'offset', 0, 0, 100_000);
const days = period(url),
sort = url.searchParams.get('sort') ?? (q.trim() ? 'relevance' : 'name');
if (!['relevance', 'name', 'name-desc', 'recent', 'rises', 'falls', 'marketCap', 'price', 'volume', 'value', 'buyValue'].includes(sort)) throw new Error('Invalid sort.');
const data = snapshot(days),
byId = new Map(data.rows.map(row => [row.item, row]));
const empty = {
bid: null,
ask: null,
buyQuantity: 0,
buyValue: 0,
sellQuantity: 0,
volume: 0,
gross: 0,
tax: 0,
trades: 0,
lastPrice: null,
lastTradeAt: null,
referencePrice: null,
vwap: null,
change: null,
changePercent: null,
marketCap: null
};
let matches = catalog(q, sort === 'name' || sort === 'name-desc' ? sort : 'relevance').map(i => ({ ...i, ...empty, item: i.id, ...byId.get(i.id) }));
if (sort === 'rises') matches = matches.filter(i => i.changePercent !== null && i.changePercent > 0 && i.volume > 0);
if (sort === 'falls') matches = matches.filter(i => i.changePercent !== null && i.changePercent < 0 && i.volume > 0);
if (sort === 'marketCap') matches = matches.filter(i => i.marketCap !== null && i.marketCap > 0);
if (sort === 'buyValue') matches = matches.filter(i => i.buyValue > 0);
if (sort === 'price') matches = matches.filter(i => i.lastPrice !== null);
if (sort === 'recent') matches = matches.filter(i => i.lastTradeAt !== null);
if (sort === 'volume' || sort === 'value') matches = matches.filter(i => i.volume > 0);
const sortKeys = { recent: 'lastTradeAt', rises: 'changePercent', falls: 'changePercent', marketCap: 'marketCap', price: 'lastPrice', volume: 'volume', value: 'gross', buyValue: 'buyValue' } as const;
const key = sortKeys[sort as keyof typeof sortKeys];
if (key) matches.sort((a, b) => ((b[key] ?? -Infinity) - (a[key] ?? -Infinity)) * (sort === 'falls' ? -1 : 1) || a.name.localeCompare(b.name) || a.id - b.id);
const eligible = new Set(catalog().map(i => i.id)),
active = data.rows.filter(i => eligible.has(i.item));
const summary = { totalItems: eligible.size, volume: active.reduce((n, i) => n + i.volume, 0), gross: active.reduce((n, i) => n + i.gross, 0), activeItems: active.filter(i => i.volume > 0).length };
const featured = [...active].filter(i => i.volume > 0).sort((a, b) => b.volume - a.volume)[0];
return Response.json(
{ items: matches.slice(offset, offset + limit), total: matches.length, offset, limit, days, sort, updatedAt: data.at, summary, featured: featured ? { ...catalog().find(i => i.id === featured.item), ...featured } : null },
{ headers }
);
}
const match = /^\/api\/market\/items\/(\d+)(\/history)?$/.exec(url.pathname);
if (!match) return Response.json({ error: 'Not found' }, { status: 404, headers });
const id = positive(Number(match[1]), 'Item');
let item;
try {
item = marketItem(id);
} catch {
return Response.json({ error: 'Tradeable item not found' }, { status: 404, headers });
}
if (!match[2]) {
const days = period(url),
row = snapshot(days).rows.find(i => i.item === id);
return Response.json({ id, name: item.name, basePrice: item.cost, ...marketStore().quote(id, Date.now() - days * 86_400_000), referencePrice: null, change: null, changePercent: null, marketCap: null, ...row }, { headers });
}
const now = Date.now();
const from = integer(url, 'from', now - 30 * 86_400_000, 0, Number.MAX_SAFE_INTEGER);
const to = integer(url, 'to', now, 1, Number.MAX_SAFE_INTEGER);
const interval = integer(url, 'interval', 86_400_000, 3_600_000, 86_400_000);
if (![3_600_000, 86_400_000].includes(interval) || to <= from || to - from > 366 * 86_400_000 || Math.ceil((to - from) / interval) > 1000)
throw new Error('Use hour/day intervals, an increasing range up to 366 days, and at most 1000 buckets.');
return Response.json({ item: id, from, to, interval, history: marketStore().history(id, from, to, interval) }, { headers });
} catch (error) {
if (error instanceof Error && /Invalid |Search is |Use hour|must be a whole/.test(error.message)) return Response.json({ error: error.message }, { status: 400, headers });
console.error('[market] Read failed', error);
return Response.json({ error: 'Market temporarily unavailable' }, { status: 503, headers: { ...headers, 'Cache-Control': 'no-store' } });
}
}
+25
View File
@@ -0,0 +1,25 @@
import { expect, test } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import FileStream from '../src/io/FileStream';
test('a changed map is immediately readable by the same pack pass and after reopening', () => {
const dir = mkdtempSync(join(tmpdir(), 'ge-cache-'));
const cache = new FileStream(dir, true);
try {
cache.write(4, 263, Uint8Array.of(1, 2, 3), 1);
expect(cache.read(4, 263)).toEqual(Uint8Array.of(1, 2, 3, 0, 1));
cache.write(4, 263, Uint8Array.of(4, 5, 6, 7), 1);
expect(cache.read(4, 263)).toEqual(Uint8Array.of(4, 5, 6, 7, 0, 1));
const reopened = new FileStream(dir, false, true);
try {
expect(reopened.read(4, 263)).toEqual(cache.read(4, 263));
} finally {
reopened.close();
}
} finally {
cache.close();
rmSync(dir, { recursive: true, force: true });
}
});
+33
View File
@@ -0,0 +1,33 @@
import { expect, test } from 'bun:test';
import { createItemSearch } from '../src/engine/market/ItemSearch';
const names = ['Rune scimitar', 'Mithril scimitar', 'Adamant platebody', 'Blue dragonhide body', 'Dragon dagger(p)', 'Rune sword', 'Rune 2h sword', 'Oak logs', 'Lobster', 'Rune', 'Rune arrow', 'Rune scimitar'];
const search = createItemSearch(names.map((name, id) => ({ name, id: id + 1 })));
test('ranks exact, phrase and fuzzy matches with deterministic ties', () => {
expect(search('Rune')[0].name).toBe('Rune');
expect(search('rune scmitar').map(i => i.id)).toEqual([1, 12]);
expect(search('runesword')[0].name).toBe('Rune sword');
expect(search('rune nonexistent')).toEqual([]);
expect(search('zx')).toEqual([]);
});
test('expands common equipment, material and food aliases', () => {
for (const [query, name] of [
['rscim', 'Rune scimitar'],
['addy pl8', 'Adamant platebody'],
['mith scim', 'Mithril scimitar'],
['blue d hide body', 'Blue dragonhide body'],
['dds', 'Dragon dagger(p)'],
['r2h', 'Rune 2h sword'],
['lobbies', 'Lobster'],
['ÓAK LOG', 'Oak logs']
])
expect(search(query)[0]?.name).toBe(name);
});
test('sorts matching results by name in both directions and preserves supplied item eligibility', () => {
const matches = search('rune', 'name');
expect(matches.map(i => i.name)).toEqual(matches.map(i => i.name).sort((a, b) => a.localeCompare(b, 'en')));
const reverse = search('rune', 'name-desc');
expect(reverse.map(i => i.name)).toEqual([...matches.map(i => i.name)].reverse());
expect(search('')).toHaveLength(names.length);
expect(createItemSearch([{ id: 1, name: 'Logs' }])('rscim')).toEqual([]);
});
+81
View File
@@ -0,0 +1,81 @@
// Isolated process: real packed world with GE disabled, using temporary saves only.
import { mkdtempSync, existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { strict as assert } from 'node:assert';
const dir = mkdtempSync(join(tmpdir(), 'ge-disabled-'));
process.env.GE_ENABLED = 'false';
process.env.GE_DATABASE = join(dir, 'market.sqlite');
const { default: Environment } = await import('../src/util/Environment.js');
const { default: World } = await import('../src/engine/World.js');
const { default: LocType } = await import('../src/cache/config/LocType.js');
const { default: NpcType } = await import('../src/cache/config/NpcType.js');
const { default: Component } = await import('../src/cache/config/Component.js');
const { default: Packet } = await import('../src/io/Packet.js');
const { PlayerLoading } = await import('../src/engine/entity/PlayerLoading.js');
const { isMapBlocked } = await import('../src/engine/GameMap.js');
const { MarketStore, marketStore, playerSaveStore } = await import('../src/engine/market/MarketStore.js');
const { openExchange, exchangeButton, exchangeCount, exchangeSearch, tickExchange } = await import('../src/engine/market/GrandExchange.js');
const { handleMarket } = await import('../src/web/pages/market.js');
const { default: LocAddChange } = await import('../src/network/game/server/model/LocAddChange.js');
try {
assert.equal(Environment.GE_ENABLED, false);
World.reload();
World.gameMap.init();
assert.equal(World.getLoc(3180, 3443, 0, LocType.getId('grand_exchange_booth')), null);
assert(World.getLoc(3180, 3443, 0, LocType.getId('banktable')), 'Disabled GE restores the original table');
for (const z of [3443, 3444]) assert(isMapBlocked(3180, z, 0), 'Original table collision remains');
assert(![...World.npcs].some(n => n.type === NpcType.getId('grand_exchange_teller')));
for (const x of [3180, 3181]) for (const z of [3439, 3440]) assert(!isMapBlocked(x, z, 0), 'Removed stall has no collision');
const p = PlayerLoading.load('disabledtest', new Packet(new Uint8Array()), null);
p.x = 3182; p.z = 3439; p.level = 0;
const packets: unknown[] = [];
p.write = packet => { packets.push(packet); };
World.gameMap.getZone(3180, 3443, 0).writeFullFollows(p);
assert(packets.some(packet => packet instanceof LocAddChange && packet.loc === LocType.getId('banktable')), 'Clients receive the original bank table');
const messages: string[] = [];
p.messageGame = message => { messages.push(message); };
openExchange(p, 3180, 3443);
assert(messages.includes('Grand Exchange is not available on this server.'));
assert.equal(p.modalMain, -1);
assert.equal(exchangeButton(p, Component.getId('grand_exchange:buy')), true);
assert.equal(exchangeCount(p, 10), false);
assert.equal(exchangeSearch(p, 'logs'), false);
tickExchange(p);
const plainSave = p.save();
assert(PlayerLoading.verify(new Packet(plainSave)));
assert.equal(PlayerLoading.load(p.username, new Packet(plainSave), null).x, 3182);
assert.equal(playerSaveStore(), undefined);
assert.throws(() => marketStore(), /not available/);
for (const path of ['/market', '/market/', '/api/market/items', '/api/market/items/1511', '/api/market/items/1511/history', '/api/market/offers']) {
for (const method of ['GET', 'POST']) {
const url = new URL('http://localhost' + path);
const response = handleMarket(new Request(url, { method }), url)!;
assert.equal(response.status, 503);
assert.equal(response.headers.get('Cache-Control'), 'no-store');
assert.equal((await response.json()).reason, 'ge_unavailable');
}
}
const url = new URL('http://localhost/api/market/status');
assert.equal((await handleMarket(new Request(url), url)!.json()).enabled, false);
assert.equal(handleMarket(new Request('http://localhost/'), new URL('http://localhost/')), null);
assert(!existsSync(process.env.GE_DATABASE), 'No database created by disabled-world gameplay or HTTP');
// Prior participants keep ledger recovery and later gameplay across the toggle.
const seed = new MarketStore(process.env.GE_DATABASE);
p.x = 3200;
seed.checkpoint(p.username, p.save(), true);
seed.db.close();
const recovered = PlayerLoading.load(p.username, new Packet(plainSave), null);
assert.equal(recovered.x, 3200, 'Recover the latest atomic checkpoint even with GE off');
recovered.x = 3210;
recovered.save();
Environment.GE_ENABLED = true;
assert.equal(PlayerLoading.load(p.username, new Packet(plainSave), null).x, 3210, 'Re-enabling cannot roll back progress made with GE off');
assert.equal((await handleMarket(new Request(url), url)!.json()).enabled, true);
console.log('Disabled GE: world, original table restoration, handlers, HTTP, ordinary saves and checkpoint recovery passed.');
} finally {
playerSaveStore()?.db.close();
rmSync(dir, { recursive: true, force: true });
await Promise.all([World.loginThread.terminate(), World.friendThread.terminate(), World.loggerThread.terminate()]);
}
+368
View File
@@ -0,0 +1,368 @@
// Real packed content, Player inventories/saves, compiled booth script, native handlers
// and public HTTP routes. No production account or persistent game DB is touched.
import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { strict as assert } from 'node:assert';
const dir = mkdtempSync(join(tmpdir(), 'ge-integration-'));
process.env.GE_DATABASE = join(dir, 'market.sqlite');
const { default: World } = await import('../src/engine/World.js');
const { default: Component } = await import('../src/cache/config/Component.js');
const { default: NpcType } = await import('../src/cache/config/NpcType.js');
const { findPathToLoc, findPathToEntity, isMapBlocked } = await import('../src/engine/GameMap.js');
const { default: ObjType } = await import('../src/cache/config/ObjType.js');
const { default: InvType } = await import('../src/cache/config/InvType.js');
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: 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');
const { default: CountHandler } = await import('../src/network/game/client/handler/ResumePCountDialogHandler.js');
const { default: Count } = await import('../src/network/game/client/model/ResumePCountDialog.js');
const { default: ScriptProvider } = await import('../src/engine/script/ScriptProvider.js');
const { default: ScriptRunner } = await import('../src/engine/script/ScriptRunner.js');
const { default: ServerTriggerType } = await import('../src/engine/script/ServerTriggerType.js');
const { default: Loc } = await import('../src/engine/entity/Loc.js');
const { marketStore } = await import('../src/engine/market/MarketStore.js');
const { noteFor, canonicalItem } = await import('../src/engine/market/MarketCatalog.js');
const { openExchange, atExchange, tickExchange } = await import('../src/engine/market/GrandExchange.js');
const { handleMarket } = await import('../src/web/pages/market.js');
try {
World.reload();
World.gameMap.init();
const booth = World.getLoc(3180, 3443, 0, LocType.getId('grand_exchange_booth'));
assert(booth, 'The existing west-wall bank table hosts the exchange');
assert.equal(booth.width, 2);
assert.equal(booth.length, 1);
assert.equal(booth.angle, 1, 'The bank table keeps its original rotation');
assert.deepEqual(LocType.get(booth.type).models, LocType.get(LocType.getId('banktable')).models, 'The original table model is reused');
assert.equal(World.getLoc(3180, 3439, 0, booth.type), null, 'No added stall');
const teller = [...World.npcs].find(n => n.type === NpcType.getId('grand_exchange_teller'));
assert(teller && teller.x === 3181 && teller.z === 3445, 'Exchange teller is beside the bank table');
assert(!LocType.get(LocType.getId('bankbooth')).op.includes('Grand-Exchange'), 'Normal bank booths keep only banking');
assert(isMapBlocked(3180, 3443, 0));
assert(isMapBlocked(3180, 3444, 0));
for (const x of [3180, 3181]) for (const z of [3439, 3440]) assert(!isMapBlocked(x, z, 0), 'The removed stall leaves clear floor');
assert(!isMapBlocked(3181, 3443, 0), 'The table approach remains open');
assert(!isMapBlocked(teller.x, teller.z, 0), 'The teller stands on clear floor');
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');
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(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);
const buyer = PlayerLoading.load('gebuyer', new Packet(new Uint8Array()), null);
const inv = InvType.getId('inv'),
coins = ObjType.getId('coins'),
logs = ObjType.getId('logs'),
note = noteFor(logs);
assert(note !== logs);
assert.equal(canonicalItem(note), logs);
for (const p of [seller, buyer]) {
p.x = 3181;
p.z = 3443;
p.level = 0;
}
World.playerLoop.add(1n, seller);
World.playerLoop.add(2n, buyer);
seller.invAdd(inv, note, 10);
buyer.invAdd(inv, coins, 200);
const originalSeller = seller.save();
// Reproduce a running server whose cache predates optional SDK metadata.
const componentNames = (Component as any).componentNames as Map<string, number>;
const metadataId = Component.getId('grand_exchange:agent_state');
assert(metadataId >= 0, 'SDK metadata is included in the rebuilt interface');
const originalWrite = buyer.write;
const openingPackets: any[] = [];
try {
componentNames.delete('grand_exchange:agent_state');
buyer.write = message => {
openingPackets.push(message);
};
openExchange(buyer, 3180, 3443);
assert.equal(buyer.modalMain, Component.getId('grand_exchange'));
assert(openingPackets.length > 0);
assert(
openingPackets.every(m => m.component === undefined || m.component >= 0),
'Opening with an older cache never emits component -1 (65535 on the wire)'
);
} finally {
componentNames.set('grand_exchange:agent_state', metadataId);
buyer.write = originalWrite;
buyer.closeModal();
}
const click = (p: typeof seller, name: string) => assert(new IfButtonHandler().handle(new IfButton(Component.getId('grand_exchange:' + name)), p));
const search = (p: typeof seller, query: string, item = 0) => {
const packet = new Packet(new Uint8Array(256));
packet.pjstr(query);
packet.p2(item);
packet.pos = 0;
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 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');
click(seller, 'slot4_sell');
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.
assert(!search(seller, 'x'.repeat(49)));
assert(!search(seller, '<script>'));
assert(search(seller, 'log', logs));
assert(!search(seller, 'logs'), 'Search is closed once the draft opens');
click(seller, 'offer_qty_all');
click(seller, 'offer_quantity_plus');
click(seller, 'offer_quantity_minus');
click(seller, 'offer_price');
count(seller, 10);
click(seller, 'offer_confirm');
assert.equal(seller.getInventory(inv)!.getItemCount(note), 0, 'notes escrowed');
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');
buyer.x = 3182;
buyer.z = 3445;
for (const trigger of [ServerTriggerType.OPNPC1, ServerTriggerType.OPNPC2]) {
buyer.closeModal();
const script = ScriptProvider.getByTrigger(trigger, teller.type, -1);
assert(script, 'Both teller options are compiled');
buyer.executeScript(ScriptRunner.init(script, buyer, teller), true);
assert.equal(buyer.modalMain, Component.getId('grand_exchange'));
}
const updates: any[] = [];
seller.write = message => {
updates.push(message);
};
click(buyer, 'slot2_buy');
assert(search(buyer, 'logss'), 'Fuzzy search accepts a small typo');
assert(search(buyer, 'logss', logs));
const buyerUpdates: any[] = [];
buyer.write = message => buyerUpdates.push(message);
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_sell_quantity'))?.hidden, true);
click(buyer, 'offer_quantity');
count(buyer, 10);
click(buyer, 'offer_price');
count(buyer, 20);
click(buyer, 'offer_price_higher');
click(buyer, 'offer_price_lower');
click(buyer, 'offer_confirm');
assert.equal(buyer.getInventory(inv)!.getItemCount(coins), 0);
assert.equal(marketStore().quote(logs).tax, 5);
tickExchange(seller);
assert(
updates.some(m => m.component === Component.getId('grand_exchange:view_progress') && m.text === 'Offer completed - 10 of 10 traded'),
'Counterparty fill pushes into the open UI without refresh'
);
const sentCount = updates.length;
tickExchange(seller);
assert.equal(updates.length, sentCount, 'An unchanged ledger does not redraw the UI');
click(buyer, 'view_notes');
assert.equal(buyer.getInventory(inv)!.getItemCount(note), 10);
assert.equal(buyer.getInventory(inv)!.getItemCount(coins), 100);
click(seller, 'view_collect');
assert.equal(seller.getInventory(inv)!.getItemCount(coins), 95);
const before = buyer.getInventory(inv)!.getItemCount(note);
click(buyer, 'view_notes');
assert.equal(buyer.getInventory(inv)!.getItemCount(note), before);
click(buyer, 'home');
click(buyer, 'slot2_buy');
assert(search(buyer, 'log'));
assert(search(buyer, 'log', logs));
click(buyer, 'offer_price');
count(buyer, 20);
click(buyer, 'offer_confirm');
assert.equal(buyer.getInventory(inv)!.getItemCount(coins), 80);
assert.equal(buyerUpdates.findLast(m => m.component === Component.getId('grand_exchange:view_claim_controls'))?.hidden, true);
click(buyer, 'view_cancel');
assert.equal(buyerUpdates.findLast(m => m.component === Component.getId('grand_exchange:view_cancel_controls'))?.hidden, true);
click(buyer, 'view_collect');
assert.equal(buyer.getInventory(inv)!.getItemCount(coins), 100, 'Cancelled buy escrow is refunded through the visual collection control');
buyer.x = 3200;
assert(!atExchange(buyer, 3181, 3445));
assert(!search(buyer, 'logs'), 'No searching remotely');
assert.equal(new IfButtonHandler().handle(new IfButton(Component.getId('grand_exchange:buy')), buyer), false);
assert.equal(buyer.modalMain, -1);
assert.equal(new IfButtonHandler().handle(new IfButton(Component.getId('grand_exchange:row5')), buyer), false);
assert(!atExchange({ x: 3185, z: 3436, level: 0 }, 3186, 3436), 'The old bank counter cannot authorize GE actions');
assert(!atExchange({ x: 3181, z: 3443, level: 1 }, 3180, 3443), 'No upstairs use');
assert(!atExchange({ x: 3182, z: 3439, level: 0 }, 3180, 3439), 'Removed stall cannot authorize GE actions');
assert(atExchange({ x: 3181, z: 3444, level: 0 }, 3180, 3443), 'Both tiles of the rotated table accept adjacent players');
const restored = PlayerLoading.load(seller.username, new Packet(originalSeller), null);
assert.equal(restored.getInventory(inv)!.getItemCount(note), 0);
assert.equal(restored.getInventory(inv)!.getItemCount(coins), 95);
assert(PlayerLoading.verify(new Packet(restored.save())));
const request = (path: string, method = 'GET') => {
const url = new URL('http://localhost' + path);
return handleMarket(new Request(url, { method }), url)!;
};
const quote = await request('/api/market/items/' + logs).json();
assert.equal(quote.volume, 10);
assert.equal(quote.lastPrice, 10);
assert.equal(quote.tax, 5);
assert.equal((await request('/api/market/items/' + logs + '/history').json()).history.length, 1);
assert.equal(request('/api/market/items/' + logs, 'POST').status, 405);
assert.equal(request('/api/market/items?limit=10000').status, 400);
assert.equal(request('/api/market/items/' + logs + '/history?interval=1').status, 400);
assert.equal(request('/api/market/items/999999').status, 404);
const results = await request('/api/market/items?q=logs').json();
assert(results.items.some((i: any) => i.id === logs));
const alias = await request('/api/market/items?q=rscim').json();
assert.equal(alias.items[0]?.name, 'Rune scimitar');
assert.equal(alias.sort, 'relevance');
const sorted = await request('/api/market/items?q=rune&sort=name-desc').json();
assert.deepEqual(
sorted.items.map((i: any) => i.name),
sorted.items.map((i: any) => i.name).sort((a: string, b: string) => b.localeCompare(a, 'en'))
);
assert.equal(request('/api/market/items?sort=unknown').status, 400);
assert(!JSON.stringify(results).includes('geseller'));
assert((await request('/market').text()).includes('Grand Exchange'));
// Agent porcelain -> actual native button/count/search handlers -> encoded server observations.
// Dynamic paths keep this cross-project integration outside the engine compiler's rootDir.
const sdkModule = '../../../sdk/index.ts',
actionsModule = '../../../sdk/actions.ts';
const stateModule = '../../webclient/src/bot/ge-state.ts';
const { BotSDK } = await import(sdkModule);
const { BotActions } = await import(actionsModule);
const { collectGEState } = await import(stateModule);
const { default: TextEncoder } = await import('../src/network/game/server/codec/IfSetTextEncoder.js');
let largestStatePacket = 0;
let agentIndex = 100n;
const agent = (name: string) => {
const p = PlayerLoading.load(name, new Packet(new Uint8Array()), null);
p.x = 3181;
p.z = 3443;
p.level = 0;
p.invAdd(inv, coins, 1000);
p.invAdd(inv, note, 20);
World.playerLoop.add(agentIndex++, p);
let ge: any = null;
const sdk = new BotSDK({ botUsername: name, autoLaunchBrowser: false });
sdk.isConnected = () => true;
sdk.getStateAge = () => 0;
const sync = () => {
sdk.state = { modalOpen: p.modalMain === Component.getId('grand_exchange'), ge: p.modalMain === Component.getId('grand_exchange') ? ge : null };
};
p.write = message => {
if ((message as any).component !== Component.getId('grand_exchange:agent_state')) return;
const packet = Packet.alloc(1); // Same capacity as the real socket output buffer.
new TextEncoder().encode(packet, message as any);
largestStatePacket = Math.max(largestStatePacket, packet.pos);
packet.pos = 0;
assert.equal(packet.g2(), Component.getId('grand_exchange:agent_state'));
const text = packet.gjstr();
ge = collectGEState([{ clientCode: 30403, text, children: null }], 0);
assert(!text.includes('"owner"'), 'Private observations need no account-name field');
sync();
packet.release();
};
const sent: string[] = [];
sdk.sendAction = async (action: any) => {
sent.push(action.type);
let ok = false;
if (action.type === 'clickComponent') ok = new IfButtonHandler().handle(new IfButton(action.componentId), p);
if (action.type === 'submitCountDialog') ok = new CountHandler().handle(new Count(action.value), p);
if (action.type === 'searchGE') ok = search(p, action.query);
sync();
return { success: ok, message: ok ? 'Dispatched' : 'Rejected by handler', phase: 'dispatch' };
};
const bot = new BotActions(sdk);
openExchange(p, 3180, 3443);
sync();
return { p, sdk, bot, sent, sync };
};
const a = agent('sdkbuyer'),
b = agent('sdkseller');
const found = await a.bot.searchGE('logss');
assert(found.success, found.message);
assert(found.state.search.results.some((i: any) => i.item === logs));
const sold = await b.bot.placeGEOffer({ item: logs, side: 'sell', quantity: 5, price: 10, slot: 4 });
assert(sold.success, sold.message);
assert.equal(sold.offer.slot, 4);
assert.equal(b.p.getInventory(inv)!.getItemCount(note), 15);
const bought = await a.bot.placeGEOffer({ item: logs, side: 'buy', quantity: 5, price: 20 });
assert(bought.success, bought.message);
assert.equal(bought.offer.filled, 5);
assert.equal(bought.offer.coins, 50, 'Improved price is an observable collection claim');
const collected = await a.bot.collectGEOffer(bought.offer.id);
assert(collected.success, collected.message);
assert.equal(collected.items, 5);
assert.equal(collected.coins, 50);
const proceeds = await b.bot.collectGE();
assert(proceeds.success, proceeds.message);
assert.equal(proceeds.coins, 48);
const pending = await a.bot.placeGEOffer({ item: logs, side: 'buy', quantity: 3, price: 1 });
assert(pending.success, pending.message);
const cancelled = await a.bot.cancelGEOffer(pending.offer.id);
assert(cancelled.success, cancelled.message);
assert.equal(cancelled.offer.state, 'cancelled');
assert.equal(cancelled.offer.coins, 3);
assert.equal((await a.bot.collectGE()).coins, 3);
const rejected = await a.bot.placeGEOffer({ item: logs, side: 'buy', quantity: 100, price: 100 });
assert.equal(rejected.success, false);
assert.equal(rejected.reason, 'server_rejected');
assert.equal(a.sdk.getGEState().offers.length, 0, 'Unfunded request creates no offer');
const beforeInvalid = a.sent.length;
assert.equal((await a.bot.placeGEOffer({ item: logs, side: 'buy', quantity: 0, price: 1 })).success, false);
assert.equal(a.sent.length, beforeInvalid, 'Invalid values do not dispatch');
for (let slot = 0; slot < 6; slot++) {
const result = await a.bot.placeGEOffer({ item: logs, side: 'buy', quantity: 1, price: 1, slot });
assert(result.success, result.message);
}
assert.equal((await a.bot.placeGEOffer({ item: logs, side: 'buy', quantity: 1, price: 1 })).reason, 'no_slot');
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']) {
const c = agent('remote' + operation);
let component = Component.getId('grand_exchange:collect');
if (operation === 'confirm' || operation === 'count') {
click(c.p, 'slot0_buy');
click(c.p, 'find');
count(c.p, logs);
component = Component.getId('grand_exchange:offer_confirm');
if (operation === 'count') click(c.p, 'offer_quantity');
} else if (operation === 'cancel') {
const offer = await c.bot.placeGEOffer({ item: logs, side: 'buy', quantity: 1, price: 1 });
assert(offer.success, offer.message);
component = Component.getId('grand_exchange:row5');
} else if (operation === 'search') click(c.p, 'slot0_buy');
const ledger = JSON.stringify(marketStore().offers(c.p.username));
const inventory = c.p.getInventory(inv)!.getItemCount(coins);
c.p.x = 3200;
if (operation === 'count') new CountHandler().handle(new Count(200), c.p);
else if (operation === 'search') assert.equal(search(c.p, 'logs'), false);
else new IfButtonHandler().handle(new IfButton(component), c.p);
assert.equal(JSON.stringify(marketStore().offers(c.p.username)), ledger, operation + ' cannot mutate remotely');
assert.equal(c.p.getInventory(inv)!.getItemCount(coins), inventory);
assert.equal(c.p.modalMain, -1, 'Remote session is closed');
}
const upstairs = agent('sdkupstairs');
upstairs.p.level = 1;
const upstairsCoins = upstairs.p.getInventory(inv)!.getItemCount(coins);
click(upstairs.p, 'collect');
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.'
);
} finally {
marketStore().db.close();
rmSync(dir, { recursive: true, force: true });
await Promise.all([World.loginThread.terminate(), World.friendThread.terminate(), World.loggerThread.terminate()]);
}
@@ -0,0 +1,144 @@
// Native packed scenery preview, no running game or player account required.
import { readFileSync, mkdirSync } from 'node:fs';
import { resolve } from 'node:path';
import { Jimp } from 'jimp';
import { strict as assert } from 'node:assert';
const clientRoot = new URL('../../webclient/src/', import.meta.url);
await import(new URL('lite/dom-shim.ts', clientRoot).href);
const { ItemViewer } = await import(new URL('viewer/ItemViewer.ts', clientRoot).href);
const viewer = new ItemViewer();
viewer.initFromData(
{ config: readFileSync('data/pack/client/config'), textures: readFileSync('data/pack/client/textures'), versionlist: readFileSync('data/pack/client/versionlist'), ondemand: readFileSync('data/pack/ondemand.zip') },
{ lazyModels: true }
);
const ids = new Map(
readFileSync('../content/pack/loc.pack', 'utf8')
.trim()
.split('\n')
.map(line => {
const [id, name] = line.split('=');
return [name, Number(id)] as const;
})
);
const out = resolve('../../screenshots/grand-exchange');
mkdirSync(out, { recursive: true });
for (const name of process.argv.slice(2).length ? process.argv.slice(2) : ['grand_exchange_booth', 'bankbooth']) {
const sprite = viewer.renderLocSprite(ids.get(name)!, 256, 256, 256, 224, 0x38382e);
if (!sprite) throw Error('No model: ' + name);
const rgba = Buffer.alloc(sprite.data.length * 4);
sprite.data.forEach((rgb: number, i: number) => {
rgba[i * 4] = (rgb >> 16) & 255;
rgba[i * 4 + 1] = (rgb >> 8) & 255;
rgba[i * 4 + 2] = rgb & 255;
rgba[i * 4 + 3] = 255;
});
await new Jimp({ width: 256, height: 256, data: rgba }).write(`${out}/${name}.png`);
}
// Render the actual packed bank scene with roofs removed (level 0), using the
// game's scene builder, terrain, scenery and NPC models, not a layout mockup.
const { default: ClientBuild } = await import(new URL('client/ClientBuild.ts', clientRoot).href);
const { default: Scene } = await import(new URL('dash3d/World.ts', clientRoot).href);
const { default: CollisionMap } = await import(new URL('dash3d/CollisionMap.ts', clientRoot).href);
const { default: NpcType } = await import(new URL('config/NpcType.ts', clientRoot).href);
const { default: Model } = await import(new URL('dash3d/Model.ts', clientRoot).href);
const { default: Pix2D } = await import(new URL('graphics/Pix2D.ts', clientRoot).href);
const { default: Pix3D } = await import(new URL('dash3d/Pix3D.ts', clientRoot).href);
const { default: JagFile } = await import(new URL('io/JagFile.ts', clientRoot).href);
const { default: Packet } = await import(new URL('io/Packet.ts', clientRoot).href);
const { Int32Array3d, Uint8Array3d } = await import(new URL('util/Arrays.ts', clientRoot).href);
const { unzipSync, gunzipSync } = await import('fflate');
const heights = new Int32Array3d(4, 105, 105),
flags = new Uint8Array3d(4, 104, 104);
const build = new ClientBuild(104, 104, heights, flags),
scene = new Scene(heights, 104, 4, 104);
ClientBuild.lowMem = false;
ClientBuild.hueOff = 0;
ClientBuild.ligOff = 0;
const collisions = [new CollisionMap(), new CollisionMap(), new CollisionMap(), new CollisionMap()];
const zip = unzipSync(readFileSync('data/pack/ondemand.zip'));
const index = new Packet(new JagFile(readFileSync('data/pack/client/versionlist')).read('map_index')!);
const maps: { x: number; z: number; land: number; loc: number }[] = [];
while (index.pos < index.data.length) {
const coord = index.g2(),
land = index.g2(),
loc = index.g2();
index.g1();
const x = coord >> 8,
z = coord & 255;
if (x >= 49 && x <= 50 && z >= 53 && z <= 54) maps.push({ x, z, land, loc });
}
for (const m of maps) build.loadGround(gunzipSync(zip[`4.${m.land}`]), 3136, 3392, (m.x - 49) * 64, (m.z - 53) * 64);
for (const m of maps) build.loadLocations(gunzipSync(zip[`4.${m.loc}`]), (m.x - 49) * 64, (m.z - 53) * 64, scene, collisions);
build.finishBuild(scene, collisions);
assert(
scene.squares[0][44][51]?.sprites.some((sprite: any) => sprite && ((sprite.typecode >> 14) & 32767) === ids.get('grand_exchange_booth')),
'The client map uses the existing bank table for the exchange'
);
let npcSection = false;
for (const line of readFileSync('../content/maps/m49_53.jm2', 'utf8').split('\n')) {
if (line.startsWith('====')) {
npcSection = line.includes('NPC');
continue;
}
if (!npcSection || !line.startsWith('0 ')) continue;
const [coords, id] = line.split(': ');
const [, x, z] = coords.split(' ').map(Number);
if (x < 44 || x > 52 || z < 41 || z > 55) continue;
assert(Number(id) < NpcType.idx.length, 'NPC is available in the client cache');
const model = NpcType.list(Number(id)).getTempModel(-1, -1, null);
if (model) {
const copy = Model.copyForAnim(model, true, true, false);
copy.faceColourA = model.faceColourA;
copy.faceColourB = model.faceColourB;
copy.faceColourC = model.faceColourC;
copy.calcBoundingCylinder();
scene.addDynamic(0, x * 128 + 64, heights[0][x][z], z * 128 + 64, copy, 1, 0, 60, false);
}
}
// A close view of the teller also verifies the new NPC's packed appearance.
const tellerId = Number(
readFileSync('../content/pack/npc.pack', 'utf8')
.split('\n')
.find(line => line.endsWith('=grand_exchange_teller'))!
.split('=')[0]
);
const tellerType = NpcType.list(tellerId);
assert.equal(tellerType.name, 'Grand Exchange teller');
assert.equal(tellerType.op[0], 'Exchange');
const tellerModel = tellerType.getTempModel(-1, -1, null)!;
const portrait = new Int32Array(180 * 240);
Pix2D.setPixels(portrait, 180, 240);
Pix3D.setRenderClipping();
Pix2D.fillRect(0, 0, 180, 240, 0x38382e);
tellerModel.objRender(0, 0, 0, 0, 0, Math.floor(tellerModel.minY / 2), Math.floor((tellerModel.minY * 512) / (240 * 0.8)));
const portraitRgba = Buffer.alloc(portrait.length * 4);
portrait.forEach((rgb: number, i: number) => {
portraitRgba[i * 4] = (rgb >> 16) & 255;
portraitRgba[i * 4 + 1] = (rgb >> 8) & 255;
portraitRgba[i * 4 + 2] = rgb & 255;
portraitRgba[i * 4 + 3] = 255;
});
await new Jimp({ width: 180, height: 240, data: portraitRgba }).write(`${out}/teller.png`);
const w = 800,
h = 650,
pixels = new Int32Array(w * h);
Pix2D.setPixels(pixels, w, h);
Pix3D.setRenderClipping();
Pix3D.lowDetail = false;
const distance = new Int32Array(9);
for (let i = 0; i < 9; i++) {
const angle = i * 32 + 143;
distance[i] = ((angle * 3 + 600) * Pix3D.sinTable[angle]) >> 16;
}
Scene.resetVisCalc(distance, 500, 1200, w, h);
scene.renderAll(48 * 128, heights[0][48][48] - 1350, 48 * 128 - 1350, 0, 0, 256);
const rgba = Buffer.alloc(pixels.length * 4);
pixels.forEach((rgb: number, i: number) => {
rgba[i * 4] = (rgb >> 16) & 255;
rgba[i * 4 + 1] = (rgb >> 8) & 255;
rgba[i * 4 + 2] = rgb & 255;
rgba[i * 4 + 3] = 255;
});
await new Jimp({ width: w, height: h, data: rgba }).write(`${out}/west-bank.png`);
console.log('Native booth and bank scene previews written to ' + out);
+209
View File
@@ -0,0 +1,209 @@
// Render the actual 274 interface/client drawing code with disposable market fixtures.
// Run from server/engine: bun test/market-preview.ts [output-directory]
import { mkdtempSync, rmSync, readFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve, join } from 'node:path';
import { Jimp } from 'jimp';
import { strict as assert } from 'node:assert';
const temp = mkdtempSync(join(tmpdir(), 'ge-preview-'));
process.env.GE_DATABASE = join(temp, 'market.sqlite');
const out = resolve(process.argv[2] ?? '../../screenshots/grand-exchange');
mkdirSync(out, { recursive: true });
const { default: World } = await import('../src/engine/World.js');
const { default: Component } = await import('../src/cache/config/Component.js');
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 { marketStore } = await import('../src/engine/market/MarketStore.js');
try {
World.reload();
const clientRoot = new URL('../../webclient/src/', import.meta.url);
// Dynamic imports preserve the webclient package's #/ aliases.
await import(new URL('lite/dom-shim.ts', clientRoot).href);
const { ItemViewer } = await import(new URL('viewer/ItemViewer.ts', clientRoot).href);
const viewer = new ItemViewer();
viewer.initFromData(
{
config: readFileSync('data/pack/client/config'),
textures: readFileSync('data/pack/client/textures'),
versionlist: readFileSync('data/pack/client/versionlist'),
ondemand: readFileSync('data/pack/ondemand.zip')
},
{ lazyModels: true }
);
const { default: ClientObjType } = await import(new URL('config/ObjType.ts', clientRoot).href);
const { default: IfType } = await import(new URL('config/IfType.ts', clientRoot).href);
const { default: JagFile } = await import(new URL('io/JagFile.ts', clientRoot).href);
const { default: PixFont } = await import(new URL('graphics/PixFont.ts', clientRoot).href);
const { default: Pix2D } = await import(new URL('graphics/Pix2D.ts', clientRoot).href);
const { default: Pix3D } = await import(new URL('dash3d/Pix3D.ts', clientRoot).href);
const { MarketSearchInput } = await import(new URL('client/MarketSearchInput.ts', clientRoot).href);
const { Client } = await import(new URL('client/Client.ts', clientRoot).href);
const jag = (name: string) => new JagFile(readFileSync(`data/pack/client/${name}`));
const title = jag('title');
const fonts = ['p11_full', 'p12_full', 'b12_full', 'q8_full'].map((name, i) => PixFont.depack(title, name, i === 3));
IfType.init(jag('interface'), null, fonts);
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.write = (message: any) => {
const c = IfType.list[message.component];
if (message.component === undefined) return;
assert(c, `Server update references missing client component ${message.component}`);
switch (message.constructor.name) {
case 'IfSetText':
c.text = message.text;
break;
case 'IfSetObject': {
const obj = ClientObjType.list(message.obj);
c.model1Type = 4;
c.model1Id = message.obj;
c.modelXAn = obj.xan2d;
c.modelYAn = obj.yan2d;
c.modelZoom = Math.floor((obj.zoom2d * 100) / message.scale);
break;
}
case 'IfSetHide':
c.hide = message.hidden;
break;
case 'IfSetPosition':
c.x = message.x;
c.y = message.y;
break;
case 'IfSetColour': {
const n = message.colour;
c.colour = (((n >> 10) & 31) << 19) | (((n >> 5) & 31) << 11) | ((n & 31) << 3);
break;
}
case 'UpdateInvFull':
c.linkObjType.fill(0);
c.linkObjNumber.fill(0);
message.inv.items.forEach((item: any, i: number) => {
if (item) {
c.linkObjType[i] = item.id + 1;
c.linkObjNumber[i] = item.count;
}
});
break;
}
};
const save = async (name: string) => {
const pixels = new Int32Array(512 * 334);
Pix2D.setPixels(pixels, 512, 334);
Pix3D.setRenderClipping();
client.drawInterface(IfType.list[Component.getId('grand_exchange')], 0, 0, 0);
const rgba = Buffer.alloc(pixels.length * 4);
pixels.forEach((rgb: number, i: number) => {
rgba[i * 4] = (rgb >> 16) & 255;
rgba[i * 4 + 1] = (rgb >> 8) & 255;
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`);
};
openExchange(player, 3180, 3439);
await save('empty');
// Isolated visual fixtures, never connected to a game world or normal ledger.
const fixtures = [
['ashes', 'buy', 1000, 3, 400, 'cancelled'],
['rune_sword', 'sell', 1, 554, 1, 'completed'],
['eye_of_newt', 'buy', 498, 5, 0, 'open'],
['bucket_water', 'sell', 200, 31, 80, 'open']
];
for (let i = 0; i < fixtures.length; i++) {
const [name, side, quantity, price, filled, state] = fixtures[i];
const id = ObjType.getId(String(name));
if (id < 0) throw Error('Unknown fixture ' + name);
marketStore()
.db.query('INSERT INTO offers(owner,slot,item,side,quantity,price,remaining,filled,state,created,gross,tax,items,coins) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)')
.run(
player.username,
i,
id,
side as string,
quantity as number,
price as number,
state === 'open' ? (quantity as number) - (filled as number) : 0,
filled as number,
state as string,
Date.now(),
(filled as number) * (price as number),
side === 'sell' ? Math.floor(((filled as number) * (price as number)) / 20) : 0,
side === 'buy' ? (filled as number) : 0,
side === 'sell' ? (filled as number) * (price as number) - Math.floor(((filled as number) * (price as number)) / 20) : state === 'cancelled' ? ((quantity as number) - (filled as number)) * (price as number) : 0
);
}
exchangeButton(player, Component.getId('grand_exchange:home'));
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) {
exchangeButton(player, Component.getId('grand_exchange:home'));
exchangeButton(player, Component.getId(`grand_exchange:slot${slot}_view`));
await save(name);
}
exchangeButton(player, Component.getId('grand_exchange:home'));
exchangeButton(player, Component.getId('grand_exchange:slot4_buy'));
// Exercise the real client's keyboard routing and mouse hit testing on packed UI.
client.mainModalId = Component.getId('grand_exchange');
client.dialogInputOpen = false;
client.marketSearchInput = new MarketSearchInput(
(query: string, item: number) => {
assert(exchangeSearch(player, query, item));
},
() => player.closeModal()
);
const type = async (query: string) => {
client.marketSearchInput.sync(IfType.list, client.mainModalId);
client.marketSearchInput.click(Component.getId('grand_exchange:search_clear'));
const keys = [...query].map(char => char.charCodeAt(0)).concat(13);
client.pollKey = () => keys.shift() ?? -1;
await client.handleInputKey();
client.marketSearchInput.sync(IfType.list, client.mainModalId);
};
await type('rune');
await save('search');
await type('rscim');
await save('search-alias');
Object.assign(client, { menuNumEntries: 0, menuAction: [], menuParamA: [], menuParamB: [], menuParamC: [], menuOption: [] });
client.addComponentOptions(IfType.list[client.mainModalId], 38, 138, 0, 0, 0);
const last = client.menuNumEntries - 1;
assert.equal(client.menuParamC[last], Component.getId('grand_exchange:result0_select'), 'Thumbnail is the default left-click action');
client.doAction(last);
assert.equal(IfType.list[Component.getId('grand_exchange:offer_name')].text, 'Rune scimitar');
assert(IfType.list[Component.getId('grand_exchange:offer_sell_quantity')].hide, 'Buy quantity hides All');
client.menuNumEntries = 0;
client.addComponentOptions(IfType.list[client.mainModalId], 216, 219, 0, 0, 0);
assert(!client.menuParamC.slice(0, client.menuNumEntries).includes(Component.getId('grand_exchange:offer_qty_all')), 'Hidden All has no mouse action');
await save('search-selected');
client.marketSearchInput.sync(IfType.list, client.mainModalId);
exchangeButton(player, Component.getId('grand_exchange:row6'));
await type('unobtainium');
await save('search-empty');
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'));
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');
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) {
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`);
}
console.log('Native client renders written to ' + out);
} finally {
marketStore().db.close();
rmSync(temp, { recursive: true, force: true });
await Promise.all([World.loginThread.terminate(), World.friendThread.terminate(), World.loggerThread.terminate()]);
}
+34
View File
@@ -0,0 +1,34 @@
// Exercise the actual browser packet reader, including obsolete interface caches.
// Run from server/engine: bun test/market-protocol.ts
import { strict as assert } from 'node:assert';
const clientRoot = new URL('../../webclient/src/', import.meta.url);
await import(new URL('lite/dom-shim.ts', clientRoot).href);
const { Client } = await import(new URL('client/Client.ts', clientRoot).href);
const { default: IfType } = await import(new URL('config/IfType.ts', clientRoot).href);
const { default: Packet } = await import(new URL('io/Packet.ts', clientRoot).href);
const { ServerProt, ServerProtSizes } = await import(new URL('io/ServerProt.ts', clientRoot).href);
const frame = (opcode: number, payload: number[]) => [opcode, ...(ServerProtSizes[opcode] === -2 ? [payload.length >> 8, payload.length & 255] : ServerProtSizes[opcode] === -1 ? [payload.length] : []), ...payload];
const cases = [
[ServerProt.IF_SETTEXT, [...Buffer.from('{"isOpen":true}'), 10]],
[ServerProt.IF_SETHIDE, [1]],
[ServerProt.IF_SETCOLOUR, [0, 0]],
[ServerProt.IF_SETOBJECT, [0, 1, 0, 100]],
[ServerProt.IF_SETPOSITION, [0, 0, 0, 0]],
[ServerProt.UPDATE_INV_FULL, [0]]
] as const;
for (const [opcode, body] of cases) {
const client = Object.create(Client.prototype);
let offset = 0, logouts = 0;
const data = Uint8Array.from([...frame(opcode, [255, 255, ...body]), ...frame(ServerProt.IF_SETTEXT, [0, 0, ...Buffer.from('Still connected'), 10])]);
IfType.list = [{ text: '', layerId: 0 }];
Object.assign(client, {
in: new Packet(new Uint8Array(5000)), ptype: -1, randomIn: null, sideIcon: [0], activeIcon: 0,
stream: { get available() { return data.length - offset; }, async readBytes(dst: Uint8Array, start: number, count: number) { dst.set(data.subarray(offset, offset + count), start); offset += count; } },
logout: async () => { logouts++; }, addChat: () => {}
});
await client.tcpIn();
assert.equal(logouts, 0, `Unknown component in packet ${opcode} must not log out the player`);
await client.tcpIn();
assert.equal(IfType.list[0].text, 'Still connected', 'The following packet stays aligned and applies normally');
}
console.log('PASS: stale/missing exchange components never disconnect or desynchronize the native client.');
+81
View File
@@ -0,0 +1,81 @@
import { afterAll, expect, test } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import ObjType from '../src/cache/config/ObjType.js';
import { marketStore } from '../src/engine/market/MarketStore.js';
import { handleMarket } from '../src/web/pages/market.js';
const dir = mkdtempSync(join(tmpdir(), 'market-web-'));
const previousDatabase = process.env.GE_DATABASE;
process.env.GE_DATABASE = join(dir, 'market.sqlite');
ObjType.load(join(import.meta.dir, '../data/pack'));
afterAll(() => {
marketStore().db.close();
rmSync(dir, { recursive: true, force: true });
if (previousDatabase === undefined) delete process.env.GE_DATABASE;
else process.env.GE_DATABASE = previousDatabase;
});
const request = (path: string) => {
const url = new URL('http://localhost' + path);
return handleMarket(new Request(url), url)!;
};
test('HTTP rankings, search, pagination and item details use the same period metrics', async () => {
const now = Date.now(),
logs = ObjType.getId('logs'),
iron = ObjType.getId('iron_ore'),
coal = ObjType.getId('coal');
expect(logs).toBeGreaterThan(0);
const insert = marketStore().db.query('INSERT INTO trades(item,buy_offer,sell_offer,quantity,price,gross,tax,time) VALUES(?,1,2,?,?,?,0,?)');
for (const [id, oldPrice, price, quantity, age] of [
[logs, 100, 120, 1, 3000],
[iron, 200, 150, 50, 1000],
[coal, 300, 450, 2, 2000]
]) {
insert.run(id, 1, oldPrice, oldPrice, now - 8 * 86400000);
insert.run(id, quantity, price, quantity * price, now - age);
}
marketStore().db.query("INSERT INTO offers(owner,slot,item,side,quantity,price,remaining,created) VALUES('private-owner',-1,?,'sell',3,500,3,?)").run(coal, now);
const feather = ObjType.getId('feather');
const buyOffer = marketStore().db.query("INSERT INTO offers(owner,slot,item,side,quantity,price,remaining,created) VALUES('private-buyer',-1,?,'buy',?,?,?,?)");
buyOffer.run(feather, 50, 100, 50, now);
buyOffer.run(logs, 10, 70, 10, now);
buyOffer.run(iron, 20, 5, 20, now);
const ranked = async (sort: string, extra = '') => await request('/api/market/items?days=7&sort=' + sort + extra).json();
expect((await ranked('recent')).items[0].id).toBe(iron);
const buys = await ranked('buyValue');
expect(buys.items.map((i: { id: number }) => i.id)).toEqual([feather, logs, iron]);
expect(buys.items.map((i: { buyValue: number }) => i.buyValue)).toEqual([5000, 700, 100]);
expect(buys.items[0].lastPrice).toBeNull();
expect((await ranked('buyValue', '&limit=1&offset=1')).items[0].id).toBe(logs);
expect((await ranked('buyValue', '&q=iron')).items[0].id).toBe(iron);
expect((await request('/api/market/items?sort=buyValue&days=1').json()).items[0].buyValue).toBe(5000);
expect((await request('/api/market/items/' + feather + '?days=7').json()).buyValue).toBe(5000);
expect(JSON.stringify(buys)).not.toContain('private-buyer');
expect((await ranked('rises')).items[0].id).toBe(coal);
const falls = await ranked('falls');
expect(falls.items.map((i: { id: number }) => i.id)).toEqual([iron]);
expect(falls.items[0].changePercent).toBe(-25);
expect((await ranked('volume')).items[0].id).toBe(iron);
expect((await ranked('price', '&limit=1&offset=1')).items[0].id).toBe(iron);
const caps = await ranked('marketCap');
expect(caps.items[0].id).toBe(coal);
expect(caps.items[0].marketCap).toBe(1350);
expect(caps.summary.volume).toBe(53);
expect(caps.featured.id).toBe(iron);
expect(JSON.stringify(caps)).not.toContain('private-owner');
expect((await ranked('price', '&q=logs')).items.map((i: { id: number }) => i.id)).toEqual([logs]);
expect((await ranked('price', '&q=nonexistent-item')).total).toBe(0);
expect((await request('/api/market/items/' + coal + '?days=7').json()).changePercent).toBe(50);
expect((await request('/api/market/items/' + coal + '/history?from=' + (now - 7 * 86400000) + '&to=' + now).json()).history[0].volume).toBe(2);
expect((await ranked('rises', '&q=coal')).total).toBe(1);
// A long period without an earlier price must not claim a percentage gain.
expect((await request('/api/market/items?sort=rises&days=90').json()).total).toBe(0);
expect(request('/api/market/items?sort=invalid').status).toBe(400);
expect(request('/api/market/items?days=2').status).toBe(400);
expect(request('/api/market/items?limit=101').status).toBe(400);
expect(request('/api/market/items/999999').status).toBe(404);
expect((await request('/api/market/items?q=logs').json()).items.length).toBeGreaterThan(1);
});
+331
View File
@@ -0,0 +1,331 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { MarketStore, MAX_GP, type MarketAccount } from '../src/engine/market/MarketStore.js';
const COINS = 995,
ITEM = 1511;
let stores: MarketStore[] = [];
afterEach(() => {
for (const s of stores) s.db.close();
stores = [];
});
function store(path = ':memory:') {
const s = new MarketStore(path);
stores.push(s);
return s;
}
class Wallet {
inv: Record<number, number>;
capacity = MAX_GP;
failSave = false;
constructor(readonly owner: string, coins = 0, items = 0) {
this.inv = { [COINS]: coins, [ITEM]: items };
}
account(): MarketAccount {
const before = { ...this.inv };
return {
owner: this.owner,
take: (id, n) => {
if ((this.inv[id] ?? 0) < n) throw Error('Insufficient inventory');
this.inv[id] -= n;
},
give: (id, n) => {
const added = Math.min(n, Math.max(0, this.capacity - (this.inv[id] ?? 0)));
this.inv[id] = (this.inv[id] ?? 0) + added;
return added;
},
save: () => {
if (this.failSave) throw Error('Disk failure');
return new TextEncoder().encode(JSON.stringify(this.inv));
},
rollback: () => {
this.inv = before;
}
};
}
}
describe('Grand Exchange conservation and matching', () => {
test('unmatched offers escrow real assets and never invent a counterparty', () => {
const s = store(),
a = new Wallet('buyer', 200);
const o = s.place(a.account(), ITEM, 'buy', 10, 20, COINS);
expect(a.inv[COINS]).toBe(0);
expect(o.remaining).toBe(10);
expect(o.items).toBe(0);
expect(s.quote(ITEM).volume).toBe(0);
});
test('resting sell price, buyer refund, tax and partial collection', () => {
const s = store(),
a = new Wallet('seller', 0, 10),
b = new Wallet('buyer', 200);
const sell = s.place(a.account(), ITEM, 'sell', 10, 10, COINS);
const buy = s.place(b.account(), ITEM, 'buy', 10, 20, COINS);
expect(buy.items).toBe(10);
expect(buy.coins).toBe(100);
expect(s.offers(a.owner)[0]?.coins).toBe(95);
expect(s.quote(ITEM).tax).toBe(5);
s.collect(a.account(), sell.id, COINS);
s.collect(b.account(), buy.id, COINS);
expect(a.inv[ITEM] + b.inv[ITEM]).toBe(10);
expect(a.inv[COINS] + b.inv[COINS] + 5).toBe(200);
expect(s.offers(a.owner)).toEqual([]);
expect(s.offers(b.owner)).toEqual([]);
expect(() => s.collect(b.account(), buy.id, COINS)).toThrow();
expect(b.inv[ITEM]).toBe(10);
});
test('incoming sell receives higher resting bid', () => {
const s = store(),
a = new Wallet('buyer', 200),
b = new Wallet('seller', 0, 10);
s.place(a.account(), ITEM, 'buy', 10, 20, COINS);
const sell = s.place(b.account(), ITEM, 'sell', 10, 10, COINS);
expect(sell.coins).toBe(190);
expect(sell.tax).toBe(10);
expect(s.quote(ITEM).lastPrice).toBe(20);
});
test('best price then FIFO, across multiple offers; self-match excluded', () => {
const s = store(),
a = new Wallet('a', 1000, 5),
b = new Wallet('b', 0, 5),
c = new Wallet('c', 0, 5),
d = new Wallet('d', 0, 5);
const self = s.place(a.account(), ITEM, 'sell', 5, 1, COINS);
const high = s.place(b.account(), ITEM, 'sell', 5, 20, COINS);
const older = s.place(c.account(), ITEM, 'sell', 5, 10, COINS);
const newer = s.place(d.account(), ITEM, 'sell', 5, 10, COINS);
s.place(a.account(), ITEM, 'buy', 7, 20, COINS);
const remaining = (id: number) => s.db.query<{ remaining: number }, [number]>('SELECT remaining FROM offers WHERE id=?').get(id)?.remaining;
expect(remaining(self.id)).toBe(5);
expect(remaining(high.id)).toBe(5);
expect(remaining(older.id)).toBe(0);
expect(remaining(newer.id)).toBe(3);
});
test('partial cancel refunds only unfilled escrow and preserves completed assets', () => {
const s = store(),
b = new Wallet('b', 200),
a = new Wallet('a', 0, 4);
const buy = s.place(b.account(), ITEM, 'buy', 10, 20, COINS);
s.place(a.account(), ITEM, 'sell', 4, 10, COINS);
s.cancel(b.owner, buy.id);
s.cancel(b.owner, buy.id);
const o = s.offers(b.owner)[0]!;
expect(o.items).toBe(4);
expect(o.coins).toBe(120);
expect(o.state).toBe('cancelled');
s.collect(b.account(), buy.id, COINS);
expect(b.inv[COINS]).toBe(120);
});
test('cancel sell returns unsold items; full backpack retains collection', () => {
const s = store(),
a = new Wallet('a', 0, 10);
const o = s.place(a.account(), ITEM, 'sell', 10, 10, COINS);
s.cancel(a.owner, o.id);
a.capacity = 3;
s.collect(a.account(), o.id, COINS);
expect(a.inv[ITEM]).toBe(3);
expect(s.offers(a.owner)[0]?.items).toBe(7);
a.capacity = 100;
s.collect(a.account(), o.id, COINS);
expect(a.inv[ITEM]).toBe(10);
expect(s.offers(a.owner)).toEqual([]);
});
test('six occupied slots include uncollected completed and cancelled offers', () => {
const s = store(),
a = new Wallet('a', 1000);
for (let i = 0; i < 6; i++) s.place(a.account(), ITEM, 'buy', 1, 1, COINS);
expect(() => s.place(a.account(), ITEM, 'buy', 1, 1, COINS)).toThrow('six');
const first = s.offers(a.owner)[0]!;
s.cancel(a.owner, first.id);
expect(() => s.place(a.account(), ITEM, 'buy', 1, 1, COINS)).toThrow('six');
s.collect(a.account(), first.id, COINS);
expect(s.place(a.account(), ITEM, 'buy', 1, 1, COINS).slot).toBe(0);
});
test('foreign owners cannot cancel or collect', () => {
const s = store(),
a = new Wallet('a', 100),
b = new Wallet('b');
const o = s.place(a.account(), ITEM, 'buy', 1, 10, COINS);
expect(() => s.cancel('b', o.id)).toThrow();
expect(() => s.collect(b.account(), o.id, COINS)).toThrow();
});
test('invalid totals and insufficient assets do not change ledger or inventory', () => {
const s = store(),
a = new Wallet('a', 100);
for (const n of [0, -1, 1.5, NaN, Infinity, MAX_GP + 1]) expect(() => s.place(a.account(), ITEM, 'buy', n, 10, COINS)).toThrow();
expect(() => s.place(a.account(), ITEM, 'buy', MAX_GP, 2, COINS)).toThrow();
expect(() => s.place(a.account(), ITEM, 'buy', 11, 10, COINS)).toThrow();
expect(s.offers(a.owner)).toEqual([]);
expect(a.inv[COINS]).toBe(100);
});
test('partial fills round tax cumulatively: twenty 1 gp trades burn 1 gp', () => {
const s = store(),
a = new Wallet('a', 0, 20);
s.place(a.account(), ITEM, 'sell', 20, 1, COINS);
for (let i = 0; i < 20; i++) s.place(new Wallet('buyer' + i, 1).account(), ITEM, 'buy', 1, 1, COINS);
expect(s.offers(a.owner)[0]?.coins).toBe(19);
expect(s.quote(ITEM).tax).toBe(1);
});
test('serialization failure rolls back matching, tax, escrow and snapshots', () => {
const s = store(),
a = new Wallet('a', 0, 10),
b = new Wallet('b', 100);
s.place(a.account(), ITEM, 'sell', 10, 10, COINS);
b.failSave = true;
expect(() => s.place(b.account(), ITEM, 'buy', 10, 10, COINS)).toThrow('Disk');
expect(b.inv[COINS]).toBe(100);
expect(s.offers(a.owner)[0]?.remaining).toBe(10);
expect(s.quote(ITEM).volume).toBe(0);
expect(s.saved(b.owner)).toBeUndefined();
});
test('failed collection serialization restores claims and inventory', () => {
const s = store(),
a = new Wallet('a', 100);
const o = s.place(a.account(), ITEM, 'buy', 10, 10, COINS);
s.cancel(a.owner, o.id);
a.failSave = true;
expect(() => s.collect(a.account(), o.id, COINS)).toThrow();
expect(a.inv[COINS]).toBe(0);
expect(s.offers(a.owner)[0]?.coins).toBe(100);
});
test('restart restores market and matching player checkpoint, then latest normal save', () => {
const dir = mkdtempSync(join(tmpdir(), 'rs-ge-')),
path = join(dir, 'market.sqlite');
let s = new MarketStore(path);
try {
const a = new Wallet('a', 100);
const o = s.place(a.account(), ITEM, 'buy', 10, 10, COINS);
s.db.close();
s = new MarketStore(path);
expect(JSON.parse(new TextDecoder().decode(s.saved('a')))[COINS]).toBe(0);
expect(s.offers('a')[0]?.id).toBe(o.id);
s.cancel('a', o.id);
s.collect(a.account(), o.id, COINS);
a.inv[COINS] = 80;
s.checkpoint('a', a.account().save());
s.db.close();
s = new MarketStore(path);
expect(JSON.parse(new TextDecoder().decode(s.saved('a')))[COINS]).toBe(80);
expect(s.offers('a')).toEqual([]);
} finally {
s.db.close();
rmSync(dir, { recursive: true, force: true });
}
});
test('history aggregates only real fills with quantity-weighted prices', () => {
const s = store(),
a = new Wallet('a', 0, 4),
b = new Wallet('b', 100);
s.place(a.account(), ITEM, 'sell', 1, 10, COINS);
s.place(a.account(), ITEM, 'sell', 3, 20, COINS);
s.place(b.account(), ITEM, 'buy', 4, 25, COINS);
const h = s.history(ITEM, 0, Date.now() + 1, 86400000);
expect(h).toHaveLength(1);
expect(h[0]?.volume).toBe(4);
expect(h[0]?.vwap).toBe(17.5);
expect(h[0]?.low).toBe(10);
expect(h[0]?.high).toBe(20);
});
test('SQLite write failure rolls back real escrow and all matching effects', () => {
const s = store(),
a = new Wallet('a', 0, 10),
b = new Wallet('b', 100);
s.place(a.account(), ITEM, 'sell', 10, 10, COINS);
s.db.exec("CREATE TRIGGER fail_trade BEFORE INSERT ON trades BEGIN SELECT RAISE(ABORT, 'simulated I/O failure'); END");
expect(() => s.place(b.account(), ITEM, 'buy', 10, 10, COINS)).toThrow('simulated');
expect(b.inv[COINS]).toBe(100);
expect(s.offers(a.owner)[0]?.remaining).toBe(10);
expect(s.quote(ITEM).tax).toBe(0);
expect(s.offers(b.owner)).toEqual([]);
});
test('mixed matching and cancellation conserves all assets over many offers', () => {
const s = store(),
wallets = Array.from({ length: 8 }, (_, i) => new Wallet('p' + i, 10000, 100));
let seed = 321;
const random = (max: number) => {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed % max;
};
for (let i = 0; i < 160; i++) {
const w = wallets[random(wallets.length)]!;
for (const o of s.offers(w.owner)) {
if (o.state !== 'open' || random(3) === 0) {
s.cancel(w.owner, o.id);
s.collect(w.account(), o.id, COINS);
}
}
if (s.offers(w.owner).length < 6) s.place(w.account(), ITEM, random(2) ? 'buy' : 'sell', 1 + random(4), 10 + random(15), COINS);
}
for (const w of wallets)
for (const o of s.offers(w.owner)) {
s.cancel(w.owner, o.id);
s.collect(w.account(), o.id, COINS);
}
expect(s.quote(ITEM).volume).toBeGreaterThan(0);
expect(wallets.reduce((n, w) => n + w.inv[ITEM], 0)).toBe(800);
expect(wallets.reduce((n, w) => n + w.inv[COINS], 0) + s.quote(ITEM).tax).toBe(80000);
});
});
describe('Market overview metrics', () => {
test('buy value sums unfilled commitments at each bid price and drops after fills and cancellations', () => {
const s = store();
const a = new Wallet('a', 1000),
b = new Wallet('b', 150);
const first = s.place(a.account(), ITEM, 'buy', 10, 100, COINS);
s.place(b.account(), ITEM, 'buy', 3, 50, COINS);
s.place(new Wallet('expensive-sell', 0, 1).account(), ITEM, 'sell', 1, 1000000, COINS);
const value = () => {
const row = s.overview()[0]!;
expect(row.buyValue).toBe(s.quote(ITEM).buyValue);
return row.buyValue;
};
expect(s.quote(ITEM).lastPrice).toBeNull();
expect(value()).toBe(1150);
s.place(new Wallet('seller', 0, 4).account(), ITEM, 'sell', 4, 90, COINS);
expect(value()).toBe(750);
s.cancel(a.owner, first.id);
expect(value()).toBe(150);
s.place(new Wallet('seller2', 0, 3).account(), ITEM, 'sell', 3, 50, COINS);
expect(value()).toBe(0);
});
test('period boundaries, weighted totals, price changes and listed supply use real ledger values', () => {
const s = store();
const insert = s.db.query('INSERT INTO trades(item,buy_offer,sell_offer,quantity,price,gross,tax,time) VALUES(?,1,2,?,?,?,?,?)');
insert.run(ITEM, 10, 100, 1000, 50, 999);
insert.run(ITEM, 2, 120, 240, 12, 1000);
insert.run(ITEM, 3, 150, 450, 22, 1500);
insert.run(ITEM, 1, 160, 160, 8, 1500); // Same-time trades use newest ID.
insert.run(ITEM, 1, 999, 999, 49, 2000); // Excluded upper boundary.
s.place(new Wallet('seller', 0, 7).account(), ITEM, 'sell', 7, 200, COINS);
s.place(new Wallet('bidder', 200).account(), ITEM, 'buy', 2, 50, COINS);
const row = s.overview(1000, 2000)[0]!;
expect(row.volume).toBe(6);
expect(row.gross).toBe(850);
expect(row.vwap).toBeCloseTo(850 / 6);
expect(row.trades).toBe(3);
expect(row.referencePrice).toBe(100);
expect(row.lastPrice).toBe(160);
expect(row.lastTradeAt).toBe(1500);
expect(row.change).toBe(60);
expect(row.changePercent).toBe(60);
expect(row.marketCap).toBe(1120); // Seven listed sells, excluding buy demand.
expect(row.ask).toBe(200);
expect(row.bid).toBe(50);
expect(row.buyQuantity).toBe(2);
});
test('newly traded and untraded items do not fabricate comparison prices or market cap', () => {
const s = store();
s.place(new Wallet('seller', 0, 10).account(), ITEM, 'sell', 10, 100, COINS);
let row = s.overview(0, Date.now() + 1)[0]!;
expect(row.marketCap).toBeNull();
expect(row.lastPrice).toBeNull();
expect(row.changePercent).toBeNull();
s.place(new Wallet('buyer', 500).account(), ITEM, 'buy', 5, 100, COINS);
row = s.overview(0, Date.now() + 1)[0]!;
expect(row.changePercent).toBeNull();
expect(row.volume).toBe(5);
expect(row.marketCap).toBe(500);
expect(s.overview(Date.now() + 1, Date.now() + 100)[0]!.volume).toBe(0);
});
});
+1
View File
@@ -387,6 +387,7 @@
<a class="green" href="#" id="screenshot" onclick="saveScreenshot();">Take screenshot</a> |
<a class="green" href="/mapview/" target="_blank">Map</a> |
<a class="green" href="/hiscores">Hiscores</a> |
<% if (geEnabled) { %><a class="green" href="/market">Market</a> |<% } %>
<a class="green" href="https://discord.gg/3DcuU5cMJN" target="_blank">Discord</a> |
<a class="green" href="https://github.com/MaxBittker/rs-sdk" target="_blank">GitHub</a> |
<span class="green" id="player-count">0 online</span>
@@ -139,6 +139,9 @@ export class ActionExecutor {
'Failed to click dialog option'
);
case 'searchGE':
return this.wrapBool(this.client.searchGE(action.query), 'GE search dispatched', 'Open the GE catalogue before searching');
case 'clickComponent':
// IF_BUTTON packet - for simple buttons, spellcasting, etc.
return this.wrapBool(
@@ -0,0 +1,78 @@
import { expect, test } from 'bun:test';
import { MarketSearchInput, displayedSearchItem, SEARCH_FIELD, SEARCH_CLEAR, SEARCH_ACK, RESULT_BUTTON, RESULT_ICON, SEARCH_PLACEHOLDER, type SearchComponent } from '../client/MarketSearchInput';
function fixture() {
const list: SearchComponent[] = Array.from({ length: 7 }, (_, id) => ({ id, clientCode: 0, type: 4, hide: false, children: null, text: '' }));
Object.assign(list[0], { type: 0, children: [1, 2, 3, 4, 5] });
Object.assign(list[1], { clientCode: SEARCH_FIELD, text: SEARCH_PLACEHOLDER });
list[2].clientCode = SEARCH_CLEAR;
list[3].clientCode = SEARCH_ACK;
list[4].clientCode = RESULT_BUTTON;
Object.assign(list[5], { clientCode: RESULT_ICON, type: 2, linkObjType: [1334] });
const sent: { query: string; item: number }[] = [];
let closed = false;
const input = new MarketSearchInput(
(query, item) => sent.push({ query, item }),
() => (closed = true)
);
input.sync(list, 0, false, 0);
const type = (s: string, now = 10) => {
for (const char of s) expect(input.key(char.charCodeAt(0), now)).toBe(true);
};
return { list, input, sent, type, closed: () => closed };
}
test('typing debounces, stays out of chat, and Enter flushes immediately', () => {
const { input, list, sent, type } = fixture();
type('rscim');
input.sync(list, 0, false, 189);
expect(sent).toHaveLength(0);
input.sync(list, 0, false, 190);
expect(sent).toEqual([{ query: 'rscim', item: 0 }]);
expect(list[1].text).toBe('rscim*');
expect(input.key(8, 200)).toBe(true);
input.key(13, 201);
expect(sent[1]).toEqual({ query: 'rsci', item: 0 });
expect(input.key('@'.charCodeAt(0))).toBe(true);
});
test('stale results cannot select another item while filtering is in flight', () => {
const { input, list, sent, type } = fixture();
type('rune');
input.click(4);
expect(sent).toEqual([{ query: 'rune', item: 0 }]);
list[3].text = 'rune';
input.click(4);
expect(sent[1]).toEqual({ query: 'rune', item: 1333 });
expect(displayedSearchItem(list, 0, 99)).toBeUndefined();
list[0].hide = true;
expect(displayedSearchItem(list, 0, 4)).toBeUndefined();
});
test('reopening uses the server receipt, never the painted cursor or truncated query', () => {
const { input, list, sent } = fixture();
input.sync(list, -1);
list[1].text = '...a truncated display*';
list[3].text = 'rscim';
input.sync(list, 0);
input.click(4);
expect(sent).toEqual([{query:'rscim',item:1333}]);
});
test('clear, navigation, input bounds and modal closure flush or discard as appropriate', () => {
const { input, list, sent, type, closed } = fixture();
type('x'.repeat(60));
input.key(13);
expect(sent[0].query).toHaveLength(48);
input.click(2);
expect(sent[1]).toEqual({ query: '', item: 0 });
type('logs');
expect(input.click(6)).toBe(false);
expect(sent[2]).toEqual({ query: 'logs', item: 0 });
type('oops');
input.sync(list, -1, false, 1000);
input.sync(list, -1, false, 2000);
expect(sent).toHaveLength(3);
expect(input.key(65)).toBe(false);
list[1].text = 'logs';
input.sync(list, 0, false, 2001);
input.key(27);
expect(closed()).toBe(true);
});
@@ -6,6 +6,7 @@ import type ClientNpc from '#/dash3d/ClientNpc.js';
import type ClientPlayer from '#/dash3d/ClientPlayer.js';
import type ClientObj from '#/dash3d/ClientObj.js';
import IfType from '#/config/IfType.js';
import { collectGEState } from './ge-state.js';
import ObjType from '#/config/ObjType.js';
import NpcType from '#/config/NpcType.js';
import LocType from '#/config/LocType.js';
@@ -178,6 +179,7 @@ export class BotStateCollector implements ScanProvider {
shop: this.collectShopState(),
bank: this.collectBankState(),
trade: this.collectTradeState(),
ge: collectGEState(IfType.list, c.mainModalId ?? -1),
inGame: c.ingame || false,
combatEvents: [...this.combatEvents], // Return copy of events
dialog: this.collectDialogState(),
+7 -1
View File
@@ -269,8 +269,14 @@ export function formatWorldStateForAgent(state: BotWorldState, goal: string): st
}
}
if (state.ge?.isOpen && state.modalOpen) {
lines.push('', `### Grand Exchange: ${state.ge.screen}`);
for (const offer of state.ge.offers) lines.push(`Offer #${offer.id} (slot ${offer.slot}): ${offer.side} ${offer.quantity} ${offer.name} @ ${offer.price} gp; ${offer.filled} filled; collect ${offer.items} items / ${offer.coins} gp`);
if (state.ge.error) lines.push(`GE error: ${state.ge.error}`);
}
// Interface state (crafting menus like fletching)
if (state.interface && state.interface.isOpen) {
if (state.interface && state.interface.isOpen && !state.ge?.isOpen) {
lines.push('');
lines.push(`### Interface Open (id: ${state.interface.interfaceId})`);
if (state.interface.options.length > 0) {
+93
View File
@@ -0,0 +1,93 @@
import { afterEach, expect, test } from 'bun:test';
import '../lite/dom-shim.js';
import IfType from '#/config/IfType.js';
import { Client } from '#/client/Client.js';
import { getInterfaceOptions, searchGE } from '../lite/actions.js';
import { collectGEState } from './ge-state.js';
import type { GEState } from '../../../../sdk/ge-types.js';
import Packet from '#/io/Packet.js';
import { MarketSearchInput } from '../client/MarketSearchInput.js';
import { ClientProt } from '#/io/ClientProt.js';
const original = IfType.list;
afterEach(() => {
IfType.list = original;
});
test('viewport collectors omit hidden descendants and prefer displayed text', () => {
IfType.list = [
{ type: 0, children: [1, 3, 4] },
{ type: 0, hide: true, children: [2] },
{ type: 4, buttonType: 1, buttonText: 'Confirm offer', text: '' },
{ type: 4, buttonType: 1, buttonText: 'Select', text: 'Collect as banknotes and coins' }
] as any;
for (const options of [getInterfaceOptions({ mainModalId: 0 } as any), Client.prototype.getInterfaceOptions.call({ mainModalId: 0 } as any)]) {
expect(options).toEqual([{ index: 1, text: 'Collect as banknotes and coins', componentId: 3 }]);
}
});
test('GE metadata is atomic, root-scoped, and unavailable when closed or invalid', () => {
const state: GEState = {
isOpen: true, session: 1, revision: 2, actionRevision: 0, screen: 'home', offers: [],
controls: {home: 10}, selectedOffer: null, draft: null, search: null, input: null,
notice: '', error: null, receipt: null,
};
const list = [
{ clientCode: 0, text: null, children: [1] },
{ clientCode: 30403, text: JSON.stringify(state), children: null }
];
expect(collectGEState(list, 0)).toEqual(state);
expect(collectGEState(list, -1)).toBeNull();
expect(collectGEState(list, 999)).toBeNull();
list[1]!.text = '{';
expect(collectGEState(list, 0)).toBeNull();
list[1]!.text = '';
expect(collectGEState(list, 0)).toBeNull();
});
test('bot search uses the native MARKET_SEARCH wire packet and requires visible catalogue', () => {
IfType.list = [
{ id: 0, type: 0, children: [1] },
{ id: 1, type: 4, clientCode: 30400, children: null }
] as any;
for (const normal of [false, true]) {
const out = Packet.alloc(1);
const marketSearchInput = new MarketSearchInput(
(query, item) => {
out.p1(ClientProt.MARKET_SEARCH);
out.p1(query.length + 3);
out.pjstr(query);
out.p2(item);
},
() => {}
);
const client = {
marketSearchInput,
ingame: true,
isInGame: () => true,
mainModalId: 0,
dialogInputOpen: false,
out,
writeOpcode: (id: number) => out.p1(id)
};
const send = (query: string) => (normal ? Client.prototype.searchGE.call(client as any, query) : searchGE(client as any, query));
expect(send('rscim')).toBe(true);
out.pos = 0;
expect(out.g1()).toBe(ClientProt.MARKET_SEARCH);
expect(out.g1()).toBe(8);
expect(out.gjstr()).toBe('rscim');
expect(out.g2()).toBe(0);
out.pos = 0;
client.mainModalId = -1;
expect(send('logs')).toBe(false);
expect(out.pos).toBe(0);
client.mainModalId = 0;
client.dialogInputOpen = true;
expect(send('logs')).toBe(false);
expect(out.pos).toBe(0);
client.dialogInputOpen = false;
expect(send('x'.repeat(49))).toBe(false);
expect(send('☃')).toBe(false);
expect(out.pos).toBe(0);
out.release();
}
});
+35
View File
@@ -0,0 +1,35 @@
import type { GEState } from '../../../../sdk/ge-types.js';
interface Component {
clientCode: number;
text: string | null;
children: number[] | null;
}
/** Reads an atomic server snapshot, never reconstructs offers from labels or cached inventory icons. */
export function collectGEState(list: Component[], root: number): GEState | null {
if (root < 0) return null;
const scan = (id: number, depth = 0): GEState | null => {
const c = list[id];
if (!c || depth > 12) return null;
if (c.clientCode === 30403) {
try {
const s = JSON.parse(c.text ?? '') as GEState;
return s.isOpen === true &&
Number.isSafeInteger(s.session) &&
Number.isSafeInteger(s.revision) &&
Number.isSafeInteger(s.actionRevision) &&
Array.isArray(s.offers) &&
s.controls
? s
: null;
} catch {
return null;
}
}
for (const child of c.children ?? []) {
const state = scan(child, depth + 1);
if (state) return state;
}
return null;
};
return scan(root);
}
+3
View File
@@ -1,3 +1,4 @@
import type { GEState } from '../../../../sdk/ge-types.js';
// types.ts - Bot SDK Type Definitions
// All interfaces, constants, and type definitions for the Bot SDK
@@ -424,6 +425,7 @@ export interface BotState {
shop: ShopState;
bank: BankState;
trade: TradeState;
ge?: GEState | null;
inGame: boolean;
/** Recent combat events (damage, kills) - bounded to last ~50 ticks */
combatEvents: CombatEvent[];
@@ -509,6 +511,7 @@ export type BotAction =
| { type: 'privateMessage'; targetName: string; message: string; reason: string }
| { type: 'bankDeposit'; slot: number; amount: number; reason: string }
| { type: 'bankWithdraw'; slot: number; amount: number; reason: string }
| { type: 'searchGE'; query: string; reason: string }
| { type: 'submitCountDialog'; value: number; reason: string }
// On-demand scanning (returns data in action result)
| { type: 'scanNearbyLocs'; radius?: number; reason: string }
+52 -6
View File
@@ -1,3 +1,4 @@
import { MarketSearchInput, RESULT_ICON, visibleSearchComponent, SEARCH_FIELD } from '#/client/MarketSearchInput.js';
import { playWave, setWaveVolume } from '#3rdparty/audio.js';
import { stopMidi, setMidiVolume, playMidi } from '#3rdparty/tinymidipcm.js';
@@ -644,6 +645,18 @@ export class Client extends GameShell {
private idkDesignColour: Int32Array = new Int32Array(5);
private idkDesignButton1: Pix32 | null = null;
private idkDesignButton2: Pix32 | null = null;
private readonly marketSearchInput = new MarketSearchInput((query, item) => {
this.out.p1Enc(ClientProt.MARKET_SEARCH);
this.out.p1(query.length + 3);
this.out.pjstr(query);
this.out.p2(item);
}, () => this.closeModal());
private handleMarketComponent(id: number): boolean {
this.marketSearchInput?.sync(IfType.list, this.mainModalId, this.dialogInputOpen);
return this.marketSearchInput?.click(id) ?? false;
}
private readonly searchParams: URLSearchParams;
// rs-sdk: Bot SDK overlay for bot development (dynamically loaded when enabled)
@@ -1868,6 +1881,12 @@ export class Client extends GameShell {
/**
* Submit a value for the P_COUNTDIALOG prompt (e.g. Withdraw-X, Deposit-X).
*/
searchGE(query: string): boolean {
if (!this.ingame || this.dialogInputOpen || !/^[a-zA-Z0-9 '()*-]{0,48}$/.test(query) || !visibleSearchComponent(IfType.list, this.mainModalId, SEARCH_FIELD)) return false;
this.marketSearchInput.sync(IfType.list, this.mainModalId, this.dialogInputOpen);
return this.marketSearchInput.setQuery(query);
}
submitCountDialog(value: number): boolean {
if (!this.ingame || !this.out) {
console.log('[Client] submitCountDialog failed - not in game');
@@ -2802,14 +2821,16 @@ export class Client extends GameShell {
return;
}
if (com.type === 0 && com.hide) return;
const isClickable = com.buttonType === ButtonType.BUTTON_OK ||
com.buttonType === ButtonType.BUTTON_TARGET ||
com.buttonType === ButtonType.BUTTON_SELECT;
if (isClickable && (com.buttonText || com.text)) {
if (isClickable && !(com.width <= 0 || com.height <= 0) && (com.buttonText || com.text)) {
options.push({
index: options.length + 1,
text: normaliseComponentText(com.buttonText) || normaliseComponentText(com.text) || `Option ${options.length + 1}`,
text: normaliseComponentText(com.text) || normaliseComponentText(com.buttonText) || `Option ${options.length + 1}`,
componentId: comId
});
}
@@ -2840,9 +2861,7 @@ export class Client extends GameShell {
const interfaceOptions = this.getInterfaceOptions();
if (optionIndex > 0 && optionIndex <= interfaceOptions.length) {
const option = interfaceOptions[optionIndex - 1];
this.writePacketOpcode(ClientProt.IF_BUTTON);
this.out.p2(option.componentId);
return true;
return this.clickComponent(option.componentId);
}
return false;
}
@@ -2855,6 +2874,8 @@ export class Client extends GameShell {
return false;
}
if (this.marketSearchInput && this.handleMarketComponent(componentId)) return true;
// buttontype=close components (every "Close Window" X) are handled
// locally by the real client - the server registers no if_button
// trigger for them and answers IF_BUTTON with "No trigger for ...",
@@ -5407,6 +5428,7 @@ export class Client extends GameShell {
// todo: order
private async handleInputKey(): Promise<void> {
this.marketSearchInput?.sync(IfType.list, this.mainModalId, this.dialogInputOpen);
Client.cyclelogic4++;
if (Client.cyclelogic4 > 192) {
Client.cyclelogic4 = 0;
@@ -5424,6 +5446,8 @@ export class Client extends GameShell {
return;
}
if (this.marketSearchInput?.key(key)) continue;
if (this.mainModalId !== -1 && this.mainModalId === this.reportAbuseComId) {
if (key === 8 && this.reportAbuseInput.length > 0) {
this.reportAbuseInput = this.reportAbuseInput.substring(0, this.reportAbuseInput.length - 1);
@@ -8351,6 +8375,8 @@ export class Client extends GameShell {
return type !== 1;
}
private interfaceReloadWarning = false;
private async tcpIn(): Promise<boolean> {
if (!this.stream) {
return false;
@@ -8405,6 +8431,25 @@ export class Client extends GameShell {
this.ptype1 = this.ptype0;
this.ptype0 = this.ptype;
// A browser can retain an older interface cache across a content update.
// These packets are already framed and read in full, so an unknown
// component can be skipped without losing alignment or logging out.
if (this.ptype === ServerProt.IF_SETTEXT || this.ptype === ServerProt.IF_SETHIDE ||
this.ptype === ServerProt.IF_SETCOLOUR || this.ptype === ServerProt.IF_SETOBJECT ||
this.ptype === ServerProt.IF_SETPOSITION || this.ptype === ServerProt.UPDATE_INV_FULL) {
const comId = this.in.g2();
this.in.pos = 0;
if (!IfType.list[comId]) {
console.warn(`Ignoring interface update ${this.ptype} for missing component ${comId}; reload the page to update the interface cache.`);
if (!this.interfaceReloadWarning) {
this.interfaceReloadWarning = true;
this.addChat(0, 'The game interface has changed. Please reload this page.', '');
}
this.ptype = -1;
return true;
}
}
if (this.ptype === ServerProt.IF_OPENCHAT) {
const comId: number = this.in.g2();
this.ifAnimReset(comId);
@@ -11668,6 +11713,7 @@ export class Client extends GameShell {
}
if (action === MiniMenuAction.IF_BUTTON) {
if (this.handleMarketComponent(c)) return;
const com: IfType = IfType.list[c];
let notify: boolean = true;
@@ -12548,7 +12594,7 @@ export class Client extends GameShell {
icon.plotSprite(slotX, slotY);
}
if (icon.owi === 33 || child.linkObjNumber[slot] !== 1) {
if ((child.clientCode < RESULT_ICON || child.clientCode >= RESULT_ICON + 12) && (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);
@@ -0,0 +1,132 @@
// rs-sdk native GE search. Codes live in grand_exchange.if; component IDs stay pack-owned.
export const SEARCH_FIELD = 30400;
export const SEARCH_CLEAR = 30401;
export const SEARCH_ACK = 30402;
export const RESULT_BUTTON = 30410;
export const RESULT_ICON = 30440;
export const SEARCH_PLACEHOLDER = 'Type a name or alias...';
export interface SearchComponent {
id: number;
clientCode: number;
type: number;
hide: boolean;
children: number[] | null;
text: string | null;
linkObjType?: (number[] | Int32Array | Uint16Array) | null;
}
export function visibleSearchComponent(list: SearchComponent[], root: number, code: number): SearchComponent | undefined {
const visit = (id: number, depth: number): SearchComponent | undefined => {
const c = list[id];
if (!c || depth > 12 || (c.type === 0 && c.hide)) return;
if (c.clientCode === code) return c;
for (const child of c.children ?? []) {
const found = visit(child, depth + 1);
if (found) return found;
}
};
return visit(root, 0);
}
export function displayedSearchItem(list: SearchComponent[], root: number, id: number): { query: string; item: number } | undefined {
const c = list[id];
if (!c) return;
const index = c.clientCode - RESULT_BUTTON;
if (index < 0 || index >= 12 || !visibleSearchComponent(list, root, SEARCH_FIELD)) return;
const icon = visibleSearchComponent(list, root, RESULT_ICON + index);
const item = (icon?.linkObjType?.[0] ?? 0) - 1;
const ack = visibleSearchComponent(list, root, SEARCH_ACK);
if (item > 0 && ack) return { query: ack.text ?? '', item };
}
/** Poll-driven debounce means closing/logging out leaves no timer that can send later. */
export class MarketSearchInput {
private field: SearchComponent | undefined;
private list: SearchComponent[] = [];
private root = -1;
private query = '';
private dirty = false;
private changed = 0;
constructor(
private readonly send: (query: string, item: number) => void,
private readonly close: () => void
) {}
sync(list: SearchComponent[], root: number, disabled = false, now = Date.now()) {
const field = disabled ? undefined : visibleSearchComponent(list, root, SEARCH_FIELD);
if (!field) {
this.field = undefined;
this.dirty = false;
this.root = -1;
return;
}
this.list = list;
this.root = root;
if (!this.field) {
const receipt = visibleSearchComponent(list, root, SEARCH_ACK);
this.query = receipt?.text ?? (field.text === SEARCH_PLACEHOLDER ? '' : (field.text ?? ''));
this.dirty = false;
}
this.field = field;
if (this.dirty && now - this.changed >= 180) this.flush();
this.paint();
}
private paint() {
if (this.field) this.field.text = this.query ? (this.query.length > 32 ? '...' + this.query.slice(-32) : this.query) + '*' : SEARCH_PLACEHOLDER;
}
private flush() {
if (this.field && this.dirty) {
this.send(this.query, 0);
this.dirty = false;
}
}
/** Explicit bot search shares the keyboard's query, receipt and selection state. */
setQuery(query: string): boolean {
if (!this.field || !/^[a-zA-Z0-9 '()*-]{0,48}$/.test(query)) return false;
this.query = query;
this.dirty = true;
this.flush();
this.paint();
return true;
}
key(key: number, now = Date.now()): boolean {
if (!this.field) return false;
if (key === 27) {
this.close();
this.field = undefined;
this.dirty = false;
return true;
}
if (key === 10 || key === 13) {
this.flush();
return true;
}
let next = this.query;
if (key === 8 || key === 127) next = next.slice(0, -1);
else if (key >= 32 && key <= 126 && /^[a-zA-Z0-9 '()*-]$/.test(String.fromCharCode(key)) && next.length < 48) next += String.fromCharCode(key);
if (next !== this.query) {
this.query = next;
this.dirty = true;
this.changed = now;
this.paint();
}
// Typing into the search must never leak into public chat.
return true;
}
click(id: number): boolean {
if (!this.field) return false;
const code = this.list[id]?.clientCode;
if (code === SEARCH_FIELD) return true;
if (code === SEARCH_CLEAR) {
this.query = '';
this.dirty = true;
this.flush();
this.paint();
return true;
}
const selected = displayedSearchItem(this.list, this.root, id);
if (code >= RESULT_BUTTON && code < RESULT_BUTTON + 12) {
this.flush();
if (selected && selected.query === this.query) this.send(selected.query, selected.item);
return true;
}
this.flush();
return false;
}
}
+1
View File
@@ -1,4 +1,5 @@
export const enum ClientProt {
MARKET_SEARCH = 243, // rs-sdk: query string + displayed item ID (0 = filter)
NO_TIMEOUT = 120, // index: 6 - NXT naming
IDLE_TIMER = 209, // index: 30
+4
View File
@@ -803,6 +803,10 @@ export class LiteClient {
closeBotModal(): boolean {
return A.closeBotModal(this);
}
searchGE(query: string): boolean {
return A.searchGE(this, query);
}
submitCountDialog(value: number): boolean {
return A.submitCountDialog(this, value);
}
+20 -2
View File
@@ -18,6 +18,7 @@ import ObjType from '#/config/ObjType.js';
import VarBitType from '#/config/VarBitType.js';
import { ClientCode } from '#/client/ClientCode.js';
import Skill from '#/client/Skill.js';
import { displayedSearchItem, visibleSearchComponent, SEARCH_FIELD } from '#/client/MarketSearchInput.js';
import JString from '#/datastruct/JString.js';
import { ClientProt } from '#/io/ClientProt.js';
@@ -378,10 +379,27 @@ export function spellOnItem(c: LiteClient, slot: number, spellComponent: number,
// ============================================================= interfaces
export function searchGE(c: LiteClient, query: string): boolean {
if (!c.isInGame() || c.dialogInputOpen || !/^[a-zA-Z0-9 '()*-]{0,48}$/.test(query) || !visibleSearchComponent(IfType.list, c.mainModalId, SEARCH_FIELD)) return false;
c.writeOpcode(ClientProt.MARKET_SEARCH);
c.out.p1(query.length + 3);
c.out.pjstr(query);
c.out.p2(0);
return true;
}
export function clickComponent(c: LiteClient, componentId: number): boolean {
if (!c.isInGame()) {
return false;
}
const selected = displayedSearchItem(IfType.list, c.mainModalId, componentId);
if (selected) {
c.writeOpcode(ClientProt.MARKET_SEARCH);
c.out.p1(selected.query.length + 3);
c.out.pjstr(selected.query);
c.out.p2(selected.item);
return true;
}
c.writeOpcode(ClientProt.IF_BUTTON);
c.out.p2(componentId);
return true;
@@ -569,10 +587,10 @@ export function getInterfaceOptions(c: LiteClient): Array<{ index: number; text:
const scan = (comId: number, depth = 0): void => {
if (depth > 10) return;
const com = IfType.list[comId];
if (!com) return;
if (!com || (com.type === 0 && com.hide)) return;
const clickable = com.buttonType === ButtonType.BUTTON_OK || com.buttonType === ButtonType.BUTTON_TARGET || com.buttonType === ButtonType.BUTTON_SELECT;
if (clickable && (com.buttonText || com.text)) {
if (clickable && !(com.width <= 0 || com.height <= 0) && (com.buttonText || com.text)) {
const text = normaliseComponentText(com.text) || normaliseComponentText(com.buttonText);
if (text) {
options.push({ index: options.length + 1, text, componentId: comId });