dredless 0.7.0 → 1.0.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.
Files changed (40) hide show
  1. package/README.md +22 -57
  2. package/dist/browser/dredless.global.js +794 -98
  3. package/dist/browser/dredless.global.js.map +4 -4
  4. package/docs/ACCOUNT.md +146 -0
  5. package/docs/API_REFERENCE.md +63 -2563
  6. package/docs/BROWSER_API.md +151 -70
  7. package/docs/CLIENT.md +183 -0
  8. package/docs/EVENTS.md +136 -0
  9. package/docs/GETTING_STARTED.md +135 -0
  10. package/docs/MACHINES.md +351 -0
  11. package/docs/MANAGEMENT.md +285 -0
  12. package/docs/NODE_API.md +120 -318
  13. package/docs/PLAYER_INVENTORY.md +235 -0
  14. package/docs/PROTOCOL.md +227 -0
  15. package/docs/README.md +44 -0
  16. package/docs/TROUBLESHOOTING.md +91 -0
  17. package/docs/WORLD_STATE.md +183 -0
  18. package/package.json +1 -2
  19. package/src/browser.d.ts +31 -1
  20. package/src/browser.js +10 -0
  21. package/src/client/core.js +302 -15
  22. package/src/client/domains/index.js +57 -7
  23. package/src/client/index.d.ts +1 -1
  24. package/src/index.d.ts +299 -13
  25. package/src/index.js +10 -0
  26. package/src/network/account.js +182 -0
  27. package/src/network/disconnect.d.ts +2 -0
  28. package/src/network/disconnect.js +53 -0
  29. package/src/network/index.d.ts +1 -0
  30. package/src/network/index.js +1 -0
  31. package/src/network/invites.d.ts +0 -3
  32. package/src/network/invites.js +3 -40
  33. package/src/network/session.d.ts +2 -0
  34. package/src/network/session.js +21 -0
  35. package/src/protocol/outbound/ship-management.d.ts +66 -1
  36. package/src/protocol/outbound/ship-management.js +83 -0
  37. package/src/state/model/decoder.js +70 -10
  38. package/src/state/model/index.d.ts +4 -0
  39. package/src/state/model/state.js +22 -7
  40. 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,2 @@
1
+ export {DropReason, DisconnectError, normalizeDisconnect} from "../index.js";
2
+ export type {DisconnectInfo, SaveShipOptions} from "../index.js";
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  export * from "./connection.js";
2
+ export * from "./disconnect.js";
2
3
  export * from "./servers.js";
3
4
  export * from "./ships.js";
4
5
  export * from "./session.js";
@@ -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";
@@ -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;
@@ -1,48 +1,11 @@
1
- import {getFetch, isBrowser} from "../runtime.js";
1
+ import {getFetch} from "../runtime.js";
2
2
 
3
- export async function fetchInviteMetadata(invite, {fetchImpl = null, includeColor = false} = {}) {
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
- 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
+ return parseInviteMetadata(await response.text());
46
9
  }
47
10
 
48
11
  export function parseInviteMetadata(html) {
@@ -5,3 +5,5 @@ export {
5
5
  createAnonSession,
6
6
  createAnonToken
7
7
  } from "../index.js";
8
+
9
+ export type {AccountDetails, AccountPerkShip, ClanTagDetails} from "../index.js";
@@ -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: unknown[];
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,