dredless 0.6.1 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/browser/dredless.global.js +372 -178
- package/dist/browser/dredless.global.js.map +4 -4
- package/docs/API_REFERENCE.md +76 -0
- package/docs/NODE_API.md +20 -1
- package/package.json +2 -1
- package/src/browser.d.ts +3 -0
- package/src/client/core.js +3 -0
- package/src/client/domains/index.js +12 -0
- package/src/client/domains/tips.js +153 -0
- package/src/client/index.d.ts +1 -1
- package/src/index.d.ts +38 -2
- package/src/network/invites.d.ts +6 -2
- package/src/network/invites.js +43 -6
package/docs/API_REFERENCE.md
CHANGED
|
@@ -99,6 +99,29 @@ interface Ship {
|
|
|
99
99
|
}
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
+
Ship names are returned exactly as provided by the Drednot server. In
|
|
103
|
+
particular, `fetchShips()` and `fetchShipList()` preserve leading and trailing
|
|
104
|
+
spaces; it is up to callers to optionally trim those spaces from `name`.
|
|
105
|
+
|
|
106
|
+
### `InviteMetadata`
|
|
107
|
+
|
|
108
|
+
Returned by `fetchInviteMetadata()`.
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
interface InviteMetadata {
|
|
112
|
+
shipId: string | null;
|
|
113
|
+
name: string | null;
|
|
114
|
+
previewImageUrl: string | null;
|
|
115
|
+
hostname: string;
|
|
116
|
+
inviteCode: string;
|
|
117
|
+
color?: string | null;
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Invite metadata also preserves leading and trailing spaces in the
|
|
122
|
+
ship name returned by the invite page. The optional `color` field is included
|
|
123
|
+
only when `includeColor: true` is passed and is sampled from the preview image.
|
|
124
|
+
|
|
102
125
|
### `ShipList`
|
|
103
126
|
|
|
104
127
|
Returned by `fetchShipList()`.
|
|
@@ -302,6 +325,59 @@ once(type: string, callback: (...args: unknown[]) => void): this;
|
|
|
302
325
|
|
|
303
326
|
Events are implementation-defined strings emitted by the client event bus.
|
|
304
327
|
|
|
328
|
+
### Context menus: `client.tips`
|
|
329
|
+
|
|
330
|
+
```js
|
|
331
|
+
const door = client.currentShip().machines.doors()[0];
|
|
332
|
+
const menu = await door.openTips();
|
|
333
|
+
console.log(menu.options);
|
|
334
|
+
client.tips.select("Restrict to Crew"); // Also accepts a zero-based option index.
|
|
335
|
+
|
|
336
|
+
// Open and select in one operation:
|
|
337
|
+
await client.tips.choose(door, "Restrict to Captains");
|
|
338
|
+
|
|
339
|
+
// Dismiss without selecting an option:
|
|
340
|
+
await door.openTips();
|
|
341
|
+
await client.tips.close();
|
|
342
|
+
|
|
343
|
+
// Set access and wait for the server's model update:
|
|
344
|
+
await door.setRank("guest");
|
|
345
|
+
await client.currentShip().machines.spawnPoints()[0].setRank("crew");
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
`client.tips.open(entity, options?)` accepts an entity handle or an entity id in
|
|
349
|
+
the current world and resolves to a frozen `TipList` with
|
|
350
|
+
`{ entity, world, filter, x, y, options }`. Every entity handle also exposes
|
|
351
|
+
`openTips(options?)`. `client.tips.current` is the latest menu, or `null` after
|
|
352
|
+
selection, an empty menu, or disconnect. The `tip-list` event receives
|
|
353
|
+
`(menu, world)` including empty menus; the existing raw `side-event` is preserved.
|
|
354
|
+
The server does not send an entity id in a tip list: `entity` is inferred from
|
|
355
|
+
the API's requested target and is `null` for unsolicited menus. Avoid mixing raw
|
|
356
|
+
context-menu commands with an API operation in progress.
|
|
357
|
+
|
|
358
|
+
`select(option, command?)` sends the selected index and returns the client.
|
|
359
|
+
`choose(entity, option, options?)` opens a fresh menu and selects by exact text
|
|
360
|
+
or index, resolving to the client once selection is sent. Neither method waits
|
|
361
|
+
for the resulting action to complete.
|
|
362
|
+
|
|
363
|
+
`close(options?)` sends the captured left-click dismissal input with no focused
|
|
364
|
+
entity or selected option, then resolves to the client after the server sends an
|
|
365
|
+
empty tip list for the menu's world. It skips sending when no menu is open and
|
|
366
|
+
uses the same timeout and overlap rules as `open()`. Optional `command` fields
|
|
367
|
+
can supply click coordinates or inventory/view settings.
|
|
368
|
+
|
|
369
|
+
Door and spawn-point `setRank(rank, options?)` accept `"guest"`, `"crew"`,
|
|
370
|
+
`"captain"`, or `0`, `1`, `3`. They resolve to the client after the model confirms
|
|
371
|
+
the requested rank, and skip sending when the rank already matches. These are
|
|
372
|
+
access restrictions; they do not change a player's ship rank.
|
|
373
|
+
|
|
374
|
+
Options are `{ timeout?: number, command?: Command }`. The timeout defaults to
|
|
375
|
+
5000 milliseconds per response (menu and, for `setRank`, rank confirmation).
|
|
376
|
+
Calls reject on timeout, disconnect, unknown options, invalid ranks, or a target
|
|
377
|
+
outside the current world. Only one asynchronous tip operation may run per
|
|
378
|
+
client; overlapping calls reject. The server still enforces permissions and
|
|
379
|
+
interaction range; these helpers do not move the player or grant access.
|
|
380
|
+
|
|
305
381
|
## `client.state()` Output
|
|
306
382
|
|
|
307
383
|
```ts
|
package/docs/NODE_API.md
CHANGED
|
@@ -44,6 +44,7 @@ interface DredlessNamespace {
|
|
|
44
44
|
fetchServers: typeof fetchServers;
|
|
45
45
|
fetchShips: typeof fetchShips;
|
|
46
46
|
fetchShipList: typeof fetchShipList;
|
|
47
|
+
fetchInviteMetadata: typeof fetchInviteMetadata;
|
|
47
48
|
joinShip: typeof joinShip;
|
|
48
49
|
startShip: typeof startShip;
|
|
49
50
|
startNewShip: typeof startNewShip;
|
|
@@ -133,6 +134,9 @@ function fetchShips(session: Session, server: ServerRef): Promise<Ship[]>;
|
|
|
133
134
|
|
|
134
135
|
Authenticated convenience wrapper around `session.fetchShips(server)`.
|
|
135
136
|
|
|
137
|
+
Ship names in the returned objects are canonical Drednot server data. Leading
|
|
138
|
+
and trailing spaces are preserved exactly; callers should not trim them.
|
|
139
|
+
|
|
136
140
|
### `Dredless.fetchShipList(session, server)`
|
|
137
141
|
|
|
138
142
|
```ts
|
|
@@ -141,6 +145,22 @@ function fetchShipList(session: Session, server: ServerRef): Promise<ShipList>;
|
|
|
141
145
|
|
|
142
146
|
Authenticated convenience wrapper around `session.fetchShipList(server)`.
|
|
143
147
|
|
|
148
|
+
Ship names in the returned objects are canonical Drednot server data. Leading
|
|
149
|
+
and trailing spaces are preserved exactly; callers should not trim them.
|
|
150
|
+
|
|
151
|
+
### `Dredless.fetchInviteMetadata(invite, options?)`
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
function fetchInviteMetadata(
|
|
155
|
+
invite: string | URL,
|
|
156
|
+
options?: { includeColor?: boolean }
|
|
157
|
+
): Promise<InviteMetadata>;
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Fetches and parses an invite page. The returned ship name preserves leading
|
|
161
|
+
and trailing spaces exactly as returned by Drednot. Set `includeColor: true`
|
|
162
|
+
to sample the preview image’s top-left pixel; otherwise no image is fetched.
|
|
163
|
+
|
|
144
164
|
### `Dredless.joinShip(server, ship?, session?)`
|
|
145
165
|
|
|
146
166
|
```ts
|
|
@@ -371,4 +391,3 @@ interface ConnectionSnapshot {
|
|
|
371
391
|
session: SessionSnapshot;
|
|
372
392
|
}
|
|
373
393
|
```
|
|
374
|
-
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dredless",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "A fully-featured and from-scratch headless drednot.io client implementation written in pure js.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/certainhuman/dredless#readme",
|
|
@@ -152,6 +152,7 @@
|
|
|
152
152
|
}
|
|
153
153
|
},
|
|
154
154
|
"dependencies": {
|
|
155
|
+
"sharp": "^0.35.4",
|
|
155
156
|
"ws": "^8.18.0"
|
|
156
157
|
},
|
|
157
158
|
"files": [
|
package/src/browser.d.ts
CHANGED
package/src/client/core.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {INITIAL_OUTFIT_MESSAGE, JOIN_USER_AGENT, KEEPALIVE_INTERVAL_MS, KEEPALIVE_MESSAGE} from "../constants.js";
|
|
2
2
|
import {getWebSocket, isNode} from "../runtime.js";
|
|
3
3
|
import {EventBus} from "../events.js";
|
|
4
|
+
import {TipDomain} from "./domains/tips.js";
|
|
4
5
|
import {cookieHeader} from "../network/cookies.js";
|
|
5
6
|
import {fetchServers} from "../network/servers.js";
|
|
6
7
|
import {Connection} from "../network/connection.js";
|
|
@@ -143,6 +144,7 @@ export class DredlessClient extends EventBus {
|
|
|
143
144
|
this.debug = new ClientDebugDomain(this);
|
|
144
145
|
this.player = new PlayerDomain(this);
|
|
145
146
|
this.inventory = new InventoryDomain(this);
|
|
147
|
+
this.tips = new TipDomain(this);
|
|
146
148
|
this.management = new ShipManagementDomain(this);
|
|
147
149
|
this.cpuLoad = null;
|
|
148
150
|
this.inventoryState = normalizeInventoryEvent({type: "inventory", general_slots: 5});
|
|
@@ -1042,6 +1044,7 @@ export class DredlessClient extends EventBus {
|
|
|
1042
1044
|
|
|
1043
1045
|
#handleSideEvent(event, world) {
|
|
1044
1046
|
if (!event || typeof event !== "object") return;
|
|
1047
|
+
if (event.type === "tip_list") this.tips.receive(event, world);
|
|
1045
1048
|
if (event.type === "inventory") {
|
|
1046
1049
|
this.inventoryState = normalizeInventoryEvent(event);
|
|
1047
1050
|
this.emit("inventory", this.inventoryState, world);
|
|
@@ -835,6 +835,10 @@ export class EntityHandle {
|
|
|
835
835
|
use(options = {}, command = {}) {
|
|
836
836
|
return this.client.player.useEntity(this.id, options, command);
|
|
837
837
|
}
|
|
838
|
+
|
|
839
|
+
openTips(options = {}) {
|
|
840
|
+
return this.client.tips.open(this, options);
|
|
841
|
+
}
|
|
838
842
|
}
|
|
839
843
|
|
|
840
844
|
export class MachineHandle extends EntityHandle {
|
|
@@ -1228,6 +1232,10 @@ export class HelmHandle extends MachineHandle {
|
|
|
1228
1232
|
}
|
|
1229
1233
|
|
|
1230
1234
|
export class DoorHandle extends MachineHandle {
|
|
1235
|
+
setRank(rank, options = {}) {
|
|
1236
|
+
return this.client.tips.setRank(this, rank, options);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1231
1239
|
get state() {
|
|
1232
1240
|
return summaryForEntity(this.client, this.scope, this.id)?.contents?.door || null;
|
|
1233
1241
|
}
|
|
@@ -1242,6 +1250,10 @@ export class DoorHandle extends MachineHandle {
|
|
|
1242
1250
|
}
|
|
1243
1251
|
|
|
1244
1252
|
export class SpawnPointHandle extends MachineHandle {
|
|
1253
|
+
setRank(rank, options = {}) {
|
|
1254
|
+
return this.client.tips.setRank(this, rank, options);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1245
1257
|
get state() {
|
|
1246
1258
|
return summaryForEntity(this.client, this.scope, this.id)?.contents?.spawnPoint || null;
|
|
1247
1259
|
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import {normalizePlayerRank} from "../../protocol/outbound/ship-management.js";
|
|
2
|
+
|
|
3
|
+
function timeoutValue(options) {
|
|
4
|
+
const timeout = options.timeout ?? 5000;
|
|
5
|
+
if (!Number.isFinite(timeout) || timeout <= 0) throw new RangeError("timeout must be positive milliseconds");
|
|
6
|
+
return timeout;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function waitFor(client, eventType, read, timeout, label, send = null) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const cleanup = () => {
|
|
12
|
+
clearTimeout(timer);
|
|
13
|
+
client.off(eventType, check);
|
|
14
|
+
client.off("close", closed);
|
|
15
|
+
};
|
|
16
|
+
const check = (...args) => {
|
|
17
|
+
const value = read(...args);
|
|
18
|
+
if (value) { cleanup(); resolve(value); }
|
|
19
|
+
};
|
|
20
|
+
const closed = () => { cleanup(); reject(new Error(`Disconnected while waiting for ${label}`)); };
|
|
21
|
+
const timer = setTimeout(() => { cleanup(); reject(new Error(`Timed out waiting for ${label}`)); }, timeout);
|
|
22
|
+
client.on(eventType, check);
|
|
23
|
+
client.on("close", closed);
|
|
24
|
+
if (send) {
|
|
25
|
+
try { send(); } catch (error) { cleanup(); reject(error); return; }
|
|
26
|
+
}
|
|
27
|
+
check();
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class TipDomain {
|
|
32
|
+
#busy = false;
|
|
33
|
+
#target = null;
|
|
34
|
+
#current = null;
|
|
35
|
+
|
|
36
|
+
constructor(client) {
|
|
37
|
+
this.client = client;
|
|
38
|
+
client.on("close", () => { this.#current = null; this.#target = null; });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get current() {
|
|
42
|
+
return this.#current;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
receive(event, world) {
|
|
46
|
+
const options = Array.isArray(event.options) ? event.options.map(String) : [];
|
|
47
|
+
const entity = this.#target?.world === world?.id ? this.#target.entity : null;
|
|
48
|
+
const menu = Object.freeze({entity, world: world?.id ?? null, filter: event.filter ?? null,
|
|
49
|
+
x: event.x ?? 0, y: event.y ?? 0, options: Object.freeze(options)});
|
|
50
|
+
this.#current = options.length ? menu : null;
|
|
51
|
+
this.client.emit("tip-list", menu, world);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async #exclusive(action) {
|
|
55
|
+
if (this.#busy) throw new Error("A tip menu operation is already in progress");
|
|
56
|
+
this.#busy = true;
|
|
57
|
+
try {
|
|
58
|
+
return await action();
|
|
59
|
+
} catch (error) {
|
|
60
|
+
this.#current = null;
|
|
61
|
+
this.#target = null;
|
|
62
|
+
throw error;
|
|
63
|
+
} finally { this.#busy = false; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async #open(entity, options) {
|
|
67
|
+
if (!this.client.ready || !this.client.connected) throw new Error("Client must be connected and ready to open tips");
|
|
68
|
+
const timeout = timeoutValue(options);
|
|
69
|
+
const handle = typeof entity === "number" ? this.client.currentShip()?.entities.get(entity) : entity;
|
|
70
|
+
if (!handle || handle.client !== this.client || !handle.exists()) throw new Error("Tip target must be an existing entity on this client");
|
|
71
|
+
const world = this.client.worlds.currentWorldId;
|
|
72
|
+
const scopeWorld = handle.scope === "ship" ? this.client.worlds.shipWorld()
|
|
73
|
+
: handle.scope === "current" ? this.client.worlds.worlds.get(world)
|
|
74
|
+
: handle.scope === "overworld" ? this.client.worlds.overworld()
|
|
75
|
+
: this.client.worlds.worlds.get(Number(handle.scope));
|
|
76
|
+
if (scopeWorld?.id !== world) {
|
|
77
|
+
throw new Error("Tip target must be in the current world");
|
|
78
|
+
}
|
|
79
|
+
const position = handle.position;
|
|
80
|
+
if (!position) throw new Error("Tip target has no position");
|
|
81
|
+
this.#current = null;
|
|
82
|
+
this.#target = {entity: handle.id, world, command: {...options.command,
|
|
83
|
+
focus_ent: handle.id, mx: position.x, my: position.y}};
|
|
84
|
+
// Subscribe before sending: attached sockets can deliver a reply synchronously.
|
|
85
|
+
return waitFor(this.client, "tip-list", () => {
|
|
86
|
+
return this.#current?.entity === handle.id && this.#current.world === world ? this.#current : null;
|
|
87
|
+
}, timeout, "tip menu", () => {
|
|
88
|
+
this.client.send({...this.#target.command, act1: false, act2: true, tip_select: null});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
open(entity, options = {}) {
|
|
93
|
+
return this.#exclusive(() => this.#open(entity, options));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
close(options = {}) {
|
|
97
|
+
return this.#exclusive(async () => {
|
|
98
|
+
const timeout = timeoutValue(options);
|
|
99
|
+
const menu = this.#current;
|
|
100
|
+
if (!menu) return this.client;
|
|
101
|
+
if (!this.client.connected || !this.client.ready) throw new Error("Client must be connected and ready to close tips");
|
|
102
|
+
if (menu.world !== this.client.worlds.currentWorldId) throw new Error("Tip menu belongs to another world");
|
|
103
|
+
await waitFor(this.client, "tip-list", (update) => update?.world === menu.world
|
|
104
|
+
&& update.options.length === 0, timeout, "tip menu dismissal", () => {
|
|
105
|
+
// The official client dismisses with a normal left click outside an entity.
|
|
106
|
+
this.client.send({...options.command, focus_ent: null, config_ent: null,
|
|
107
|
+
act1: true, act1_held: true, act2: false, tip_select: null});
|
|
108
|
+
});
|
|
109
|
+
this.#target = null;
|
|
110
|
+
return this.client;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
select(option, command = {}) {
|
|
115
|
+
if (this.#busy) throw new Error("A tip menu operation is already in progress");
|
|
116
|
+
return this.#select(option, command);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#select(option, command = {}) {
|
|
120
|
+
if (!this.client.connected || !this.client.ready) throw new Error("Client must be connected and ready to select tips");
|
|
121
|
+
const menu = this.#current;
|
|
122
|
+
if (!menu || menu.entity == null || !this.#target) throw new Error("Open an entity's tip menu before selecting an option");
|
|
123
|
+
if (menu.world !== this.client.worlds.currentWorldId) throw new Error("Tip menu belongs to another world");
|
|
124
|
+
const index = typeof option === "string" ? menu.options.indexOf(option) : option;
|
|
125
|
+
if (!Number.isInteger(index) || index < 0 || index >= menu.options.length) throw new RangeError("Tip option is not in the current menu");
|
|
126
|
+
this.client.send({...this.#target.command, ...command, focus_ent: menu.entity,
|
|
127
|
+
act1: false, act2: false, tip_select: index});
|
|
128
|
+
this.#current = null;
|
|
129
|
+
this.#target = null;
|
|
130
|
+
return this.client;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
choose(entity, option, options = {}) {
|
|
134
|
+
return this.#exclusive(async () => {
|
|
135
|
+
await this.#open(entity, options);
|
|
136
|
+
return this.#select(option);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
setRank(handle, rank, options = {}) {
|
|
141
|
+
return this.#exclusive(async () => {
|
|
142
|
+
const value = normalizePlayerRank(rank);
|
|
143
|
+
const timeout = timeoutValue(options);
|
|
144
|
+
if (!handle.exists()) throw new Error("Rank target no longer exists");
|
|
145
|
+
if (handle.rank === value) return this.client;
|
|
146
|
+
const text = {0: "Restrict to Guests", 1: "Restrict to Crew", 3: "Restrict to Captains"}[value];
|
|
147
|
+
await this.#open(handle, options);
|
|
148
|
+
await waitFor(this.client, "model", () => handle.rank === value, timeout, "access rank update",
|
|
149
|
+
() => this.#select(text));
|
|
150
|
+
return this.client;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
package/src/client/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export {DredlessClient, WrenchMode, TurretMode} from "../index.js";
|
|
2
|
-
export type {DredlessClientOptions, ClientSnapshot} from "../index.js";
|
|
2
|
+
export type {DredlessClientOptions, ClientSnapshot, TipDomain, TipList, TipOptions} from "../index.js";
|
package/src/index.d.ts
CHANGED
|
@@ -26,12 +26,16 @@ export interface ShipList {
|
|
|
26
26
|
export interface InviteMetadata {
|
|
27
27
|
shipId: string | null;
|
|
28
28
|
name: string | null;
|
|
29
|
-
color
|
|
29
|
+
/** CSS RGB color sampled from the preview image's top-left pixel, when available. */
|
|
30
|
+
color?: string | null;
|
|
30
31
|
previewImageUrl: string | null;
|
|
31
32
|
hostname: string;
|
|
32
33
|
inviteCode: string;
|
|
33
34
|
}
|
|
34
|
-
export interface InviteFetchOptions {
|
|
35
|
+
export interface InviteFetchOptions {
|
|
36
|
+
fetchImpl?: typeof fetch;
|
|
37
|
+
includeColor?: boolean;
|
|
38
|
+
}
|
|
35
39
|
export function fetchInviteMetadata(invite: string | URL, options?: InviteFetchOptions): Promise<InviteMetadata>;
|
|
36
40
|
export function parseInviteMetadata(html: string): InviteMetadata;
|
|
37
41
|
|
|
@@ -475,6 +479,7 @@ export class DredlessClient {
|
|
|
475
479
|
debug: ClientDebugDomain;
|
|
476
480
|
player: PlayerDomain;
|
|
477
481
|
inventory: InventoryDomain;
|
|
482
|
+
tips: TipDomain;
|
|
478
483
|
management: ShipManagementDomain;
|
|
479
484
|
readyPromise: Promise<this>;
|
|
480
485
|
|
|
@@ -508,13 +513,41 @@ export class DredlessClient {
|
|
|
508
513
|
|
|
509
514
|
entity(entityId: number, scope?: number | string): EntitySnapshot | null;
|
|
510
515
|
|
|
516
|
+
on(type: "tip-list", callback: (menu: TipList, world: WorldState) => void): this;
|
|
511
517
|
on(type: string, callback: (...args: unknown[]) => void): this;
|
|
512
518
|
|
|
519
|
+
off(type: "tip-list", callback: (menu: TipList, world: WorldState) => void): this;
|
|
513
520
|
off(type: string, callback: (...args: unknown[]) => void): this;
|
|
514
521
|
|
|
522
|
+
once(type: "tip-list", callback: (menu: TipList, world: WorldState) => void): this;
|
|
515
523
|
once(type: string, callback: (...args: unknown[]) => void): this;
|
|
516
524
|
}
|
|
517
525
|
|
|
526
|
+
export interface TipList {
|
|
527
|
+
/** Target inferred from tips.open(); null for unsolicited menus. */
|
|
528
|
+
readonly entity: number | null;
|
|
529
|
+
readonly world: number | null;
|
|
530
|
+
readonly filter: number | null;
|
|
531
|
+
readonly x: number;
|
|
532
|
+
readonly y: number;
|
|
533
|
+
readonly options: readonly string[];
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export interface TipOptions {
|
|
537
|
+
/** Milliseconds to wait per server response; default 5000. */
|
|
538
|
+
timeout?: number;
|
|
539
|
+
command?: Command;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export interface TipDomain {
|
|
543
|
+
readonly current: TipList | null;
|
|
544
|
+
open(entity: number | EntityHandle, options?: TipOptions): Promise<TipList>;
|
|
545
|
+
/** Dismiss the menu and wait for the server's empty tip list. */
|
|
546
|
+
close(options?: TipOptions): Promise<DredlessClient>;
|
|
547
|
+
select(option: number | string, command?: Command): DredlessClient;
|
|
548
|
+
choose(entity: number | EntityHandle, option: number | string, options?: TipOptions): Promise<DredlessClient>;
|
|
549
|
+
}
|
|
550
|
+
|
|
518
551
|
export interface ClientNetDomain {
|
|
519
552
|
connected: boolean;
|
|
520
553
|
ready: boolean;
|
|
@@ -948,6 +981,7 @@ export interface EntityHandle {
|
|
|
948
981
|
asExpandoBox(): ExpandoBoxHandle | null;
|
|
949
982
|
|
|
950
983
|
use(options?: { invSlot?: number; hold?: boolean }, command?: Command): DredlessClient;
|
|
984
|
+
openTips(options?: TipOptions): Promise<TipList>;
|
|
951
985
|
}
|
|
952
986
|
|
|
953
987
|
export interface MachineHandle extends EntityHandle {
|
|
@@ -1133,11 +1167,13 @@ export interface DoorHandle extends EntityHandle {
|
|
|
1133
1167
|
state: DoorSummary | null;
|
|
1134
1168
|
rank: number | undefined;
|
|
1135
1169
|
open: boolean | undefined;
|
|
1170
|
+
setRank(rank: ShipPlayerRank, options?: TipOptions): Promise<DredlessClient>;
|
|
1136
1171
|
}
|
|
1137
1172
|
|
|
1138
1173
|
export interface SpawnPointHandle extends MachineHandle {
|
|
1139
1174
|
state: SpawnPointSummary | null;
|
|
1140
1175
|
rank: number | undefined;
|
|
1176
|
+
setRank(rank: ShipPlayerRank, options?: TipOptions): Promise<DredlessClient>;
|
|
1141
1177
|
}
|
|
1142
1178
|
|
|
1143
1179
|
export interface ShieldProjectorHandle extends MachineHandle {
|
package/src/network/invites.d.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
export interface InviteMetadata {
|
|
2
2
|
shipId: string | null;
|
|
3
3
|
name: string | null;
|
|
4
|
-
color
|
|
4
|
+
/** CSS RGB color sampled from the preview image's top-left pixel, when available. */
|
|
5
|
+
color?: string | null;
|
|
5
6
|
previewImageUrl: string | null;
|
|
6
7
|
hostname: string;
|
|
7
8
|
inviteCode: string;
|
|
8
9
|
}
|
|
9
|
-
export interface InviteFetchOptions {
|
|
10
|
+
export interface InviteFetchOptions {
|
|
11
|
+
fetchImpl?: typeof fetch;
|
|
12
|
+
includeColor?: boolean;
|
|
13
|
+
}
|
|
10
14
|
export function fetchInviteMetadata(invite: string | URL, options?: InviteFetchOptions): Promise<InviteMetadata>;
|
|
11
15
|
export function parseInviteMetadata(html: string): InviteMetadata;
|
package/src/network/invites.js
CHANGED
|
@@ -1,10 +1,48 @@
|
|
|
1
|
-
import {getFetch} from "../runtime.js";
|
|
1
|
+
import {getFetch, isBrowser} from "../runtime.js";
|
|
2
2
|
|
|
3
|
-
export async function fetchInviteMetadata(invite, {fetchImpl = null} = {}) {
|
|
3
|
+
export async function fetchInviteMetadata(invite, {fetchImpl = null, includeColor = false} = {}) {
|
|
4
4
|
const url = normalizeInviteUrl(invite);
|
|
5
|
-
const
|
|
5
|
+
const fetcher = fetchImpl || getFetch();
|
|
6
|
+
const response = await fetcher(url.href, {headers: {accept: "text/html"}});
|
|
6
7
|
if (!response.ok) throw new Error(`Invite metadata request failed: ${response.status} ${response.statusText}`);
|
|
7
|
-
|
|
8
|
+
const metadata = parseInviteMetadata(await response.text());
|
|
9
|
+
if (!includeColor || !metadata.previewImageUrl) return metadata;
|
|
10
|
+
return Object.freeze({...metadata, color: await fetchTopLeftColor(metadata.previewImageUrl, fetcher)});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function fetchTopLeftColor(imageUrl, fetcher) {
|
|
14
|
+
try {
|
|
15
|
+
let response = await fetcher(imageUrl, {headers: {range: "bytes=0-1023"}});
|
|
16
|
+
if (!response.ok) return null;
|
|
17
|
+
const buffer = await response.arrayBuffer();
|
|
18
|
+
try {
|
|
19
|
+
return await decodeImageColor(buffer);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (response.status !== 206) throw error;
|
|
22
|
+
response = await fetcher(imageUrl);
|
|
23
|
+
if (!response.ok) return null;
|
|
24
|
+
return await decodeImageColor(await response.arrayBuffer());
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function decodeImageColor(buffer) {
|
|
32
|
+
if (isBrowser()) {
|
|
33
|
+
const bitmap = await createImageBitmap(new Blob([buffer]));
|
|
34
|
+
const canvas = typeof OffscreenCanvas === "function" ? new OffscreenCanvas(1, 1) : document.createElement("canvas");
|
|
35
|
+
canvas.width = canvas.height = 1;
|
|
36
|
+
const context = canvas.getContext("2d");
|
|
37
|
+
context.drawImage(bitmap, 0, 0, 1, 1);
|
|
38
|
+
const [red, green, blue] = context.getImageData(0, 0, 1, 1).data;
|
|
39
|
+
bitmap.close();
|
|
40
|
+
return `rgb(${red},${green},${blue})`;
|
|
41
|
+
}
|
|
42
|
+
const sharpModule = await import("sharp");
|
|
43
|
+
const sharp = sharpModule.default || sharpModule;
|
|
44
|
+
const {data} = await sharp(buffer).extract({left: 0, top: 0, width: 1, height: 1}).raw().toBuffer({resolveWithObject: true});
|
|
45
|
+
return `rgb(${data[0]},${data[1]},${data[2]})`;
|
|
8
46
|
}
|
|
9
47
|
|
|
10
48
|
export function parseInviteMetadata(html) {
|
|
@@ -15,7 +53,6 @@ export function parseInviteMetadata(html) {
|
|
|
15
53
|
return Object.freeze({
|
|
16
54
|
shipId: parsedTitle.shipId,
|
|
17
55
|
name: parsedTitle.name,
|
|
18
|
-
color: metaContent(html, "theme-color") || metaContent(html, "color") || null,
|
|
19
56
|
previewImageUrl: preview ? new URL(preview, url).href : null,
|
|
20
57
|
hostname: url.hostname,
|
|
21
58
|
inviteCode: url.pathname.split("/").filter(Boolean).at(-1)
|
|
@@ -29,7 +66,7 @@ function normalizeInviteUrl(invite) {
|
|
|
29
66
|
}
|
|
30
67
|
|
|
31
68
|
function parseInviteTitle(title) {
|
|
32
|
-
const match = title.match(/^Invite:\s?(
|
|
69
|
+
const match = title.match(/^Invite:\s?(.+?) \{([^{}]+)}(?:\s*-\s*drednot\.io)?\s*$/i);
|
|
33
70
|
return match ? {name: decodeHtml(match[1]), shipId: decodeHtml(match[2])} : {name: null, shipId: null};
|
|
34
71
|
}
|
|
35
72
|
|