dredless 0.7.0 → 1.0.1
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 +22 -58
- package/dist/browser/dredless.global.js +794 -98
- package/dist/browser/dredless.global.js.map +4 -4
- package/docs/ACCOUNT.md +146 -0
- package/docs/API_REFERENCE.md +63 -2563
- package/docs/BROWSER_API.md +151 -70
- package/docs/CLIENT.md +183 -0
- package/docs/EVENTS.md +136 -0
- package/docs/GETTING_STARTED.md +135 -0
- package/docs/MACHINES.md +351 -0
- package/docs/MANAGEMENT.md +285 -0
- package/docs/NODE_API.md +120 -318
- package/docs/PLAYER_INVENTORY.md +235 -0
- package/docs/PROTOCOL.md +227 -0
- package/docs/README.md +44 -0
- package/docs/TROUBLESHOOTING.md +91 -0
- package/docs/WORLD_STATE.md +183 -0
- package/package.json +1 -2
- package/src/browser.d.ts +31 -1
- package/src/browser.js +10 -0
- package/src/client/core.js +302 -15
- package/src/client/domains/index.js +57 -7
- package/src/client/index.d.ts +1 -1
- package/src/index.d.ts +299 -13
- package/src/index.js +10 -0
- package/src/network/account.js +182 -0
- package/src/network/disconnect.d.ts +2 -0
- package/src/network/disconnect.js +53 -0
- package/src/network/index.d.ts +1 -0
- package/src/network/index.js +1 -0
- package/src/network/invites.d.ts +0 -3
- package/src/network/invites.js +3 -40
- package/src/network/session.d.ts +2 -0
- package/src/network/session.js +21 -0
- package/src/protocol/outbound/ship-management.d.ts +66 -1
- package/src/protocol/outbound/ship-management.js +83 -0
- package/src/state/model/decoder.js +70 -10
- package/src/state/model/index.d.ts +4 -0
- package/src/state/model/state.js +22 -7
- package/src/state/world/index.js +27 -1
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import {isBrowser} from "../runtime.js";
|
|
2
|
+
|
|
3
|
+
export async function fetchAccountDetails(session) {
|
|
4
|
+
const response = await session.request("account/", {
|
|
5
|
+
method: "GET",
|
|
6
|
+
headers: {accept: "text/html"},
|
|
7
|
+
credentials: "include",
|
|
8
|
+
cache: "no-store"
|
|
9
|
+
});
|
|
10
|
+
session.mergeSetCookies(response);
|
|
11
|
+
if (!response.ok) throw new Error(`Account page request failed: ${response.status} ${response.statusText}`);
|
|
12
|
+
return parseAccountDetails(await response.text());
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function fetchClanTag(session) {
|
|
16
|
+
return (await fetchAccountDetails(session)).clan;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function setClanTag(session, tag) {
|
|
20
|
+
if (typeof tag !== "string") throw new TypeError("tag must be a string");
|
|
21
|
+
return updateClanTag(session, tag);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function clearClanTag(session) {
|
|
25
|
+
return updateClanTag(session, "");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function updateClanTag(session, tag) {
|
|
29
|
+
// The Clear form has no named fields; ordinary updates contain only `tag`.
|
|
30
|
+
const body = tag === "" ? "" : new URLSearchParams({tag}).toString();
|
|
31
|
+
await postAccountForm(session, "account/clan", body, "Clan tag update");
|
|
32
|
+
const clan = await fetchClanTag(session);
|
|
33
|
+
if ((clan.tag ?? "") !== tag) throw new Error("Clan tag update was not confirmed by the account page");
|
|
34
|
+
return clan;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function removeShipPerks(session, shipId) {
|
|
38
|
+
const id = requirePerkShipId(shipId);
|
|
39
|
+
await postAccountForm(session, "account/remove_perks", new URLSearchParams({ship: id}).toString(), "Ship perk removal");
|
|
40
|
+
const {shipsWithPerks} = await fetchAccountDetails(session);
|
|
41
|
+
if (shipsWithPerks === null || shipsWithPerks.some((ship) => ship.shipId === id)) {
|
|
42
|
+
throw new Error("Ship perk removal was not confirmed by the account page");
|
|
43
|
+
}
|
|
44
|
+
return shipsWithPerks;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function postAccountForm(session, path, body, operation) {
|
|
48
|
+
const response = await session.request(path, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: {
|
|
51
|
+
accept: "text/html",
|
|
52
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
53
|
+
...(!isBrowser() ? {origin: new URL(session.baseUrl).origin} : {})
|
|
54
|
+
},
|
|
55
|
+
body,
|
|
56
|
+
credentials: "include",
|
|
57
|
+
referrer: `${session.baseUrl}/account/`,
|
|
58
|
+
// Node must merge cookies before following the form's redirect.
|
|
59
|
+
redirect: isBrowser() ? "follow" : "manual"
|
|
60
|
+
});
|
|
61
|
+
session.mergeSetCookies(response);
|
|
62
|
+
if (!response.ok && response.status !== 302 && response.status !== 303) {
|
|
63
|
+
throw new Error(`${operation} failed: ${response.status} ${response.statusText}`);
|
|
64
|
+
}
|
|
65
|
+
// Consume the redirect/HTML response before fetching the confirmation page.
|
|
66
|
+
await response.text();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseAccountDetails(html) {
|
|
70
|
+
// Only extract the captured account form and labeled fields. Never execute HTML.
|
|
71
|
+
const source = String(html).replace(/<!--[\s\S]*?-->|<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, "");
|
|
72
|
+
let tag = null;
|
|
73
|
+
let found = false;
|
|
74
|
+
for (const form of source.matchAll(/<form\b((?:"[^"]*"|'[^']*'|[^'">])*)>([\s\S]*?)<\/form\s*>/gi)) {
|
|
75
|
+
const action = attributes(form[1]).get("action");
|
|
76
|
+
if (!action || new URL(action, "https://drednot.io/").pathname !== "/account/clan") continue;
|
|
77
|
+
for (const input of form[2].matchAll(/<input\b((?:"[^"]*"|'[^']*'|[^'">])*)>/gi)) {
|
|
78
|
+
const attrs = attributes(input[1]);
|
|
79
|
+
if (attrs.get("name") !== "tag") continue;
|
|
80
|
+
tag = attrs.get("value") || null;
|
|
81
|
+
found = true;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
if (found) break;
|
|
85
|
+
}
|
|
86
|
+
if (!found) throw new Error("Account page has no clan form; authentication may be required or the page format has changed");
|
|
87
|
+
|
|
88
|
+
const fields = new Map();
|
|
89
|
+
let name = null;
|
|
90
|
+
let color = null;
|
|
91
|
+
for (const paragraph of source.matchAll(/<p\b[^>]*>([\s\S]*?)<\/p\s*>/gi)) {
|
|
92
|
+
const label = paragraph[1].match(/<b\b[^>]*>([\s\S]*?)<\/b\s*>/i);
|
|
93
|
+
const code = paragraph[1].match(/<code\b((?:"[^"]*"|'[^']*'|[^'">])*)>([\s\S]*?)<\/code\s*>/i);
|
|
94
|
+
if (!code) continue;
|
|
95
|
+
const attrs = attributes(code[1]);
|
|
96
|
+
if (label) fields.set(text(label[1]).replace(/:\s*$/, "").toLowerCase(), attrs.get("data-info") ?? null);
|
|
97
|
+
if ((attrs.get("class") || "").split(/\s+/).includes("user") && /^You are:$/i.test(text(paragraph[1].slice(0, code.index)))) {
|
|
98
|
+
name = decodeHtml(code[2].replace(/<[^>]*>/g, ""));
|
|
99
|
+
color = parseAccountColor(attrs.get("style") || "");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const rawIdentity = fields.get("identity (service)") || null;
|
|
103
|
+
const identityParts = rawIdentity?.match(/^(.*?)\s*\(([^()]*)\)\s*$/);
|
|
104
|
+
const count = text(source).match(/Your current clan tag has\s+([\d,]+)\s+other users?\b/i);
|
|
105
|
+
return {
|
|
106
|
+
name,
|
|
107
|
+
color,
|
|
108
|
+
email: fields.get("email") || null,
|
|
109
|
+
identity: identityParts ? identityParts[1].trim() : rawIdentity,
|
|
110
|
+
identityService: identityParts ? identityParts[2].trim() : null,
|
|
111
|
+
clan: {tag, otherUsers: tag !== null && count ? Number(count[1].replace(/,/g, "")) : null},
|
|
112
|
+
shipsWithPerks: parseShipsWithPerks(source)
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseAccountColor(style) {
|
|
117
|
+
let color = null;
|
|
118
|
+
for (const declaration of style.split(";")) {
|
|
119
|
+
const match = declaration.match(/^\s*background(?:-color)?\s*:\s*#([0-9a-f]{6}|[0-9a-f]{3})\s*(?:!important\s*)?$/i);
|
|
120
|
+
if (!match) continue;
|
|
121
|
+
const hex = match[1].length === 3 ? [...match[1]].map((digit) => digit + digit).join("") : match[1];
|
|
122
|
+
color = parseInt(hex, 16);
|
|
123
|
+
}
|
|
124
|
+
return color;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseShipsWithPerks(source) {
|
|
128
|
+
const heading = source.match(/<(b|strong)\b[^>]*>\s*Ships\s+with\s+Perks\s+Applied\s*:\s*<\/\1\s*>/i);
|
|
129
|
+
if (!heading) return null;
|
|
130
|
+
const list = source.slice(heading.index + heading[0].length).match(/^\s*(?:<\/[^>]+>\s*)*<ul\b[^>]*>([\s\S]*?)<\/ul\s*>/i);
|
|
131
|
+
if (!list) throw new Error("Account page's applied-perks list could not be parsed");
|
|
132
|
+
const ships = [];
|
|
133
|
+
for (const item of list[1].matchAll(/<li\b[^>]*>([\s\S]*?)<\/li\s*>/gi)) {
|
|
134
|
+
const form = item[1].match(/<form\b((?:"[^"]*"|'[^']*'|[^'">])*)>([\s\S]*?)<\/form\s*>/i);
|
|
135
|
+
const action = form && attributes(form[1]).get("action");
|
|
136
|
+
if (!action || new URL(action, "https://drednot.io/").pathname !== "/account/remove_perks") {
|
|
137
|
+
throw new Error("Account page's applied-perks entry has no removal form");
|
|
138
|
+
}
|
|
139
|
+
let shipId = null;
|
|
140
|
+
for (const input of form[2].matchAll(/<input\b((?:"[^"]*"|'[^']*'|[^'">])*)>/gi)) {
|
|
141
|
+
const attrs = attributes(input[1]);
|
|
142
|
+
if (attrs.get("name") === "ship") shipId = requirePerkShipId(attrs.get("value"));
|
|
143
|
+
}
|
|
144
|
+
if (shipId === null) throw new Error("Account page's applied-perks entry has no ship identifier");
|
|
145
|
+
const label = decodeHtml(item[1].slice(0, form.index).replace(/<[^>]*>/g, ""));
|
|
146
|
+
const parts = label.match(/^([\s\S]*?) \{([^{}]+)\}\s*$/);
|
|
147
|
+
ships.push({shipId, name: parts ? parts[1] : label.trim(), hexCode: parts ? parts[2] : null});
|
|
148
|
+
}
|
|
149
|
+
return ships;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function requirePerkShipId(value) {
|
|
153
|
+
if (typeof value === "number" && (!Number.isSafeInteger(value) || value <= 0)) {
|
|
154
|
+
throw new RangeError("shipId must be a positive safe integer or a decimal ID string from shipsWithPerks");
|
|
155
|
+
}
|
|
156
|
+
if ((typeof value !== "string" && typeof value !== "number") || !/^[1-9]\d*$/.test(String(value))) {
|
|
157
|
+
throw new TypeError("shipId must be a positive integer or a decimal ID string from shipsWithPerks");
|
|
158
|
+
}
|
|
159
|
+
return String(value);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function attributes(source) {
|
|
163
|
+
const result = new Map();
|
|
164
|
+
for (const match of source.matchAll(/([^\s=<>"'`/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
|
|
165
|
+
result.set(match[1].toLowerCase(), decodeHtml(match[2] ?? match[3] ?? match[4] ?? ""));
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function text(source) {
|
|
171
|
+
return decodeHtml(source.replace(/<[^>]*>/g, " ")).replace(/\s+/g, " ").trim();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function decodeHtml(value) {
|
|
175
|
+
const named = {amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " "};
|
|
176
|
+
return value.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi, (raw, entity) => {
|
|
177
|
+
const name = entity.toLowerCase();
|
|
178
|
+
if (Object.hasOwn(named, name)) return named[name];
|
|
179
|
+
const code = name.startsWith("#x") ? parseInt(name.slice(2), 16) : Number(name.slice(1));
|
|
180
|
+
return code > 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff) ? String.fromCodePoint(code) : "\ufffd";
|
|
181
|
+
});
|
|
182
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Application close codes from the official client's DropReason enum.
|
|
2
|
+
export const DropReason = Object.freeze({
|
|
3
|
+
ProtocolError: 4000,
|
|
4
|
+
BadVersion: 4001,
|
|
5
|
+
ConnectLimit: 4002,
|
|
6
|
+
ConnectRate: 4003,
|
|
7
|
+
TeamLimit: 4004,
|
|
8
|
+
FullPlayers: 4005,
|
|
9
|
+
FullTeams: 4006,
|
|
10
|
+
TeamNotAvailable: 4007,
|
|
11
|
+
Kicked: 4008,
|
|
12
|
+
Banned: 4009,
|
|
13
|
+
Idle: 4010,
|
|
14
|
+
ShipDestruct: 4011,
|
|
15
|
+
JoinBlocked: 4012,
|
|
16
|
+
HardBanned: 4013,
|
|
17
|
+
NoLoad: 4014,
|
|
18
|
+
Saved: 4015,
|
|
19
|
+
InvalidInvite: 4016,
|
|
20
|
+
Shutdown: 4017,
|
|
21
|
+
ServerError: 4018,
|
|
22
|
+
GTFO: 4019
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const reasonNames = new Map(Object.entries(DropReason).map(([name, code]) => [code, name]));
|
|
26
|
+
const textDecoder = new TextDecoder();
|
|
27
|
+
|
|
28
|
+
export function normalizeDisconnect(eventOrCode, reason = "") {
|
|
29
|
+
const event = eventOrCode != null && typeof eventOrCode === "object" ? eventOrCode : null;
|
|
30
|
+
const code = event?.code ?? eventOrCode;
|
|
31
|
+
const rawReason = event?.reason ?? reason;
|
|
32
|
+
let text = "";
|
|
33
|
+
if (typeof rawReason === "string") text = rawReason;
|
|
34
|
+
else if (ArrayBuffer.isView(rawReason)) {
|
|
35
|
+
text = textDecoder.decode(new Uint8Array(rawReason.buffer, rawReason.byteOffset, rawReason.byteLength));
|
|
36
|
+
} else if (rawReason instanceof ArrayBuffer) text = textDecoder.decode(rawReason);
|
|
37
|
+
const normalizedCode = Number.isInteger(code) ? code : 1006;
|
|
38
|
+
return Object.freeze({
|
|
39
|
+
code: normalizedCode,
|
|
40
|
+
name: reasonNames.get(normalizedCode) ?? null,
|
|
41
|
+
reason: text,
|
|
42
|
+
// EventEmitter close callbacks don't report whether the handshake was clean.
|
|
43
|
+
wasClean: typeof event?.wasClean === "boolean" ? event.wasClean : null
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class DisconnectError extends Error {
|
|
48
|
+
constructor(disconnect, message = "Disconnected") {
|
|
49
|
+
super(`${message}: ${disconnect.name ?? "Unknown"} (${disconnect.code})${disconnect.reason ? ` — ${disconnect.reason}` : ""}`);
|
|
50
|
+
this.name = "DisconnectError";
|
|
51
|
+
this.disconnect = disconnect;
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/network/index.d.ts
CHANGED
package/src/network/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export {Connection} from "./connection.js";
|
|
2
|
+
export {DropReason, DisconnectError, normalizeDisconnect} from "./disconnect.js";
|
|
2
3
|
export {fetchGameVersion, fetchNoticeVersion, fetchServerStatus, fetchServerStatuses, fetchServers, resolveServer} from "./servers.js";
|
|
3
4
|
export {fetchShips, fetchShipList, createShipSpec, createInviteShipSpec, shipRef} from "./ships.js";
|
|
4
5
|
export {Session, AnonSession, createSession, createAnonSession, createAnonToken} from "./session.js";
|
package/src/network/invites.d.ts
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
export interface InviteMetadata {
|
|
2
2
|
shipId: string | null;
|
|
3
3
|
name: string | null;
|
|
4
|
-
/** CSS RGB color sampled from the preview image's top-left pixel, when available. */
|
|
5
|
-
color?: string | null;
|
|
6
4
|
previewImageUrl: string | null;
|
|
7
5
|
hostname: string;
|
|
8
6
|
inviteCode: string;
|
|
9
7
|
}
|
|
10
8
|
export interface InviteFetchOptions {
|
|
11
9
|
fetchImpl?: typeof fetch;
|
|
12
|
-
includeColor?: boolean;
|
|
13
10
|
}
|
|
14
11
|
export function fetchInviteMetadata(invite: string | URL, options?: InviteFetchOptions): Promise<InviteMetadata>;
|
|
15
12
|
export function parseInviteMetadata(html: string): InviteMetadata;
|
package/src/network/invites.js
CHANGED
|
@@ -1,48 +1,11 @@
|
|
|
1
|
-
import {getFetch
|
|
1
|
+
import {getFetch} from "../runtime.js";
|
|
2
2
|
|
|
3
|
-
export async function fetchInviteMetadata(invite, {fetchImpl = null
|
|
3
|
+
export async function fetchInviteMetadata(invite, {fetchImpl = null} = {}) {
|
|
4
4
|
const url = normalizeInviteUrl(invite);
|
|
5
5
|
const fetcher = fetchImpl || getFetch();
|
|
6
6
|
const response = await fetcher(url.href, {headers: {accept: "text/html"}});
|
|
7
7
|
if (!response.ok) throw new Error(`Invite metadata request failed: ${response.status} ${response.statusText}`);
|
|
8
|
-
|
|
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
|
+
return parseInviteMetadata(await response.text());
|
|
46
9
|
}
|
|
47
10
|
|
|
48
11
|
export function parseInviteMetadata(html) {
|
package/src/network/session.d.ts
CHANGED
package/src/network/session.js
CHANGED
|
@@ -6,6 +6,7 @@ import {fetchGameVersion, resolveServer, serverId} from "./servers.js";
|
|
|
6
6
|
import {createInviteShipSpec, createShipSpec, fetchShipList, fetchShips, shipRef} from "./ships.js";
|
|
7
7
|
import {Connection} from "./connection.js";
|
|
8
8
|
import {DredlessClient} from "../client/index.js";
|
|
9
|
+
import {fetchAccountDetails, fetchClanTag, setClanTag, clearClanTag, removeShipPerks} from "./account.js";
|
|
9
10
|
|
|
10
11
|
export class Session {
|
|
11
12
|
constructor(gameSession = null, gameVersion = null, baseUrl = DEFAULT_BASE_URL) {
|
|
@@ -61,6 +62,26 @@ export class Session {
|
|
|
61
62
|
return raw;
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
fetchAccountDetails() {
|
|
66
|
+
return fetchAccountDetails(this);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fetchClanTag() {
|
|
70
|
+
return fetchClanTag(this);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setClanTag(tag) {
|
|
74
|
+
return setClanTag(this, tag);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
clearClanTag() {
|
|
78
|
+
return clearClanTag(this);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
removeShipPerks(shipId) {
|
|
82
|
+
return removeShipPerks(this, shipId);
|
|
83
|
+
}
|
|
84
|
+
|
|
64
85
|
async fetchShips(server) {
|
|
65
86
|
return fetchShips(this, server);
|
|
66
87
|
}
|
|
@@ -1,19 +1,60 @@
|
|
|
1
|
+
export const GravityDirection: {
|
|
2
|
+
readonly Down: "down";
|
|
3
|
+
readonly Up: "up";
|
|
4
|
+
readonly Left: "left";
|
|
5
|
+
readonly Right: "right";
|
|
6
|
+
};
|
|
7
|
+
export type GravityDirection = typeof GravityDirection[keyof typeof GravityDirection];
|
|
8
|
+
export type ShipGravityDirection = GravityDirection | 0 | 1 | 2 | 3;
|
|
9
|
+
|
|
1
10
|
export type ShipPrivacy = 0 | 1 | boolean | "public" | "private";
|
|
2
11
|
export type ShipPlayerRank = 0 | 1 | 3 | "guest" | "crew" | "captain";
|
|
3
12
|
|
|
13
|
+
export interface CrewLogEntry {
|
|
14
|
+
icon: string | null;
|
|
15
|
+
/** Plain text; formatting, images, badges, and date tokens remain in rawText. */
|
|
16
|
+
text: string;
|
|
17
|
+
rawText: unknown;
|
|
18
|
+
count: number;
|
|
19
|
+
refId: number | null;
|
|
20
|
+
secondaryRefId: number | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CrewLog {
|
|
24
|
+
/** Retained entries in server order, oldest first; at most 500. */
|
|
25
|
+
entries: CrewLogEntry[];
|
|
26
|
+
/** Entries appended by the latest message, including amended replacements. */
|
|
27
|
+
changes: CrewLogEntry[];
|
|
28
|
+
reset: boolean;
|
|
29
|
+
amend: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
4
32
|
export interface ShipManagementMessage {
|
|
5
33
|
type: 4;
|
|
6
34
|
act: string;
|
|
7
35
|
arg: unknown;
|
|
36
|
+
arg2?: unknown;
|
|
8
37
|
rank?: unknown;
|
|
9
38
|
}
|
|
10
39
|
|
|
40
|
+
export interface ShipPatronPerk {
|
|
41
|
+
name: string;
|
|
42
|
+
/** Packed RGB color, as sent by the server. */
|
|
43
|
+
color: number;
|
|
44
|
+
/** Official tiers include bronze, silver, gold, plat, and flux. */
|
|
45
|
+
tier: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PatronPerksResponse {
|
|
49
|
+
readonly failure: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
11
52
|
export interface ShipConfig {
|
|
12
53
|
privacy: number | null;
|
|
13
54
|
privacyName: "public" | "private" | null;
|
|
14
55
|
inviteKey: string | null;
|
|
15
56
|
teamId: number | null;
|
|
16
|
-
patronPerks:
|
|
57
|
+
patronPerks: ShipPatronPerk[];
|
|
17
58
|
}
|
|
18
59
|
|
|
19
60
|
export interface CaptainSubrank {
|
|
@@ -49,10 +90,22 @@ export function buildShipManagementMessage(act: string, arg?: unknown, extra?: R
|
|
|
49
90
|
|
|
50
91
|
export function buildShipPrivacyMessage(privacy: ShipPrivacy): ShipManagementMessage;
|
|
51
92
|
|
|
93
|
+
export function buildShipGravityMessage(direction: ShipGravityDirection): ShipManagementMessage;
|
|
94
|
+
|
|
95
|
+
export function buildSetPatronPerksMessage(enabled: boolean): ShipManagementMessage;
|
|
96
|
+
|
|
97
|
+
export function normalizeGravityDirection(direction: ShipGravityDirection): 0 | 1 | 2 | 3;
|
|
98
|
+
|
|
52
99
|
export function buildStarterRecoveryMessage(itemId: number): ShipManagementMessage;
|
|
53
100
|
|
|
54
101
|
export function buildPlayerListMessage(): ShipManagementMessage;
|
|
55
102
|
|
|
103
|
+
export function buildSaveShipMessage(): ShipManagementMessage;
|
|
104
|
+
|
|
105
|
+
export function buildShipRenameRequestMessage(): ShipManagementMessage;
|
|
106
|
+
|
|
107
|
+
export function buildShipRenameCommitMessage(name: string, color: string): ShipManagementMessage;
|
|
108
|
+
|
|
56
109
|
export function buildInviteResetMessage(): ShipManagementMessage;
|
|
57
110
|
|
|
58
111
|
export function buildSetPlayerRankMessage(refId: number, rank: ShipPlayerRank): ShipManagementMessage;
|
|
@@ -61,6 +114,8 @@ export function buildKickPlayerMessage(refId: number): ShipManagementMessage;
|
|
|
61
114
|
|
|
62
115
|
export function buildBanPlayerMessage(refId: number): ShipManagementMessage;
|
|
63
116
|
|
|
117
|
+
export function buildUnbanPlayerMessage(refId: number): ShipManagementMessage;
|
|
118
|
+
|
|
64
119
|
export function buildDemoteSelfMessage(): ShipManagementMessage;
|
|
65
120
|
|
|
66
121
|
export function normalizePrivacy(privacy: ShipPrivacy): 0 | 1;
|
|
@@ -71,14 +126,24 @@ export function normalizeShipConfig(event: unknown): ShipConfig;
|
|
|
71
126
|
|
|
72
127
|
export function normalizeCaptainSubrank(event: unknown): CaptainSubrank;
|
|
73
128
|
|
|
129
|
+
export function normalizeCrewLog(event: unknown, previous?: CrewLog | null): CrewLog;
|
|
130
|
+
|
|
74
131
|
export function normalizeShipPlayerList(event: unknown, previous?: ShipPlayerList | null, currentCaptainSubrank?: CaptainSubrank | null): ShipPlayerList;
|
|
75
132
|
|
|
133
|
+
export const CREW_LOG_LENGTH: 500;
|
|
134
|
+
export const GRAVITY_ACTION: "gravity";
|
|
135
|
+
export const SET_PATRON_PERKS_ACTION: "set_patron_perks";
|
|
76
136
|
export const BAN_ACTION: "ban";
|
|
137
|
+
export const UNBAN_ACTION: "unban";
|
|
77
138
|
export const DEMOTE_SELF_ACTION: "demote_self";
|
|
78
139
|
export const KICK_ACTION: "kick";
|
|
79
140
|
export const PLAYER_LIST_ACTION: "player_list";
|
|
141
|
+
export const SAVE_SHIP_ACTION: "save_team";
|
|
80
142
|
export const INVITE_RESET_ACTION: "invite_reset";
|
|
81
143
|
export const SET_PRIVACY_ACTION: "set_privacy";
|
|
82
144
|
export const SET_RANK_ACTION: "set_rank";
|
|
83
145
|
export const SHIP_MANAGEMENT_TYPE: 4;
|
|
84
146
|
export const STARTER_RECOVERY_ACTION: "starter_recovery";
|
|
147
|
+
|
|
148
|
+
export const RENAME_REQUEST_ACTION: "rename_request";
|
|
149
|
+
export const RENAME_COMMIT_ACTION: "rename_commit";
|
|
@@ -1,12 +1,36 @@
|
|
|
1
|
+
import {flattenRichText} from "./comms.js";
|
|
2
|
+
|
|
1
3
|
const SHIP_MANAGEMENT_TYPE = 4;
|
|
4
|
+
const CREW_LOG_LENGTH = 500;
|
|
2
5
|
const SET_PRIVACY_ACTION = "set_privacy";
|
|
6
|
+
const GRAVITY_ACTION = "gravity";
|
|
7
|
+
const SET_PATRON_PERKS_ACTION = "set_patron_perks";
|
|
3
8
|
const STARTER_RECOVERY_ACTION = "starter_recovery";
|
|
4
9
|
const PLAYER_LIST_ACTION = "player_list";
|
|
5
10
|
const INVITE_RESET_ACTION = "invite_reset";
|
|
6
11
|
const SET_RANK_ACTION = "set_rank";
|
|
7
12
|
const KICK_ACTION = "kick";
|
|
8
13
|
const BAN_ACTION = "ban";
|
|
14
|
+
const UNBAN_ACTION = "unban";
|
|
9
15
|
const DEMOTE_SELF_ACTION = "demote_self";
|
|
16
|
+
const SAVE_SHIP_ACTION = "save_team";
|
|
17
|
+
const RENAME_REQUEST_ACTION = "rename_request";
|
|
18
|
+
const RENAME_COMMIT_ACTION = "rename_commit";
|
|
19
|
+
|
|
20
|
+
export const GravityDirection = Object.freeze({
|
|
21
|
+
Down: "down",
|
|
22
|
+
Up: "up",
|
|
23
|
+
Left: "left",
|
|
24
|
+
Right: "right"
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export function normalizeGravityDirection(value) {
|
|
28
|
+
if (value === GravityDirection.Down || value === 0) return 0;
|
|
29
|
+
if (value === GravityDirection.Up || value === 1) return 1;
|
|
30
|
+
if (value === GravityDirection.Left || value === 2) return 2;
|
|
31
|
+
if (value === GravityDirection.Right || value === 3) return 3;
|
|
32
|
+
throw new RangeError('direction must be "down", "up", "left", "right", or an integer from 0 to 3');
|
|
33
|
+
}
|
|
10
34
|
|
|
11
35
|
function requireNonNegativeInteger(value, name) {
|
|
12
36
|
const number = Number(value);
|
|
@@ -42,6 +66,15 @@ export function buildShipPrivacyMessage(privacy) {
|
|
|
42
66
|
return buildShipManagementMessage(SET_PRIVACY_ACTION, normalizePrivacy(privacy));
|
|
43
67
|
}
|
|
44
68
|
|
|
69
|
+
export function buildShipGravityMessage(direction) {
|
|
70
|
+
return buildShipManagementMessage(GRAVITY_ACTION, normalizeGravityDirection(direction));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildSetPatronPerksMessage(enabled) {
|
|
74
|
+
if (typeof enabled !== "boolean") throw new TypeError("enabled must be a boolean");
|
|
75
|
+
return buildShipManagementMessage(SET_PATRON_PERKS_ACTION, enabled);
|
|
76
|
+
}
|
|
77
|
+
|
|
45
78
|
export function buildStarterRecoveryMessage(itemId) {
|
|
46
79
|
return buildShipManagementMessage(STARTER_RECOVERY_ACTION, requireNonNegativeInteger(itemId, "itemId"));
|
|
47
80
|
}
|
|
@@ -66,10 +99,31 @@ export function buildBanPlayerMessage(refId) {
|
|
|
66
99
|
return buildShipManagementMessage(BAN_ACTION, normalizePlayerRef(refId));
|
|
67
100
|
}
|
|
68
101
|
|
|
102
|
+
export function buildUnbanPlayerMessage(refId) {
|
|
103
|
+
return buildShipManagementMessage(UNBAN_ACTION, normalizePlayerRef(refId));
|
|
104
|
+
}
|
|
105
|
+
|
|
69
106
|
export function buildDemoteSelfMessage() {
|
|
70
107
|
return buildShipManagementMessage(DEMOTE_SELF_ACTION);
|
|
71
108
|
}
|
|
72
109
|
|
|
110
|
+
export function buildSaveShipMessage() {
|
|
111
|
+
return buildShipManagementMessage(SAVE_SHIP_ACTION);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function buildShipRenameRequestMessage() {
|
|
115
|
+
return buildShipManagementMessage(RENAME_REQUEST_ACTION);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function buildShipRenameCommitMessage(name, color) {
|
|
119
|
+
if (typeof name !== "string") throw new TypeError("name must be a string");
|
|
120
|
+
if (name.length > 32) throw new RangeError("name must be at most 32 UTF-16 code units");
|
|
121
|
+
if (typeof color !== "string" || !/^#[0-9a-f]{6}$/i.test(color)) {
|
|
122
|
+
throw new TypeError("color must be a #RRGGBB hex string");
|
|
123
|
+
}
|
|
124
|
+
return buildShipManagementMessage(RENAME_COMMIT_ACTION, name, {arg2: color});
|
|
125
|
+
}
|
|
126
|
+
|
|
73
127
|
export function normalizeShipConfig(event = {}) {
|
|
74
128
|
const source = event && typeof event === "object" ? event : {};
|
|
75
129
|
const config = source.config && typeof source.config === "object" ? source.config : {};
|
|
@@ -90,6 +144,28 @@ export function normalizeCaptainSubrank(event = {}) {
|
|
|
90
144
|
};
|
|
91
145
|
}
|
|
92
146
|
|
|
147
|
+
export function normalizeCrewLog(event = {}, previous = null) {
|
|
148
|
+
const source = event && typeof event === "object" ? event : {};
|
|
149
|
+
const reset = Boolean(source.reset);
|
|
150
|
+
const amend = Boolean(source.amend);
|
|
151
|
+
const entries = reset ? [] : (previous?.entries || []).slice();
|
|
152
|
+
if (amend) entries.pop();
|
|
153
|
+
const changes = Array.isArray(source.entries) ? source.entries.map((entry) => {
|
|
154
|
+
const value = entry && typeof entry === "object" ? entry : {};
|
|
155
|
+
return {
|
|
156
|
+
icon: value.icon ?? null,
|
|
157
|
+
text: flattenRichText(value.text),
|
|
158
|
+
rawText: value.text ?? null,
|
|
159
|
+
count: value.count ?? 1,
|
|
160
|
+
refId: value.ref ?? null,
|
|
161
|
+
secondaryRefId: value.ref2 ?? null
|
|
162
|
+
};
|
|
163
|
+
}) : [];
|
|
164
|
+
for (const entry of changes) entries.push(entry);
|
|
165
|
+
if (entries.length > CREW_LOG_LENGTH) entries.splice(0, entries.length - CREW_LOG_LENGTH);
|
|
166
|
+
return {entries, changes, reset, amend};
|
|
167
|
+
}
|
|
168
|
+
|
|
93
169
|
export function normalizeShipPlayerList(event = {}, previous = null, currentCaptainSubrank = null) {
|
|
94
170
|
const source = event && typeof event === "object" ? event : {};
|
|
95
171
|
const changes = Array.isArray(source.player_list) ? source.player_list.map(normalizePlayerListChange) : [];
|
|
@@ -208,10 +284,17 @@ function playerCanBeManagedBy(player, currentRank) {
|
|
|
208
284
|
|
|
209
285
|
export {
|
|
210
286
|
BAN_ACTION,
|
|
287
|
+
CREW_LOG_LENGTH,
|
|
288
|
+
GRAVITY_ACTION,
|
|
289
|
+
SET_PATRON_PERKS_ACTION,
|
|
290
|
+
UNBAN_ACTION,
|
|
211
291
|
DEMOTE_SELF_ACTION,
|
|
212
292
|
INVITE_RESET_ACTION,
|
|
213
293
|
KICK_ACTION,
|
|
214
294
|
PLAYER_LIST_ACTION,
|
|
295
|
+
RENAME_REQUEST_ACTION,
|
|
296
|
+
RENAME_COMMIT_ACTION,
|
|
297
|
+
SAVE_SHIP_ACTION,
|
|
215
298
|
SET_PRIVACY_ACTION,
|
|
216
299
|
SET_RANK_ACTION,
|
|
217
300
|
SHIP_MANAGEMENT_TYPE,
|