dredless 0.6.2 → 0.7.0
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 +459 -1192
- package/dist/browser/dredless.global.js.map +4 -4
- package/docs/API_REFERENCE.md +90 -1
- package/docs/NODE_API.md +20 -1
- package/package.json +1 -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 +36 -1
- package/src/network/invites.js +1 -1
- package/src/state/model/decoder.js +31 -46
- package/src/state/model/loader-config.js +0 -997
- package/src/state/model/state.js +6 -73
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
|
|
@@ -1009,6 +1085,13 @@ interface LoaderFullConfig extends LoaderConfig {
|
|
|
1009
1085
|
|
|
1010
1086
|
State output:
|
|
1011
1087
|
|
|
1088
|
+
Loader state uses the official component defaults when configuration fields
|
|
1089
|
+
are omitted: pick `3` (middle-left), place `4` (middle-right), normal priority,
|
|
1090
|
+
stack `16`, cycle `1` second, and both flags disabled. Cycle times are converted
|
|
1091
|
+
from ticks at 20 ticks per second. Flag updates set their absolute boolean
|
|
1092
|
+
values; repeated updates do not toggle them. Raw loader configuration is stored
|
|
1093
|
+
in model table `69` (wire section `162`), separately from navigation state.
|
|
1094
|
+
|
|
1012
1095
|
```ts
|
|
1013
1096
|
interface LoaderSummary {
|
|
1014
1097
|
entity: number;
|
|
@@ -1364,6 +1447,11 @@ interface CargoEjectorHandle extends MachineHandle {
|
|
|
1364
1447
|
|
|
1365
1448
|
Associated summary shapes:
|
|
1366
1449
|
|
|
1450
|
+
Cannon summaries expose `relativeAngleRadians` relative to the mount and
|
|
1451
|
+
`absoluteAngleRadians` in ship coordinates, normalized to `[0, 2π)`, replacing
|
|
1452
|
+
the raw `aim` field. The relative angle is `null` when aim is unavailable; the
|
|
1453
|
+
absolute angle is `null` when either aim or mounting rotation is unavailable.
|
|
1454
|
+
|
|
1367
1455
|
```ts
|
|
1368
1456
|
interface CannonSummary {
|
|
1369
1457
|
entity: number;
|
|
@@ -1372,7 +1460,8 @@ interface CannonSummary {
|
|
|
1372
1460
|
ammoItemId: number | null;
|
|
1373
1461
|
ammoName: string | null;
|
|
1374
1462
|
ammoCount: number;
|
|
1375
|
-
|
|
1463
|
+
relativeAngleRadians: number | null; // Barrel angle relative to its mount.
|
|
1464
|
+
absoluteAngleRadians: number | null; // Ship coordinates, normalized to [0, 2π).
|
|
1376
1465
|
recoil: number | null;
|
|
1377
1466
|
recoil2: number | null;
|
|
1378
1467
|
recoils: [number | null, number | null];
|
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.
|
|
3
|
+
"version": "0.7.0",
|
|
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",
|
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
|
@@ -479,6 +479,7 @@ export class DredlessClient {
|
|
|
479
479
|
debug: ClientDebugDomain;
|
|
480
480
|
player: PlayerDomain;
|
|
481
481
|
inventory: InventoryDomain;
|
|
482
|
+
tips: TipDomain;
|
|
482
483
|
management: ShipManagementDomain;
|
|
483
484
|
readyPromise: Promise<this>;
|
|
484
485
|
|
|
@@ -512,13 +513,41 @@ export class DredlessClient {
|
|
|
512
513
|
|
|
513
514
|
entity(entityId: number, scope?: number | string): EntitySnapshot | null;
|
|
514
515
|
|
|
516
|
+
on(type: "tip-list", callback: (menu: TipList, world: WorldState) => void): this;
|
|
515
517
|
on(type: string, callback: (...args: unknown[]) => void): this;
|
|
516
518
|
|
|
519
|
+
off(type: "tip-list", callback: (menu: TipList, world: WorldState) => void): this;
|
|
517
520
|
off(type: string, callback: (...args: unknown[]) => void): this;
|
|
518
521
|
|
|
522
|
+
once(type: "tip-list", callback: (menu: TipList, world: WorldState) => void): this;
|
|
519
523
|
once(type: string, callback: (...args: unknown[]) => void): this;
|
|
520
524
|
}
|
|
521
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
|
+
|
|
522
551
|
export interface ClientNetDomain {
|
|
523
552
|
connected: boolean;
|
|
524
553
|
ready: boolean;
|
|
@@ -952,6 +981,7 @@ export interface EntityHandle {
|
|
|
952
981
|
asExpandoBox(): ExpandoBoxHandle | null;
|
|
953
982
|
|
|
954
983
|
use(options?: { invSlot?: number; hold?: boolean }, command?: Command): DredlessClient;
|
|
984
|
+
openTips(options?: TipOptions): Promise<TipList>;
|
|
955
985
|
}
|
|
956
986
|
|
|
957
987
|
export interface MachineHandle extends EntityHandle {
|
|
@@ -1137,11 +1167,13 @@ export interface DoorHandle extends EntityHandle {
|
|
|
1137
1167
|
state: DoorSummary | null;
|
|
1138
1168
|
rank: number | undefined;
|
|
1139
1169
|
open: boolean | undefined;
|
|
1170
|
+
setRank(rank: ShipPlayerRank, options?: TipOptions): Promise<DredlessClient>;
|
|
1140
1171
|
}
|
|
1141
1172
|
|
|
1142
1173
|
export interface SpawnPointHandle extends MachineHandle {
|
|
1143
1174
|
state: SpawnPointSummary | null;
|
|
1144
1175
|
rank: number | undefined;
|
|
1176
|
+
setRank(rank: ShipPlayerRank, options?: TipOptions): Promise<DredlessClient>;
|
|
1145
1177
|
}
|
|
1146
1178
|
|
|
1147
1179
|
export interface ShieldProjectorHandle extends MachineHandle {
|
|
@@ -1800,7 +1832,10 @@ export interface CannonSummary {
|
|
|
1800
1832
|
ammoItemId: number | null;
|
|
1801
1833
|
ammoName: string | null;
|
|
1802
1834
|
ammoCount: number;
|
|
1803
|
-
|
|
1835
|
+
/** Barrel angle relative to its mount, in radians. */
|
|
1836
|
+
relativeAngleRadians: number | null;
|
|
1837
|
+
/** Barrel direction in ship coordinates, normalized to [0, 2π); null if aim or mounting rotation is unavailable. */
|
|
1838
|
+
absoluteAngleRadians: number | null;
|
|
1804
1839
|
recoil: number | null;
|
|
1805
1840
|
recoil2: number | null;
|
|
1806
1841
|
recoils: [number | null, number | null];
|
package/src/network/invites.js
CHANGED
|
@@ -66,7 +66,7 @@ function normalizeInviteUrl(invite) {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
function parseInviteTitle(title) {
|
|
69
|
-
const match = title.match(/^Invite:\s?(
|
|
69
|
+
const match = title.match(/^Invite:\s?(.+?) \{([^{}]+)}(?:\s*-\s*drednot\.io)?\s*$/i);
|
|
70
70
|
return match ? {name: decodeHtml(match[1]), shipId: decodeHtml(match[2])} : {name: null, shipId: null};
|
|
71
71
|
}
|
|
72
72
|
|
|
@@ -2,8 +2,7 @@ import {decoder} from "../../constants.js";
|
|
|
2
2
|
import {
|
|
3
3
|
LOADER_FILTER_MODE_NAMES,
|
|
4
4
|
LOADER_POSITION_NAMES,
|
|
5
|
-
LOADER_PRIORITY_NAMES
|
|
6
|
-
LoaderConfigTracker
|
|
5
|
+
LOADER_PRIORITY_NAMES
|
|
7
6
|
} from "./loader-config.js";
|
|
8
7
|
import {navigationDestinationFromEncodedValue, navigationZoneFromBaseId} from "../overworld.js";
|
|
9
8
|
import {maybeSolveGeneratorMazeSeed} from "../generator-maze.js";
|
|
@@ -284,7 +283,11 @@ export const MODEL_TABLE_SPECS = new Map([
|
|
|
284
283
|
[63, {name: "numeric_pair", ...TWO_FIELD_SPEC}],
|
|
285
284
|
[67, {name: "numeric_single", fields: numericFields({1: 20})}],
|
|
286
285
|
[69, {
|
|
287
|
-
name: "
|
|
286
|
+
name: "loader_config",
|
|
287
|
+
// The official loader component starts with these values. Wire section
|
|
288
|
+
// 162 sends a packed absolute flag word before the five numeric deltas.
|
|
289
|
+
defaults: {q20: 3, q24: 4, q28: 1, q32: 16, q36: 20, q41: 0, q42: 0},
|
|
290
|
+
packedBeforeValues: true,
|
|
288
291
|
packedBits: [{bit: 32, offset: 41}, {bit: 64, offset: 42}],
|
|
289
292
|
fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36})
|
|
290
293
|
}],
|
|
@@ -407,7 +410,7 @@ export const WIRE_TAG_TABLES = new Map([
|
|
|
407
410
|
[158, 75],
|
|
408
411
|
[160, 76],
|
|
409
412
|
[161, 77],
|
|
410
|
-
[162,
|
|
413
|
+
[162, 69], // Loader configuration; distinct from navigation state (168).
|
|
411
414
|
[163, 72],
|
|
412
415
|
[164, 73],
|
|
413
416
|
[165, 74],
|
|
@@ -574,32 +577,6 @@ export function firstRecord(records, predicate = null) {
|
|
|
574
577
|
return null;
|
|
575
578
|
}
|
|
576
579
|
|
|
577
|
-
export const TABLE_78_FIELD_BITS = [
|
|
578
|
-
["q20", 1],
|
|
579
|
-
["q24", 2],
|
|
580
|
-
["q28", 4],
|
|
581
|
-
["q32", 8],
|
|
582
|
-
["q36", 16],
|
|
583
|
-
["q40", 32],
|
|
584
|
-
["q44", 64]
|
|
585
|
-
];
|
|
586
|
-
|
|
587
|
-
export function table78DeltaRecord(changed) {
|
|
588
|
-
const record = {lastMask: changed.mask};
|
|
589
|
-
for (const [field, bit] of TABLE_78_FIELD_BITS) {
|
|
590
|
-
if (!(changed.mask & bit)) continue;
|
|
591
|
-
if (field === "q44" && ((changed.mask & 32) || changed.record?.q44 == null)) continue;
|
|
592
|
-
record[field] = (changed.record?.[field] ?? 0) - (changed.previous?.[field] ?? 0);
|
|
593
|
-
}
|
|
594
|
-
return record;
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
export function isSemanticLoaderDelta(changed, full) {
|
|
598
|
-
return !full &&
|
|
599
|
-
changed.previous != null &&
|
|
600
|
-
(changed.mask & 96) === 96;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
580
|
export function decodeText(blob) {
|
|
604
581
|
if (!(blob instanceof Uint8Array)) return null;
|
|
605
582
|
try {
|
|
@@ -679,8 +656,14 @@ export function summarizeCargoEjector(entity, typeId, record) {
|
|
|
679
656
|
};
|
|
680
657
|
}
|
|
681
658
|
|
|
682
|
-
export function summarizeCannon(entity, record, typeId = null) {
|
|
659
|
+
export function summarizeCannon(entity, record, typeId = null, transformRecord = null) {
|
|
683
660
|
if (!record || !CANNON_TYPE_IDS.has(typeId)) return null;
|
|
661
|
+
const relativeAngleRadians = numberOrNull(record.q32, 200);
|
|
662
|
+
const mountingAngleRadians = numberOrNull(transformRecord?.q28, 127.324);
|
|
663
|
+
const tau = 2 * Math.PI;
|
|
664
|
+
const absoluteAngleRadians = relativeAngleRadians == null || mountingAngleRadians == null
|
|
665
|
+
? null
|
|
666
|
+
: (((mountingAngleRadians + relativeAngleRadians) % tau) + tau) % tau;
|
|
684
667
|
const ammoItemId = record.q24 == null ? null : CANNON_AMMO_COLOR_ITEM_IDS.get(record.q24) ?? null;
|
|
685
668
|
const charge = record.q40 ?? null;
|
|
686
669
|
const recoil = record.q36 ?? null;
|
|
@@ -692,7 +675,8 @@ export function summarizeCannon(entity, record, typeId = null) {
|
|
|
692
675
|
ammoItemId,
|
|
693
676
|
ammoName: entityNameFromType(ammoItemId),
|
|
694
677
|
ammoCount: record.q28 ?? 0,
|
|
695
|
-
|
|
678
|
+
relativeAngleRadians,
|
|
679
|
+
absoluteAngleRadians,
|
|
696
680
|
recoil,
|
|
697
681
|
recoil2,
|
|
698
682
|
recoils: [recoil, recoil2],
|
|
@@ -1142,15 +1126,14 @@ export function summarizeHugeThruster(entity, markerRecord, sizeRecord = null) {
|
|
|
1142
1126
|
};
|
|
1143
1127
|
}
|
|
1144
1128
|
|
|
1145
|
-
export function summarizeLoader(entity, loaderRecord, loaderFilterRecord = null, filterSlotsRecord = null,
|
|
1129
|
+
export function summarizeLoader(entity, loaderRecord, loaderFilterRecord = null, filterSlotsRecord = null, includeDefaults = false, itemHolderRecord = null) {
|
|
1146
1130
|
if (!loaderRecord && !loaderFilterRecord && !includeDefaults) return null;
|
|
1147
|
-
|
|
1148
|
-
const
|
|
1149
|
-
const
|
|
1150
|
-
const
|
|
1151
|
-
const
|
|
1152
|
-
const
|
|
1153
|
-
const filterMode = config.filterMode ?? null;
|
|
1131
|
+
// Match the official component defaults, then apply the already decoded state.
|
|
1132
|
+
const loader = {...MODEL_TABLE_SPECS.get(69).defaults, ...loaderRecord};
|
|
1133
|
+
const pick = loader.q20;
|
|
1134
|
+
const place = loader.q24;
|
|
1135
|
+
const priority = loader.q28 - 1;
|
|
1136
|
+
const filterMode = loaderFilterRecord?.q20 ?? 0;
|
|
1154
1137
|
const heldItem = itemSummary(itemHolderRecord?.q20, itemHolderRecord?.q24 ?? null);
|
|
1155
1138
|
return {
|
|
1156
1139
|
entity,
|
|
@@ -1160,13 +1143,15 @@ export function summarizeLoader(entity, loaderRecord, loaderFilterRecord = null,
|
|
|
1160
1143
|
placeName: enumValueName(LOADER_POSITION_NAMES, place),
|
|
1161
1144
|
priority,
|
|
1162
1145
|
priorityName: enumValueName(LOADER_PRIORITY_NAMES, priority),
|
|
1163
|
-
requireOutput:
|
|
1164
|
-
waitForStack:
|
|
1165
|
-
stack:
|
|
1166
|
-
cycle:
|
|
1146
|
+
requireOutput: Boolean(loader.q41),
|
|
1147
|
+
waitForStack: Boolean(loader.q42),
|
|
1148
|
+
stack: loader.q32,
|
|
1149
|
+
cycle: loader.q36 / 20,
|
|
1167
1150
|
filterMode,
|
|
1168
1151
|
filterModeName: enumValueName(LOADER_FILTER_MODE_NAMES, filterMode),
|
|
1169
|
-
filterSlots:
|
|
1152
|
+
filterSlots: filterSlotsRecord
|
|
1153
|
+
? [filterSlotsRecord.q20, filterSlotsRecord.q24, filterSlotsRecord.q28].map(value => value || null)
|
|
1154
|
+
: null,
|
|
1170
1155
|
heldItemId: heldItem.itemId,
|
|
1171
1156
|
heldItemName: heldItem.itemName,
|
|
1172
1157
|
heldCount: heldItem.count,
|
|
@@ -1206,7 +1191,7 @@ export function summarizePusher(entity, pusherRecord, filterSlotsRecord = null)
|
|
|
1206
1191
|
filteredModeName: enumValueName(PUSHER_MODE_NAMES, filteredMode),
|
|
1207
1192
|
angle: numberOrNull(pusherRecord.q28, 10),
|
|
1208
1193
|
speed: pusherRecord.q32 == null ? 20 : (pusherRecord.q32 / 100) + 20,
|
|
1209
|
-
length:
|
|
1194
|
+
length: 1000 + (pusherRecord.q40 ?? 0) / 10,
|
|
1210
1195
|
filterInventory: pusherRecord.q36 === 1,
|
|
1211
1196
|
filterSlots: filterSlotsRecord ? [filterSlotsRecord.q20 ?? null, filterSlotsRecord.q24 ?? null, filterSlotsRecord.q28 ?? null] : null,
|
|
1212
1197
|
state: cloneRecord(pusherRecord),
|