dredless 0.2.0 → 0.2.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.
Files changed (77) hide show
  1. package/README.md +48 -48
  2. package/docs/API_REFERENCE.md +2481 -2481
  3. package/docs/BROWSER_API.md +98 -98
  4. package/docs/NODE_API.md +359 -359
  5. package/package.json +34 -34
  6. package/src/browser.d.ts +212 -212
  7. package/src/browser.js +133 -133
  8. package/src/client/core.js +1202 -1202
  9. package/src/client/domains/index.js +1818 -1818
  10. package/src/client/index.d.ts +2 -2
  11. package/src/client/index.js +1 -1
  12. package/src/compression/lz4.d.ts +1 -1
  13. package/src/compression/lz4.js +112 -112
  14. package/src/constants.js +74 -74
  15. package/src/crypto/chacha-native.d.ts +9 -9
  16. package/src/crypto/chacha-native.js +48 -48
  17. package/src/crypto/chacha.d.ts +25 -25
  18. package/src/crypto/chacha.js +274 -274
  19. package/src/events.js +39 -39
  20. package/src/index.d.ts +2277 -2277
  21. package/src/index.js +125 -125
  22. package/src/network/browser-session.js +291 -291
  23. package/src/network/connection.d.ts +1 -1
  24. package/src/network/connection.js +31 -31
  25. package/src/network/cookies.js +85 -85
  26. package/src/network/http.js +34 -34
  27. package/src/network/index.d.ts +4 -4
  28. package/src/network/index.js +4 -4
  29. package/src/network/servers.d.ts +7 -7
  30. package/src/network/servers.js +133 -133
  31. package/src/network/session.d.ts +7 -7
  32. package/src/network/session.js +320 -320
  33. package/src/network/ships.d.ts +12 -12
  34. package/src/network/ships.js +73 -73
  35. package/src/node.d.ts +2 -2
  36. package/src/node.js +8 -8
  37. package/src/protocol/codec/binary.d.ts +3 -3
  38. package/src/protocol/codec/binary.js +52 -52
  39. package/src/protocol/codec/msgpack.d.ts +3 -3
  40. package/src/protocol/codec/msgpack.js +555 -555
  41. package/src/protocol/inbound/frame.d.ts +1 -1
  42. package/src/protocol/inbound/frame.js +8 -8
  43. package/src/protocol/inbound/world-payload.d.ts +3 -3
  44. package/src/protocol/inbound/world-payload.js +21 -21
  45. package/src/protocol/index.d.ts +11 -11
  46. package/src/protocol/index.js +19 -19
  47. package/src/protocol/outbound/blueprint.d.ts +22 -22
  48. package/src/protocol/outbound/blueprint.js +36 -36
  49. package/src/protocol/outbound/commands.d.ts +3 -3
  50. package/src/protocol/outbound/commands.js +149 -149
  51. package/src/protocol/outbound/comms.d.ts +28 -28
  52. package/src/protocol/outbound/comms.js +40 -40
  53. package/src/protocol/outbound/inventory.d.ts +64 -64
  54. package/src/protocol/outbound/inventory.js +147 -147
  55. package/src/protocol/outbound/ship-management.d.ts +84 -84
  56. package/src/protocol/outbound/ship-management.js +221 -221
  57. package/src/protocol/outbound/sign.d.ts +18 -18
  58. package/src/protocol/outbound/sign.js +45 -45
  59. package/src/protocol/outbound/ui-config.d.ts +157 -157
  60. package/src/protocol/outbound/ui-config.js +578 -578
  61. package/src/runtime.js +36 -36
  62. package/src/state/generator-maze.d.ts +32 -32
  63. package/src/state/generator-maze.js +370 -370
  64. package/src/state/index.d.ts +3 -3
  65. package/src/state/index.js +3 -3
  66. package/src/state/items.js +20 -20
  67. package/src/state/model/decoder.js +1554 -1554
  68. package/src/state/model/index.d.ts +33 -33
  69. package/src/state/model/index.js +2 -2
  70. package/src/state/model/loader-config.js +1020 -1020
  71. package/src/state/model/reader.js +69 -69
  72. package/src/state/model/state.js +1257 -1257
  73. package/src/state/overworld.js +72 -72
  74. package/src/state/tilesets.js +53 -53
  75. package/src/state/world/index.d.ts +29 -29
  76. package/src/state/world/index.js +180 -180
  77. package/src/state/world/state.js +297 -297
@@ -1,1554 +1,1554 @@
1
- import {decoder} from "../../constants.js";
2
- import {
3
- LOADER_FILTER_MODE_NAMES,
4
- LOADER_POSITION_NAMES,
5
- LOADER_PRIORITY_NAMES,
6
- LoaderConfigTracker
7
- } from "./loader-config.js";
8
- import {navigationDestinationFromEncodedValue, navigationZoneFromBaseId} from "../overworld.js";
9
- import {maybeSolveGeneratorMazeSeed} from "../generator-maze.js";
10
- import {itemNameFromId} from "../items.js";
11
- import {ModelReader} from "./reader.js";
12
-
13
- export {ModelReader} from "./reader.js";
14
-
15
- export function defineLazyProperty(target, key, compute) {
16
- let cached = null;
17
- let resolved = false;
18
- Object.defineProperty(target, key, {
19
- enumerable: true,
20
- configurable: true,
21
- get() {
22
- if (!resolved) {
23
- cached = compute();
24
- resolved = true;
25
- }
26
- return cached;
27
- },
28
- set(value) {
29
- cached = value;
30
- resolved = true;
31
- }
32
- });
33
- return target;
34
- }
35
-
36
- // string on every field of every record while decoding.
37
- export function numericFields(bitsToOffsets) {
38
- return Object.entries(bitsToOffsets)
39
- .map(([bit, offset]) => ({bit: Number(bit), offset, key: qKey(offset)}))
40
- .sort((a, b) => a.bit - b.bit);
41
- }
42
-
43
- export const TWO_FIELD_SPEC = {
44
- fields: numericFields({1: 20, 2: 24})
45
- };
46
-
47
- export const LABEL_STATE_SPEC = {
48
- name: "label_state",
49
- read(reader, record, mask) {
50
- if (mask & 1) record.blob20 = reader.readBlob();
51
- if (mask & 2) {
52
- if (mask & 1) record.blob24 = reader.readBlob();
53
- else record.q20 = (record.q20 || 0) + reader.readFieldDelta();
54
- }
55
- if (mask & 4) record.q28 = (record.q28 || 0) + reader.readFieldDelta();
56
- if (mask & 8) {
57
- if (mask & 1) record.q32 = (record.q32 || 0) + reader.readFieldDelta();
58
- else record.blob28 = reader.readBlob();
59
- }
60
- if (mask & 16) record.q36 = (record.q36 || 0) + reader.readFieldDelta();
61
- }
62
- };
63
-
64
- export const SIMPLE_LABEL_STATE_SPEC = {
65
- name: "label_state",
66
- read(reader, record, mask) {
67
- if (mask & 1) record.blob20 = reader.readBlob();
68
- if (mask & 2) record.q24 = (record.q24 || 0) + reader.readFieldDelta();
69
- if (mask & 4) record.q28 = (record.q28 || 0) + reader.readFieldDelta();
70
- if (mask & 8) record.q32 = (record.q32 || 0) + reader.readFieldDelta();
71
- if (mask & 16) record.q36 = (record.q36 || 0) + reader.readFieldDelta();
72
- }
73
- };
74
-
75
- export function peekStreamInt(reader) {
76
- let result = 0;
77
- let shift = 0;
78
- let offset = reader.offset;
79
- while (offset < reader.bytes.length) {
80
- const byte = reader.bytes[offset++];
81
- result += (byte & 0x7f) * (2 ** shift);
82
- if ((byte & 0x80) === 0) {
83
- return {
84
- value: (result & 1) === 0 ? result / 2 : -((result + 1) / 2),
85
- offset
86
- };
87
- }
88
- shift += 7;
89
- if (shift > 53) return null;
90
- }
91
- return null;
92
- }
93
-
94
- export function nextValueLooksLikeTextBlob(reader) {
95
- const peek = peekStreamInt(reader);
96
- if (!peek || peek.value <= 0) return false;
97
- const end = peek.offset + peek.value;
98
- if (end > reader.bytes.length) return false;
99
- const blob = reader.bytes.slice(peek.offset, end);
100
- try {
101
- const text = decoder.decode(blob);
102
- return text.length > 0 && !/[\u0000-\u0008\u000e-\u001f]/.test(text);
103
- } catch (_) {
104
- return false;
105
- }
106
- }
107
-
108
- export const MODEL_TABLE_SPECS = new Map([
109
- [0, {
110
- name: "transform",
111
- packedBits: [{bit: 8, offset: 33}, {bit: 16, offset: 34}, {bit: 32, offset: 35}],
112
- fields: numericFields({1: 20, 2: 24, 4: 28}),
113
- scale(record) {
114
- return {
115
- x: numberOrNull(record.q20, 40),
116
- y: numberOrNull(record.q24, 40),
117
- rot: numberOrNull(record.q28, 127.324)
118
- };
119
- }
120
- }],
121
- [1, {
122
- name: "body_state",
123
- fields: numericFields({
124
- 1: 20, 2: 24, 4: 28, 8: 32, 16: 36, 32: 40, 64: 44, 128: 48,
125
- 256: 52, 512: 56, 1024: 60, 2048: 64, 4096: 68, 8192: 72,
126
- 16384: 76, 32768: 80, 65536: 84, 131072: 88, 262144: 92,
127
- 524288: 96, 1048576: 100, 2097152: 104
128
- })
129
- }],
130
- [2, {name: "numeric_pair", ...TWO_FIELD_SPEC}],
131
- [3, {name: "gate_width", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36, 32: 40})}],
132
- [4, {name: "motion_aux", packedBits: [{bit: 8, offset: 33}], fields: numericFields({1: 20, 2: 24, 4: 28})}],
133
- [5, {name: "entity_health", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32})}],
134
- [6, {name: "item_holder", ...TWO_FIELD_SPEC}],
135
- [7, {
136
- name: "entity_type",
137
- fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36, 32: 40, 64: 44, 128: 48, 256: 52})
138
- }],
139
- [8, {name: "numeric_sparse", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36, 32: 40})}],
140
- [9, LABEL_STATE_SPEC],
141
- [10, SIMPLE_LABEL_STATE_SPEC],
142
- [11, {name: "numeric_sparse", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36})}],
143
- [12, {name: "numeric_sparse", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32})}],
144
- [14, {name: "size_state", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36, 32: 40, 64: 44})}],
145
- [16, {
146
- name: "label_numeric_state",
147
- read(reader, record, mask) {
148
- if ((mask & 1) && nextValueLooksLikeTextBlob(reader)) {
149
- SIMPLE_LABEL_STATE_SPEC.read(reader, record, mask);
150
- return;
151
- }
152
- if (mask & 1) record.q20 = (record.q20 || 0) + reader.readFieldDelta();
153
- if (mask & 2) {
154
- if (mask & 1) record.blob24 = reader.readBlob();
155
- else record.q24 = (record.q24 || 0) + reader.readFieldDelta();
156
- }
157
- if (mask & 4) record.q28 = (record.q28 || 0) + reader.readFieldDelta();
158
- if (mask & 8) record.q32 = (record.q32 || 0) + reader.readFieldDelta();
159
- if (mask & 16) record.q36 = (record.q36 || 0) + reader.readFieldDelta();
160
- }
161
- }],
162
- [17, {name: "numeric_sparse", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36})}],
163
- [18, {
164
- name: "mob_combat_state",
165
- fields: numericFields({
166
- 2: 20,
167
- 4: 24,
168
- 8: 28,
169
- 16: 32,
170
- 32: 36,
171
- 64: 40,
172
- 128: 44,
173
- 256: 48,
174
- 512: 52,
175
- 1024: 56,
176
- 2048: 60,
177
- 4096: 64,
178
- 8192: 68,
179
- 16384: 72,
180
- 32768: 76,
181
- 65536: 80,
182
- 131072: 84,
183
- 524288: 88,
184
- 4194304: 92
185
- })
186
- }],
187
- [19, {name: "motion_state", packedBits: [{bit: 4, offset: 29}], ...TWO_FIELD_SPEC}],
188
- [20, {
189
- name: "ship_control",
190
- packedBeforeValues: true,
191
- orderedValues: true,
192
- packedBits: [{bit: 0x1000, offset: 153}],
193
- fields: numericFields({
194
- 1: 20, 2: 24, 4: 28, 32: 32, 64: 36, 128: 40, 256: 44, 512: 48,
195
- 1024: 52, 2048: 56, 8192: 60, 16384: 64, 32768: 68, 65536: 72,
196
- 131072: 76, 262144: 80, 524288: 84, 1048576: 88, 2097152: 92,
197
- 4194304: 96, 8388608: 100, 16777216: 104, 67108864: 108, 134217728: 112
198
- }),
199
- blobs: [
200
- {bit: 8, offset: 116},
201
- {bit: 16, offset: 128},
202
- {bit: 33554432, offset: 140}
203
- ],
204
- scale(record) {
205
- return {
206
- thrustX: numberOrNull(record.q24, 20),
207
- thrustY: numberOrNull(record.q28, 20),
208
- value52: numberOrNull(record.q52, 20)
209
- };
210
- }
211
- }],
212
- [21, {
213
- name: "gate_portal",
214
- orderedValues: true,
215
- fields: numericFields({1: 20, 2: 24}),
216
- blobs: [{bit: 4, offset: 28}, {bit: 8, offset: 40}]
217
- }],
218
- [24, {name: "projectile_state", packedBits: [{bit: 8, offset: 33}], fields: numericFields({1: 20, 2: 24, 4: 28})}],
219
- [25, {
220
- name: "zone_label",
221
- orderedValues: true,
222
- fields: numericFields({1: 20, 2: 24}),
223
- blobs: [{bit: 4, offset: 28}, {bit: 8, offset: 40}]
224
- }],
225
- [26, {name: "numeric_single", fields: numericFields({1: 20})}],
226
- [31, {name: "numeric_single", fields: numericFields({1: 20})}],
227
- [37, {name: "loose_item_marker", packedBits: [{bit: 2, offset: 29}], fields: numericFields({1: 20, 4: 24})}],
228
- [38, {name: "flag_state", packedBits: [{bit: 1, offset: 21}], fields: []}],
229
- [39, {name: "comms_transmit", ...TWO_FIELD_SPEC}],
230
- [41, {name: "blob_state", fields: numericFields({2: 20}), blobs: [{bit: 1, offset: 24}]}],
231
- [42, {name: "numeric_pair", ...TWO_FIELD_SPEC}],
232
- [43, {name: "numeric_single", fields: numericFields({1: 20})}],
233
- [44, {name: "numeric_sparse", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32})}],
234
- [45, {name: "numeric_flag", packedBits: [{bit: 1, offset: 25}], fields: numericFields({2: 20})}],
235
- [47, {name: "flag_state", packedBits: [{bit: 1, offset: 21}], fields: []}],
236
- [49, {name: "processor_cycle", packedBits: [{bit: 2, offset: 33}], fields: numericFields({1: 20, 4: 24, 8: 28})}],
237
- [50, {
238
- name: "rare_snapshot",
239
- packedBits: [{bit: 2, offset: 33}, {bit: 8, offset: 34}, {bit: 32, offset: 35}],
240
- fields: numericFields({1: 20, 4: 24, 16: 28})
241
- }],
242
- [51, {
243
- name: "numeric_sparse",
244
- fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36, 32: 40, 64: 44, 128: 48, 256: 52})
245
- }],
246
- [53, {
247
- name: "fabricator",
248
- packedBits: [{bit: 2, offset: 61}],
249
- fields: numericFields({1: 20, 4: 24, 8: 28, 16: 32, 32: 36, 64: 40, 128: 44, 256: 48, 512: 52, 1024: 56})
250
- }],
251
- [54, {
252
- name: "starter_cannon",
253
- fields: numericFields({1: 20, 2: 32, 4: 24, 8: 28, 16: 44, 32: 36, 64: 48, 128: 52, 256: 40, 512: 56, 1024: 60})
254
- }],
255
- [55, {
256
- name: "player",
257
- packedBeforeValues: true,
258
- fields: numericFields({
259
- 256: 20, 512: 24, 1024: 28, 2048: 32, 4096: 36, 8192: 40,
260
- 16384: 44, 32768: 48, 65536: 52, 131072: 56, 262144: 60,
261
- 524288: 64, 1048576: 68, 2097152: 72, 4194304: 76, 8388608: 80,
262
- 33554432: 84, 67108864: 88
263
- }),
264
- blobs: [{bit: 1, offset: 92}],
265
- packedBits: [
266
- {bit: 2, offset: 105},
267
- {bit: 4, offset: 106},
268
- {bit: 16, offset: 107},
269
- {bit: 32, offset: 108},
270
- {bit: 64, offset: 109},
271
- {bit: 128, offset: 110},
272
- {bit: 16777216, offset: 111},
273
- {bit: 134217728, offset: 112}
274
- ]
275
- }],
276
- [56, {
277
- name: "numeric_snapshot",
278
- fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36, 64: 40, 256: 44, 1024: 48})
279
- }],
280
- [59, {name: "numeric_sparse", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36})}],
281
- [60, {name: "fluid_tank", packedBits: [{bit: 4, offset: 29}], ...TWO_FIELD_SPEC}],
282
- [61, {name: "shield_charge", ...TWO_FIELD_SPEC}],
283
- [62, {name: "flag_state", packedBits: [{bit: 1, offset: 21}], fields: []}],
284
- [63, {name: "numeric_pair", ...TWO_FIELD_SPEC}],
285
- [67, {name: "numeric_single", fields: numericFields({1: 20})}],
286
- [69, {
287
- name: "rare_snapshot",
288
- packedBits: [{bit: 32, offset: 41}, {bit: 64, offset: 42}],
289
- fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36})
290
- }],
291
- [70, {
292
- name: "rare_snapshot",
293
- packedBits: [{bit: 16, offset: 40}],
294
- fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 32: 36})
295
- }],
296
- [72, {
297
- name: "pusher_config",
298
- packedBits: [{bit: 16, offset: 36}],
299
- fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 32: 40})
300
- }],
301
- [73, {name: "expando_box_marker", fields: []}],
302
- [74, {name: "processor_marker", fields: []}],
303
- [75, {name: "rare_snapshot", fields: numericFields({1: 20, 8: 24, 16: 28})}],
304
- [76, {name: "numeric_single", fields: numericFields({1: 20})}],
305
- [77, {name: "numeric_sparse", fields: numericFields({1: 20, 2: 24, 4: 28})}],
306
- [78, {
307
- name: "local_session_marker",
308
- read(reader, record, mask) {
309
- for (const {bit, key} of TABLE_78_NUMERIC_FIELDS) {
310
- if (!(mask & bit)) continue;
311
- record[key] = (record[key] || 0) + reader.readFieldDelta();
312
- }
313
- if ((mask & 64) && !(mask & 32)) {
314
- record.q44 = (record.q44 || 0) + reader.readFieldDelta();
315
- }
316
- }
317
- }]
318
- ]);
319
-
320
- // Precompute everything about a spec that is fixed at module load. Decoding a
321
- // full model packet runs #applyRecordSpec thousands of times, and each run used
322
- // to rebuild record key strings and -- for ordered specs -- an entire merged,
323
- // sorted item array.
324
- for (const spec of MODEL_TABLE_SPECS.values()) {
325
- for (const blob of spec.blobs || []) blob.key = blobKey(blob.offset);
326
- for (const item of spec.packedBits || []) item.key = qKey(item.offset);
327
- if (spec.packedOffsets) spec.packedOffsetKeys = spec.packedOffsets.map(qKey);
328
- if (spec.orderedValues) {
329
- spec.orderedItems = [
330
- ...(spec.blobs || []).map((item) => ({bit: item.bit, offset: item.offset, key: item.key, kind: "blob"})),
331
- ...(spec.fields || []).map((item) => ({bit: item.bit, offset: item.offset, key: item.key, kind: "field"}))
332
- ].sort((a, b) => a.bit - b.bit);
333
- }
334
- }
335
-
336
- export const TABLE_78_NUMERIC_FIELDS = numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36, 32: 40});
337
-
338
- export const WIRE_TAG_TABLES = new Map([
339
- [1, 0],
340
- [2, 1],
341
- [3, 2],
342
- [4, 3],
343
- [5, 4],
344
- [26, 9],
345
- [40, 5],
346
- [42, 6],
347
- [43, 7],
348
- [44, 8],
349
- [45, 9],
350
- [46, 10],
351
- [47, 11],
352
- [48, 12],
353
- [49, 13],
354
- [50, 14],
355
- [51, 51],
356
- [52, 16],
357
- [74, 17],
358
- [80, 17],
359
- [81, 18],
360
- [82, 19],
361
- [83, 20],
362
- [84, 21],
363
- [85, 22],
364
- [86, 23],
365
- [87, 24],
366
- [88, 25],
367
- [89, 26],
368
- [90, 27],
369
- [91, 28],
370
- [93, 30],
371
- [94, 31],
372
- [95, 32],
373
- [99, 36],
374
- [120, 37],
375
- [121, 38],
376
- [122, 39],
377
- [123, 40],
378
- [124, 41],
379
- [125, 42],
380
- [126, 43],
381
- [127, 44],
382
- [128, 45],
383
- [129, 46],
384
- [130, 47],
385
- [131, 48],
386
- [132, 49],
387
- [133, 50],
388
- [134, 51],
389
- [135, 52],
390
- [136, 53],
391
- [137, 54],
392
- [138, 55],
393
- [139, 56],
394
- [141, 58],
395
- [142, 59],
396
- [143, 60],
397
- [144, 61],
398
- [145, 62],
399
- [146, 63],
400
- [147, 64],
401
- [148, 65],
402
- [149, 66],
403
- [150, 67],
404
- [151, 68],
405
- [152, 69],
406
- [153, 70],
407
- [158, 75],
408
- [160, 76],
409
- [161, 77],
410
- [162, 78],
411
- [163, 72],
412
- [164, 73],
413
- [165, 74],
414
- [166, 75],
415
- [167, 76],
416
- [168, 78],
417
- [169, 77]
418
- ]);
419
-
420
- export const MASK_ONLY_TABLES = new Set([13, 22, 23, 27, 28, 30, 32, 36, 40, 46, 48, 52, 58, 64, 65, 66, 68, 73, 74]);
421
-
422
- export const ENTITY_FOOTPRINTS = new Map([
423
- [240, {width: 2, height: 2}],
424
- [247, {width: 2, height: 2}],
425
- [248, {width: 2, height: 2}],
426
- [249, {width: 2, height: 2}],
427
- [251, {width: 2, height: 2}],
428
- [256, {width: 2, height: 2}],
429
- [261, {width: 2, height: 2}]
430
- ]);
431
-
432
- export const PLACED_ENTITY_TYPE_IDS = new Set([
433
- 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 226, 227, 228, 229,
434
- 230, 231, 240, 241, 242, 243, 245, 246, 247, 248, 249, 250, 251, 252,
435
- 253, 255, 256, 257, 258, 259, 260, 261, 263, 264, 265
436
- ]);
437
-
438
- export const FabricatorType = Object.freeze({
439
- Starter: "starter",
440
- Munitions: "munitions",
441
- Engineering: "engineering",
442
- Equipment: "equipment"
443
- });
444
-
445
- export const FABRICATOR_TYPES = new Map([
446
- [0, FabricatorType.Starter],
447
- [1, FabricatorType.Munitions],
448
- [2, FabricatorType.Engineering],
449
- [3, FabricatorType.Equipment]
450
- ]);
451
-
452
- export const HELM_TYPE_IDS = new Set([215, 216]);
453
- export const COMMS_STATION_TYPE_ID = 217;
454
- export const COMMS_STATION_MAX_CHARGES = 5;
455
- export const CARGO_HATCH_TYPE_IDS = new Set([221, 222]);
456
- export const THRUSTER_TYPE_IDS = new Set([230, 231]);
457
- export const EXPANDO_BOX_TYPE_ID = 240;
458
- export const ITEM_LAUNCHER_TYPE_ID = 243;
459
- export const LOADER_TYPE_ID = 252;
460
- export const NAVIGATION_UNIT_TYPE_ID = 261;
461
-
462
- export function entityTypeIdFromRecord(record) {
463
- if (!record) return null;
464
- const candidates = [
465
- record.q20,
466
- record.q24,
467
- record.q28,
468
- record.q32,
469
- record.q36,
470
- record.q40,
471
- record.q44,
472
- record.q48,
473
- record.q52
474
- ].filter((value) => Number.isFinite(Number(value)) && Number(value) !== 0).map(Number);
475
- return candidates.find((value) => PLACED_ENTITY_TYPE_IDS.has(value)) ?? candidates[0] ?? null;
476
- }
477
-
478
- export const CANNON_AMMO_COLOR_ITEM_IDS = new Map([
479
- [0xff9600, 150]
480
- ]);
481
-
482
- export const CANNON_TYPE_IDS = new Set([226, 227, 228, 229, 263, 265]);
483
-
484
- export const THRUSTER_FACING_NAMES = new Map([
485
- [0, "bottom"],
486
- [1, "top"],
487
- [2, "right"],
488
- [3, "left"],
489
- [4, "bottom-right"],
490
- [5, "bottom-left"],
491
- [6, "top-right"],
492
- [7, "top-left"]
493
- ]);
494
-
495
- export const DEFAULT_OVERWORLD_WARP_DURATION_SECONDS = 120;
496
- export const OVERWORLD_WARP_TICKS_PER_SECOND = 20;
497
-
498
- export const MARKER_TYPE_IDS = new Map([
499
- [73, 240]
500
- ]);
501
-
502
- export const TEAM_RANK_NAMES = new Map([
503
- [0, "Guest"],
504
- [1, "Crew"],
505
- [2, "CrewInvitePending_DEPRECATED"],
506
- [3, "Captain"],
507
- [4, "Banned"]
508
- ]);
509
-
510
- export const PLAYER_SHIP_RANKS = new Map([
511
- [0, "guest"],
512
- [1, "crew"],
513
- [2, "crew-invite-pending-deprecated"],
514
- [3, "captain"],
515
- [4, "banned"]
516
- ]);
517
-
518
- export const PUSHER_MODE_NAMES = new Map([
519
- [0, "Push"],
520
- [1, "Pull"],
521
- [2, "Do Nothing"]
522
- ]);
523
-
524
- export const SIGN_DISPLAY_MODE_NAMES = new Map([
525
- [0, "always"],
526
- [1, "when-near"],
527
- [2, "on-hover"]
528
- ]);
529
-
530
- export const SHIELD_GENERATOR_BOOST_STATE_NAMES = new Map([
531
- [0, "inactive"],
532
- [1, "boosted"],
533
- [2, "failed"]
534
- ]);
535
-
536
- export const NAVIGATION_DEFAULT_DESTINATION = 10;
537
-
538
- export function navigationDestinationName(destination) {
539
- return navigationZoneFromBaseId(destination)?.key ?? null;
540
- }
541
-
542
- export function isNavigationDestination(destination) {
543
- return navigationDestinationName(destination) != null;
544
- }
545
-
546
- export function numberOrNull(value, divisor = 1) {
547
- return typeof value === "number" ? value / divisor : null;
548
- }
549
-
550
- export function qKey(offset) {
551
- return `q${offset}`;
552
- }
553
-
554
- export function blobKey(offset) {
555
- return `blob${offset}`;
556
- }
557
-
558
- // Blob fields stay expanded to plain arrays: that shape is part of the public
559
- // record surface. Avoiding the Object.entries pair array is the win available
560
- // without changing what callers see.
561
- export function cloneRecord(record) {
562
- const out = {};
563
- for (const key in record) {
564
- const value = record[key];
565
- out[key] = value instanceof Uint8Array ? Array.from(value) : value;
566
- }
567
- return out;
568
- }
569
-
570
- export function firstRecord(records, predicate = null) {
571
- for (const record of records.values()) {
572
- if (!predicate || predicate(record)) return record;
573
- }
574
- return null;
575
- }
576
-
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
- export function decodeText(blob) {
604
- if (!(blob instanceof Uint8Array)) return null;
605
- try {
606
- return decoder.decode(blob);
607
- } catch (_) {
608
- return null;
609
- }
610
- }
611
-
612
- export function entityNameFromType(typeId) {
613
- return itemNameFromId(typeId);
614
- }
615
-
616
- export function markerTypeIdForTables(tableIds) {
617
- for (const tableId of tableIds) {
618
- if (MARKER_TYPE_IDS.has(tableId)) return MARKER_TYPE_IDS.get(tableId);
619
- }
620
- return null;
621
- }
622
-
623
- export function itemSummary(itemId, count = null) {
624
- const id = itemId == null ? null : Number(itemId);
625
- const normalizedId = Number.isFinite(id) && id !== 0 ? id : null;
626
- return {
627
- itemId: normalizedId,
628
- itemName: normalizedId == null ? null : entityNameFromType(normalizedId),
629
- count: count === 0 ? null : count
630
- };
631
- }
632
-
633
- export function summarizeItemHolder(entity, record) {
634
- if (!record) return null;
635
- return {
636
- entity,
637
- ...itemSummary(record.q20, record.q24 ?? null)
638
- };
639
- }
640
-
641
- export function boundedProgress(value) {
642
- if (value == null) return null;
643
- const number = Number(value);
644
- if (!Number.isFinite(number)) return null;
645
- return Math.max(0, Math.min(100, number));
646
- }
647
-
648
- export function summarizeFabricator(entity, record, itemHolderRecord = null, typeRecord = null) {
649
- if (!record) return null;
650
- const progressRaw = record.q24 ?? null;
651
- const craftingItem = itemSummary(itemHolderRecord?.q20, itemHolderRecord?.q24 ?? null);
652
- return {
653
- entity,
654
- type: FABRICATOR_TYPES.get(typeRecord?.q32) ?? null,
655
- typeIndex: typeRecord?.q32 ?? null,
656
- state: cloneRecord(record),
657
- rows: [
658
- itemSummary(record.q28, record.q40 ?? null),
659
- itemSummary(record.q32, record.q44 ?? null),
660
- itemSummary(record.q36, record.q48 ?? null)
661
- ],
662
- progress: boundedProgress(progressRaw),
663
- progressRaw,
664
- active: Boolean(record.q61) || (typeof progressRaw === "number" && progressRaw > 0),
665
- craftingItemId: craftingItem.itemId,
666
- craftingItemName: craftingItem.itemName,
667
- craftingCount: craftingItem.count
668
- };
669
- }
670
-
671
- export function summarizeCargoEjector(entity, typeId, record) {
672
- if (typeId !== 223) return null;
673
- return {
674
- entity,
675
- typeId,
676
- typeName: entityNameFromType(typeId),
677
- progress: boundedProgress(record?.q24 ?? null),
678
- active: record?.q33 == null ? null : Boolean(record.q33)
679
- };
680
- }
681
-
682
- export function summarizeCannon(entity, record, typeId = null) {
683
- if (!record || !CANNON_TYPE_IDS.has(typeId)) return null;
684
- const ammoItemId = record.q24 == null ? null : CANNON_AMMO_COLOR_ITEM_IDS.get(record.q24) ?? null;
685
- const charge = record.q40 ?? null;
686
- const recoil = record.q36 ?? null;
687
- const recoil2 = record.q48 ?? null;
688
- return {
689
- entity,
690
- typeId,
691
- typeName: entityNameFromType(typeId),
692
- ammoItemId,
693
- ammoName: entityNameFromType(ammoItemId),
694
- ammoCount: record.q28 ?? 0,
695
- aim: record.q32 ?? null,
696
- recoil,
697
- recoil2,
698
- recoils: [recoil, recoil2],
699
- charge,
700
- charged: charge == null ? null : charge >= 50,
701
- spin: record.q52 ?? null,
702
- coolingCellCount: record.q56 ?? 0,
703
- state: cloneRecord(record)
704
- };
705
- }
706
-
707
- export function summarizeThruster(entity, typeId, record) {
708
- if (!THRUSTER_TYPE_IDS.has(typeId)) return null;
709
- const facing = Number.isFinite(Number(record?.q20)) ? Number(record.q20) : 0;
710
- return {
711
- entity,
712
- typeId,
713
- typeName: entityNameFromType(typeId),
714
- facing,
715
- facingName: enumValueName(THRUSTER_FACING_NAMES, facing),
716
- fuel: record?.q24 ?? null,
717
- state: cloneRecord(record || {})
718
- };
719
- }
720
-
721
- export function summarizeHelm(entity, typeId, occupied = false) {
722
- if (!HELM_TYPE_IDS.has(typeId)) return null;
723
- return {
724
- entity,
725
- typeId,
726
- typeName: entityNameFromType(typeId),
727
- occupied: Boolean(occupied)
728
- };
729
- }
730
-
731
- export function navigationDestinationFromRecord(record, changedMask = record?.lastMask ?? 0) {
732
- if (!record) return NAVIGATION_DEFAULT_DESTINATION;
733
- const q32Destination = navigationDestinationFromEncodedValue(record.q32);
734
- const q36Destination = navigationDestinationFromEncodedValue(record.q36);
735
- const q24Destination = navigationDestinationFromEncodedValue(record.q24);
736
- const q20Destination = navigationDestinationFromEncodedValue(record.q20);
737
- if ((changedMask & 9) === 9 && record.q20 === 0 && record.q24 == null && q32Destination != null) return q32Destination;
738
- if ((changedMask & 25) === 25 && record.q20 === 0 && record.q24 == null && q32Destination != null) return q32Destination;
739
- if ((changedMask & 17) === 17 && record.q20 === 0 && record.q24 == null && record.q32 == null && q36Destination != null) return q36Destination;
740
- if (changedMask === 1 && q20Destination != null) return q20Destination;
741
- if ((changedMask & 1) && q20Destination != null && record.q20 !== 0) return q20Destination;
742
- if (record.q20 === 0 && q24Destination != null) return q24Destination;
743
- return q20Destination ?? NAVIGATION_DEFAULT_DESTINATION;
744
- }
745
-
746
- export function summarizeNavigationUnit(entity, typeId, record, trackedState = null) {
747
- if (typeId !== NAVIGATION_UNIT_TYPE_ID || !record) return null;
748
- const destination = trackedState?.destination ?? navigationDestinationFromRecord(record);
749
- return {
750
- entity,
751
- destination,
752
- destinationName: navigationDestinationName(destination),
753
- autoWarpOnShieldFailure: trackedState?.shieldFailure ?? null,
754
- autoWarpOnNoCaptains: trackedState?.noCaptains ?? null,
755
- state: cloneRecord(record)
756
- };
757
- }
758
-
759
- export function summarizeCommsStation(entity, typeId, record, occupied = false) {
760
- if (typeId !== COMMS_STATION_TYPE_ID) return null;
761
- const charges = record?.q20 ?? null;
762
- return {
763
- entity,
764
- typeId,
765
- typeName: entityNameFromType(typeId),
766
- charges,
767
- maxCharges: COMMS_STATION_MAX_CHARGES,
768
- chargeRatio: typeof charges === "number" ? charges / COMMS_STATION_MAX_CHARGES : null,
769
- occupied: Boolean(occupied),
770
- state: record ? cloneRecord(record) : {}
771
- };
772
- }
773
-
774
- export function summarizeHealth(entity, record) {
775
- if (!record) return null;
776
- const maxHp = record.q20 ?? null;
777
- const hp = record.q24 ?? null;
778
- return {
779
- entity,
780
- hp,
781
- maxHp,
782
- ratio: typeof hp === "number" && typeof maxHp === "number" && maxHp !== 0 ? hp / maxHp : null,
783
- state: cloneRecord(record)
784
- };
785
- }
786
-
787
- export function summarizeBot(entity, {
788
- health,
789
- table2Record,
790
- smallRecord,
791
- combatRecord,
792
- motionRecord,
793
- table10Record,
794
- table51Record,
795
- itemCrate,
796
- player,
797
- shipControl
798
- }) {
799
- if (!health || itemCrate || player || shipControl || isBoulderBotExclusion(health, table2Record, combatRecord)) return null;
800
- const className = botClassFromState({
801
- health,
802
- table2Record,
803
- smallRecord,
804
- combatRecord,
805
- table10Record,
806
- table51Record
807
- });
808
- return {
809
- entity,
810
- className,
811
- identifier: botIdentifierFromState({combatRecord, motionRecord, smallRecord}),
812
- typeA: health.state?.q28 ?? null,
813
- typeB: health.state?.q32 ?? null
814
- };
815
- }
816
-
817
- export function isBoulderBotExclusion(health, table2Record, combatRecord) {
818
- return Boolean(
819
- health?.maxHp === 2000 &&
820
- health?.state?.q28 === 30 &&
821
- health?.state?.q32 != null &&
822
- table2Record?.q20 != null &&
823
- table2Record?.q24 == null &&
824
- !combatRecord
825
- );
826
- }
827
-
828
- export function botClassFromState({health, table2Record, smallRecord, combatRecord, table10Record, table51Record}) {
829
- if (isYellowMineGuardBotState({health, smallRecord, combatRecord, table2Record})) return "yellow-mine-guard";
830
- if (isShieldHelperBotState({health, table2Record})) return "shield-helper";
831
- if (isShieldMasterBotState({health, table2Record})) return "shield-master";
832
- if (isLazerEnthusiastBotState({health, table2Record, table10Record, table51Record})) return "lazer-enthusiast";
833
- if (isCowardBossState({health})) return "the-coward";
834
- if (isOrangeFoolBotState({combatRecord})) return "orange-fool";
835
- if (isZombieBossBotState({health, table2Record, combatRecord})) return "zombie-boss";
836
- if (combatRecord?.q20 === 762523904) return "zombie";
837
- if (combatRecord?.q20 === 1967883008) return "zombie-hunter";
838
- if (combatRecord?.q20 === 945371904) return "zombie-tank";
839
- if (isRedSentryBotState({health, table2Record, combatRecord})) return "red-sentry";
840
- if (isRedSniperBotState({health, table2Record})) return "red-sniper";
841
- if (isBlueRusherBotState({health, table2Record, combatRecord})) return "blue-rusher";
842
- if (isYellowHunterBotState({health, table2Record, combatRecord})) return "yellow-hunter";
843
- if (isAquaShielderBotState({health, table2Record, combatRecord})) return "aqua-shielder";
844
- if (table2Record) return "table2-bot";
845
- if (combatRecord) return "combat-bot";
846
- return "bot";
847
- }
848
-
849
- export function isCowardBossState({health}) {
850
- return health?.state?.q28 === 70 && health?.state?.q32 === 78;
851
- }
852
-
853
- export function isOrangeFoolBotState({combatRecord}) {
854
- return combatRecord?.q20 === -427551232 && combatRecord?.q24 === 1 && combatRecord?.q72 === 3;
855
- }
856
-
857
- export function isZombieBossBotState({health, table2Record, combatRecord}) {
858
- return (
859
- combatRecord?.q20 === -430233088 &&
860
- health?.state?.q28 === 30 &&
861
- health?.state?.q32 === 166 &&
862
- table2Record?.q20 === 320 &&
863
- table2Record?.q24 === -900
864
- );
865
- }
866
-
867
- export function isRedSentryBotState({health, table2Record, combatRecord}) {
868
- return (
869
- combatRecord?.q20 === 508251904 && combatRecord?.q72 === 2 ||
870
- health?.state?.q28 === 70 && health?.state?.q32 === 166 && table2Record?.q20 === 320 && table2Record?.q24 === -900
871
- );
872
- }
873
-
874
- export function isBlueRusherBotState({health, table2Record, combatRecord}) {
875
- return (
876
- combatRecord?.q20 === 1113052928 && combatRecord?.q72 === 2 ||
877
- health?.state?.q28 === 30 && health?.state?.q32 === 34 && table2Record?.q20 === 80 && table2Record?.q24 === -900
878
- );
879
- }
880
-
881
- export function isRedSniperBotState({health, table2Record}) {
882
- return health?.state?.q28 === 35 && health?.state?.q32 === 89 && table2Record?.q20 === 180 && table2Record?.q24 === -900;
883
- }
884
-
885
- export function isYellowHunterBotState({health, table2Record, combatRecord}) {
886
- return (
887
- combatRecord?.q20 === -775665152 && combatRecord?.q72 === 4 && combatRecord?.q44 === -944515328 ||
888
- health?.state?.q28 === 30 && health?.state?.q32 === 56 && table2Record?.q20 === 120 && table2Record?.q24 === -900
889
- );
890
- }
891
-
892
- export function isAquaShielderBotState({health, table2Record, combatRecord}) {
893
- return (
894
- combatRecord?.q20 === 13697024 && combatRecord?.q24 === 1 ||
895
- health?.state?.q28 === 50 && health?.state?.q32 === 122 && table2Record?.q20 === 240 && table2Record?.q24 === -900
896
- );
897
- }
898
-
899
- export function isLazerEnthusiastBotState({health, table2Record, table10Record, table51Record}) {
900
- return (
901
- health?.state?.q28 === 140 &&
902
- health?.state?.q32 === 155 &&
903
- table2Record?.q20 === 300 &&
904
- table2Record?.q24 === -900 &&
905
- Boolean(table10Record) &&
906
- Boolean(table51Record)
907
- );
908
- }
909
-
910
- export function isYellowMineGuardBotState({health, smallRecord, combatRecord, table2Record}) {
911
- return (
912
- health?.state?.q28 === 30 &&
913
- health?.state?.q32 === 33 &&
914
- smallRecord?.q20 === 78 &&
915
- smallRecord?.q24 === 78 &&
916
- combatRecord?.q24 === -995542016 &&
917
- !table2Record
918
- );
919
- }
920
-
921
- export function isShieldHelperBotState({health, table2Record}) {
922
- return health?.state?.q28 === 30 && health?.state?.q32 === 33 && health?.maxHp === 1000 && !table2Record;
923
- }
924
-
925
- export function isShieldMasterBotState({health, table2Record}) {
926
- return health?.state?.q28 === 190 && health?.state?.q32 === 210 && health?.maxHp === 15000 && Boolean(table2Record);
927
- }
928
-
929
- export function botIdentifierFromState({combatRecord, motionRecord, smallRecord}) {
930
- if (combatRecord) return `t18:${combatRecord.q20 ?? "-"}:${combatRecord.q24 ?? "-"}:${combatRecord.q72 ?? "-"}`;
931
- if (motionRecord) return `t19:${motionRecord.q20 ?? "-"}:${motionRecord.q24 ?? "-"}`;
932
- if (smallRecord) return `t3:${smallRecord.q20 ?? "-"}:${smallRecord.q24 ?? "-"}`;
933
- return "-";
934
- }
935
-
936
- export function summarizeShieldGenerator(entity, shieldRecord = null, itemHolderRecord = null, boostRecord = null) {
937
- const charge = shieldRecord?.q20 ?? 0;
938
- const maxCharge = 5000;
939
- const efficiencyPercent = shieldRecord?.q24 ?? null;
940
- const storedItem = itemSummary(itemHolderRecord?.q20 ?? null, itemHolderRecord?.q24 ?? null);
941
- const boostState = boostRecord?.q24 ?? 0;
942
- const boostTimer = boostRecord?.q28 ?? 0;
943
- const puzzleSeed = boostRecord?.q20 ?? null;
944
- const summary = {
945
- entity,
946
- charge,
947
- maxCharge,
948
- chargeRatio: typeof charge === "number" ? charge / maxCharge : null,
949
- efficiencyPercent,
950
- efficiency: typeof efficiencyPercent === "number" ? efficiencyPercent / 100 : null,
951
- storedItemId: storedItem.itemId,
952
- storedItemName: storedItem.itemName,
953
- storedItemCount: storedItem.count,
954
- hasShieldCore: storedItem.itemId === 123,
955
- boostState,
956
- boostStateName: enumValueName(SHIELD_GENERATOR_BOOST_STATE_NAMES, boostState),
957
- boostTimer,
958
- boostActive: boostState !== 0 || boostTimer > 0,
959
- puzzleSeed,
960
- // Replaced in place below by a lazy accessor; declared here so the key keeps
961
- // its original position in the object.
962
- puzzleSolution: null,
963
- state: cloneRecord(shieldRecord || {}),
964
- itemState: cloneRecord(itemHolderRecord || {}),
965
- boostStateRaw: cloneRecord(boostRecord || {})
966
- };
967
- // Solving the maze costs ~190us and was by far the most expensive step of a
968
- // model rebuild, run for every shield generator whether or not the caller
969
- // ever looks at the solution. Defer it to first read.
970
- return defineLazyProperty(summary, "puzzleSolution", () => maybeSolveGeneratorMazeSeed(puzzleSeed));
971
- }
972
-
973
- export function summarizeShipControl(entity, record) {
974
- if (!record) return null;
975
- const color = record.q32 == null ? null : Number(record.q32);
976
- const shieldMaxHpOffset = typeof record.q68 === "number" ? record.q68 : null;
977
- const shieldMaxHp = shieldMaxHpOffset == null ? null : shieldMaxHpOffset + 2000;
978
- const shieldBaseHp = typeof record.q72 === "number" ? record.q72 : null;
979
- const activeTankHp = typeof record.q76 === "number" ? record.q76 : null;
980
- const inactiveTankHp = typeof record.q80 === "number" ? record.q80 : null;
981
- const tankValues = [activeTankHp, inactiveTankHp].filter((value) => typeof value === "number");
982
- const warpTicks = typeof record.q84 === "number" ? record.q84 : null;
983
- const warpElapsedSeconds = warpTicks == null ? null : warpTicks / OVERWORLD_WARP_TICKS_PER_SECOND;
984
- const warpDurationSeconds = warpTicks == null ? null : record.q88 ?? DEFAULT_OVERWORLD_WARP_DURATION_SECONDS;
985
- const warpRemainingSeconds = warpElapsedSeconds == null ? null : Math.max(0, warpDurationSeconds - warpElapsedSeconds);
986
- return {
987
- entity,
988
- name: decodeText(record.blob116),
989
- hexCode: decodeText(record.blob128),
990
- shipWorldId: record.q20 ?? null,
991
- color: Number.isFinite(color) ? color : null,
992
- colorCss: Number.isFinite(color) ? colorToCss(color) : null,
993
- thrustX: numberOrNull(record.q24, 20),
994
- thrustY: numberOrNull(record.q28, 20),
995
- value52: numberOrNull(record.q52, 20),
996
- value84: numberOrNull(record.q84, 20),
997
- value96: numberOrNull(record.q96, 1000),
998
- shield: shieldMaxHp == null && shieldBaseHp == null && !tankValues.length ? null : {
999
- maxHp: shieldMaxHp,
1000
- baseHp: shieldBaseHp,
1001
- activeTankHp,
1002
- inactiveTankHp,
1003
- tankValues
1004
- },
1005
- warp: warpTicks == null ? null : {
1006
- active: record.q28 === 3,
1007
- ticks: warpTicks,
1008
- elapsedSeconds: warpElapsedSeconds,
1009
- durationSeconds: warpDurationSeconds,
1010
- remainingSeconds: warpRemainingSeconds
1011
- },
1012
- state: cloneRecord(record)
1013
- };
1014
- }
1015
-
1016
- export function summarizeShipSize(entity, record) {
1017
- if (!record) return null;
1018
- return {
1019
- entity,
1020
- width: record.q20 == null ? null : Math.round(record.q20 / 10),
1021
- height: record.q24 == null ? null : Math.round(record.q24 / 10),
1022
- rawWidth: record.q20 ?? null,
1023
- rawHeight: record.q24 ?? null,
1024
- state: cloneRecord(record)
1025
- };
1026
- }
1027
-
1028
- export function summarizeShipWorldMetadata(lockdownRecord, shipRecord) {
1029
- if (!lockdownRecord && !shipRecord) return null;
1030
- const color = shipRecord?.q24 == null ? null : Number(shipRecord.q24);
1031
- const lockdownCountdownSeconds = typeof lockdownRecord?.q20 === "number" ? lockdownRecord.q20 : null;
1032
- const onlineShipOwnerCount = typeof lockdownRecord?.q24 === "number" ? lockdownRecord.q24 : null;
1033
- const requiredShipOwnerCount = typeof lockdownRecord?.q28 === "number" ? lockdownRecord.q28 : null;
1034
- return {
1035
- name: decodeText(shipRecord?.blob20),
1036
- color: Number.isFinite(color) ? color : null,
1037
- colorCss: Number.isFinite(color) ? colorToCss(color) : null,
1038
- width: typeof shipRecord?.q32 === "number" ? shipRecord.q32 : null,
1039
- height: typeof shipRecord?.q36 === "number" ? shipRecord.q36 : null,
1040
- lockdownTimerSeconds: lockdownCountdownSeconds,
1041
- lockdownCountdownSeconds,
1042
- onlineShipOwnerCount,
1043
- requiredShipOwnerCount,
1044
- allShipOwnersOnline: onlineShipOwnerCount == null || requiredShipOwnerCount == null ? null : onlineShipOwnerCount >= requiredShipOwnerCount,
1045
- lockdownEngaged: lockdownCountdownSeconds == null ? null : lockdownCountdownSeconds > 0,
1046
- lockdownState: cloneRecord(lockdownRecord || {}),
1047
- shipState: cloneRecord(shipRecord || {})
1048
- };
1049
- }
1050
-
1051
- export function summarizeItemCrate(entity, sizeRecord, itemRecord = null, healthRecord = null) {
1052
- if (!sizeRecord) return null;
1053
- const itemId = itemRecord?.q20 ?? null;
1054
- const count = itemRecord?.q24 ?? null;
1055
- return {
1056
- entity,
1057
- ...itemSummary(itemId, count),
1058
- width: sizeRecord.q20 ?? null,
1059
- height: sizeRecord.q24 ?? null,
1060
- itemState: cloneRecord(itemRecord || healthRecord || {}),
1061
- sizeState: cloneRecord(sizeRecord)
1062
- };
1063
- }
1064
-
1065
- export function scaledSizeSummary(record) {
1066
- if (!record) return null;
1067
- const rawWidth = record.q20 ?? null;
1068
- const rawHeight = record.q24 ?? null;
1069
- return {
1070
- width: rawWidth == null ? null : rawWidth / 10,
1071
- height: rawHeight == null ? null : rawHeight / 10,
1072
- rawWidth,
1073
- rawHeight,
1074
- state: cloneRecord(record)
1075
- };
1076
- }
1077
-
1078
- export function summarizeExpandoBox(entity, itemHolderRecord, sizeRecord) {
1079
- if (!itemHolderRecord && !sizeRecord) return null;
1080
- const size = scaledSizeSummary(sizeRecord);
1081
- return {
1082
- entity,
1083
- ...itemSummary(itemHolderRecord?.q20 ?? null, itemHolderRecord?.q24 ?? null),
1084
- width: size?.width ?? null,
1085
- height: size?.height ?? null,
1086
- rawWidth: size?.rawWidth ?? null,
1087
- rawHeight: size?.rawHeight ?? null,
1088
- itemState: cloneRecord(itemHolderRecord || {}),
1089
- sizeState: cloneRecord(sizeRecord || {})
1090
- };
1091
- }
1092
-
1093
- export function summarizeHoverOutline(entity, record) {
1094
- const size = scaledSizeSummary(record);
1095
- if (!size) return null;
1096
- return {entity, ...size};
1097
- }
1098
-
1099
- export function summarizeMapMarker(entity, labelRecord, zoneRecord = null, sizeRecord = null) {
1100
- if (!labelRecord && !zoneRecord) return null;
1101
- const title = decodeText(zoneRecord?.blob28) ?? decodeText(labelRecord?.blob20);
1102
- const key = decodeText(labelRecord?.blob24) ?? decodeText(labelRecord?.blob28);
1103
- const description = decodeText(zoneRecord?.blob40);
1104
- const color = zoneRecord?.q20 ?? labelRecord?.q28 ?? null;
1105
- const accentColor = zoneRecord?.q24 ?? labelRecord?.q32 ?? null;
1106
- const kind = key === "mine" ? "mining_zone" : key === "dock" ? "dock" : zoneRecord ? "portal" : "marker";
1107
- return {
1108
- entity,
1109
- kind,
1110
- title,
1111
- key,
1112
- description,
1113
- color,
1114
- colorCss: color != null && Number.isFinite(Number(color)) ? colorToCss(Number(color)) : null,
1115
- accentColor,
1116
- accentColorCss: accentColor != null && Number.isFinite(Number(accentColor)) ? colorToCss(Number(accentColor)) : null,
1117
- width: sizeRecord?.q20 ?? null,
1118
- height: sizeRecord?.q24 ?? null,
1119
- labelState: cloneRecord(labelRecord || {}),
1120
- zoneState: cloneRecord(zoneRecord || {})
1121
- };
1122
- }
1123
-
1124
- export function summarizeDockingSpring(entity, springRecord, bodyRecord = null, sizeRecord = null) {
1125
- if (!springRecord || bodyRecord?.q32 !== -1 || sizeRecord?.q20 !== 160 || sizeRecord?.q24 !== 160) return null;
1126
- return {
1127
- entity,
1128
- id: springRecord.q20 ?? null,
1129
- width: sizeRecord.q20,
1130
- height: sizeRecord.q24,
1131
- state: cloneRecord(springRecord)
1132
- };
1133
- }
1134
-
1135
- export function summarizeHugeThruster(entity, markerRecord, sizeRecord = null) {
1136
- if (!markerRecord || sizeRecord?.q20 !== 320 || sizeRecord?.q24 !== 160) return null;
1137
- return {
1138
- entity,
1139
- width: sizeRecord.q20,
1140
- height: sizeRecord.q24,
1141
- state: cloneRecord(markerRecord)
1142
- };
1143
- }
1144
-
1145
- export function summarizeLoader(entity, loaderRecord, loaderFilterRecord = null, filterSlotsRecord = null, tracker = null, includeDefaults = false, itemHolderRecord = null) {
1146
- if (!loaderRecord && !loaderFilterRecord && !includeDefaults) return null;
1147
- const config = tracker?.getConfig(null, entity, loaderRecord, loaderFilterRecord, filterSlotsRecord) ?? {};
1148
- const hasLoaderState = Boolean(loaderRecord);
1149
- const hasPositionConfig = Boolean(tracker?.hasPositionConfig(null, entity));
1150
- const pick = hasLoaderState && hasPositionConfig ? config.pick ?? null : null;
1151
- const place = hasLoaderState && hasPositionConfig ? config.place ?? null : null;
1152
- const priority = config.priority ?? 0;
1153
- const filterMode = config.filterMode ?? null;
1154
- const heldItem = itemSummary(itemHolderRecord?.q20, itemHolderRecord?.q24 ?? null);
1155
- return {
1156
- entity,
1157
- pick,
1158
- pickName: enumValueName(LOADER_POSITION_NAMES, pick),
1159
- place,
1160
- placeName: enumValueName(LOADER_POSITION_NAMES, place),
1161
- priority,
1162
- priorityName: enumValueName(LOADER_PRIORITY_NAMES, priority),
1163
- requireOutput: config.requireOutput ?? false,
1164
- waitForStack: config.waitForStack ?? false,
1165
- stack: config.stack ?? 16,
1166
- cycle: Math.max(1, config.cycle ?? 1),
1167
- filterMode,
1168
- filterModeName: enumValueName(LOADER_FILTER_MODE_NAMES, filterMode),
1169
- filterSlots: config.filterSlots ?? null,
1170
- heldItemId: heldItem.itemId,
1171
- heldItemName: heldItem.itemName,
1172
- heldCount: heldItem.count,
1173
- active: heldItem.itemId != null,
1174
- progress: null,
1175
- state: cloneRecord(loaderRecord || {}),
1176
- filterState: cloneRecord(loaderFilterRecord || {}),
1177
- filterSlotsState: cloneRecord(filterSlotsRecord || {})
1178
- };
1179
- }
1180
-
1181
- export function summarizeCargoHatch(entity, typeId, filterRecord = null, filterSlotsRecord = null, animationRecord = null) {
1182
- if (!CARGO_HATCH_TYPE_IDS.has(typeId)) return null;
1183
- const filterMode = filterRecord?.q20 ?? 0;
1184
- return {
1185
- entity,
1186
- typeId,
1187
- typeName: entityNameFromType(typeId),
1188
- filterMode,
1189
- filterModeName: enumValueName(LOADER_FILTER_MODE_NAMES, filterMode),
1190
- filterSlots: filterSlotsRecord ? [filterSlotsRecord.q20 ?? null, filterSlotsRecord.q24 ?? null, filterSlotsRecord.q28 ?? null] : null,
1191
- openFraction: animationRecord?.q20 == null ? null : boundedProgress(animationRecord.q20) / 100,
1192
- filterState: cloneRecord(filterRecord || {}),
1193
- filterSlotsState: cloneRecord(filterSlotsRecord || {})
1194
- };
1195
- }
1196
-
1197
- export function summarizePusher(entity, pusherRecord, filterSlotsRecord = null) {
1198
- if (!pusherRecord) return null;
1199
- const mode = pusherRecord.q20 == null ? 2 : pusherRecord.q20 + 2;
1200
- const filteredMode = pusherRecord.q24 ?? 0;
1201
- return {
1202
- entity,
1203
- mode,
1204
- modeName: enumValueName(PUSHER_MODE_NAMES, mode),
1205
- filteredMode,
1206
- filteredModeName: enumValueName(PUSHER_MODE_NAMES, filteredMode),
1207
- angle: numberOrNull(pusherRecord.q28, 10),
1208
- speed: pusherRecord.q32 == null ? 20 : (pusherRecord.q32 / 100) + 20,
1209
- length: pusherRecord.q40 == null ? 1000 : ((pusherRecord.q40 + 50) / 10) + 995,
1210
- filterInventory: pusherRecord.q36 === 1,
1211
- filterSlots: filterSlotsRecord ? [filterSlotsRecord.q20 ?? null, filterSlotsRecord.q24 ?? null, filterSlotsRecord.q28 ?? null] : null,
1212
- state: cloneRecord(pusherRecord),
1213
- filterSlotsState: cloneRecord(filterSlotsRecord || {})
1214
- };
1215
- }
1216
-
1217
- export function summarizePusherBeam(entity, beamRecord) {
1218
- if (!beamRecord) return null;
1219
- const lengthRaw = beamRecord.q20 ?? 0;
1220
- const mode = beamRecord.q24 ?? 2;
1221
- return {
1222
- entity,
1223
- active: mode !== 2,
1224
- mode,
1225
- modeName: enumValueName(PUSHER_MODE_NAMES, mode),
1226
- lengthRaw,
1227
- length: lengthRaw / 10,
1228
- state: cloneRecord(beamRecord)
1229
- };
1230
- }
1231
-
1232
- export function normalizeDegrees(value) {
1233
- if (!Number.isFinite(Number(value))) return null;
1234
- return ((Number(value) % 360) + 360) % 360;
1235
- }
1236
-
1237
- export function summarizeItemLauncher(entity, typeId, launcherRecord) {
1238
- if (typeId !== ITEM_LAUNCHER_TYPE_ID || !launcherRecord) return null;
1239
- const angleRadians = numberOrNull(launcherRecord.q32 ?? 0, 200);
1240
- return {
1241
- entity,
1242
- angleRaw: launcherRecord.q32 ?? null,
1243
- angleRadians,
1244
- angleDegrees: angleRadians == null ? null : normalizeDegrees((angleRadians * 180) / Math.PI),
1245
- state: cloneRecord(launcherRecord)
1246
- };
1247
- }
1248
-
1249
- export function summarizeSign(entity, signRecord) {
1250
- if (!signRecord) return null;
1251
- const displayMode = signRecord.q20 ?? 0;
1252
- return {
1253
- entity,
1254
- text: decodeText(signRecord.blob24) ?? "",
1255
- displayMode,
1256
- displayModeName: enumValueName(SIGN_DISPLAY_MODE_NAMES, displayMode),
1257
- state: cloneRecord(signRecord)
1258
- };
1259
- }
1260
-
1261
- export function summarizeShieldProjector(entity, projectorRecord) {
1262
- return {
1263
- entity,
1264
- active: projectorRecord?.q21 !== 0,
1265
- state: cloneRecord(projectorRecord || {})
1266
- };
1267
- }
1268
-
1269
- export function summarizeSpawnPoint(entity, spawnRecord) {
1270
- const rank = Number.isFinite(Number(spawnRecord?.q20)) ? Number(spawnRecord.q20) : 0;
1271
- return {
1272
- entity,
1273
- rank,
1274
- rankName: enumValueName(TEAM_RANK_NAMES, rank),
1275
- state: cloneRecord(spawnRecord || {})
1276
- };
1277
- }
1278
-
1279
- export function summarizeDoor(entity, rankRecord, doorRecord) {
1280
- const rank = Number.isFinite(Number(rankRecord?.q20)) ? Number(rankRecord.q20) : 0;
1281
- return {
1282
- entity,
1283
- rank,
1284
- rankName: enumValueName(TEAM_RANK_NAMES, rank),
1285
- open: doorRecord?.q21 === 1,
1286
- rankState: cloneRecord(rankRecord || {}),
1287
- state: cloneRecord(doorRecord || {})
1288
- };
1289
- }
1290
-
1291
- export function enumValueName(map, value) {
1292
- return value == null ? null : map.get(value) ?? null;
1293
- }
1294
-
1295
- export function colorToCss(color) {
1296
- return `rgb(${(color >> 16) & 0xff},${(color >> 8) & 0xff},${color & 0xff})`;
1297
- }
1298
-
1299
- export function previewActionName(color) {
1300
- if (color === 0x00ff00) return "place";
1301
- if (color === 0xff0000) return "break";
1302
- if (color === 0x0000ff) return "blueprint";
1303
- return null;
1304
- }
1305
-
1306
- export function bitOffsets(value) {
1307
- if (!Number.isSafeInteger(value) || value < 1) return [];
1308
- let bits = BigInt(value);
1309
- const offsets = [];
1310
- let offset = 0;
1311
- while (bits > 0n && offset < 64) {
1312
- if (bits & 1n) offsets.push(offset);
1313
- bits >>= 1n;
1314
- offset++;
1315
- }
1316
- return offsets;
1317
- }
1318
-
1319
- export function summarizeBlueprintPreview(entity, record, transformRecord = null) {
1320
- if (!record || record.q20 == null) return null;
1321
- const rawBits = Number.isSafeInteger(Number(record.q24)) ? Number(record.q24) : null;
1322
- const bits = rawBits == null ? 1 : rawBits + 1;
1323
- const placementOffsets = bitOffsets(bits);
1324
- const transform = transformRecord ? {
1325
- entity,
1326
- x: numberOrNull(transformRecord.q20, 40),
1327
- y: numberOrNull(transformRecord.q24, 40),
1328
- rot: numberOrNull(transformRecord.q28, 127.324)
1329
- } : null;
1330
- return {
1331
- entity,
1332
- itemId: record.q20,
1333
- itemName: entityNameFromType(record.q20),
1334
- bits,
1335
- rawBits,
1336
- placementOffsets,
1337
- placementCount: placementOffsets.length,
1338
- placements: placementOffsets.map((offset) => ({
1339
- offset,
1340
- x: transform?.x == null ? null : transform.x + offset,
1341
- y: transform?.y ?? null,
1342
- itemId: record.q20,
1343
- itemName: entityNameFromType(record.q20)
1344
- })),
1345
- x: transform?.x ?? null,
1346
- y: transform?.y ?? null,
1347
- rot: transform?.rot ?? null,
1348
- state: cloneRecord(record)
1349
- };
1350
- }
1351
-
1352
- export function summarizePlayerPreview(entity, record, blueprintItems = []) {
1353
- if (!record) return null;
1354
- const color = record.q40 == null ? null : Number(record.q40);
1355
- const active = typeof record.q36 === "number" && record.q36 !== 0;
1356
- if (!active && record.q36 == null) return null;
1357
- const actionName = Number.isFinite(color) ? previewActionName(color) : null;
1358
- return {
1359
- entity,
1360
- active,
1361
- x: numberOrNull(record.q20, 10),
1362
- y: numberOrNull(record.q24, 10),
1363
- width: numberOrNull(record.q28, 10),
1364
- height: numberOrNull(record.q32, 10),
1365
- progress: record.q36 ?? null,
1366
- color: Number.isFinite(color) ? color : null,
1367
- colorCss: Number.isFinite(color) ? colorToCss(color) : null,
1368
- actionName,
1369
- blueprintId: record.q44 ?? null,
1370
- blueprintItems: actionName === "blueprint" ? blueprintItems : [],
1371
- state: cloneRecord(record)
1372
- };
1373
- }
1374
-
1375
- export function summarizePlayer(entity, record, previewRecord = null, blueprintItems = []) {
1376
- if (!record) return null;
1377
- const rawHeldItemId = record.q28 == null ? null : Number(record.q28);
1378
- const heldItemId = Number.isFinite(rawHeldItemId) && rawHeldItemId !== 0 ? rawHeldItemId : null;
1379
- const teamRank = Number.isFinite(Number(record.q72)) ? Number(record.q72) : 0;
1380
- const gameRank = Number.isFinite(Number(record.q76)) ? Number(record.q76) : 0;
1381
- return {
1382
- entity,
1383
- name: decodeText(record.blob92),
1384
- heldItemId,
1385
- heldItemName: entityNameFromType(heldItemId),
1386
- repairTargetDistance: record.q56 == null ? null : record.q56 / 10,
1387
- repairTargetAngle: record.q80 == null ? null : record.q80 / 100,
1388
- shipRank: PLAYER_SHIP_RANKS.get(teamRank) || null,
1389
- patronTier: patronTierName(gameRank),
1390
- isDeveloper: gameRank === 1,
1391
- isPatron: gameRank >= 2 && gameRank <= 6,
1392
- piloting: Boolean(record.q107),
1393
- muted: Boolean(record.q112),
1394
- actionPreview: summarizePlayerPreview(entity, previewRecord, blueprintItems),
1395
- state: cloneRecord(record)
1396
- };
1397
- }
1398
-
1399
- export function patronTierName(gameRank) {
1400
- switch (gameRank) {
1401
- case 2:
1402
- return "bronze";
1403
- case 3:
1404
- return "silver";
1405
- case 4:
1406
- return "gold";
1407
- case 5:
1408
- return "plat";
1409
- case 6:
1410
- return "flux";
1411
- default:
1412
- return null;
1413
- }
1414
- }
1415
-
1416
- // Called once per entity with a fixed set of named components. Taking (key,
1417
- // value) pairs avoids allocating ~32 single-key object literals per entity and
1418
- // an Object.entries pair array for each of them.
1419
- export function mergeContents(components) {
1420
- const out = {};
1421
- let any = false;
1422
- for (const key in components) {
1423
- const value = components[key];
1424
- if (value == null) continue;
1425
- out[key] = value;
1426
- any = true;
1427
- }
1428
- return any ? out : null;
1429
- }
1430
-
1431
- // A hover outline is a highlight rectangle, not a physical footprint. Overworld
1432
- // boundary entities report outlines as large as 720x32, and indexing those cell
1433
- // by cell produced ~106k block cells for 183 entities -- the dominant cost in
1434
- // every derived read. Outlines beyond this budget fall through to the ordinary
1435
- // marker/crate/type heuristics.
1436
- export const MAX_HOVER_OUTLINE_FOOTPRINT_CELLS = 1024;
1437
-
1438
- export function entityFootprint(entity) {
1439
- if (entity?.hoverOutline && Number.isFinite(Number(entity.hoverOutline.width)) && Number.isFinite(Number(entity.hoverOutline.height))) {
1440
- const width = Math.ceil(Number(entity.hoverOutline.width));
1441
- const height = Math.ceil(Number(entity.hoverOutline.height));
1442
- if (width * height <= MAX_HOVER_OUTLINE_FOOTPRINT_CELLS) {
1443
- return {width, height, source: "hover_outline"};
1444
- }
1445
- }
1446
- if (entity?.markerTypeId != null && ENTITY_FOOTPRINTS.has(entity.markerTypeId)) {
1447
- const footprint = ENTITY_FOOTPRINTS.get(entity.markerTypeId);
1448
- return {...footprint, source: "marker"};
1449
- }
1450
- if (entity?.hugeThruster) {
1451
- return {
1452
- width: entity.hugeThruster.width,
1453
- height: entity.hugeThruster.height,
1454
- source: "huge_thruster"
1455
- };
1456
- }
1457
- if (entity?.itemCrate && Number.isFinite(Number(entity.itemCrate.width)) && Number.isFinite(Number(entity.itemCrate.height))) {
1458
- return {
1459
- width: Number(entity.itemCrate.width),
1460
- height: Number(entity.itemCrate.height),
1461
- source: "crate"
1462
- };
1463
- }
1464
- if (entity?.typeId != null && ENTITY_FOOTPRINTS.has(entity.typeId)) {
1465
- const footprint = ENTITY_FOOTPRINTS.get(entity.typeId);
1466
- return {...footprint, source: "type"};
1467
- }
1468
- if (entity?.fabricator || entity?.shieldGenerator) return {width: 2, height: 2, source: "heuristic"};
1469
- return {width: 1, height: 1, source: "default"};
1470
- }
1471
-
1472
- export function entityLabel(entity) {
1473
- if (entity?.category === "metadata" && entity?.typeName) return `Metadata ${entity.typeName}`;
1474
- if (entity?.blueprintPreview) return `Blueprint Preview (${entity.blueprintPreview.itemName ?? entity.blueprintPreview.itemId ?? "item"})`;
1475
- if (entity?.category === "loose_item" && entity?.itemHolder?.itemName) return `Loose ${entity.itemHolder.itemName}`;
1476
- if (entity?.category === "untyped_holder" && entity?.itemHolder?.itemName) return `Untyped Holder (${entity.itemHolder.itemName})`;
1477
- if (entity?.mapMarker) return `Map Marker (${entity.mapMarker.title ?? entity.mapMarker.key ?? entity.mapMarker.kind ?? "marker"})`;
1478
- if (entity?.dockingSpring) return "Docking Spring";
1479
- if (entity?.hugeThruster) return "Huge Thruster";
1480
- if (entity?.shipControl && entity?.isOverworld) return "Overworld Ship";
1481
- if (entity?.expandoBox) return "Expando Box";
1482
- if (entity?.markerTypeName) return entity.markerTypeName;
1483
- if (entity?.typeName) return entity.typeName;
1484
- if (entity?.fabricator) return "Fabricator";
1485
- if (entity?.itemCrate) return "Item Crate";
1486
- if (entity?.shieldGenerator) return "Shield Generator";
1487
- if (entity?.fluidTank) return "Fluid Tank";
1488
- if (entity?.cannon) return "Cannon";
1489
- if (entity?.pusher) return "Pusher";
1490
- if (entity?.pusherBeam) return "Pusher Beam";
1491
- if (entity?.launcher) return "Item Launcher";
1492
- if (entity?.loader) return "Loader";
1493
- if (entity?.cargoHatch) return entity.cargoHatch.typeName || "Cargo Hatch";
1494
- if (entity?.cargoEjector) return entity.cargoEjector.typeName || "Cargo Ejector";
1495
- if (entity?.itemHolder && !entity?.cargoEjector && !entity?.cannon && !entity?.pusher && !entity?.launcher && !entity?.loader && !entity?.fluidTank && !entity?.shieldGenerator && !entity?.shieldProjector) {
1496
- return entity.itemHolder.itemName || "Item Holder";
1497
- }
1498
- if (entity?.player) return "Player";
1499
- if (entity?.shipControl) return "Ship Control";
1500
- return entity?.typeId != null ? `Entity ${entity.typeId}` : "Entity";
1501
- }
1502
-
1503
- export function entityCategory(entity) {
1504
- if (entity?.player) return "player";
1505
- if (entity?.blueprintPreview) return "blueprint_preview";
1506
- if (entity?.shipControl) return "ship_control";
1507
- if (entity?.itemCrate) return "item_crate";
1508
- if (entity?.mapMarker) return "map_marker";
1509
- if (entity?.dockingSpring) return "docking_spring";
1510
- if (entity?.hugeThruster) return "huge_thruster";
1511
- const hasMachineComponent = Boolean(entity?.fabricator || entity?.cargoEjector || entity?.cannon || entity?.pusher || entity?.launcher || entity?.loader || entity?.cargoHatch || entity?.commsStation || entity?.fluidTank || entity?.shieldGenerator || entity?.shieldProjector);
1512
- const hasValidTransform = Boolean(
1513
- entity?.transform &&
1514
- Number.isFinite(entity.transform.x) &&
1515
- Number.isFinite(entity.transform.y)
1516
- );
1517
- const hasPhysicalComponent = Boolean(hasValidTransform || entity?.itemHolder || hasMachineComponent);
1518
- if (!hasPhysicalComponent) return "metadata";
1519
- if (entity?.transform && !hasValidTransform && !entity?.itemHolder && !hasMachineComponent) return "metadata";
1520
- if (entity?.typeId != null && !hasPhysicalComponent) return "metadata";
1521
- if (entity?.markerTypeId != null) return "placed_entity";
1522
- if (entity?.looseItemMarker && entity?.itemHolder?.itemId != null) return "loose_item";
1523
- if (entity?.itemHolder?.itemId != null && !entity?.cargoEjector && !entity?.cannon && !entity?.pusher && !entity?.launcher && !entity?.loader && !entity?.cargoHatch && !entity?.commsStation && !entity?.fluidTank && !entity?.shieldGenerator && !entity?.shieldProjector) {
1524
- if (entity.typeId != null && (!PLACED_ENTITY_TYPE_IDS.has(Number(entity.typeId)) || entity.typeId === entity.itemHolder.itemId)) return "loose_item";
1525
- if (entity.typeId == null) return "untyped_holder";
1526
- }
1527
- if (entity?.fabricator || entity?.cannon || entity?.pusher || entity?.launcher || entity?.loader || entity?.cargoHatch || entity?.cargoEjector || entity?.commsStation || entity?.fluidTank || entity?.shieldGenerator || entity?.shieldProjector) return "placed_entity";
1528
- if (entity?.typeId != null && PLACED_ENTITY_TYPE_IDS.has(Number(entity.typeId))) return "placed_entity";
1529
- if (entity?.typeId != null && !hasPhysicalComponent) return "metadata";
1530
- if (entity?.itemHolder?.itemId != null && !entity?.cargoEjector && !entity?.cannon && !entity?.pusher && !entity?.launcher && !entity?.loader && !entity?.cargoHatch && !entity?.commsStation && !entity?.fluidTank && !entity?.shieldGenerator && !entity?.shieldProjector) return "untyped_holder";
1531
- return "entity";
1532
- }
1533
-
1534
- // Block coordinates are packed into a single number so the block index avoids a
1535
- // template-string key per occupied cell. Covers the coordinate range in use.
1536
- export const BLOCK_KEY_OFFSET = 1 << 24;
1537
- export const BLOCK_KEY_STRIDE = 1 << 25;
1538
-
1539
- export function blockKey(x, y) {
1540
- return ((y + BLOCK_KEY_OFFSET) * BLOCK_KEY_STRIDE) + (x + BLOCK_KEY_OFFSET);
1541
- }
1542
-
1543
- // Returned by ModelState.table() for absent tables. Never mutated.
1544
- export const EMPTY_TABLE = new Map();
1545
-
1546
- // snapshot() only ever exposed the last 50 removals and last 10 errors, but both
1547
- // arrays grew without bound behind that. Retain a little more than is surfaced.
1548
- export const MAX_RETAINED_REMOVALS = 500;
1549
- export const MAX_RETAINED_ERRORS = 100;
1550
-
1551
- export function pushCapped(target, value, limit) {
1552
- target.push(value);
1553
- if (target.length > limit) target.splice(0, target.length - limit);
1554
- }
1
+ import {decoder} from "../../constants.js";
2
+ import {
3
+ LOADER_FILTER_MODE_NAMES,
4
+ LOADER_POSITION_NAMES,
5
+ LOADER_PRIORITY_NAMES,
6
+ LoaderConfigTracker
7
+ } from "./loader-config.js";
8
+ import {navigationDestinationFromEncodedValue, navigationZoneFromBaseId} from "../overworld.js";
9
+ import {maybeSolveGeneratorMazeSeed} from "../generator-maze.js";
10
+ import {itemNameFromId} from "../items.js";
11
+ import {ModelReader} from "./reader.js";
12
+
13
+ export {ModelReader} from "./reader.js";
14
+
15
+ export function defineLazyProperty(target, key, compute) {
16
+ let cached = null;
17
+ let resolved = false;
18
+ Object.defineProperty(target, key, {
19
+ enumerable: true,
20
+ configurable: true,
21
+ get() {
22
+ if (!resolved) {
23
+ cached = compute();
24
+ resolved = true;
25
+ }
26
+ return cached;
27
+ },
28
+ set(value) {
29
+ cached = value;
30
+ resolved = true;
31
+ }
32
+ });
33
+ return target;
34
+ }
35
+
36
+ // string on every field of every record while decoding.
37
+ export function numericFields(bitsToOffsets) {
38
+ return Object.entries(bitsToOffsets)
39
+ .map(([bit, offset]) => ({bit: Number(bit), offset, key: qKey(offset)}))
40
+ .sort((a, b) => a.bit - b.bit);
41
+ }
42
+
43
+ export const TWO_FIELD_SPEC = {
44
+ fields: numericFields({1: 20, 2: 24})
45
+ };
46
+
47
+ export const LABEL_STATE_SPEC = {
48
+ name: "label_state",
49
+ read(reader, record, mask) {
50
+ if (mask & 1) record.blob20 = reader.readBlob();
51
+ if (mask & 2) {
52
+ if (mask & 1) record.blob24 = reader.readBlob();
53
+ else record.q20 = (record.q20 || 0) + reader.readFieldDelta();
54
+ }
55
+ if (mask & 4) record.q28 = (record.q28 || 0) + reader.readFieldDelta();
56
+ if (mask & 8) {
57
+ if (mask & 1) record.q32 = (record.q32 || 0) + reader.readFieldDelta();
58
+ else record.blob28 = reader.readBlob();
59
+ }
60
+ if (mask & 16) record.q36 = (record.q36 || 0) + reader.readFieldDelta();
61
+ }
62
+ };
63
+
64
+ export const SIMPLE_LABEL_STATE_SPEC = {
65
+ name: "label_state",
66
+ read(reader, record, mask) {
67
+ if (mask & 1) record.blob20 = reader.readBlob();
68
+ if (mask & 2) record.q24 = (record.q24 || 0) + reader.readFieldDelta();
69
+ if (mask & 4) record.q28 = (record.q28 || 0) + reader.readFieldDelta();
70
+ if (mask & 8) record.q32 = (record.q32 || 0) + reader.readFieldDelta();
71
+ if (mask & 16) record.q36 = (record.q36 || 0) + reader.readFieldDelta();
72
+ }
73
+ };
74
+
75
+ export function peekStreamInt(reader) {
76
+ let result = 0;
77
+ let shift = 0;
78
+ let offset = reader.offset;
79
+ while (offset < reader.bytes.length) {
80
+ const byte = reader.bytes[offset++];
81
+ result += (byte & 0x7f) * (2 ** shift);
82
+ if ((byte & 0x80) === 0) {
83
+ return {
84
+ value: (result & 1) === 0 ? result / 2 : -((result + 1) / 2),
85
+ offset
86
+ };
87
+ }
88
+ shift += 7;
89
+ if (shift > 53) return null;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ export function nextValueLooksLikeTextBlob(reader) {
95
+ const peek = peekStreamInt(reader);
96
+ if (!peek || peek.value <= 0) return false;
97
+ const end = peek.offset + peek.value;
98
+ if (end > reader.bytes.length) return false;
99
+ const blob = reader.bytes.slice(peek.offset, end);
100
+ try {
101
+ const text = decoder.decode(blob);
102
+ return text.length > 0 && !/[\u0000-\u0008\u000e-\u001f]/.test(text);
103
+ } catch (_) {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ export const MODEL_TABLE_SPECS = new Map([
109
+ [0, {
110
+ name: "transform",
111
+ packedBits: [{bit: 8, offset: 33}, {bit: 16, offset: 34}, {bit: 32, offset: 35}],
112
+ fields: numericFields({1: 20, 2: 24, 4: 28}),
113
+ scale(record) {
114
+ return {
115
+ x: numberOrNull(record.q20, 40),
116
+ y: numberOrNull(record.q24, 40),
117
+ rot: numberOrNull(record.q28, 127.324)
118
+ };
119
+ }
120
+ }],
121
+ [1, {
122
+ name: "body_state",
123
+ fields: numericFields({
124
+ 1: 20, 2: 24, 4: 28, 8: 32, 16: 36, 32: 40, 64: 44, 128: 48,
125
+ 256: 52, 512: 56, 1024: 60, 2048: 64, 4096: 68, 8192: 72,
126
+ 16384: 76, 32768: 80, 65536: 84, 131072: 88, 262144: 92,
127
+ 524288: 96, 1048576: 100, 2097152: 104
128
+ })
129
+ }],
130
+ [2, {name: "numeric_pair", ...TWO_FIELD_SPEC}],
131
+ [3, {name: "gate_width", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36, 32: 40})}],
132
+ [4, {name: "motion_aux", packedBits: [{bit: 8, offset: 33}], fields: numericFields({1: 20, 2: 24, 4: 28})}],
133
+ [5, {name: "entity_health", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32})}],
134
+ [6, {name: "item_holder", ...TWO_FIELD_SPEC}],
135
+ [7, {
136
+ name: "entity_type",
137
+ fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36, 32: 40, 64: 44, 128: 48, 256: 52})
138
+ }],
139
+ [8, {name: "numeric_sparse", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36, 32: 40})}],
140
+ [9, LABEL_STATE_SPEC],
141
+ [10, SIMPLE_LABEL_STATE_SPEC],
142
+ [11, {name: "numeric_sparse", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36})}],
143
+ [12, {name: "numeric_sparse", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32})}],
144
+ [14, {name: "size_state", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36, 32: 40, 64: 44})}],
145
+ [16, {
146
+ name: "label_numeric_state",
147
+ read(reader, record, mask) {
148
+ if ((mask & 1) && nextValueLooksLikeTextBlob(reader)) {
149
+ SIMPLE_LABEL_STATE_SPEC.read(reader, record, mask);
150
+ return;
151
+ }
152
+ if (mask & 1) record.q20 = (record.q20 || 0) + reader.readFieldDelta();
153
+ if (mask & 2) {
154
+ if (mask & 1) record.blob24 = reader.readBlob();
155
+ else record.q24 = (record.q24 || 0) + reader.readFieldDelta();
156
+ }
157
+ if (mask & 4) record.q28 = (record.q28 || 0) + reader.readFieldDelta();
158
+ if (mask & 8) record.q32 = (record.q32 || 0) + reader.readFieldDelta();
159
+ if (mask & 16) record.q36 = (record.q36 || 0) + reader.readFieldDelta();
160
+ }
161
+ }],
162
+ [17, {name: "numeric_sparse", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36})}],
163
+ [18, {
164
+ name: "mob_combat_state",
165
+ fields: numericFields({
166
+ 2: 20,
167
+ 4: 24,
168
+ 8: 28,
169
+ 16: 32,
170
+ 32: 36,
171
+ 64: 40,
172
+ 128: 44,
173
+ 256: 48,
174
+ 512: 52,
175
+ 1024: 56,
176
+ 2048: 60,
177
+ 4096: 64,
178
+ 8192: 68,
179
+ 16384: 72,
180
+ 32768: 76,
181
+ 65536: 80,
182
+ 131072: 84,
183
+ 524288: 88,
184
+ 4194304: 92
185
+ })
186
+ }],
187
+ [19, {name: "motion_state", packedBits: [{bit: 4, offset: 29}], ...TWO_FIELD_SPEC}],
188
+ [20, {
189
+ name: "ship_control",
190
+ packedBeforeValues: true,
191
+ orderedValues: true,
192
+ packedBits: [{bit: 0x1000, offset: 153}],
193
+ fields: numericFields({
194
+ 1: 20, 2: 24, 4: 28, 32: 32, 64: 36, 128: 40, 256: 44, 512: 48,
195
+ 1024: 52, 2048: 56, 8192: 60, 16384: 64, 32768: 68, 65536: 72,
196
+ 131072: 76, 262144: 80, 524288: 84, 1048576: 88, 2097152: 92,
197
+ 4194304: 96, 8388608: 100, 16777216: 104, 67108864: 108, 134217728: 112
198
+ }),
199
+ blobs: [
200
+ {bit: 8, offset: 116},
201
+ {bit: 16, offset: 128},
202
+ {bit: 33554432, offset: 140}
203
+ ],
204
+ scale(record) {
205
+ return {
206
+ thrustX: numberOrNull(record.q24, 20),
207
+ thrustY: numberOrNull(record.q28, 20),
208
+ value52: numberOrNull(record.q52, 20)
209
+ };
210
+ }
211
+ }],
212
+ [21, {
213
+ name: "gate_portal",
214
+ orderedValues: true,
215
+ fields: numericFields({1: 20, 2: 24}),
216
+ blobs: [{bit: 4, offset: 28}, {bit: 8, offset: 40}]
217
+ }],
218
+ [24, {name: "projectile_state", packedBits: [{bit: 8, offset: 33}], fields: numericFields({1: 20, 2: 24, 4: 28})}],
219
+ [25, {
220
+ name: "zone_label",
221
+ orderedValues: true,
222
+ fields: numericFields({1: 20, 2: 24}),
223
+ blobs: [{bit: 4, offset: 28}, {bit: 8, offset: 40}]
224
+ }],
225
+ [26, {name: "numeric_single", fields: numericFields({1: 20})}],
226
+ [31, {name: "numeric_single", fields: numericFields({1: 20})}],
227
+ [37, {name: "loose_item_marker", packedBits: [{bit: 2, offset: 29}], fields: numericFields({1: 20, 4: 24})}],
228
+ [38, {name: "flag_state", packedBits: [{bit: 1, offset: 21}], fields: []}],
229
+ [39, {name: "comms_transmit", ...TWO_FIELD_SPEC}],
230
+ [41, {name: "blob_state", fields: numericFields({2: 20}), blobs: [{bit: 1, offset: 24}]}],
231
+ [42, {name: "numeric_pair", ...TWO_FIELD_SPEC}],
232
+ [43, {name: "numeric_single", fields: numericFields({1: 20})}],
233
+ [44, {name: "numeric_sparse", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32})}],
234
+ [45, {name: "numeric_flag", packedBits: [{bit: 1, offset: 25}], fields: numericFields({2: 20})}],
235
+ [47, {name: "flag_state", packedBits: [{bit: 1, offset: 21}], fields: []}],
236
+ [49, {name: "processor_cycle", packedBits: [{bit: 2, offset: 33}], fields: numericFields({1: 20, 4: 24, 8: 28})}],
237
+ [50, {
238
+ name: "rare_snapshot",
239
+ packedBits: [{bit: 2, offset: 33}, {bit: 8, offset: 34}, {bit: 32, offset: 35}],
240
+ fields: numericFields({1: 20, 4: 24, 16: 28})
241
+ }],
242
+ [51, {
243
+ name: "numeric_sparse",
244
+ fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36, 32: 40, 64: 44, 128: 48, 256: 52})
245
+ }],
246
+ [53, {
247
+ name: "fabricator",
248
+ packedBits: [{bit: 2, offset: 61}],
249
+ fields: numericFields({1: 20, 4: 24, 8: 28, 16: 32, 32: 36, 64: 40, 128: 44, 256: 48, 512: 52, 1024: 56})
250
+ }],
251
+ [54, {
252
+ name: "starter_cannon",
253
+ fields: numericFields({1: 20, 2: 32, 4: 24, 8: 28, 16: 44, 32: 36, 64: 48, 128: 52, 256: 40, 512: 56, 1024: 60})
254
+ }],
255
+ [55, {
256
+ name: "player",
257
+ packedBeforeValues: true,
258
+ fields: numericFields({
259
+ 256: 20, 512: 24, 1024: 28, 2048: 32, 4096: 36, 8192: 40,
260
+ 16384: 44, 32768: 48, 65536: 52, 131072: 56, 262144: 60,
261
+ 524288: 64, 1048576: 68, 2097152: 72, 4194304: 76, 8388608: 80,
262
+ 33554432: 84, 67108864: 88
263
+ }),
264
+ blobs: [{bit: 1, offset: 92}],
265
+ packedBits: [
266
+ {bit: 2, offset: 105},
267
+ {bit: 4, offset: 106},
268
+ {bit: 16, offset: 107},
269
+ {bit: 32, offset: 108},
270
+ {bit: 64, offset: 109},
271
+ {bit: 128, offset: 110},
272
+ {bit: 16777216, offset: 111},
273
+ {bit: 134217728, offset: 112}
274
+ ]
275
+ }],
276
+ [56, {
277
+ name: "numeric_snapshot",
278
+ fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36, 64: 40, 256: 44, 1024: 48})
279
+ }],
280
+ [59, {name: "numeric_sparse", fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36})}],
281
+ [60, {name: "fluid_tank", packedBits: [{bit: 4, offset: 29}], ...TWO_FIELD_SPEC}],
282
+ [61, {name: "shield_charge", ...TWO_FIELD_SPEC}],
283
+ [62, {name: "flag_state", packedBits: [{bit: 1, offset: 21}], fields: []}],
284
+ [63, {name: "numeric_pair", ...TWO_FIELD_SPEC}],
285
+ [67, {name: "numeric_single", fields: numericFields({1: 20})}],
286
+ [69, {
287
+ name: "rare_snapshot",
288
+ packedBits: [{bit: 32, offset: 41}, {bit: 64, offset: 42}],
289
+ fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36})
290
+ }],
291
+ [70, {
292
+ name: "rare_snapshot",
293
+ packedBits: [{bit: 16, offset: 40}],
294
+ fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 32: 36})
295
+ }],
296
+ [72, {
297
+ name: "pusher_config",
298
+ packedBits: [{bit: 16, offset: 36}],
299
+ fields: numericFields({1: 20, 2: 24, 4: 28, 8: 32, 32: 40})
300
+ }],
301
+ [73, {name: "expando_box_marker", fields: []}],
302
+ [74, {name: "processor_marker", fields: []}],
303
+ [75, {name: "rare_snapshot", fields: numericFields({1: 20, 8: 24, 16: 28})}],
304
+ [76, {name: "numeric_single", fields: numericFields({1: 20})}],
305
+ [77, {name: "numeric_sparse", fields: numericFields({1: 20, 2: 24, 4: 28})}],
306
+ [78, {
307
+ name: "local_session_marker",
308
+ read(reader, record, mask) {
309
+ for (const {bit, key} of TABLE_78_NUMERIC_FIELDS) {
310
+ if (!(mask & bit)) continue;
311
+ record[key] = (record[key] || 0) + reader.readFieldDelta();
312
+ }
313
+ if ((mask & 64) && !(mask & 32)) {
314
+ record.q44 = (record.q44 || 0) + reader.readFieldDelta();
315
+ }
316
+ }
317
+ }]
318
+ ]);
319
+
320
+ // Precompute everything about a spec that is fixed at module load. Decoding a
321
+ // full model packet runs #applyRecordSpec thousands of times, and each run used
322
+ // to rebuild record key strings and -- for ordered specs -- an entire merged,
323
+ // sorted item array.
324
+ for (const spec of MODEL_TABLE_SPECS.values()) {
325
+ for (const blob of spec.blobs || []) blob.key = blobKey(blob.offset);
326
+ for (const item of spec.packedBits || []) item.key = qKey(item.offset);
327
+ if (spec.packedOffsets) spec.packedOffsetKeys = spec.packedOffsets.map(qKey);
328
+ if (spec.orderedValues) {
329
+ spec.orderedItems = [
330
+ ...(spec.blobs || []).map((item) => ({bit: item.bit, offset: item.offset, key: item.key, kind: "blob"})),
331
+ ...(spec.fields || []).map((item) => ({bit: item.bit, offset: item.offset, key: item.key, kind: "field"}))
332
+ ].sort((a, b) => a.bit - b.bit);
333
+ }
334
+ }
335
+
336
+ export const TABLE_78_NUMERIC_FIELDS = numericFields({1: 20, 2: 24, 4: 28, 8: 32, 16: 36, 32: 40});
337
+
338
+ export const WIRE_TAG_TABLES = new Map([
339
+ [1, 0],
340
+ [2, 1],
341
+ [3, 2],
342
+ [4, 3],
343
+ [5, 4],
344
+ [26, 9],
345
+ [40, 5],
346
+ [42, 6],
347
+ [43, 7],
348
+ [44, 8],
349
+ [45, 9],
350
+ [46, 10],
351
+ [47, 11],
352
+ [48, 12],
353
+ [49, 13],
354
+ [50, 14],
355
+ [51, 51],
356
+ [52, 16],
357
+ [74, 17],
358
+ [80, 17],
359
+ [81, 18],
360
+ [82, 19],
361
+ [83, 20],
362
+ [84, 21],
363
+ [85, 22],
364
+ [86, 23],
365
+ [87, 24],
366
+ [88, 25],
367
+ [89, 26],
368
+ [90, 27],
369
+ [91, 28],
370
+ [93, 30],
371
+ [94, 31],
372
+ [95, 32],
373
+ [99, 36],
374
+ [120, 37],
375
+ [121, 38],
376
+ [122, 39],
377
+ [123, 40],
378
+ [124, 41],
379
+ [125, 42],
380
+ [126, 43],
381
+ [127, 44],
382
+ [128, 45],
383
+ [129, 46],
384
+ [130, 47],
385
+ [131, 48],
386
+ [132, 49],
387
+ [133, 50],
388
+ [134, 51],
389
+ [135, 52],
390
+ [136, 53],
391
+ [137, 54],
392
+ [138, 55],
393
+ [139, 56],
394
+ [141, 58],
395
+ [142, 59],
396
+ [143, 60],
397
+ [144, 61],
398
+ [145, 62],
399
+ [146, 63],
400
+ [147, 64],
401
+ [148, 65],
402
+ [149, 66],
403
+ [150, 67],
404
+ [151, 68],
405
+ [152, 69],
406
+ [153, 70],
407
+ [158, 75],
408
+ [160, 76],
409
+ [161, 77],
410
+ [162, 78],
411
+ [163, 72],
412
+ [164, 73],
413
+ [165, 74],
414
+ [166, 75],
415
+ [167, 76],
416
+ [168, 78],
417
+ [169, 77]
418
+ ]);
419
+
420
+ export const MASK_ONLY_TABLES = new Set([13, 22, 23, 27, 28, 30, 32, 36, 40, 46, 48, 52, 58, 64, 65, 66, 68, 73, 74]);
421
+
422
+ export const ENTITY_FOOTPRINTS = new Map([
423
+ [240, {width: 2, height: 2}],
424
+ [247, {width: 2, height: 2}],
425
+ [248, {width: 2, height: 2}],
426
+ [249, {width: 2, height: 2}],
427
+ [251, {width: 2, height: 2}],
428
+ [256, {width: 2, height: 2}],
429
+ [261, {width: 2, height: 2}]
430
+ ]);
431
+
432
+ export const PLACED_ENTITY_TYPE_IDS = new Set([
433
+ 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 226, 227, 228, 229,
434
+ 230, 231, 240, 241, 242, 243, 245, 246, 247, 248, 249, 250, 251, 252,
435
+ 253, 255, 256, 257, 258, 259, 260, 261, 263, 264, 265
436
+ ]);
437
+
438
+ export const FabricatorType = Object.freeze({
439
+ Starter: "starter",
440
+ Munitions: "munitions",
441
+ Engineering: "engineering",
442
+ Equipment: "equipment"
443
+ });
444
+
445
+ export const FABRICATOR_TYPES = new Map([
446
+ [0, FabricatorType.Starter],
447
+ [1, FabricatorType.Munitions],
448
+ [2, FabricatorType.Engineering],
449
+ [3, FabricatorType.Equipment]
450
+ ]);
451
+
452
+ export const HELM_TYPE_IDS = new Set([215, 216]);
453
+ export const COMMS_STATION_TYPE_ID = 217;
454
+ export const COMMS_STATION_MAX_CHARGES = 5;
455
+ export const CARGO_HATCH_TYPE_IDS = new Set([221, 222]);
456
+ export const THRUSTER_TYPE_IDS = new Set([230, 231]);
457
+ export const EXPANDO_BOX_TYPE_ID = 240;
458
+ export const ITEM_LAUNCHER_TYPE_ID = 243;
459
+ export const LOADER_TYPE_ID = 252;
460
+ export const NAVIGATION_UNIT_TYPE_ID = 261;
461
+
462
+ export function entityTypeIdFromRecord(record) {
463
+ if (!record) return null;
464
+ const candidates = [
465
+ record.q20,
466
+ record.q24,
467
+ record.q28,
468
+ record.q32,
469
+ record.q36,
470
+ record.q40,
471
+ record.q44,
472
+ record.q48,
473
+ record.q52
474
+ ].filter((value) => Number.isFinite(Number(value)) && Number(value) !== 0).map(Number);
475
+ return candidates.find((value) => PLACED_ENTITY_TYPE_IDS.has(value)) ?? candidates[0] ?? null;
476
+ }
477
+
478
+ export const CANNON_AMMO_COLOR_ITEM_IDS = new Map([
479
+ [0xff9600, 150]
480
+ ]);
481
+
482
+ export const CANNON_TYPE_IDS = new Set([226, 227, 228, 229, 263, 265]);
483
+
484
+ export const THRUSTER_FACING_NAMES = new Map([
485
+ [0, "bottom"],
486
+ [1, "top"],
487
+ [2, "right"],
488
+ [3, "left"],
489
+ [4, "bottom-right"],
490
+ [5, "bottom-left"],
491
+ [6, "top-right"],
492
+ [7, "top-left"]
493
+ ]);
494
+
495
+ export const DEFAULT_OVERWORLD_WARP_DURATION_SECONDS = 120;
496
+ export const OVERWORLD_WARP_TICKS_PER_SECOND = 20;
497
+
498
+ export const MARKER_TYPE_IDS = new Map([
499
+ [73, 240]
500
+ ]);
501
+
502
+ export const TEAM_RANK_NAMES = new Map([
503
+ [0, "Guest"],
504
+ [1, "Crew"],
505
+ [2, "CrewInvitePending_DEPRECATED"],
506
+ [3, "Captain"],
507
+ [4, "Banned"]
508
+ ]);
509
+
510
+ export const PLAYER_SHIP_RANKS = new Map([
511
+ [0, "guest"],
512
+ [1, "crew"],
513
+ [2, "crew-invite-pending-deprecated"],
514
+ [3, "captain"],
515
+ [4, "banned"]
516
+ ]);
517
+
518
+ export const PUSHER_MODE_NAMES = new Map([
519
+ [0, "Push"],
520
+ [1, "Pull"],
521
+ [2, "Do Nothing"]
522
+ ]);
523
+
524
+ export const SIGN_DISPLAY_MODE_NAMES = new Map([
525
+ [0, "always"],
526
+ [1, "when-near"],
527
+ [2, "on-hover"]
528
+ ]);
529
+
530
+ export const SHIELD_GENERATOR_BOOST_STATE_NAMES = new Map([
531
+ [0, "inactive"],
532
+ [1, "boosted"],
533
+ [2, "failed"]
534
+ ]);
535
+
536
+ export const NAVIGATION_DEFAULT_DESTINATION = 10;
537
+
538
+ export function navigationDestinationName(destination) {
539
+ return navigationZoneFromBaseId(destination)?.key ?? null;
540
+ }
541
+
542
+ export function isNavigationDestination(destination) {
543
+ return navigationDestinationName(destination) != null;
544
+ }
545
+
546
+ export function numberOrNull(value, divisor = 1) {
547
+ return typeof value === "number" ? value / divisor : null;
548
+ }
549
+
550
+ export function qKey(offset) {
551
+ return `q${offset}`;
552
+ }
553
+
554
+ export function blobKey(offset) {
555
+ return `blob${offset}`;
556
+ }
557
+
558
+ // Blob fields stay expanded to plain arrays: that shape is part of the public
559
+ // record surface. Avoiding the Object.entries pair array is the win available
560
+ // without changing what callers see.
561
+ export function cloneRecord(record) {
562
+ const out = {};
563
+ for (const key in record) {
564
+ const value = record[key];
565
+ out[key] = value instanceof Uint8Array ? Array.from(value) : value;
566
+ }
567
+ return out;
568
+ }
569
+
570
+ export function firstRecord(records, predicate = null) {
571
+ for (const record of records.values()) {
572
+ if (!predicate || predicate(record)) return record;
573
+ }
574
+ return null;
575
+ }
576
+
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
+ export function decodeText(blob) {
604
+ if (!(blob instanceof Uint8Array)) return null;
605
+ try {
606
+ return decoder.decode(blob);
607
+ } catch (_) {
608
+ return null;
609
+ }
610
+ }
611
+
612
+ export function entityNameFromType(typeId) {
613
+ return itemNameFromId(typeId);
614
+ }
615
+
616
+ export function markerTypeIdForTables(tableIds) {
617
+ for (const tableId of tableIds) {
618
+ if (MARKER_TYPE_IDS.has(tableId)) return MARKER_TYPE_IDS.get(tableId);
619
+ }
620
+ return null;
621
+ }
622
+
623
+ export function itemSummary(itemId, count = null) {
624
+ const id = itemId == null ? null : Number(itemId);
625
+ const normalizedId = Number.isFinite(id) && id !== 0 ? id : null;
626
+ return {
627
+ itemId: normalizedId,
628
+ itemName: normalizedId == null ? null : entityNameFromType(normalizedId),
629
+ count: count === 0 ? null : count
630
+ };
631
+ }
632
+
633
+ export function summarizeItemHolder(entity, record) {
634
+ if (!record) return null;
635
+ return {
636
+ entity,
637
+ ...itemSummary(record.q20, record.q24 ?? null)
638
+ };
639
+ }
640
+
641
+ export function boundedProgress(value) {
642
+ if (value == null) return null;
643
+ const number = Number(value);
644
+ if (!Number.isFinite(number)) return null;
645
+ return Math.max(0, Math.min(100, number));
646
+ }
647
+
648
+ export function summarizeFabricator(entity, record, itemHolderRecord = null, typeRecord = null) {
649
+ if (!record) return null;
650
+ const progressRaw = record.q24 ?? null;
651
+ const craftingItem = itemSummary(itemHolderRecord?.q20, itemHolderRecord?.q24 ?? null);
652
+ return {
653
+ entity,
654
+ type: FABRICATOR_TYPES.get(typeRecord?.q32) ?? null,
655
+ typeIndex: typeRecord?.q32 ?? null,
656
+ state: cloneRecord(record),
657
+ rows: [
658
+ itemSummary(record.q28, record.q40 ?? null),
659
+ itemSummary(record.q32, record.q44 ?? null),
660
+ itemSummary(record.q36, record.q48 ?? null)
661
+ ],
662
+ progress: boundedProgress(progressRaw),
663
+ progressRaw,
664
+ active: Boolean(record.q61) || (typeof progressRaw === "number" && progressRaw > 0),
665
+ craftingItemId: craftingItem.itemId,
666
+ craftingItemName: craftingItem.itemName,
667
+ craftingCount: craftingItem.count
668
+ };
669
+ }
670
+
671
+ export function summarizeCargoEjector(entity, typeId, record) {
672
+ if (typeId !== 223) return null;
673
+ return {
674
+ entity,
675
+ typeId,
676
+ typeName: entityNameFromType(typeId),
677
+ progress: boundedProgress(record?.q24 ?? null),
678
+ active: record?.q33 == null ? null : Boolean(record.q33)
679
+ };
680
+ }
681
+
682
+ export function summarizeCannon(entity, record, typeId = null) {
683
+ if (!record || !CANNON_TYPE_IDS.has(typeId)) return null;
684
+ const ammoItemId = record.q24 == null ? null : CANNON_AMMO_COLOR_ITEM_IDS.get(record.q24) ?? null;
685
+ const charge = record.q40 ?? null;
686
+ const recoil = record.q36 ?? null;
687
+ const recoil2 = record.q48 ?? null;
688
+ return {
689
+ entity,
690
+ typeId,
691
+ typeName: entityNameFromType(typeId),
692
+ ammoItemId,
693
+ ammoName: entityNameFromType(ammoItemId),
694
+ ammoCount: record.q28 ?? 0,
695
+ aim: record.q32 ?? null,
696
+ recoil,
697
+ recoil2,
698
+ recoils: [recoil, recoil2],
699
+ charge,
700
+ charged: charge == null ? null : charge >= 50,
701
+ spin: record.q52 ?? null,
702
+ coolingCellCount: record.q56 ?? 0,
703
+ state: cloneRecord(record)
704
+ };
705
+ }
706
+
707
+ export function summarizeThruster(entity, typeId, record) {
708
+ if (!THRUSTER_TYPE_IDS.has(typeId)) return null;
709
+ const facing = Number.isFinite(Number(record?.q20)) ? Number(record.q20) : 0;
710
+ return {
711
+ entity,
712
+ typeId,
713
+ typeName: entityNameFromType(typeId),
714
+ facing,
715
+ facingName: enumValueName(THRUSTER_FACING_NAMES, facing),
716
+ fuel: record?.q24 ?? null,
717
+ state: cloneRecord(record || {})
718
+ };
719
+ }
720
+
721
+ export function summarizeHelm(entity, typeId, occupied = false) {
722
+ if (!HELM_TYPE_IDS.has(typeId)) return null;
723
+ return {
724
+ entity,
725
+ typeId,
726
+ typeName: entityNameFromType(typeId),
727
+ occupied: Boolean(occupied)
728
+ };
729
+ }
730
+
731
+ export function navigationDestinationFromRecord(record, changedMask = record?.lastMask ?? 0) {
732
+ if (!record) return NAVIGATION_DEFAULT_DESTINATION;
733
+ const q32Destination = navigationDestinationFromEncodedValue(record.q32);
734
+ const q36Destination = navigationDestinationFromEncodedValue(record.q36);
735
+ const q24Destination = navigationDestinationFromEncodedValue(record.q24);
736
+ const q20Destination = navigationDestinationFromEncodedValue(record.q20);
737
+ if ((changedMask & 9) === 9 && record.q20 === 0 && record.q24 == null && q32Destination != null) return q32Destination;
738
+ if ((changedMask & 25) === 25 && record.q20 === 0 && record.q24 == null && q32Destination != null) return q32Destination;
739
+ if ((changedMask & 17) === 17 && record.q20 === 0 && record.q24 == null && record.q32 == null && q36Destination != null) return q36Destination;
740
+ if (changedMask === 1 && q20Destination != null) return q20Destination;
741
+ if ((changedMask & 1) && q20Destination != null && record.q20 !== 0) return q20Destination;
742
+ if (record.q20 === 0 && q24Destination != null) return q24Destination;
743
+ return q20Destination ?? NAVIGATION_DEFAULT_DESTINATION;
744
+ }
745
+
746
+ export function summarizeNavigationUnit(entity, typeId, record, trackedState = null) {
747
+ if (typeId !== NAVIGATION_UNIT_TYPE_ID || !record) return null;
748
+ const destination = trackedState?.destination ?? navigationDestinationFromRecord(record);
749
+ return {
750
+ entity,
751
+ destination,
752
+ destinationName: navigationDestinationName(destination),
753
+ autoWarpOnShieldFailure: trackedState?.shieldFailure ?? null,
754
+ autoWarpOnNoCaptains: trackedState?.noCaptains ?? null,
755
+ state: cloneRecord(record)
756
+ };
757
+ }
758
+
759
+ export function summarizeCommsStation(entity, typeId, record, occupied = false) {
760
+ if (typeId !== COMMS_STATION_TYPE_ID) return null;
761
+ const charges = record?.q20 ?? null;
762
+ return {
763
+ entity,
764
+ typeId,
765
+ typeName: entityNameFromType(typeId),
766
+ charges,
767
+ maxCharges: COMMS_STATION_MAX_CHARGES,
768
+ chargeRatio: typeof charges === "number" ? charges / COMMS_STATION_MAX_CHARGES : null,
769
+ occupied: Boolean(occupied),
770
+ state: record ? cloneRecord(record) : {}
771
+ };
772
+ }
773
+
774
+ export function summarizeHealth(entity, record) {
775
+ if (!record) return null;
776
+ const maxHp = record.q20 ?? null;
777
+ const hp = record.q24 ?? null;
778
+ return {
779
+ entity,
780
+ hp,
781
+ maxHp,
782
+ ratio: typeof hp === "number" && typeof maxHp === "number" && maxHp !== 0 ? hp / maxHp : null,
783
+ state: cloneRecord(record)
784
+ };
785
+ }
786
+
787
+ export function summarizeBot(entity, {
788
+ health,
789
+ table2Record,
790
+ smallRecord,
791
+ combatRecord,
792
+ motionRecord,
793
+ table10Record,
794
+ table51Record,
795
+ itemCrate,
796
+ player,
797
+ shipControl
798
+ }) {
799
+ if (!health || itemCrate || player || shipControl || isBoulderBotExclusion(health, table2Record, combatRecord)) return null;
800
+ const className = botClassFromState({
801
+ health,
802
+ table2Record,
803
+ smallRecord,
804
+ combatRecord,
805
+ table10Record,
806
+ table51Record
807
+ });
808
+ return {
809
+ entity,
810
+ className,
811
+ identifier: botIdentifierFromState({combatRecord, motionRecord, smallRecord}),
812
+ typeA: health.state?.q28 ?? null,
813
+ typeB: health.state?.q32 ?? null
814
+ };
815
+ }
816
+
817
+ export function isBoulderBotExclusion(health, table2Record, combatRecord) {
818
+ return Boolean(
819
+ health?.maxHp === 2000 &&
820
+ health?.state?.q28 === 30 &&
821
+ health?.state?.q32 != null &&
822
+ table2Record?.q20 != null &&
823
+ table2Record?.q24 == null &&
824
+ !combatRecord
825
+ );
826
+ }
827
+
828
+ export function botClassFromState({health, table2Record, smallRecord, combatRecord, table10Record, table51Record}) {
829
+ if (isYellowMineGuardBotState({health, smallRecord, combatRecord, table2Record})) return "yellow-mine-guard";
830
+ if (isShieldHelperBotState({health, table2Record})) return "shield-helper";
831
+ if (isShieldMasterBotState({health, table2Record})) return "shield-master";
832
+ if (isLazerEnthusiastBotState({health, table2Record, table10Record, table51Record})) return "lazer-enthusiast";
833
+ if (isCowardBossState({health})) return "the-coward";
834
+ if (isOrangeFoolBotState({combatRecord})) return "orange-fool";
835
+ if (isZombieBossBotState({health, table2Record, combatRecord})) return "zombie-boss";
836
+ if (combatRecord?.q20 === 762523904) return "zombie";
837
+ if (combatRecord?.q20 === 1967883008) return "zombie-hunter";
838
+ if (combatRecord?.q20 === 945371904) return "zombie-tank";
839
+ if (isRedSentryBotState({health, table2Record, combatRecord})) return "red-sentry";
840
+ if (isRedSniperBotState({health, table2Record})) return "red-sniper";
841
+ if (isBlueRusherBotState({health, table2Record, combatRecord})) return "blue-rusher";
842
+ if (isYellowHunterBotState({health, table2Record, combatRecord})) return "yellow-hunter";
843
+ if (isAquaShielderBotState({health, table2Record, combatRecord})) return "aqua-shielder";
844
+ if (table2Record) return "table2-bot";
845
+ if (combatRecord) return "combat-bot";
846
+ return "bot";
847
+ }
848
+
849
+ export function isCowardBossState({health}) {
850
+ return health?.state?.q28 === 70 && health?.state?.q32 === 78;
851
+ }
852
+
853
+ export function isOrangeFoolBotState({combatRecord}) {
854
+ return combatRecord?.q20 === -427551232 && combatRecord?.q24 === 1 && combatRecord?.q72 === 3;
855
+ }
856
+
857
+ export function isZombieBossBotState({health, table2Record, combatRecord}) {
858
+ return (
859
+ combatRecord?.q20 === -430233088 &&
860
+ health?.state?.q28 === 30 &&
861
+ health?.state?.q32 === 166 &&
862
+ table2Record?.q20 === 320 &&
863
+ table2Record?.q24 === -900
864
+ );
865
+ }
866
+
867
+ export function isRedSentryBotState({health, table2Record, combatRecord}) {
868
+ return (
869
+ combatRecord?.q20 === 508251904 && combatRecord?.q72 === 2 ||
870
+ health?.state?.q28 === 70 && health?.state?.q32 === 166 && table2Record?.q20 === 320 && table2Record?.q24 === -900
871
+ );
872
+ }
873
+
874
+ export function isBlueRusherBotState({health, table2Record, combatRecord}) {
875
+ return (
876
+ combatRecord?.q20 === 1113052928 && combatRecord?.q72 === 2 ||
877
+ health?.state?.q28 === 30 && health?.state?.q32 === 34 && table2Record?.q20 === 80 && table2Record?.q24 === -900
878
+ );
879
+ }
880
+
881
+ export function isRedSniperBotState({health, table2Record}) {
882
+ return health?.state?.q28 === 35 && health?.state?.q32 === 89 && table2Record?.q20 === 180 && table2Record?.q24 === -900;
883
+ }
884
+
885
+ export function isYellowHunterBotState({health, table2Record, combatRecord}) {
886
+ return (
887
+ combatRecord?.q20 === -775665152 && combatRecord?.q72 === 4 && combatRecord?.q44 === -944515328 ||
888
+ health?.state?.q28 === 30 && health?.state?.q32 === 56 && table2Record?.q20 === 120 && table2Record?.q24 === -900
889
+ );
890
+ }
891
+
892
+ export function isAquaShielderBotState({health, table2Record, combatRecord}) {
893
+ return (
894
+ combatRecord?.q20 === 13697024 && combatRecord?.q24 === 1 ||
895
+ health?.state?.q28 === 50 && health?.state?.q32 === 122 && table2Record?.q20 === 240 && table2Record?.q24 === -900
896
+ );
897
+ }
898
+
899
+ export function isLazerEnthusiastBotState({health, table2Record, table10Record, table51Record}) {
900
+ return (
901
+ health?.state?.q28 === 140 &&
902
+ health?.state?.q32 === 155 &&
903
+ table2Record?.q20 === 300 &&
904
+ table2Record?.q24 === -900 &&
905
+ Boolean(table10Record) &&
906
+ Boolean(table51Record)
907
+ );
908
+ }
909
+
910
+ export function isYellowMineGuardBotState({health, smallRecord, combatRecord, table2Record}) {
911
+ return (
912
+ health?.state?.q28 === 30 &&
913
+ health?.state?.q32 === 33 &&
914
+ smallRecord?.q20 === 78 &&
915
+ smallRecord?.q24 === 78 &&
916
+ combatRecord?.q24 === -995542016 &&
917
+ !table2Record
918
+ );
919
+ }
920
+
921
+ export function isShieldHelperBotState({health, table2Record}) {
922
+ return health?.state?.q28 === 30 && health?.state?.q32 === 33 && health?.maxHp === 1000 && !table2Record;
923
+ }
924
+
925
+ export function isShieldMasterBotState({health, table2Record}) {
926
+ return health?.state?.q28 === 190 && health?.state?.q32 === 210 && health?.maxHp === 15000 && Boolean(table2Record);
927
+ }
928
+
929
+ export function botIdentifierFromState({combatRecord, motionRecord, smallRecord}) {
930
+ if (combatRecord) return `t18:${combatRecord.q20 ?? "-"}:${combatRecord.q24 ?? "-"}:${combatRecord.q72 ?? "-"}`;
931
+ if (motionRecord) return `t19:${motionRecord.q20 ?? "-"}:${motionRecord.q24 ?? "-"}`;
932
+ if (smallRecord) return `t3:${smallRecord.q20 ?? "-"}:${smallRecord.q24 ?? "-"}`;
933
+ return "-";
934
+ }
935
+
936
+ export function summarizeShieldGenerator(entity, shieldRecord = null, itemHolderRecord = null, boostRecord = null) {
937
+ const charge = shieldRecord?.q20 ?? 0;
938
+ const maxCharge = 5000;
939
+ const efficiencyPercent = shieldRecord?.q24 ?? null;
940
+ const storedItem = itemSummary(itemHolderRecord?.q20 ?? null, itemHolderRecord?.q24 ?? null);
941
+ const boostState = boostRecord?.q24 ?? 0;
942
+ const boostTimer = boostRecord?.q28 ?? 0;
943
+ const puzzleSeed = boostRecord?.q20 ?? null;
944
+ const summary = {
945
+ entity,
946
+ charge,
947
+ maxCharge,
948
+ chargeRatio: typeof charge === "number" ? charge / maxCharge : null,
949
+ efficiencyPercent,
950
+ efficiency: typeof efficiencyPercent === "number" ? efficiencyPercent / 100 : null,
951
+ storedItemId: storedItem.itemId,
952
+ storedItemName: storedItem.itemName,
953
+ storedItemCount: storedItem.count,
954
+ hasShieldCore: storedItem.itemId === 123,
955
+ boostState,
956
+ boostStateName: enumValueName(SHIELD_GENERATOR_BOOST_STATE_NAMES, boostState),
957
+ boostTimer,
958
+ boostActive: boostState !== 0 || boostTimer > 0,
959
+ puzzleSeed,
960
+ // Replaced in place below by a lazy accessor; declared here so the key keeps
961
+ // its original position in the object.
962
+ puzzleSolution: null,
963
+ state: cloneRecord(shieldRecord || {}),
964
+ itemState: cloneRecord(itemHolderRecord || {}),
965
+ boostStateRaw: cloneRecord(boostRecord || {})
966
+ };
967
+ // Solving the maze costs ~190us and was by far the most expensive step of a
968
+ // model rebuild, run for every shield generator whether or not the caller
969
+ // ever looks at the solution. Defer it to first read.
970
+ return defineLazyProperty(summary, "puzzleSolution", () => maybeSolveGeneratorMazeSeed(puzzleSeed));
971
+ }
972
+
973
+ export function summarizeShipControl(entity, record) {
974
+ if (!record) return null;
975
+ const color = record.q32 == null ? null : Number(record.q32);
976
+ const shieldMaxHpOffset = typeof record.q68 === "number" ? record.q68 : null;
977
+ const shieldMaxHp = shieldMaxHpOffset == null ? null : shieldMaxHpOffset + 2000;
978
+ const shieldBaseHp = typeof record.q72 === "number" ? record.q72 : null;
979
+ const activeTankHp = typeof record.q76 === "number" ? record.q76 : null;
980
+ const inactiveTankHp = typeof record.q80 === "number" ? record.q80 : null;
981
+ const tankValues = [activeTankHp, inactiveTankHp].filter((value) => typeof value === "number");
982
+ const warpTicks = typeof record.q84 === "number" ? record.q84 : null;
983
+ const warpElapsedSeconds = warpTicks == null ? null : warpTicks / OVERWORLD_WARP_TICKS_PER_SECOND;
984
+ const warpDurationSeconds = warpTicks == null ? null : record.q88 ?? DEFAULT_OVERWORLD_WARP_DURATION_SECONDS;
985
+ const warpRemainingSeconds = warpElapsedSeconds == null ? null : Math.max(0, warpDurationSeconds - warpElapsedSeconds);
986
+ return {
987
+ entity,
988
+ name: decodeText(record.blob116),
989
+ hexCode: decodeText(record.blob128),
990
+ shipWorldId: record.q20 ?? null,
991
+ color: Number.isFinite(color) ? color : null,
992
+ colorCss: Number.isFinite(color) ? colorToCss(color) : null,
993
+ thrustX: numberOrNull(record.q24, 20),
994
+ thrustY: numberOrNull(record.q28, 20),
995
+ value52: numberOrNull(record.q52, 20),
996
+ value84: numberOrNull(record.q84, 20),
997
+ value96: numberOrNull(record.q96, 1000),
998
+ shield: shieldMaxHp == null && shieldBaseHp == null && !tankValues.length ? null : {
999
+ maxHp: shieldMaxHp,
1000
+ baseHp: shieldBaseHp,
1001
+ activeTankHp,
1002
+ inactiveTankHp,
1003
+ tankValues
1004
+ },
1005
+ warp: warpTicks == null ? null : {
1006
+ active: record.q28 === 3,
1007
+ ticks: warpTicks,
1008
+ elapsedSeconds: warpElapsedSeconds,
1009
+ durationSeconds: warpDurationSeconds,
1010
+ remainingSeconds: warpRemainingSeconds
1011
+ },
1012
+ state: cloneRecord(record)
1013
+ };
1014
+ }
1015
+
1016
+ export function summarizeShipSize(entity, record) {
1017
+ if (!record) return null;
1018
+ return {
1019
+ entity,
1020
+ width: record.q20 == null ? null : Math.round(record.q20 / 10),
1021
+ height: record.q24 == null ? null : Math.round(record.q24 / 10),
1022
+ rawWidth: record.q20 ?? null,
1023
+ rawHeight: record.q24 ?? null,
1024
+ state: cloneRecord(record)
1025
+ };
1026
+ }
1027
+
1028
+ export function summarizeShipWorldMetadata(lockdownRecord, shipRecord) {
1029
+ if (!lockdownRecord && !shipRecord) return null;
1030
+ const color = shipRecord?.q24 == null ? null : Number(shipRecord.q24);
1031
+ const lockdownCountdownSeconds = typeof lockdownRecord?.q20 === "number" ? lockdownRecord.q20 : null;
1032
+ const onlineShipOwnerCount = typeof lockdownRecord?.q24 === "number" ? lockdownRecord.q24 : null;
1033
+ const requiredShipOwnerCount = typeof lockdownRecord?.q28 === "number" ? lockdownRecord.q28 : null;
1034
+ return {
1035
+ name: decodeText(shipRecord?.blob20),
1036
+ color: Number.isFinite(color) ? color : null,
1037
+ colorCss: Number.isFinite(color) ? colorToCss(color) : null,
1038
+ width: typeof shipRecord?.q32 === "number" ? shipRecord.q32 : null,
1039
+ height: typeof shipRecord?.q36 === "number" ? shipRecord.q36 : null,
1040
+ lockdownTimerSeconds: lockdownCountdownSeconds,
1041
+ lockdownCountdownSeconds,
1042
+ onlineShipOwnerCount,
1043
+ requiredShipOwnerCount,
1044
+ allShipOwnersOnline: onlineShipOwnerCount == null || requiredShipOwnerCount == null ? null : onlineShipOwnerCount >= requiredShipOwnerCount,
1045
+ lockdownEngaged: lockdownCountdownSeconds == null ? null : lockdownCountdownSeconds > 0,
1046
+ lockdownState: cloneRecord(lockdownRecord || {}),
1047
+ shipState: cloneRecord(shipRecord || {})
1048
+ };
1049
+ }
1050
+
1051
+ export function summarizeItemCrate(entity, sizeRecord, itemRecord = null, healthRecord = null) {
1052
+ if (!sizeRecord) return null;
1053
+ const itemId = itemRecord?.q20 ?? null;
1054
+ const count = itemRecord?.q24 ?? null;
1055
+ return {
1056
+ entity,
1057
+ ...itemSummary(itemId, count),
1058
+ width: sizeRecord.q20 ?? null,
1059
+ height: sizeRecord.q24 ?? null,
1060
+ itemState: cloneRecord(itemRecord || healthRecord || {}),
1061
+ sizeState: cloneRecord(sizeRecord)
1062
+ };
1063
+ }
1064
+
1065
+ export function scaledSizeSummary(record) {
1066
+ if (!record) return null;
1067
+ const rawWidth = record.q20 ?? null;
1068
+ const rawHeight = record.q24 ?? null;
1069
+ return {
1070
+ width: rawWidth == null ? null : rawWidth / 10,
1071
+ height: rawHeight == null ? null : rawHeight / 10,
1072
+ rawWidth,
1073
+ rawHeight,
1074
+ state: cloneRecord(record)
1075
+ };
1076
+ }
1077
+
1078
+ export function summarizeExpandoBox(entity, itemHolderRecord, sizeRecord) {
1079
+ if (!itemHolderRecord && !sizeRecord) return null;
1080
+ const size = scaledSizeSummary(sizeRecord);
1081
+ return {
1082
+ entity,
1083
+ ...itemSummary(itemHolderRecord?.q20 ?? null, itemHolderRecord?.q24 ?? null),
1084
+ width: size?.width ?? null,
1085
+ height: size?.height ?? null,
1086
+ rawWidth: size?.rawWidth ?? null,
1087
+ rawHeight: size?.rawHeight ?? null,
1088
+ itemState: cloneRecord(itemHolderRecord || {}),
1089
+ sizeState: cloneRecord(sizeRecord || {})
1090
+ };
1091
+ }
1092
+
1093
+ export function summarizeHoverOutline(entity, record) {
1094
+ const size = scaledSizeSummary(record);
1095
+ if (!size) return null;
1096
+ return {entity, ...size};
1097
+ }
1098
+
1099
+ export function summarizeMapMarker(entity, labelRecord, zoneRecord = null, sizeRecord = null) {
1100
+ if (!labelRecord && !zoneRecord) return null;
1101
+ const title = decodeText(zoneRecord?.blob28) ?? decodeText(labelRecord?.blob20);
1102
+ const key = decodeText(labelRecord?.blob24) ?? decodeText(labelRecord?.blob28);
1103
+ const description = decodeText(zoneRecord?.blob40);
1104
+ const color = zoneRecord?.q20 ?? labelRecord?.q28 ?? null;
1105
+ const accentColor = zoneRecord?.q24 ?? labelRecord?.q32 ?? null;
1106
+ const kind = key === "mine" ? "mining_zone" : key === "dock" ? "dock" : zoneRecord ? "portal" : "marker";
1107
+ return {
1108
+ entity,
1109
+ kind,
1110
+ title,
1111
+ key,
1112
+ description,
1113
+ color,
1114
+ colorCss: color != null && Number.isFinite(Number(color)) ? colorToCss(Number(color)) : null,
1115
+ accentColor,
1116
+ accentColorCss: accentColor != null && Number.isFinite(Number(accentColor)) ? colorToCss(Number(accentColor)) : null,
1117
+ width: sizeRecord?.q20 ?? null,
1118
+ height: sizeRecord?.q24 ?? null,
1119
+ labelState: cloneRecord(labelRecord || {}),
1120
+ zoneState: cloneRecord(zoneRecord || {})
1121
+ };
1122
+ }
1123
+
1124
+ export function summarizeDockingSpring(entity, springRecord, bodyRecord = null, sizeRecord = null) {
1125
+ if (!springRecord || bodyRecord?.q32 !== -1 || sizeRecord?.q20 !== 160 || sizeRecord?.q24 !== 160) return null;
1126
+ return {
1127
+ entity,
1128
+ id: springRecord.q20 ?? null,
1129
+ width: sizeRecord.q20,
1130
+ height: sizeRecord.q24,
1131
+ state: cloneRecord(springRecord)
1132
+ };
1133
+ }
1134
+
1135
+ export function summarizeHugeThruster(entity, markerRecord, sizeRecord = null) {
1136
+ if (!markerRecord || sizeRecord?.q20 !== 320 || sizeRecord?.q24 !== 160) return null;
1137
+ return {
1138
+ entity,
1139
+ width: sizeRecord.q20,
1140
+ height: sizeRecord.q24,
1141
+ state: cloneRecord(markerRecord)
1142
+ };
1143
+ }
1144
+
1145
+ export function summarizeLoader(entity, loaderRecord, loaderFilterRecord = null, filterSlotsRecord = null, tracker = null, includeDefaults = false, itemHolderRecord = null) {
1146
+ if (!loaderRecord && !loaderFilterRecord && !includeDefaults) return null;
1147
+ const config = tracker?.getConfig(null, entity, loaderRecord, loaderFilterRecord, filterSlotsRecord) ?? {};
1148
+ const hasLoaderState = Boolean(loaderRecord);
1149
+ const hasPositionConfig = Boolean(tracker?.hasPositionConfig(null, entity));
1150
+ const pick = hasLoaderState && hasPositionConfig ? config.pick ?? null : null;
1151
+ const place = hasLoaderState && hasPositionConfig ? config.place ?? null : null;
1152
+ const priority = config.priority ?? 0;
1153
+ const filterMode = config.filterMode ?? null;
1154
+ const heldItem = itemSummary(itemHolderRecord?.q20, itemHolderRecord?.q24 ?? null);
1155
+ return {
1156
+ entity,
1157
+ pick,
1158
+ pickName: enumValueName(LOADER_POSITION_NAMES, pick),
1159
+ place,
1160
+ placeName: enumValueName(LOADER_POSITION_NAMES, place),
1161
+ priority,
1162
+ priorityName: enumValueName(LOADER_PRIORITY_NAMES, priority),
1163
+ requireOutput: config.requireOutput ?? false,
1164
+ waitForStack: config.waitForStack ?? false,
1165
+ stack: config.stack ?? 16,
1166
+ cycle: Math.max(1, config.cycle ?? 1),
1167
+ filterMode,
1168
+ filterModeName: enumValueName(LOADER_FILTER_MODE_NAMES, filterMode),
1169
+ filterSlots: config.filterSlots ?? null,
1170
+ heldItemId: heldItem.itemId,
1171
+ heldItemName: heldItem.itemName,
1172
+ heldCount: heldItem.count,
1173
+ active: heldItem.itemId != null,
1174
+ progress: null,
1175
+ state: cloneRecord(loaderRecord || {}),
1176
+ filterState: cloneRecord(loaderFilterRecord || {}),
1177
+ filterSlotsState: cloneRecord(filterSlotsRecord || {})
1178
+ };
1179
+ }
1180
+
1181
+ export function summarizeCargoHatch(entity, typeId, filterRecord = null, filterSlotsRecord = null, animationRecord = null) {
1182
+ if (!CARGO_HATCH_TYPE_IDS.has(typeId)) return null;
1183
+ const filterMode = filterRecord?.q20 ?? 0;
1184
+ return {
1185
+ entity,
1186
+ typeId,
1187
+ typeName: entityNameFromType(typeId),
1188
+ filterMode,
1189
+ filterModeName: enumValueName(LOADER_FILTER_MODE_NAMES, filterMode),
1190
+ filterSlots: filterSlotsRecord ? [filterSlotsRecord.q20 ?? null, filterSlotsRecord.q24 ?? null, filterSlotsRecord.q28 ?? null] : null,
1191
+ openFraction: animationRecord?.q20 == null ? null : boundedProgress(animationRecord.q20) / 100,
1192
+ filterState: cloneRecord(filterRecord || {}),
1193
+ filterSlotsState: cloneRecord(filterSlotsRecord || {})
1194
+ };
1195
+ }
1196
+
1197
+ export function summarizePusher(entity, pusherRecord, filterSlotsRecord = null) {
1198
+ if (!pusherRecord) return null;
1199
+ const mode = pusherRecord.q20 == null ? 2 : pusherRecord.q20 + 2;
1200
+ const filteredMode = pusherRecord.q24 ?? 0;
1201
+ return {
1202
+ entity,
1203
+ mode,
1204
+ modeName: enumValueName(PUSHER_MODE_NAMES, mode),
1205
+ filteredMode,
1206
+ filteredModeName: enumValueName(PUSHER_MODE_NAMES, filteredMode),
1207
+ angle: numberOrNull(pusherRecord.q28, 10),
1208
+ speed: pusherRecord.q32 == null ? 20 : (pusherRecord.q32 / 100) + 20,
1209
+ length: pusherRecord.q40 == null ? 1000 : ((pusherRecord.q40 + 50) / 10) + 995,
1210
+ filterInventory: pusherRecord.q36 === 1,
1211
+ filterSlots: filterSlotsRecord ? [filterSlotsRecord.q20 ?? null, filterSlotsRecord.q24 ?? null, filterSlotsRecord.q28 ?? null] : null,
1212
+ state: cloneRecord(pusherRecord),
1213
+ filterSlotsState: cloneRecord(filterSlotsRecord || {})
1214
+ };
1215
+ }
1216
+
1217
+ export function summarizePusherBeam(entity, beamRecord) {
1218
+ if (!beamRecord) return null;
1219
+ const lengthRaw = beamRecord.q20 ?? 0;
1220
+ const mode = beamRecord.q24 ?? 2;
1221
+ return {
1222
+ entity,
1223
+ active: mode !== 2,
1224
+ mode,
1225
+ modeName: enumValueName(PUSHER_MODE_NAMES, mode),
1226
+ lengthRaw,
1227
+ length: lengthRaw / 10,
1228
+ state: cloneRecord(beamRecord)
1229
+ };
1230
+ }
1231
+
1232
+ export function normalizeDegrees(value) {
1233
+ if (!Number.isFinite(Number(value))) return null;
1234
+ return ((Number(value) % 360) + 360) % 360;
1235
+ }
1236
+
1237
+ export function summarizeItemLauncher(entity, typeId, launcherRecord) {
1238
+ if (typeId !== ITEM_LAUNCHER_TYPE_ID || !launcherRecord) return null;
1239
+ const angleRadians = numberOrNull(launcherRecord.q32 ?? 0, 200);
1240
+ return {
1241
+ entity,
1242
+ angleRaw: launcherRecord.q32 ?? null,
1243
+ angleRadians,
1244
+ angleDegrees: angleRadians == null ? null : normalizeDegrees((angleRadians * 180) / Math.PI),
1245
+ state: cloneRecord(launcherRecord)
1246
+ };
1247
+ }
1248
+
1249
+ export function summarizeSign(entity, signRecord) {
1250
+ if (!signRecord) return null;
1251
+ const displayMode = signRecord.q20 ?? 0;
1252
+ return {
1253
+ entity,
1254
+ text: decodeText(signRecord.blob24) ?? "",
1255
+ displayMode,
1256
+ displayModeName: enumValueName(SIGN_DISPLAY_MODE_NAMES, displayMode),
1257
+ state: cloneRecord(signRecord)
1258
+ };
1259
+ }
1260
+
1261
+ export function summarizeShieldProjector(entity, projectorRecord) {
1262
+ return {
1263
+ entity,
1264
+ active: projectorRecord?.q21 !== 0,
1265
+ state: cloneRecord(projectorRecord || {})
1266
+ };
1267
+ }
1268
+
1269
+ export function summarizeSpawnPoint(entity, spawnRecord) {
1270
+ const rank = Number.isFinite(Number(spawnRecord?.q20)) ? Number(spawnRecord.q20) : 0;
1271
+ return {
1272
+ entity,
1273
+ rank,
1274
+ rankName: enumValueName(TEAM_RANK_NAMES, rank),
1275
+ state: cloneRecord(spawnRecord || {})
1276
+ };
1277
+ }
1278
+
1279
+ export function summarizeDoor(entity, rankRecord, doorRecord) {
1280
+ const rank = Number.isFinite(Number(rankRecord?.q20)) ? Number(rankRecord.q20) : 0;
1281
+ return {
1282
+ entity,
1283
+ rank,
1284
+ rankName: enumValueName(TEAM_RANK_NAMES, rank),
1285
+ open: doorRecord?.q21 === 1,
1286
+ rankState: cloneRecord(rankRecord || {}),
1287
+ state: cloneRecord(doorRecord || {})
1288
+ };
1289
+ }
1290
+
1291
+ export function enumValueName(map, value) {
1292
+ return value == null ? null : map.get(value) ?? null;
1293
+ }
1294
+
1295
+ export function colorToCss(color) {
1296
+ return `rgb(${(color >> 16) & 0xff},${(color >> 8) & 0xff},${color & 0xff})`;
1297
+ }
1298
+
1299
+ export function previewActionName(color) {
1300
+ if (color === 0x00ff00) return "place";
1301
+ if (color === 0xff0000) return "break";
1302
+ if (color === 0x0000ff) return "blueprint";
1303
+ return null;
1304
+ }
1305
+
1306
+ export function bitOffsets(value) {
1307
+ if (!Number.isSafeInteger(value) || value < 1) return [];
1308
+ let bits = BigInt(value);
1309
+ const offsets = [];
1310
+ let offset = 0;
1311
+ while (bits > 0n && offset < 64) {
1312
+ if (bits & 1n) offsets.push(offset);
1313
+ bits >>= 1n;
1314
+ offset++;
1315
+ }
1316
+ return offsets;
1317
+ }
1318
+
1319
+ export function summarizeBlueprintPreview(entity, record, transformRecord = null) {
1320
+ if (!record || record.q20 == null) return null;
1321
+ const rawBits = Number.isSafeInteger(Number(record.q24)) ? Number(record.q24) : null;
1322
+ const bits = rawBits == null ? 1 : rawBits + 1;
1323
+ const placementOffsets = bitOffsets(bits);
1324
+ const transform = transformRecord ? {
1325
+ entity,
1326
+ x: numberOrNull(transformRecord.q20, 40),
1327
+ y: numberOrNull(transformRecord.q24, 40),
1328
+ rot: numberOrNull(transformRecord.q28, 127.324)
1329
+ } : null;
1330
+ return {
1331
+ entity,
1332
+ itemId: record.q20,
1333
+ itemName: entityNameFromType(record.q20),
1334
+ bits,
1335
+ rawBits,
1336
+ placementOffsets,
1337
+ placementCount: placementOffsets.length,
1338
+ placements: placementOffsets.map((offset) => ({
1339
+ offset,
1340
+ x: transform?.x == null ? null : transform.x + offset,
1341
+ y: transform?.y ?? null,
1342
+ itemId: record.q20,
1343
+ itemName: entityNameFromType(record.q20)
1344
+ })),
1345
+ x: transform?.x ?? null,
1346
+ y: transform?.y ?? null,
1347
+ rot: transform?.rot ?? null,
1348
+ state: cloneRecord(record)
1349
+ };
1350
+ }
1351
+
1352
+ export function summarizePlayerPreview(entity, record, blueprintItems = []) {
1353
+ if (!record) return null;
1354
+ const color = record.q40 == null ? null : Number(record.q40);
1355
+ const active = typeof record.q36 === "number" && record.q36 !== 0;
1356
+ if (!active && record.q36 == null) return null;
1357
+ const actionName = Number.isFinite(color) ? previewActionName(color) : null;
1358
+ return {
1359
+ entity,
1360
+ active,
1361
+ x: numberOrNull(record.q20, 10),
1362
+ y: numberOrNull(record.q24, 10),
1363
+ width: numberOrNull(record.q28, 10),
1364
+ height: numberOrNull(record.q32, 10),
1365
+ progress: record.q36 ?? null,
1366
+ color: Number.isFinite(color) ? color : null,
1367
+ colorCss: Number.isFinite(color) ? colorToCss(color) : null,
1368
+ actionName,
1369
+ blueprintId: record.q44 ?? null,
1370
+ blueprintItems: actionName === "blueprint" ? blueprintItems : [],
1371
+ state: cloneRecord(record)
1372
+ };
1373
+ }
1374
+
1375
+ export function summarizePlayer(entity, record, previewRecord = null, blueprintItems = []) {
1376
+ if (!record) return null;
1377
+ const rawHeldItemId = record.q28 == null ? null : Number(record.q28);
1378
+ const heldItemId = Number.isFinite(rawHeldItemId) && rawHeldItemId !== 0 ? rawHeldItemId : null;
1379
+ const teamRank = Number.isFinite(Number(record.q72)) ? Number(record.q72) : 0;
1380
+ const gameRank = Number.isFinite(Number(record.q76)) ? Number(record.q76) : 0;
1381
+ return {
1382
+ entity,
1383
+ name: decodeText(record.blob92),
1384
+ heldItemId,
1385
+ heldItemName: entityNameFromType(heldItemId),
1386
+ repairTargetDistance: record.q56 == null ? null : record.q56 / 10,
1387
+ repairTargetAngle: record.q80 == null ? null : record.q80 / 100,
1388
+ shipRank: PLAYER_SHIP_RANKS.get(teamRank) || null,
1389
+ patronTier: patronTierName(gameRank),
1390
+ isDeveloper: gameRank === 1,
1391
+ isPatron: gameRank >= 2 && gameRank <= 6,
1392
+ piloting: Boolean(record.q107),
1393
+ muted: Boolean(record.q112),
1394
+ actionPreview: summarizePlayerPreview(entity, previewRecord, blueprintItems),
1395
+ state: cloneRecord(record)
1396
+ };
1397
+ }
1398
+
1399
+ export function patronTierName(gameRank) {
1400
+ switch (gameRank) {
1401
+ case 2:
1402
+ return "bronze";
1403
+ case 3:
1404
+ return "silver";
1405
+ case 4:
1406
+ return "gold";
1407
+ case 5:
1408
+ return "plat";
1409
+ case 6:
1410
+ return "flux";
1411
+ default:
1412
+ return null;
1413
+ }
1414
+ }
1415
+
1416
+ // Called once per entity with a fixed set of named components. Taking (key,
1417
+ // value) pairs avoids allocating ~32 single-key object literals per entity and
1418
+ // an Object.entries pair array for each of them.
1419
+ export function mergeContents(components) {
1420
+ const out = {};
1421
+ let any = false;
1422
+ for (const key in components) {
1423
+ const value = components[key];
1424
+ if (value == null) continue;
1425
+ out[key] = value;
1426
+ any = true;
1427
+ }
1428
+ return any ? out : null;
1429
+ }
1430
+
1431
+ // A hover outline is a highlight rectangle, not a physical footprint. Overworld
1432
+ // boundary entities report outlines as large as 720x32, and indexing those cell
1433
+ // by cell produced ~106k block cells for 183 entities -- the dominant cost in
1434
+ // every derived read. Outlines beyond this budget fall through to the ordinary
1435
+ // marker/crate/type heuristics.
1436
+ export const MAX_HOVER_OUTLINE_FOOTPRINT_CELLS = 1024;
1437
+
1438
+ export function entityFootprint(entity) {
1439
+ if (entity?.hoverOutline && Number.isFinite(Number(entity.hoverOutline.width)) && Number.isFinite(Number(entity.hoverOutline.height))) {
1440
+ const width = Math.ceil(Number(entity.hoverOutline.width));
1441
+ const height = Math.ceil(Number(entity.hoverOutline.height));
1442
+ if (width * height <= MAX_HOVER_OUTLINE_FOOTPRINT_CELLS) {
1443
+ return {width, height, source: "hover_outline"};
1444
+ }
1445
+ }
1446
+ if (entity?.markerTypeId != null && ENTITY_FOOTPRINTS.has(entity.markerTypeId)) {
1447
+ const footprint = ENTITY_FOOTPRINTS.get(entity.markerTypeId);
1448
+ return {...footprint, source: "marker"};
1449
+ }
1450
+ if (entity?.hugeThruster) {
1451
+ return {
1452
+ width: entity.hugeThruster.width,
1453
+ height: entity.hugeThruster.height,
1454
+ source: "huge_thruster"
1455
+ };
1456
+ }
1457
+ if (entity?.itemCrate && Number.isFinite(Number(entity.itemCrate.width)) && Number.isFinite(Number(entity.itemCrate.height))) {
1458
+ return {
1459
+ width: Number(entity.itemCrate.width),
1460
+ height: Number(entity.itemCrate.height),
1461
+ source: "crate"
1462
+ };
1463
+ }
1464
+ if (entity?.typeId != null && ENTITY_FOOTPRINTS.has(entity.typeId)) {
1465
+ const footprint = ENTITY_FOOTPRINTS.get(entity.typeId);
1466
+ return {...footprint, source: "type"};
1467
+ }
1468
+ if (entity?.fabricator || entity?.shieldGenerator) return {width: 2, height: 2, source: "heuristic"};
1469
+ return {width: 1, height: 1, source: "default"};
1470
+ }
1471
+
1472
+ export function entityLabel(entity) {
1473
+ if (entity?.category === "metadata" && entity?.typeName) return `Metadata ${entity.typeName}`;
1474
+ if (entity?.blueprintPreview) return `Blueprint Preview (${entity.blueprintPreview.itemName ?? entity.blueprintPreview.itemId ?? "item"})`;
1475
+ if (entity?.category === "loose_item" && entity?.itemHolder?.itemName) return `Loose ${entity.itemHolder.itemName}`;
1476
+ if (entity?.category === "untyped_holder" && entity?.itemHolder?.itemName) return `Untyped Holder (${entity.itemHolder.itemName})`;
1477
+ if (entity?.mapMarker) return `Map Marker (${entity.mapMarker.title ?? entity.mapMarker.key ?? entity.mapMarker.kind ?? "marker"})`;
1478
+ if (entity?.dockingSpring) return "Docking Spring";
1479
+ if (entity?.hugeThruster) return "Huge Thruster";
1480
+ if (entity?.shipControl && entity?.isOverworld) return "Overworld Ship";
1481
+ if (entity?.expandoBox) return "Expando Box";
1482
+ if (entity?.markerTypeName) return entity.markerTypeName;
1483
+ if (entity?.typeName) return entity.typeName;
1484
+ if (entity?.fabricator) return "Fabricator";
1485
+ if (entity?.itemCrate) return "Item Crate";
1486
+ if (entity?.shieldGenerator) return "Shield Generator";
1487
+ if (entity?.fluidTank) return "Fluid Tank";
1488
+ if (entity?.cannon) return "Cannon";
1489
+ if (entity?.pusher) return "Pusher";
1490
+ if (entity?.pusherBeam) return "Pusher Beam";
1491
+ if (entity?.launcher) return "Item Launcher";
1492
+ if (entity?.loader) return "Loader";
1493
+ if (entity?.cargoHatch) return entity.cargoHatch.typeName || "Cargo Hatch";
1494
+ if (entity?.cargoEjector) return entity.cargoEjector.typeName || "Cargo Ejector";
1495
+ if (entity?.itemHolder && !entity?.cargoEjector && !entity?.cannon && !entity?.pusher && !entity?.launcher && !entity?.loader && !entity?.fluidTank && !entity?.shieldGenerator && !entity?.shieldProjector) {
1496
+ return entity.itemHolder.itemName || "Item Holder";
1497
+ }
1498
+ if (entity?.player) return "Player";
1499
+ if (entity?.shipControl) return "Ship Control";
1500
+ return entity?.typeId != null ? `Entity ${entity.typeId}` : "Entity";
1501
+ }
1502
+
1503
+ export function entityCategory(entity) {
1504
+ if (entity?.player) return "player";
1505
+ if (entity?.blueprintPreview) return "blueprint_preview";
1506
+ if (entity?.shipControl) return "ship_control";
1507
+ if (entity?.itemCrate) return "item_crate";
1508
+ if (entity?.mapMarker) return "map_marker";
1509
+ if (entity?.dockingSpring) return "docking_spring";
1510
+ if (entity?.hugeThruster) return "huge_thruster";
1511
+ const hasMachineComponent = Boolean(entity?.fabricator || entity?.cargoEjector || entity?.cannon || entity?.pusher || entity?.launcher || entity?.loader || entity?.cargoHatch || entity?.commsStation || entity?.fluidTank || entity?.shieldGenerator || entity?.shieldProjector);
1512
+ const hasValidTransform = Boolean(
1513
+ entity?.transform &&
1514
+ Number.isFinite(entity.transform.x) &&
1515
+ Number.isFinite(entity.transform.y)
1516
+ );
1517
+ const hasPhysicalComponent = Boolean(hasValidTransform || entity?.itemHolder || hasMachineComponent);
1518
+ if (!hasPhysicalComponent) return "metadata";
1519
+ if (entity?.transform && !hasValidTransform && !entity?.itemHolder && !hasMachineComponent) return "metadata";
1520
+ if (entity?.typeId != null && !hasPhysicalComponent) return "metadata";
1521
+ if (entity?.markerTypeId != null) return "placed_entity";
1522
+ if (entity?.looseItemMarker && entity?.itemHolder?.itemId != null) return "loose_item";
1523
+ if (entity?.itemHolder?.itemId != null && !entity?.cargoEjector && !entity?.cannon && !entity?.pusher && !entity?.launcher && !entity?.loader && !entity?.cargoHatch && !entity?.commsStation && !entity?.fluidTank && !entity?.shieldGenerator && !entity?.shieldProjector) {
1524
+ if (entity.typeId != null && (!PLACED_ENTITY_TYPE_IDS.has(Number(entity.typeId)) || entity.typeId === entity.itemHolder.itemId)) return "loose_item";
1525
+ if (entity.typeId == null) return "untyped_holder";
1526
+ }
1527
+ if (entity?.fabricator || entity?.cannon || entity?.pusher || entity?.launcher || entity?.loader || entity?.cargoHatch || entity?.cargoEjector || entity?.commsStation || entity?.fluidTank || entity?.shieldGenerator || entity?.shieldProjector) return "placed_entity";
1528
+ if (entity?.typeId != null && PLACED_ENTITY_TYPE_IDS.has(Number(entity.typeId))) return "placed_entity";
1529
+ if (entity?.typeId != null && !hasPhysicalComponent) return "metadata";
1530
+ if (entity?.itemHolder?.itemId != null && !entity?.cargoEjector && !entity?.cannon && !entity?.pusher && !entity?.launcher && !entity?.loader && !entity?.cargoHatch && !entity?.commsStation && !entity?.fluidTank && !entity?.shieldGenerator && !entity?.shieldProjector) return "untyped_holder";
1531
+ return "entity";
1532
+ }
1533
+
1534
+ // Block coordinates are packed into a single number so the block index avoids a
1535
+ // template-string key per occupied cell. Covers the coordinate range in use.
1536
+ export const BLOCK_KEY_OFFSET = 1 << 24;
1537
+ export const BLOCK_KEY_STRIDE = 1 << 25;
1538
+
1539
+ export function blockKey(x, y) {
1540
+ return ((y + BLOCK_KEY_OFFSET) * BLOCK_KEY_STRIDE) + (x + BLOCK_KEY_OFFSET);
1541
+ }
1542
+
1543
+ // Returned by ModelState.table() for absent tables. Never mutated.
1544
+ export const EMPTY_TABLE = new Map();
1545
+
1546
+ // snapshot() only ever exposed the last 50 removals and last 10 errors, but both
1547
+ // arrays grew without bound behind that. Retain a little more than is surfaced.
1548
+ export const MAX_RETAINED_REMOVALS = 500;
1549
+ export const MAX_RETAINED_ERRORS = 100;
1550
+
1551
+ export function pushCapped(target, value, limit) {
1552
+ target.push(value);
1553
+ if (target.length > limit) target.splice(0, target.length - limit);
1554
+ }