dredless 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +65 -48
  2. package/dist/browser/dredless.global.js +12047 -0
  3. package/dist/browser/dredless.global.js.map +7 -0
  4. package/docs/API_REFERENCE.md +2481 -2481
  5. package/docs/BROWSER_API.md +98 -98
  6. package/docs/NODE_API.md +359 -359
  7. package/package.json +40 -35
  8. package/src/browser-global.js +3 -0
  9. package/src/browser.d.ts +212 -212
  10. package/src/browser.js +133 -133
  11. package/src/client/core.js +1202 -1202
  12. package/src/client/domains/index.js +1818 -1818
  13. package/src/client/index.d.ts +2 -2
  14. package/src/client/index.js +1 -1
  15. package/src/compression/lz4.d.ts +1 -1
  16. package/src/compression/lz4.js +112 -112
  17. package/src/constants.js +74 -74
  18. package/src/crypto/chacha-native.d.ts +9 -9
  19. package/src/crypto/chacha-native.js +48 -48
  20. package/src/crypto/chacha.d.ts +25 -25
  21. package/src/crypto/chacha.js +274 -274
  22. package/src/events.js +39 -39
  23. package/src/index.d.ts +2277 -2277
  24. package/src/index.js +125 -125
  25. package/src/network/browser-session.js +291 -291
  26. package/src/network/connection.d.ts +1 -1
  27. package/src/network/connection.js +31 -31
  28. package/src/network/cookies.js +85 -85
  29. package/src/network/http.js +34 -34
  30. package/src/network/index.d.ts +4 -4
  31. package/src/network/index.js +4 -4
  32. package/src/network/servers.d.ts +7 -7
  33. package/src/network/servers.js +133 -133
  34. package/src/network/session.d.ts +7 -7
  35. package/src/network/session.js +320 -320
  36. package/src/network/ships.d.ts +12 -12
  37. package/src/network/ships.js +73 -73
  38. package/src/node.d.ts +2 -2
  39. package/src/node.js +8 -8
  40. package/src/protocol/codec/binary.d.ts +3 -3
  41. package/src/protocol/codec/binary.js +52 -52
  42. package/src/protocol/codec/msgpack.d.ts +3 -3
  43. package/src/protocol/codec/msgpack.js +555 -555
  44. package/src/protocol/inbound/frame.d.ts +1 -1
  45. package/src/protocol/inbound/frame.js +8 -8
  46. package/src/protocol/inbound/world-payload.d.ts +3 -3
  47. package/src/protocol/inbound/world-payload.js +21 -21
  48. package/src/protocol/index.d.ts +11 -11
  49. package/src/protocol/index.js +19 -19
  50. package/src/protocol/outbound/blueprint.d.ts +22 -22
  51. package/src/protocol/outbound/blueprint.js +36 -36
  52. package/src/protocol/outbound/commands.d.ts +3 -3
  53. package/src/protocol/outbound/commands.js +149 -149
  54. package/src/protocol/outbound/comms.d.ts +28 -28
  55. package/src/protocol/outbound/comms.js +40 -40
  56. package/src/protocol/outbound/inventory.d.ts +64 -64
  57. package/src/protocol/outbound/inventory.js +147 -147
  58. package/src/protocol/outbound/ship-management.d.ts +84 -84
  59. package/src/protocol/outbound/ship-management.js +221 -221
  60. package/src/protocol/outbound/sign.d.ts +18 -18
  61. package/src/protocol/outbound/sign.js +45 -45
  62. package/src/protocol/outbound/ui-config.d.ts +157 -157
  63. package/src/protocol/outbound/ui-config.js +578 -578
  64. package/src/runtime.js +36 -36
  65. package/src/state/generator-maze.d.ts +32 -32
  66. package/src/state/generator-maze.js +370 -370
  67. package/src/state/index.d.ts +3 -3
  68. package/src/state/index.js +3 -3
  69. package/src/state/items.js +20 -20
  70. package/src/state/model/decoder.js +1554 -1554
  71. package/src/state/model/index.d.ts +33 -33
  72. package/src/state/model/index.js +2 -2
  73. package/src/state/model/loader-config.js +1020 -1020
  74. package/src/state/model/reader.js +69 -69
  75. package/src/state/model/state.js +1257 -1257
  76. package/src/state/overworld.js +72 -72
  77. package/src/state/tilesets.js +53 -53
  78. package/src/state/world/index.d.ts +29 -29
  79. package/src/state/world/index.js +180 -180
  80. package/src/state/world/state.js +297 -297
@@ -1,297 +1,297 @@
1
- import {decodeModelPayload, decodeWorldPayload, decompressWorldChunk} from "../../protocol/inbound/world-payload.js";
2
- import {ModelState} from "../model/index.js";
3
- import {overworldZoneFromId} from "../overworld.js";
4
- import {getTilesetForWorld} from "../tilesets.js";
5
-
6
- const TILE_SHAPE_NAMES = new Map([
7
- [0, "full"],
8
- [5, "bottom-half"],
9
- [7, "top-half"]
10
- ]);
11
-
12
- function tileShapeName(shape) {
13
- const id = Number(shape);
14
- return Number.isFinite(id) ? TILE_SHAPE_NAMES.get(id) ?? null : null;
15
- }
16
-
17
- const DEFAULT_HISTORY_LIMITS = {
18
- eventHistory: 200,
19
- modelPacketHistory: 100,
20
- chunkHistory: 64
21
- };
22
-
23
- function normalizeLimit(value, fallback) {
24
- if (value === Infinity) return Infinity;
25
- const limit = Number(value);
26
- if (!Number.isFinite(limit) || limit < 0) return fallback;
27
- return Math.floor(limit);
28
- }
29
-
30
- function pushBounded(target, value, limit) {
31
- if (limit === 0) return;
32
- target.push(value);
33
- if (target.length > limit) target.splice(0, target.length - limit);
34
- }
35
-
36
- const TILE_KEY_OFFSET = 1 << 24;
37
- const TILE_KEY_STRIDE = 1 << 25;
38
-
39
- export class WorldState {
40
- #materialCounts = new Map();
41
-
42
- constructor(id, limits = {}) {
43
- this.historyLimits = {
44
- eventHistory: normalizeLimit(limits.eventHistory, DEFAULT_HISTORY_LIMITS.eventHistory),
45
- modelPacketHistory: normalizeLimit(limits.modelPacketHistory, DEFAULT_HISTORY_LIMITS.modelPacketHistory),
46
- chunkHistory: normalizeLimit(limits.chunkHistory, DEFAULT_HISTORY_LIMITS.chunkHistory)
47
- };
48
- // Totals stay truthful even though the retained arrays are capped.
49
- this.totalChunkCount = 0;
50
- this.totalEventCount = 0;
51
- this.id = id;
52
- this.seed = null;
53
- this.isOverworld = null;
54
- this.tileset = null;
55
- this.blockWidth = null;
56
- this.blockHeight = null;
57
- this.parentWorld = null;
58
- this.parentEntity = null;
59
- this.tiles = new Map();
60
- this.chunks = [];
61
- this.events = [];
62
- this.modelPackets = [];
63
- this.commsBubbles = [];
64
- this._bubbleSequence = 0;
65
- this.model = new ModelState({isOverworld: this.isOverworld});
66
- this.lastChunkPatch = null;
67
- this.lastPacket = null;
68
- this.meta = null;
69
- }
70
-
71
- readMeta(packet) {
72
- this.meta = packet;
73
- this.seed = packet.seed ?? this.seed;
74
- const isOverworld = packet.is_overworld ?? this.isOverworld;
75
- this.isOverworld = isOverworld == null ? null : Boolean(isOverworld);
76
- this.tileset = this.isOverworld == null ? null : getTilesetForWorld(this.isOverworld);
77
- this.model.setWorldKind(this.isOverworld);
78
- this.blockWidth = packet.block_w ?? this.blockWidth;
79
- this.blockHeight = packet.block_h ?? this.blockHeight;
80
- this.parentWorld = packet.parent_world ?? this.parentWorld;
81
- this.parentEntity = packet.parent_ent ?? this.parentEntity;
82
- this.lastPacket = packet;
83
- }
84
-
85
- // Bounded event log. Kept as a method so every call site shares the cap and
86
- // the running total stays accurate.
87
- recordEvent(event) {
88
- this.totalEventCount += 1;
89
- pushBounded(this.events, event, this.historyLimits.eventHistory);
90
- return event;
91
- }
92
-
93
- addCommsBubble(packet) {
94
- const raw = packet?.bubble && typeof packet.bubble === "object" ? packet.bubble : {};
95
- const color = Number.isFinite(Number(raw.color)) ? Number(raw.color) : null;
96
- const bubble = {
97
- sequence: ++this._bubbleSequence,
98
- worldId: this.id,
99
- entity: Number.isFinite(Number(raw.model_id)) ? Number(raw.model_id) : null,
100
- modelId: Number.isFinite(Number(raw.model_id)) ? Number(raw.model_id) : null,
101
- message: typeof raw.msg === "string" ? raw.msg : "",
102
- color,
103
- colorCss: color == null ? null : `rgb(${(color >> 16) & 0xff},${(color >> 8) & 0xff},${color & 0xff})`,
104
- durationSeconds: Number.isFinite(Number(raw.time)) ? Number(raw.time) : null,
105
- raw: {...raw}
106
- };
107
- this.commsBubbles.push(bubble);
108
- if (this.commsBubbles.length > 50) this.commsBubbles.splice(0, this.commsBubbles.length - 50);
109
- this.recordEvent({type: "comms-bubble", packet, bubble});
110
- this.lastPacket = packet;
111
- return bubble;
112
- }
113
-
114
- decodeEncrypted(data) {
115
- if (!data || this.seed == null) return null;
116
- return decodeWorldPayload(data, this.id, this.seed);
117
- }
118
-
119
- applyTile(value) {
120
- if (!Array.isArray(value) || value.length !== 6) return null;
121
- const [x, y, material, shape, hp, color] = value;
122
- return this.setTile({x, y, material, shape, hp, integrity: hp, color});
123
- }
124
-
125
- applyChunk(value) {
126
- if (!Array.isArray(value) || value.length !== 7) return null;
127
- const [chunkX, chunkY, minX, minY, maxX, maxY, compressedPatch] = value;
128
- // Index the decompressed bytes directly; spreading into a plain array cost
129
- // ~16k boxed elements for a full chunk.
130
- const patch = decompressWorldChunk(compressedPatch);
131
- const width = maxX - minX + 1;
132
- const height = maxY - minY + 1;
133
- const count = width * height;
134
- const hasColor = patch.length >= count * 4;
135
- const tiles = new Array(count);
136
- const baseX = chunkX << 6;
137
- const baseY = chunkY << 6;
138
- for (let i = 0; i < count; i++) {
139
- const localX = minX + (i % width);
140
- const localY = minY + Math.floor(i / width);
141
- const hp = patch[i + count * 2];
142
- tiles[i] = this.#storeTile(this.#buildTile(
143
- baseX + localX,
144
- baseY + localY,
145
- patch[i],
146
- patch[i + count],
147
- hp,
148
- hasColor ? patch[i + count * 3] : null
149
- ));
150
- }
151
- this.totalChunkCount += 1;
152
- pushBounded(this.chunks, {chunkX, chunkY, minX, minY, maxX, maxY, tiles}, this.historyLimits.chunkHistory);
153
- this.lastChunkPatch = {chunkX, chunkY, minX, minY, maxX, maxY, count, hasColor};
154
- return tiles;
155
- }
156
-
157
- setTile(tile) {
158
- return this.#storeTile(this.normalizeTile(tile));
159
- }
160
-
161
- // Chunk decoding produces millions of tiles at join time, so build the final
162
- // object in one pass instead of a literal plus a spread copy.
163
- #buildTile(x, y, material, shape, hp, color) {
164
- const def = this.tileDefinition(material);
165
- const maxHp = def?.hp ?? null;
166
- const value = hp ?? null;
167
- return {
168
- x,
169
- y,
170
- material,
171
- shape,
172
- hp: value,
173
- integrity: value,
174
- color,
175
- materialName: def?.name ?? null,
176
- shapeName: tileShapeName(shape),
177
- solid: def?.solid ?? null,
178
- maxHp,
179
- hpRatio: typeof value === "number" ? value / 255 : null,
180
- hpValue: typeof value === "number" && typeof maxHp === "number" ? Math.round((value / 255) * maxHp) : null
181
- };
182
- }
183
-
184
- // Numeric key: a template string per tile was a measurable share of chunk
185
- // decode. The map is only ever read through values()/size, so the key format
186
- // is internal.
187
- #storeTile(normalized) {
188
- const key = ((normalized.y + TILE_KEY_OFFSET) * TILE_KEY_STRIDE) + (normalized.x + TILE_KEY_OFFSET);
189
- const previous = this.tiles.get(key);
190
- if (previous !== undefined) this.#countMaterial(previous.material, -1);
191
- this.#countMaterial(normalized.material, 1);
192
- this.tiles.set(key, normalized);
193
- return normalized;
194
- }
195
-
196
- #countMaterial(material, delta) {
197
- const id = Number(material);
198
- const next = (this.#materialCounts.get(id) || 0) + delta;
199
- if (next > 0) this.#materialCounts.set(id, next);
200
- else this.#materialCounts.delete(id);
201
- }
202
-
203
- normalizeTile(tile) {
204
- const def = this.tileDefinition(tile.material);
205
- const hp = tile.hp ?? tile.integrity ?? null;
206
- const maxHp = def?.hp ?? null;
207
- return {
208
- ...tile,
209
- hp,
210
- integrity: hp,
211
- materialName: def?.name ?? null,
212
- shapeName: tileShapeName(tile.shape),
213
- solid: def?.solid ?? null,
214
- maxHp,
215
- hpRatio: typeof hp === "number" ? hp / 255 : null,
216
- hpValue: typeof hp === "number" && typeof maxHp === "number" ? Math.round((hp / 255) * maxHp) : null
217
- };
218
- }
219
-
220
- // Counts are maintained as tiles are stored; this used to rescan every tile
221
- // on each call and ran on every snapshot.
222
- materials() {
223
- return [...this.#materialCounts.entries()]
224
- .sort((a, b) => a[0] - b[0])
225
- .map(([material, count]) => {
226
- const def = this.tileDefinition(material);
227
- return {
228
- material,
229
- name: def?.name ?? null,
230
- count,
231
- solid: def?.solid ?? null,
232
- hp: def?.hp ?? null
233
- };
234
- });
235
- }
236
-
237
- snapshot({includeTiles = false, includeModel = false, includeBlocks = false} = {}) {
238
- const model = this.model.snapshot({includeTables: includeModel, includeBlocks});
239
- const overworldZone = this.isOverworld ? overworldZoneFromId(this.id) : null;
240
- return {
241
- id: this.id,
242
- is_overworld: this.isOverworld,
243
- overworldZone,
244
- shipMetadata: this.model.shipMetadata(),
245
- tileset: this.tileset,
246
- seed: this.seed,
247
- block_w: this.blockWidth,
248
- block_h: this.blockHeight,
249
- parent_world: this.parentWorld,
250
- parent_ent: this.parentEntity,
251
- tileCount: this.tiles.size,
252
- chunkCount: this.totalChunkCount,
253
- lastChunkPatch: this.lastChunkPatch,
254
- lastPacket: this.lastPacket,
255
- meta: this.meta,
256
- materials: this.materials(),
257
- model,
258
- entities: model.entities,
259
- blocks: model.blocks,
260
- transforms: model.transforms,
261
- machines: model.machines,
262
- players: model.players,
263
- shipControls: model.shipControls,
264
- commsBubbles: this.commsBubbles.slice(),
265
- tiles: includeTiles ? [...this.tiles.values()] : undefined
266
- };
267
- }
268
-
269
- table(id) {
270
- return this.model.table(id);
271
- }
272
-
273
- record(tableId, entityId) {
274
- return this.model.record(tableId, entityId);
275
- }
276
-
277
- tileDefinition(material) {
278
- if (!this.tileset || !Array.isArray(this.tileset.tiles)) return null;
279
- return this.tileset.tiles[Number(material)] || null;
280
- }
281
-
282
- entity(entityId) {
283
- return this.model.entity(entityId);
284
- }
285
-
286
- entities() {
287
- return this.model.entities();
288
- }
289
-
290
- blocks() {
291
- return this.model.blocks();
292
- }
293
-
294
- blockAt(x, y) {
295
- return this.model.blockAt(x, y);
296
- }
297
- }
1
+ import {decodeModelPayload, decodeWorldPayload, decompressWorldChunk} from "../../protocol/inbound/world-payload.js";
2
+ import {ModelState} from "../model/index.js";
3
+ import {overworldZoneFromId} from "../overworld.js";
4
+ import {getTilesetForWorld} from "../tilesets.js";
5
+
6
+ const TILE_SHAPE_NAMES = new Map([
7
+ [0, "full"],
8
+ [5, "bottom-half"],
9
+ [7, "top-half"]
10
+ ]);
11
+
12
+ function tileShapeName(shape) {
13
+ const id = Number(shape);
14
+ return Number.isFinite(id) ? TILE_SHAPE_NAMES.get(id) ?? null : null;
15
+ }
16
+
17
+ const DEFAULT_HISTORY_LIMITS = {
18
+ eventHistory: 200,
19
+ modelPacketHistory: 100,
20
+ chunkHistory: 64
21
+ };
22
+
23
+ function normalizeLimit(value, fallback) {
24
+ if (value === Infinity) return Infinity;
25
+ const limit = Number(value);
26
+ if (!Number.isFinite(limit) || limit < 0) return fallback;
27
+ return Math.floor(limit);
28
+ }
29
+
30
+ function pushBounded(target, value, limit) {
31
+ if (limit === 0) return;
32
+ target.push(value);
33
+ if (target.length > limit) target.splice(0, target.length - limit);
34
+ }
35
+
36
+ const TILE_KEY_OFFSET = 1 << 24;
37
+ const TILE_KEY_STRIDE = 1 << 25;
38
+
39
+ export class WorldState {
40
+ #materialCounts = new Map();
41
+
42
+ constructor(id, limits = {}) {
43
+ this.historyLimits = {
44
+ eventHistory: normalizeLimit(limits.eventHistory, DEFAULT_HISTORY_LIMITS.eventHistory),
45
+ modelPacketHistory: normalizeLimit(limits.modelPacketHistory, DEFAULT_HISTORY_LIMITS.modelPacketHistory),
46
+ chunkHistory: normalizeLimit(limits.chunkHistory, DEFAULT_HISTORY_LIMITS.chunkHistory)
47
+ };
48
+ // Totals stay truthful even though the retained arrays are capped.
49
+ this.totalChunkCount = 0;
50
+ this.totalEventCount = 0;
51
+ this.id = id;
52
+ this.seed = null;
53
+ this.isOverworld = null;
54
+ this.tileset = null;
55
+ this.blockWidth = null;
56
+ this.blockHeight = null;
57
+ this.parentWorld = null;
58
+ this.parentEntity = null;
59
+ this.tiles = new Map();
60
+ this.chunks = [];
61
+ this.events = [];
62
+ this.modelPackets = [];
63
+ this.commsBubbles = [];
64
+ this._bubbleSequence = 0;
65
+ this.model = new ModelState({isOverworld: this.isOverworld});
66
+ this.lastChunkPatch = null;
67
+ this.lastPacket = null;
68
+ this.meta = null;
69
+ }
70
+
71
+ readMeta(packet) {
72
+ this.meta = packet;
73
+ this.seed = packet.seed ?? this.seed;
74
+ const isOverworld = packet.is_overworld ?? this.isOverworld;
75
+ this.isOverworld = isOverworld == null ? null : Boolean(isOverworld);
76
+ this.tileset = this.isOverworld == null ? null : getTilesetForWorld(this.isOverworld);
77
+ this.model.setWorldKind(this.isOverworld);
78
+ this.blockWidth = packet.block_w ?? this.blockWidth;
79
+ this.blockHeight = packet.block_h ?? this.blockHeight;
80
+ this.parentWorld = packet.parent_world ?? this.parentWorld;
81
+ this.parentEntity = packet.parent_ent ?? this.parentEntity;
82
+ this.lastPacket = packet;
83
+ }
84
+
85
+ // Bounded event log. Kept as a method so every call site shares the cap and
86
+ // the running total stays accurate.
87
+ recordEvent(event) {
88
+ this.totalEventCount += 1;
89
+ pushBounded(this.events, event, this.historyLimits.eventHistory);
90
+ return event;
91
+ }
92
+
93
+ addCommsBubble(packet) {
94
+ const raw = packet?.bubble && typeof packet.bubble === "object" ? packet.bubble : {};
95
+ const color = Number.isFinite(Number(raw.color)) ? Number(raw.color) : null;
96
+ const bubble = {
97
+ sequence: ++this._bubbleSequence,
98
+ worldId: this.id,
99
+ entity: Number.isFinite(Number(raw.model_id)) ? Number(raw.model_id) : null,
100
+ modelId: Number.isFinite(Number(raw.model_id)) ? Number(raw.model_id) : null,
101
+ message: typeof raw.msg === "string" ? raw.msg : "",
102
+ color,
103
+ colorCss: color == null ? null : `rgb(${(color >> 16) & 0xff},${(color >> 8) & 0xff},${color & 0xff})`,
104
+ durationSeconds: Number.isFinite(Number(raw.time)) ? Number(raw.time) : null,
105
+ raw: {...raw}
106
+ };
107
+ this.commsBubbles.push(bubble);
108
+ if (this.commsBubbles.length > 50) this.commsBubbles.splice(0, this.commsBubbles.length - 50);
109
+ this.recordEvent({type: "comms-bubble", packet, bubble});
110
+ this.lastPacket = packet;
111
+ return bubble;
112
+ }
113
+
114
+ decodeEncrypted(data) {
115
+ if (!data || this.seed == null) return null;
116
+ return decodeWorldPayload(data, this.id, this.seed);
117
+ }
118
+
119
+ applyTile(value) {
120
+ if (!Array.isArray(value) || value.length !== 6) return null;
121
+ const [x, y, material, shape, hp, color] = value;
122
+ return this.setTile({x, y, material, shape, hp, integrity: hp, color});
123
+ }
124
+
125
+ applyChunk(value) {
126
+ if (!Array.isArray(value) || value.length !== 7) return null;
127
+ const [chunkX, chunkY, minX, minY, maxX, maxY, compressedPatch] = value;
128
+ // Index the decompressed bytes directly; spreading into a plain array cost
129
+ // ~16k boxed elements for a full chunk.
130
+ const patch = decompressWorldChunk(compressedPatch);
131
+ const width = maxX - minX + 1;
132
+ const height = maxY - minY + 1;
133
+ const count = width * height;
134
+ const hasColor = patch.length >= count * 4;
135
+ const tiles = new Array(count);
136
+ const baseX = chunkX << 6;
137
+ const baseY = chunkY << 6;
138
+ for (let i = 0; i < count; i++) {
139
+ const localX = minX + (i % width);
140
+ const localY = minY + Math.floor(i / width);
141
+ const hp = patch[i + count * 2];
142
+ tiles[i] = this.#storeTile(this.#buildTile(
143
+ baseX + localX,
144
+ baseY + localY,
145
+ patch[i],
146
+ patch[i + count],
147
+ hp,
148
+ hasColor ? patch[i + count * 3] : null
149
+ ));
150
+ }
151
+ this.totalChunkCount += 1;
152
+ pushBounded(this.chunks, {chunkX, chunkY, minX, minY, maxX, maxY, tiles}, this.historyLimits.chunkHistory);
153
+ this.lastChunkPatch = {chunkX, chunkY, minX, minY, maxX, maxY, count, hasColor};
154
+ return tiles;
155
+ }
156
+
157
+ setTile(tile) {
158
+ return this.#storeTile(this.normalizeTile(tile));
159
+ }
160
+
161
+ // Chunk decoding produces millions of tiles at join time, so build the final
162
+ // object in one pass instead of a literal plus a spread copy.
163
+ #buildTile(x, y, material, shape, hp, color) {
164
+ const def = this.tileDefinition(material);
165
+ const maxHp = def?.hp ?? null;
166
+ const value = hp ?? null;
167
+ return {
168
+ x,
169
+ y,
170
+ material,
171
+ shape,
172
+ hp: value,
173
+ integrity: value,
174
+ color,
175
+ materialName: def?.name ?? null,
176
+ shapeName: tileShapeName(shape),
177
+ solid: def?.solid ?? null,
178
+ maxHp,
179
+ hpRatio: typeof value === "number" ? value / 255 : null,
180
+ hpValue: typeof value === "number" && typeof maxHp === "number" ? Math.round((value / 255) * maxHp) : null
181
+ };
182
+ }
183
+
184
+ // Numeric key: a template string per tile was a measurable share of chunk
185
+ // decode. The map is only ever read through values()/size, so the key format
186
+ // is internal.
187
+ #storeTile(normalized) {
188
+ const key = ((normalized.y + TILE_KEY_OFFSET) * TILE_KEY_STRIDE) + (normalized.x + TILE_KEY_OFFSET);
189
+ const previous = this.tiles.get(key);
190
+ if (previous !== undefined) this.#countMaterial(previous.material, -1);
191
+ this.#countMaterial(normalized.material, 1);
192
+ this.tiles.set(key, normalized);
193
+ return normalized;
194
+ }
195
+
196
+ #countMaterial(material, delta) {
197
+ const id = Number(material);
198
+ const next = (this.#materialCounts.get(id) || 0) + delta;
199
+ if (next > 0) this.#materialCounts.set(id, next);
200
+ else this.#materialCounts.delete(id);
201
+ }
202
+
203
+ normalizeTile(tile) {
204
+ const def = this.tileDefinition(tile.material);
205
+ const hp = tile.hp ?? tile.integrity ?? null;
206
+ const maxHp = def?.hp ?? null;
207
+ return {
208
+ ...tile,
209
+ hp,
210
+ integrity: hp,
211
+ materialName: def?.name ?? null,
212
+ shapeName: tileShapeName(tile.shape),
213
+ solid: def?.solid ?? null,
214
+ maxHp,
215
+ hpRatio: typeof hp === "number" ? hp / 255 : null,
216
+ hpValue: typeof hp === "number" && typeof maxHp === "number" ? Math.round((hp / 255) * maxHp) : null
217
+ };
218
+ }
219
+
220
+ // Counts are maintained as tiles are stored; this used to rescan every tile
221
+ // on each call and ran on every snapshot.
222
+ materials() {
223
+ return [...this.#materialCounts.entries()]
224
+ .sort((a, b) => a[0] - b[0])
225
+ .map(([material, count]) => {
226
+ const def = this.tileDefinition(material);
227
+ return {
228
+ material,
229
+ name: def?.name ?? null,
230
+ count,
231
+ solid: def?.solid ?? null,
232
+ hp: def?.hp ?? null
233
+ };
234
+ });
235
+ }
236
+
237
+ snapshot({includeTiles = false, includeModel = false, includeBlocks = false} = {}) {
238
+ const model = this.model.snapshot({includeTables: includeModel, includeBlocks});
239
+ const overworldZone = this.isOverworld ? overworldZoneFromId(this.id) : null;
240
+ return {
241
+ id: this.id,
242
+ is_overworld: this.isOverworld,
243
+ overworldZone,
244
+ shipMetadata: this.model.shipMetadata(),
245
+ tileset: this.tileset,
246
+ seed: this.seed,
247
+ block_w: this.blockWidth,
248
+ block_h: this.blockHeight,
249
+ parent_world: this.parentWorld,
250
+ parent_ent: this.parentEntity,
251
+ tileCount: this.tiles.size,
252
+ chunkCount: this.totalChunkCount,
253
+ lastChunkPatch: this.lastChunkPatch,
254
+ lastPacket: this.lastPacket,
255
+ meta: this.meta,
256
+ materials: this.materials(),
257
+ model,
258
+ entities: model.entities,
259
+ blocks: model.blocks,
260
+ transforms: model.transforms,
261
+ machines: model.machines,
262
+ players: model.players,
263
+ shipControls: model.shipControls,
264
+ commsBubbles: this.commsBubbles.slice(),
265
+ tiles: includeTiles ? [...this.tiles.values()] : undefined
266
+ };
267
+ }
268
+
269
+ table(id) {
270
+ return this.model.table(id);
271
+ }
272
+
273
+ record(tableId, entityId) {
274
+ return this.model.record(tableId, entityId);
275
+ }
276
+
277
+ tileDefinition(material) {
278
+ if (!this.tileset || !Array.isArray(this.tileset.tiles)) return null;
279
+ return this.tileset.tiles[Number(material)] || null;
280
+ }
281
+
282
+ entity(entityId) {
283
+ return this.model.entity(entityId);
284
+ }
285
+
286
+ entities() {
287
+ return this.model.entities();
288
+ }
289
+
290
+ blocks() {
291
+ return this.model.blocks();
292
+ }
293
+
294
+ blockAt(x, y) {
295
+ return this.model.blockAt(x, y);
296
+ }
297
+ }