mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
feat: ship Grand Exchange with durable Fly market storage
This commit is contained in:
1 parent
0f761d1cf3
commit
69d5b709c8
80 files changed
+12651
-23
No files matched your search
@@ -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/
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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('');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+75
@@ -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;
|
||||
}
|
||||
@@ -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)],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -4669,3 +4669,4 @@
|
||||
4668=runeflash
|
||||
4669=statue_herosguild
|
||||
4670=statue_herosguild2
|
||||
4671=grand_exchange_booth
|
||||
@@ -1357,3 +1357,4 @@
|
||||
1356=horror_dagganoth_melee
|
||||
1357=boardgames_barmaid
|
||||
1358=boardgames_barmaid2
|
||||
1359=grand_exchange_teller
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' } });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -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.');
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user