dredless 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +48 -0
- package/docs/API_REFERENCE.md +2481 -0
- package/docs/BROWSER_API.md +98 -0
- package/docs/NODE_API.md +359 -0
- package/package.json +173 -0
- package/src/browser.d.ts +212 -0
- package/src/browser.js +133 -0
- package/src/client/core.js +1202 -0
- package/src/client/domains/index.js +1818 -0
- package/src/client/index.d.ts +2 -0
- package/src/client/index.js +1 -0
- package/src/compression/lz4.d.ts +1 -0
- package/src/compression/lz4.js +112 -0
- package/src/constants.js +74 -0
- package/src/crypto/chacha-native.d.ts +9 -0
- package/src/crypto/chacha-native.js +48 -0
- package/src/crypto/chacha.d.ts +25 -0
- package/src/crypto/chacha.js +274 -0
- package/src/data/item-schema.js +2235 -0
- package/src/data/official-client-data.js +156 -0
- package/src/events.js +39 -0
- package/src/index.d.ts +2277 -0
- package/src/index.js +125 -0
- package/src/network/browser-session.js +291 -0
- package/src/network/connection.d.ts +1 -0
- package/src/network/connection.js +31 -0
- package/src/network/cookies.js +85 -0
- package/src/network/http.js +34 -0
- package/src/network/index.d.ts +4 -0
- package/src/network/index.js +4 -0
- package/src/network/servers.d.ts +7 -0
- package/src/network/servers.js +133 -0
- package/src/network/session.d.ts +7 -0
- package/src/network/session.js +320 -0
- package/src/network/ships.d.ts +12 -0
- package/src/network/ships.js +73 -0
- package/src/node.d.ts +2 -0
- package/src/node.js +8 -0
- package/src/protocol/codec/binary.d.ts +3 -0
- package/src/protocol/codec/binary.js +52 -0
- package/src/protocol/codec/msgpack.d.ts +3 -0
- package/src/protocol/codec/msgpack.js +555 -0
- package/src/protocol/inbound/frame.d.ts +1 -0
- package/src/protocol/inbound/frame.js +8 -0
- package/src/protocol/inbound/world-payload.d.ts +3 -0
- package/src/protocol/inbound/world-payload.js +21 -0
- package/src/protocol/index.d.ts +11 -0
- package/src/protocol/index.js +19 -0
- package/src/protocol/outbound/blueprint.d.ts +22 -0
- package/src/protocol/outbound/blueprint.js +36 -0
- package/src/protocol/outbound/commands.d.ts +3 -0
- package/src/protocol/outbound/commands.js +149 -0
- package/src/protocol/outbound/comms.d.ts +28 -0
- package/src/protocol/outbound/comms.js +40 -0
- package/src/protocol/outbound/inventory.d.ts +64 -0
- package/src/protocol/outbound/inventory.js +147 -0
- package/src/protocol/outbound/ship-management.d.ts +84 -0
- package/src/protocol/outbound/ship-management.js +221 -0
- package/src/protocol/outbound/sign.d.ts +18 -0
- package/src/protocol/outbound/sign.js +45 -0
- package/src/protocol/outbound/ui-config.d.ts +157 -0
- package/src/protocol/outbound/ui-config.js +578 -0
- package/src/runtime.js +36 -0
- package/src/state/generator-maze.d.ts +32 -0
- package/src/state/generator-maze.js +370 -0
- package/src/state/index.d.ts +3 -0
- package/src/state/index.js +3 -0
- package/src/state/items.js +20 -0
- package/src/state/model/decoder.js +1554 -0
- package/src/state/model/index.d.ts +33 -0
- package/src/state/model/index.js +2 -0
- package/src/state/model/loader-config.js +1020 -0
- package/src/state/model/reader.js +69 -0
- package/src/state/model/state.js +1257 -0
- package/src/state/overworld.js +72 -0
- package/src/state/tilesets.js +53 -0
- package/src/state/world/index.d.ts +29 -0
- package/src/state/world/index.js +180 -0
- package/src/state/world/state.js +297 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export const OVERWORLD_ZONES = Object.freeze([
|
|
2
|
+
Object.freeze({baseId: 0, key: "freeport", name: "Freeport", tiered: false, navDestination: false}),
|
|
3
|
+
Object.freeze({baseId: 10, key: "hummingbird", name: "Hummingbird", tiered: false, navDestination: true}),
|
|
4
|
+
Object.freeze({baseId: 20, key: "finch", name: "Finch", tiered: true, navDestination: true}),
|
|
5
|
+
Object.freeze({baseId: 30, key: "sparrow", name: "Sparrow", tiered: true, navDestination: true}),
|
|
6
|
+
Object.freeze({baseId: 40, key: "raven", name: "Raven", tiered: false, navDestination: true}),
|
|
7
|
+
Object.freeze({baseId: 50, key: "falcon", name: "Falcon", tiered: false, navDestination: true}),
|
|
8
|
+
Object.freeze({baseId: 60, key: "combat-arena", name: "Combat Arena", tiered: false, navDestination: true}),
|
|
9
|
+
Object.freeze({baseId: 66, key: "mosaic", name: "Mosaic", tiered: false, navDestination: false})
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const OVERWORLD_ZONE_BY_BASE_ID = new Map(OVERWORLD_ZONES.map((zone) => [zone.baseId, zone]));
|
|
13
|
+
const NAVIGATION_ZONE_BY_BASE_ID = new Map(OVERWORLD_ZONES
|
|
14
|
+
.filter((zone) => zone.navDestination)
|
|
15
|
+
.map((zone) => [zone.baseId, zone]));
|
|
16
|
+
|
|
17
|
+
function romanNumeral(value) {
|
|
18
|
+
const numerals = [
|
|
19
|
+
[1000, "M"], [900, "CM"], [500, "D"], [400, "CD"],
|
|
20
|
+
[100, "C"], [90, "XC"], [50, "L"], [40, "XL"],
|
|
21
|
+
[10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"]
|
|
22
|
+
];
|
|
23
|
+
let remaining = Math.max(1, Math.trunc(Number(value) || 1));
|
|
24
|
+
let out = "";
|
|
25
|
+
for (const [amount, label] of numerals) {
|
|
26
|
+
while (remaining >= amount) {
|
|
27
|
+
out += label;
|
|
28
|
+
remaining -= amount;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function overworldZoneFromBaseId(baseId) {
|
|
35
|
+
const id = Number(baseId);
|
|
36
|
+
return Number.isInteger(id) ? OVERWORLD_ZONE_BY_BASE_ID.get(id) || null : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function navigationZoneFromBaseId(baseId) {
|
|
40
|
+
const id = Number(baseId);
|
|
41
|
+
return Number.isInteger(id) ? NAVIGATION_ZONE_BY_BASE_ID.get(id) || null : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function overworldZoneFromId(overworldId) {
|
|
45
|
+
const id = Number(overworldId);
|
|
46
|
+
if (!Number.isInteger(id) || id < 0) return null;
|
|
47
|
+
const exactZone = overworldZoneFromBaseId(id);
|
|
48
|
+
if (exactZone) return overworldZoneSummary(exactZone, id, 0);
|
|
49
|
+
const baseId = Math.floor(id / 10) * 10;
|
|
50
|
+
const zone = overworldZoneFromBaseId(baseId);
|
|
51
|
+
if (!zone?.tiered) return null;
|
|
52
|
+
const layer = id - baseId;
|
|
53
|
+
return overworldZoneSummary(zone, id, layer);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function overworldZoneSummary(zone, id, layer) {
|
|
57
|
+
return {
|
|
58
|
+
id,
|
|
59
|
+
baseId: zone.baseId,
|
|
60
|
+
layer,
|
|
61
|
+
key: zone.key,
|
|
62
|
+
name: zone.name,
|
|
63
|
+
tiered: zone.tiered,
|
|
64
|
+
displayName: zone.tiered ? `${zone.name} ${romanNumeral(layer + 1)}` : zone.name
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function navigationDestinationFromEncodedValue(value) {
|
|
69
|
+
if (typeof value !== "number" || !Number.isInteger(value)) return null;
|
|
70
|
+
const baseId = value + 1;
|
|
71
|
+
return navigationZoneFromBaseId(baseId) ? baseId : null;
|
|
72
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OFFICIAL_CLIENT_OVERWORLD_MATERIAL_NAMES,
|
|
3
|
+
OFFICIAL_CLIENT_SUBWORLD_MATERIAL_NAMES,
|
|
4
|
+
OFFICIAL_CLIENT_TILESETS
|
|
5
|
+
} from "../data/official-client-data.js";
|
|
6
|
+
|
|
7
|
+
function cloneFilter(filter) {
|
|
8
|
+
if (!filter) return null;
|
|
9
|
+
return {
|
|
10
|
+
categoryBits: filter.categoryBits ?? 1,
|
|
11
|
+
maskBits: filter.maskBits ?? 65535,
|
|
12
|
+
groupIndex: filter.groupIndex ?? 0
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function cloneTile(tile) {
|
|
17
|
+
const copy = {solid: Boolean(tile.solid)};
|
|
18
|
+
if (tile.name != null) copy.name = tile.name;
|
|
19
|
+
if (tile.destruct_item != null) copy.destruct_item = tile.destruct_item;
|
|
20
|
+
if (tile.blocks_bullets != null) copy.blocks_bullets = tile.blocks_bullets;
|
|
21
|
+
if (tile.hp != null) copy.hp = tile.hp;
|
|
22
|
+
if (tile.no_build_surface != null) copy.no_build_surface = tile.no_build_surface;
|
|
23
|
+
if (tile.physics != null) {
|
|
24
|
+
copy.physics = {};
|
|
25
|
+
if (tile.physics.friction != null) copy.physics.friction = tile.physics.friction;
|
|
26
|
+
if (tile.physics.restitution != null) copy.physics.restitution = tile.physics.restitution;
|
|
27
|
+
if (tile.physics.transparent != null) copy.physics.transparent = tile.physics.transparent;
|
|
28
|
+
if (tile.physics.walkway != null) copy.physics.walkway = tile.physics.walkway;
|
|
29
|
+
if (tile.physics.filter != null) copy.physics.filter = cloneFilter(tile.physics.filter);
|
|
30
|
+
}
|
|
31
|
+
return copy;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function cloneTileset(tileset) {
|
|
35
|
+
const materialNames = tileset.atlas === "tiles_subworld"
|
|
36
|
+
? OFFICIAL_CLIENT_SUBWORLD_MATERIAL_NAMES
|
|
37
|
+
: tileset.atlas === "tiles_overworld"
|
|
38
|
+
? OFFICIAL_CLIENT_OVERWORLD_MATERIAL_NAMES
|
|
39
|
+
: null;
|
|
40
|
+
return {
|
|
41
|
+
scale: tileset.scale,
|
|
42
|
+
atlas: tileset.atlas,
|
|
43
|
+
tile_width: tileset.tile_width,
|
|
44
|
+
tiles: tileset.tiles.map((tile, materialId) => cloneTile({
|
|
45
|
+
...tile,
|
|
46
|
+
name: materialNames?.get(materialId)
|
|
47
|
+
}))
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getTilesetForWorld(isOverworld) {
|
|
52
|
+
return cloneTileset(isOverworld ? OFFICIAL_CLIENT_TILESETS.overworld : OFFICIAL_CLIENT_TILESETS.subworld);
|
|
53
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export {
|
|
2
|
+
WorldStore,
|
|
3
|
+
WorldState,
|
|
4
|
+
ModelState,
|
|
5
|
+
type Tile,
|
|
6
|
+
type TileDefinition,
|
|
7
|
+
type Tileset,
|
|
8
|
+
type OverworldZoneSummary,
|
|
9
|
+
type ShipWorldMetadataSummary,
|
|
10
|
+
type WorldSnapshot,
|
|
11
|
+
type WorldUpdate,
|
|
12
|
+
type CommsBubbleSummary,
|
|
13
|
+
type ModelRecord,
|
|
14
|
+
type ModelSnapshot,
|
|
15
|
+
type TransformSummary,
|
|
16
|
+
type EntitySummary,
|
|
17
|
+
type BotSummary,
|
|
18
|
+
type BlockSummary,
|
|
19
|
+
type BlueprintPreviewSummary,
|
|
20
|
+
type ThrusterSummary,
|
|
21
|
+
type PusherSummary,
|
|
22
|
+
type LoaderSummary,
|
|
23
|
+
type SignSummary,
|
|
24
|
+
type SpawnPointSummary,
|
|
25
|
+
type DoorSummary,
|
|
26
|
+
type ShieldProjectorSummary,
|
|
27
|
+
type ShieldGeneratorSummary,
|
|
28
|
+
type MachineSummary
|
|
29
|
+
} from "../../index.js";
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import {WorldState} from "./state.js";
|
|
2
|
+
import {ModelState} from "../model/index.js";
|
|
3
|
+
import {decodeModelPayload} from "../../protocol/inbound/world-payload.js";
|
|
4
|
+
|
|
5
|
+
export {WorldState};
|
|
6
|
+
|
|
7
|
+
// Retention limits for per-world history. These arrays previously grew without
|
|
8
|
+
// bound for the lifetime of a connection. Pass Infinity to restore that.
|
|
9
|
+
const DEFAULT_HISTORY_LIMITS = {
|
|
10
|
+
eventHistory: 200,
|
|
11
|
+
modelPacketHistory: 100,
|
|
12
|
+
chunkHistory: 64
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function normalizeLimit(value, fallback) {
|
|
16
|
+
if (value === Infinity) return Infinity;
|
|
17
|
+
const limit = Number(value);
|
|
18
|
+
if (!Number.isFinite(limit) || limit < 0) return fallback;
|
|
19
|
+
return Math.floor(limit);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function pushBounded(target, value, limit) {
|
|
23
|
+
if (limit === 0) return;
|
|
24
|
+
target.push(value);
|
|
25
|
+
if (target.length > limit) target.splice(0, target.length - limit);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class WorldStore {
|
|
29
|
+
#overworldId = null;
|
|
30
|
+
#shipWorldId = null;
|
|
31
|
+
|
|
32
|
+
constructor(limits = {}) {
|
|
33
|
+
this.currentWorldId = null;
|
|
34
|
+
this.worlds = new Map();
|
|
35
|
+
this.historyLimits = {
|
|
36
|
+
eventHistory: normalizeLimit(limits.eventHistory, DEFAULT_HISTORY_LIMITS.eventHistory),
|
|
37
|
+
modelPacketHistory: normalizeLimit(limits.modelPacketHistory, DEFAULT_HISTORY_LIMITS.modelPacketHistory),
|
|
38
|
+
chunkHistory: normalizeLimit(limits.chunkHistory, DEFAULT_HISTORY_LIMITS.chunkHistory)
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get(id) {
|
|
43
|
+
const worldId = Number(id);
|
|
44
|
+
if (!this.worlds.has(worldId)) this.worlds.set(worldId, new WorldState(worldId, this.historyLimits));
|
|
45
|
+
return this.worlds.get(worldId);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
apply(packet) {
|
|
49
|
+
if (!packet || packet.world == null) return null;
|
|
50
|
+
if (packet.type === 22) return this.#applyMeta(packet);
|
|
51
|
+
if (packet.type === 23) return this.#applyTiles(packet);
|
|
52
|
+
if (packet.type === 20) return this.#applyModel(packet);
|
|
53
|
+
if (packet.type === 13) return this.#applyCommsBubble(packet);
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
snapshot({includeTiles = false, includeModel = false, includeBlocks = false} = {}) {
|
|
58
|
+
return [...this.worlds.values()].map((world) => world.snapshot({includeTiles, includeModel, includeBlocks}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
ids() {
|
|
62
|
+
return [...this.worlds.keys()];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Both resolvers are called on nearly every public read, and each used to
|
|
66
|
+
// spread the whole world map. Cache the resolved id and re-scan only when the
|
|
67
|
+
// cached world no longer satisfies the predicate.
|
|
68
|
+
overworld() {
|
|
69
|
+
const cached = this.#overworldId != null ? this.worlds.get(this.#overworldId) : null;
|
|
70
|
+
if (cached && cached.isOverworld === true) return cached;
|
|
71
|
+
for (const world of this.worlds.values()) {
|
|
72
|
+
if (world.isOverworld === true) {
|
|
73
|
+
this.#overworldId = world.id;
|
|
74
|
+
return world;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
this.#overworldId = null;
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
shipWorld() {
|
|
82
|
+
if (this.currentWorldId != null) return this.worlds.get(Number(this.currentWorldId)) || null;
|
|
83
|
+
const cached = this.#shipWorldId != null ? this.worlds.get(this.#shipWorldId) : null;
|
|
84
|
+
if (cached && cached.isOverworld === false) return cached;
|
|
85
|
+
for (const world of this.worlds.values()) {
|
|
86
|
+
if (world.isOverworld === false) {
|
|
87
|
+
this.#shipWorldId = world.id;
|
|
88
|
+
return world;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
this.#shipWorldId = null;
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
currentShipEntity() {
|
|
96
|
+
const shipWorld = this.shipWorld();
|
|
97
|
+
if (!shipWorld || shipWorld.parentWorld == null || shipWorld.parentEntity == null) return null;
|
|
98
|
+
return this.worlds.get(Number(shipWorld.parentWorld))?.entity(shipWorld.parentEntity) || null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#applyMeta(packet) {
|
|
102
|
+
if (packet.removed) {
|
|
103
|
+
const world = this.worlds.get(Number(packet.world));
|
|
104
|
+
if (world) world.readMeta(packet);
|
|
105
|
+
this.worlds.delete(Number(packet.world));
|
|
106
|
+
if (this.currentWorldId === Number(packet.world)) this.currentWorldId = null;
|
|
107
|
+
return {type: "world-removed", world: world || null, packet};
|
|
108
|
+
}
|
|
109
|
+
const world = this.get(packet.world);
|
|
110
|
+
world.readMeta(packet);
|
|
111
|
+
if (this.currentWorldId == null && !world.isOverworld) this.currentWorldId = world.id;
|
|
112
|
+
return {type: "world", world};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#applyTiles(packet) {
|
|
116
|
+
const world = this.get(packet.world);
|
|
117
|
+
const decoded = world.decodeEncrypted(packet.data);
|
|
118
|
+
const updates = [];
|
|
119
|
+
const errors = [];
|
|
120
|
+
if (decoded && typeof decoded === "object") {
|
|
121
|
+
for (const [kind, value] of Object.entries(decoded)) {
|
|
122
|
+
try {
|
|
123
|
+
if (kind === "0") updates.push({kind, tiles: world.applyChunk(value)});
|
|
124
|
+
else if (kind === "1") updates.push({kind, tile: world.applyTile(value)});
|
|
125
|
+
else updates.push({kind, value});
|
|
126
|
+
} catch (error) {
|
|
127
|
+
errors.push(error);
|
|
128
|
+
updates.push({kind, value, error: error.message});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
world.recordEvent({type: "tiles", packet, decoded, updates, errors});
|
|
133
|
+
return {type: "tiles", world, decoded, updates, errors};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#applyModel(packet) {
|
|
137
|
+
const world = this.get(packet.world);
|
|
138
|
+
const result = {
|
|
139
|
+
worldId: packet.world,
|
|
140
|
+
full: Boolean(packet.full),
|
|
141
|
+
events: Array.isArray(packet.events) ? packet.events : [],
|
|
142
|
+
modelData: packet.model_data || null,
|
|
143
|
+
decoded: null,
|
|
144
|
+
model: null,
|
|
145
|
+
commandNumber: typeof packet.command_number === "number" ? packet.command_number : null,
|
|
146
|
+
timing: {
|
|
147
|
+
roundTimeLeft: packet.round_time_left ?? null,
|
|
148
|
+
regenTimeLeft: packet.regen_time_left ?? null,
|
|
149
|
+
removeOnRegen: packet.remove_on_regen ?? null,
|
|
150
|
+
globalEventTime: packet.global_event_time ?? null,
|
|
151
|
+
tickTime: packet.tick_time ?? null,
|
|
152
|
+
tickQuota: packet.tick_quota ?? null,
|
|
153
|
+
cpuLoad: packet.cpu_load ?? null,
|
|
154
|
+
relayTime: packet.relay_time ?? null
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
if (packet.model_data && world.seed != null) {
|
|
158
|
+
const previousModel = world.model;
|
|
159
|
+
try {
|
|
160
|
+
if (packet.full) world.model = new ModelState({isOverworld: world.isOverworld});
|
|
161
|
+
result.decoded = decodeModelPayload(packet.model_data, packet.world, world.seed);
|
|
162
|
+
result.model = world.model.apply(result.decoded, {full: Boolean(packet.full)});
|
|
163
|
+
if (result.model?.error && packet.full && previousModel) world.model = previousModel;
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (packet.full && previousModel) world.model = previousModel;
|
|
166
|
+
result.error = error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
pushBounded(world.modelPackets, result, world.historyLimits.modelPacketHistory);
|
|
170
|
+
world.recordEvent({type: "model", packet, result});
|
|
171
|
+
world.lastPacket = packet;
|
|
172
|
+
return {type: "model", world, result};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#applyCommsBubble(packet) {
|
|
176
|
+
const world = this.get(packet.world);
|
|
177
|
+
const bubble = world.addCommsBubble(packet);
|
|
178
|
+
return {type: "comms-bubble", world, packet, bubble};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +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
|
+
}
|