@lzimul/minecraft-mcp 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +219 -179
- package/SECURITY.md +38 -21
- package/build/config/server-info.js +3 -2
- package/build/config/world-limits.js +4 -1
- package/build/data/entities.js +1581 -0
- package/build/enums/entity-kind.enum.js +5 -0
- package/build/enums/inventory-section.enum.js +5 -0
- package/build/index.js +5 -6
- package/build/interfaces/entity-inventory.interface.js +1 -0
- package/build/interfaces/entity-type.interface.js +1 -0
- package/build/interfaces/entity.interface.js +1 -0
- package/build/resources/dimension-resources.js +23 -6
- package/build/resources/entity-resources.js +17 -0
- package/build/resources/index.js +2 -0
- package/build/schemas/coordinate-schema.js +5 -0
- package/build/schemas/dimension-schema.js +1 -0
- package/build/schemas/entity-schema.js +95 -0
- package/build/tools/block-tools.js +14 -23
- package/build/tools/entity-tools.js +325 -0
- package/build/tools/index.js +4 -0
- package/build/tools/result.js +3 -5
- package/build/tools/world-tools.js +139 -0
- package/build/utils/logger.js +53 -0
- package/build/world/block-spec.js +6 -6
- package/build/world/block-states.js +23 -4
- package/build/world/entity-inventory.js +84 -0
- package/build/world/item-spec.js +25 -0
- package/build/world/simulated-dimension.js +187 -13
- package/build/world/stores/file-world-store.js +67 -20
- package/build/world/stores/memory-world-store.js +13 -5
- package/build/world/stores/serialized-block.js +9 -14
- package/build/world/stores/serialized-entity.js +74 -0
- package/build/world/world.js +33 -1
- package/lib/config/server-info.d.ts.map +1 -1
- package/lib/config/world-limits.d.ts +3 -0
- package/lib/config/world-limits.d.ts.map +1 -1
- package/lib/data/entities.d.ts +5 -0
- package/lib/data/entities.d.ts.map +1 -0
- package/lib/enums/entity-kind.enum.d.ts +5 -0
- package/lib/enums/entity-kind.enum.d.ts.map +1 -0
- package/lib/enums/inventory-section.enum.d.ts +5 -0
- package/lib/enums/inventory-section.enum.d.ts.map +1 -0
- package/lib/interfaces/entity-inventory.interface.d.ts +9 -0
- package/lib/interfaces/entity-inventory.interface.d.ts.map +1 -0
- package/lib/interfaces/entity-type.interface.d.ts +8 -0
- package/lib/interfaces/entity-type.interface.d.ts.map +1 -0
- package/lib/interfaces/entity.interface.d.ts +17 -0
- package/lib/interfaces/entity.interface.d.ts.map +1 -0
- package/lib/interfaces/world-store.interface.d.ts +7 -2
- package/lib/interfaces/world-store.interface.d.ts.map +1 -1
- package/lib/resources/dimension-resources.d.ts.map +1 -1
- package/lib/resources/entity-resources.d.ts +3 -0
- package/lib/resources/entity-resources.d.ts.map +1 -0
- package/lib/resources/index.d.ts.map +1 -1
- package/lib/schemas/coordinate-schema.d.ts +5 -0
- package/lib/schemas/coordinate-schema.d.ts.map +1 -1
- package/lib/schemas/dimension-schema.d.ts +1 -0
- package/lib/schemas/dimension-schema.d.ts.map +1 -1
- package/lib/schemas/entity-schema.d.ts +134 -0
- package/lib/schemas/entity-schema.d.ts.map +1 -0
- package/lib/tools/block-tools.d.ts.map +1 -1
- package/lib/tools/entity-tools.d.ts +4 -0
- package/lib/tools/entity-tools.d.ts.map +1 -0
- package/lib/tools/index.d.ts.map +1 -1
- package/lib/tools/result.d.ts +2 -6
- package/lib/tools/result.d.ts.map +1 -1
- package/lib/tools/world-tools.d.ts +4 -0
- package/lib/tools/world-tools.d.ts.map +1 -0
- package/lib/utils/logger.d.ts +3 -0
- package/lib/utils/logger.d.ts.map +1 -0
- package/lib/world/block-states.d.ts +2 -0
- package/lib/world/block-states.d.ts.map +1 -1
- package/lib/world/entity-inventory.d.ts +16 -0
- package/lib/world/entity-inventory.d.ts.map +1 -0
- package/lib/world/item-spec.d.ts +10 -0
- package/lib/world/item-spec.d.ts.map +1 -0
- package/lib/world/simulated-dimension.d.ts +29 -0
- package/lib/world/simulated-dimension.d.ts.map +1 -1
- package/lib/world/stores/file-world-store.d.ts +3 -3
- package/lib/world/stores/file-world-store.d.ts.map +1 -1
- package/lib/world/stores/memory-world-store.d.ts +3 -4
- package/lib/world/stores/memory-world-store.d.ts.map +1 -1
- package/lib/world/stores/serialized-block.d.ts.map +1 -1
- package/lib/world/stores/serialized-entity.d.ts +24 -0
- package/lib/world/stores/serialized-entity.d.ts.map +1 -0
- package/lib/world/world.d.ts +3 -0
- package/lib/world/world.d.ts.map +1 -1
- package/package.json +113 -109
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { getItemByName } from '../data/items.js';
|
|
2
|
+
import { EInventorySection } from '../enums/inventory-section.enum.js';
|
|
3
|
+
export const HOTBAR_SLOT_COUNT = 9;
|
|
4
|
+
export const INVENTORY_SLOT_COUNT = 27;
|
|
5
|
+
export const TOTAL_SLOT_COUNT = HOTBAR_SLOT_COUNT + INVENTORY_SLOT_COUNT;
|
|
6
|
+
export function slotCount(section) {
|
|
7
|
+
return section === EInventorySection.Hotbar
|
|
8
|
+
? HOTBAR_SLOT_COUNT
|
|
9
|
+
: INVENTORY_SLOT_COUNT;
|
|
10
|
+
}
|
|
11
|
+
export function isSlotInRange(section, slot) {
|
|
12
|
+
return Number.isInteger(slot) && slot >= 0 && slot < slotCount(section);
|
|
13
|
+
}
|
|
14
|
+
export function createEmptyInventory() {
|
|
15
|
+
return {
|
|
16
|
+
hotbar: Array.from({ length: HOTBAR_SLOT_COUNT }, () => null),
|
|
17
|
+
inventory: Array.from({ length: INVENTORY_SLOT_COUNT }, () => null)
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function normalizeItemStack(value) {
|
|
21
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const record = value;
|
|
25
|
+
const { item, count } = record;
|
|
26
|
+
if (typeof item !== 'string' ||
|
|
27
|
+
typeof count !== 'number' ||
|
|
28
|
+
!Number.isInteger(count) ||
|
|
29
|
+
count <= 0) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const resolved = getItemByName(item.trim().toLowerCase());
|
|
33
|
+
if (!resolved || resolved.key === 'minecraft:air') {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
item: resolved.key,
|
|
38
|
+
count: Math.min(count, resolved.stackSize)
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function normalizeSlotArray(value, length) {
|
|
42
|
+
const source = Array.isArray(value) ? value : [];
|
|
43
|
+
return Array.from({ length }, (_, index) => normalizeItemStack(source[index]));
|
|
44
|
+
}
|
|
45
|
+
export function normalizeInventory(value) {
|
|
46
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
47
|
+
return createEmptyInventory();
|
|
48
|
+
}
|
|
49
|
+
const record = value;
|
|
50
|
+
return {
|
|
51
|
+
hotbar: normalizeSlotArray(record.hotbar, HOTBAR_SLOT_COUNT),
|
|
52
|
+
inventory: normalizeSlotArray(record.inventory, INVENTORY_SLOT_COUNT)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function getInventorySlot(inventory, section, slot) {
|
|
56
|
+
return section === EInventorySection.Hotbar
|
|
57
|
+
? inventory.hotbar[slot]
|
|
58
|
+
: inventory.inventory[slot];
|
|
59
|
+
}
|
|
60
|
+
export function withInventorySlot(inventory, section, slot, stack) {
|
|
61
|
+
if (section === EInventorySection.Hotbar) {
|
|
62
|
+
const hotbar = [...inventory.hotbar];
|
|
63
|
+
hotbar[slot] = stack;
|
|
64
|
+
return { ...inventory, hotbar };
|
|
65
|
+
}
|
|
66
|
+
const slots = [...inventory.inventory];
|
|
67
|
+
slots[slot] = stack;
|
|
68
|
+
return { ...inventory, inventory: slots };
|
|
69
|
+
}
|
|
70
|
+
export function sameItemStack(a, b) {
|
|
71
|
+
if (a === b) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
return a !== null && b !== null && a.item === b.item && a.count === b.count;
|
|
75
|
+
}
|
|
76
|
+
export function inventoryUsedSlots(inventory) {
|
|
77
|
+
return (inventory.hotbar.filter(stack => stack !== null).length +
|
|
78
|
+
inventory.inventory.filter(stack => stack !== null).length);
|
|
79
|
+
}
|
|
80
|
+
export function sectionUsedSlots(inventory, section) {
|
|
81
|
+
return (section === EInventorySection.Hotbar
|
|
82
|
+
? inventory.hotbar
|
|
83
|
+
: inventory.inventory).filter(stack => stack !== null).length;
|
|
84
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { getItemByName } from '../data/items.js';
|
|
2
|
+
const ITEM_SPEC = /^(?:([a-z0-9_.-]+):)?([a-z0-9_./-]+)$/;
|
|
3
|
+
export function parseItemSpec(input) {
|
|
4
|
+
const spec = input.trim().toLowerCase();
|
|
5
|
+
const match = ITEM_SPEC.exec(spec);
|
|
6
|
+
if (!match) {
|
|
7
|
+
return {
|
|
8
|
+
ok: false,
|
|
9
|
+
error: `Invalid item "${input}". Expected an id such as "minecraft:diamond_sword" or "diamond_sword".`
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
const namespace = match[1];
|
|
13
|
+
const name = match[2];
|
|
14
|
+
let item = getItemByName(namespace ? `${namespace}:${name}` : name);
|
|
15
|
+
if (!item && !namespace) {
|
|
16
|
+
item = getItemByName(`minecraft:${name}`);
|
|
17
|
+
}
|
|
18
|
+
if (!item) {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
error: `Unknown item "${input}". Use an item id such as "minecraft:diamond_sword"; see the resource lzimul://minecraft/items for the full list.`
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return { ok: true, item };
|
|
25
|
+
}
|
|
@@ -1,20 +1,43 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { WORLD_LIMITS } from '../config/world-limits.js';
|
|
2
3
|
import { getBlockByName, MINECRAFT_BLOCKS } from '../data/blocks.js';
|
|
4
|
+
import { getEntityTypeByName } from '../data/entities.js';
|
|
5
|
+
import { EEntityKind } from '../enums/entity-kind.enum.js';
|
|
6
|
+
import { EInventorySection } from '../enums/inventory-section.enum.js';
|
|
3
7
|
import { toErrorMessage } from '../utils/error-message.js';
|
|
4
|
-
import {
|
|
8
|
+
import { logger } from '../utils/logger.js';
|
|
9
|
+
import { EMPTY_BLOCK_STATES, isEmptyStates, normalizeStates, sameStates } from './block-states.js';
|
|
10
|
+
import { createEmptyInventory, getInventorySlot, isSlotInRange, sameItemStack, slotCount, withInventorySlot } from './entity-inventory.js';
|
|
5
11
|
const AIR = getBlockByName('air') ?? MINECRAFT_BLOCKS[0];
|
|
6
12
|
function coordinateKey(x, y, z) {
|
|
7
13
|
return `${x},${y},${z}`;
|
|
8
14
|
}
|
|
15
|
+
function playerEntityType() {
|
|
16
|
+
const type = getEntityTypeByName('minecraft:player');
|
|
17
|
+
if (!type) {
|
|
18
|
+
throw new Error('The entity registry is missing minecraft:player.');
|
|
19
|
+
}
|
|
20
|
+
return type;
|
|
21
|
+
}
|
|
22
|
+
function compareEntityIds(a, b) {
|
|
23
|
+
if (a.id === b.id) {
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
return a.id < b.id ? -1 : 1;
|
|
27
|
+
}
|
|
9
28
|
export class SimulatedDimension {
|
|
10
29
|
info;
|
|
11
30
|
blocks = new Map();
|
|
31
|
+
entities = new Map();
|
|
32
|
+
playersByName = new Map();
|
|
12
33
|
store;
|
|
13
34
|
writeDelayMs;
|
|
14
35
|
timer = null;
|
|
15
36
|
queue = Promise.resolve();
|
|
16
37
|
savePromise = null;
|
|
17
38
|
dirty = false;
|
|
39
|
+
sortedBlocksCache = null;
|
|
40
|
+
sortedEntitiesCache = null;
|
|
18
41
|
constructor(info, store, writeDelayMs = 25) {
|
|
19
42
|
this.info = info;
|
|
20
43
|
this.store = store;
|
|
@@ -23,25 +46,73 @@ export class SimulatedDimension {
|
|
|
23
46
|
get modifiedBlockCount() {
|
|
24
47
|
return this.blocks.size;
|
|
25
48
|
}
|
|
49
|
+
get entityCount() {
|
|
50
|
+
return this.entities.size;
|
|
51
|
+
}
|
|
52
|
+
get playerCount() {
|
|
53
|
+
return this.playersByName.size;
|
|
54
|
+
}
|
|
26
55
|
get placedBlocks() {
|
|
27
56
|
return [...this.blocks.values()];
|
|
28
57
|
}
|
|
58
|
+
get sortedPlacedBlocks() {
|
|
59
|
+
if (!this.sortedBlocksCache) {
|
|
60
|
+
this.sortedBlocksCache = this.placedBlocks.toSorted((a, b) => a.x - b.x || a.y - b.y || a.z - b.z);
|
|
61
|
+
}
|
|
62
|
+
return this.sortedBlocksCache;
|
|
63
|
+
}
|
|
64
|
+
get placedEntities() {
|
|
65
|
+
return [...this.entities.values()];
|
|
66
|
+
}
|
|
67
|
+
get sortedPlacedEntities() {
|
|
68
|
+
if (!this.sortedEntitiesCache) {
|
|
69
|
+
this.sortedEntitiesCache = this.placedEntities.toSorted(compareEntityIds);
|
|
70
|
+
}
|
|
71
|
+
return this.sortedEntitiesCache;
|
|
72
|
+
}
|
|
29
73
|
async load() {
|
|
30
74
|
const stored = await this.store.load(this.info.type);
|
|
31
|
-
|
|
32
|
-
|
|
75
|
+
this.blocks.clear();
|
|
76
|
+
this.entities.clear();
|
|
77
|
+
this.playersByName.clear();
|
|
78
|
+
this.sortedBlocksCache = null;
|
|
79
|
+
this.sortedEntitiesCache = null;
|
|
80
|
+
let skippedBlocks = 0;
|
|
81
|
+
for (const placed of stored.blocks) {
|
|
33
82
|
const key = coordinateKey(placed.x, placed.y, placed.z);
|
|
34
|
-
const isNew = !this.blocks.has(key);
|
|
35
83
|
const outOfRange = !this.contains(placed.x, placed.y, placed.z);
|
|
36
|
-
const overLimit =
|
|
84
|
+
const overLimit = !this.blocks.has(key) &&
|
|
85
|
+
this.blocks.size >= WORLD_LIMITS.maxModifiedBlocks;
|
|
37
86
|
if (outOfRange || overLimit) {
|
|
38
|
-
|
|
87
|
+
skippedBlocks++;
|
|
39
88
|
continue;
|
|
40
89
|
}
|
|
41
90
|
this.blocks.set(key, placed);
|
|
42
91
|
}
|
|
43
|
-
|
|
44
|
-
|
|
92
|
+
let skippedEntities = 0;
|
|
93
|
+
for (const entity of stored.entities) {
|
|
94
|
+
const duplicateId = this.entities.has(entity.id);
|
|
95
|
+
const outOfRange = !this.contains(entity.x, entity.y, entity.z) ||
|
|
96
|
+
entity.dimension !== this.info.type;
|
|
97
|
+
const overLimit = !duplicateId &&
|
|
98
|
+
this.entities.size >= WORLD_LIMITS.maxEntitiesPerDimension;
|
|
99
|
+
const playerName = entity.name.trim().toLowerCase();
|
|
100
|
+
const duplicatePlayer = entity.kind === EEntityKind.Player &&
|
|
101
|
+
this.playersByName.has(playerName);
|
|
102
|
+
if (duplicateId || outOfRange || overLimit || duplicatePlayer) {
|
|
103
|
+
skippedEntities++;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
this.entities.set(entity.id, entity);
|
|
107
|
+
if (entity.kind === EEntityKind.Player) {
|
|
108
|
+
this.playersByName.set(playerName, entity);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (skippedBlocks > 0) {
|
|
112
|
+
logger.warn(`Skipped ${skippedBlocks} invalid block(s) while loading ${this.info.key}`);
|
|
113
|
+
}
|
|
114
|
+
if (skippedEntities > 0) {
|
|
115
|
+
logger.warn(`Skipped ${skippedEntities} invalid entity/player record(s) while loading ${this.info.key}`);
|
|
45
116
|
}
|
|
46
117
|
}
|
|
47
118
|
contains(x, y, z) {
|
|
@@ -56,12 +127,108 @@ export class SimulatedDimension {
|
|
|
56
127
|
return (this.blocks.has(coordinateKey(x, y, z)) ||
|
|
57
128
|
this.blocks.size < WORLD_LIMITS.maxModifiedBlocks);
|
|
58
129
|
}
|
|
130
|
+
canAddEntity() {
|
|
131
|
+
return this.entities.size < WORLD_LIMITS.maxEntitiesPerDimension;
|
|
132
|
+
}
|
|
59
133
|
getBlock(x, y, z) {
|
|
60
134
|
return this.blocks.get(coordinateKey(x, y, z))?.block ?? AIR;
|
|
61
135
|
}
|
|
62
136
|
getStates(x, y, z) {
|
|
63
137
|
return (this.blocks.get(coordinateKey(x, y, z))?.states ?? EMPTY_BLOCK_STATES);
|
|
64
138
|
}
|
|
139
|
+
getBlockState(x, y, z) {
|
|
140
|
+
const placed = this.blocks.get(coordinateKey(x, y, z));
|
|
141
|
+
return placed
|
|
142
|
+
? { block: placed.block, states: placed.states }
|
|
143
|
+
: { block: AIR, states: EMPTY_BLOCK_STATES };
|
|
144
|
+
}
|
|
145
|
+
getEntity(id) {
|
|
146
|
+
return this.entities.get(id);
|
|
147
|
+
}
|
|
148
|
+
findPlayer(name) {
|
|
149
|
+
return this.playersByName.get(name.trim().toLowerCase());
|
|
150
|
+
}
|
|
151
|
+
removeDuplicatePlayers(claimedNames) {
|
|
152
|
+
let removed = 0;
|
|
153
|
+
for (const entity of this.entities.values()) {
|
|
154
|
+
if (entity.kind !== EEntityKind.Player) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const name = entity.name.trim().toLowerCase();
|
|
158
|
+
if (claimedNames.has(name)) {
|
|
159
|
+
this.entities.delete(entity.id);
|
|
160
|
+
this.playersByName.delete(name);
|
|
161
|
+
removed++;
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
claimedNames.add(name);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (removed > 0) {
|
|
168
|
+
this.sortedEntitiesCache = null;
|
|
169
|
+
}
|
|
170
|
+
return removed;
|
|
171
|
+
}
|
|
172
|
+
createEntity(type, name, x, y, z) {
|
|
173
|
+
if (type.name === 'player') {
|
|
174
|
+
throw new Error('Use createPlayer to create a player; createEntity only creates non-player entities.');
|
|
175
|
+
}
|
|
176
|
+
return this.addEntity(EEntityKind.Entity, type, name, x, y, z);
|
|
177
|
+
}
|
|
178
|
+
createPlayer(name, x, y, z) {
|
|
179
|
+
if (this.findPlayer(name)) {
|
|
180
|
+
throw new Error(`A player named "${name}" already exists in ${this.info.displayName}. Player names are case-insensitive.`);
|
|
181
|
+
}
|
|
182
|
+
return this.addEntity(EEntityKind.Player, playerEntityType(), name, x, y, z);
|
|
183
|
+
}
|
|
184
|
+
addEntity(kind, type, name, x, y, z) {
|
|
185
|
+
if (!this.canAddEntity()) {
|
|
186
|
+
throw new RangeError(`The ${this.info.displayName} world already holds the maximum of ${WORLD_LIMITS.maxEntitiesPerDimension} entities.`);
|
|
187
|
+
}
|
|
188
|
+
const entity = {
|
|
189
|
+
id: randomUUID(),
|
|
190
|
+
kind,
|
|
191
|
+
type,
|
|
192
|
+
name,
|
|
193
|
+
x,
|
|
194
|
+
y,
|
|
195
|
+
z,
|
|
196
|
+
dimension: this.info.type,
|
|
197
|
+
inventory: createEmptyInventory(),
|
|
198
|
+
createdAt: new Date().toISOString()
|
|
199
|
+
};
|
|
200
|
+
this.entities.set(entity.id, entity);
|
|
201
|
+
if (kind === EEntityKind.Player) {
|
|
202
|
+
this.playersByName.set(entity.name.trim().toLowerCase(), entity);
|
|
203
|
+
}
|
|
204
|
+
this.sortedEntitiesCache = null;
|
|
205
|
+
this.markDirty();
|
|
206
|
+
return entity;
|
|
207
|
+
}
|
|
208
|
+
setInventorySlot(id, section, slot, stack) {
|
|
209
|
+
const entity = this.entities.get(id);
|
|
210
|
+
if (!entity) {
|
|
211
|
+
throw new RangeError(`Unknown entity "${id}" in ${this.info.displayName}.`);
|
|
212
|
+
}
|
|
213
|
+
if (!isSlotInRange(section, slot)) {
|
|
214
|
+
throw new RangeError(`Slot ${slot} is out of range for the ${section} (0-${slotCount(section) - 1}).`);
|
|
215
|
+
}
|
|
216
|
+
const previous = getInventorySlot(entity.inventory, section, slot);
|
|
217
|
+
if (sameItemStack(previous, stack)) {
|
|
218
|
+
return { previous, entity };
|
|
219
|
+
}
|
|
220
|
+
const updated = {
|
|
221
|
+
...entity,
|
|
222
|
+
inventory: withInventorySlot(entity.inventory, section, slot, stack)
|
|
223
|
+
};
|
|
224
|
+
this.entities.set(id, updated);
|
|
225
|
+
if (updated.kind === EEntityKind.Player) {
|
|
226
|
+
this.playersByName.set(updated.name.trim().toLowerCase(), updated);
|
|
227
|
+
}
|
|
228
|
+
this.sortedEntitiesCache = null;
|
|
229
|
+
this.markDirty();
|
|
230
|
+
return { previous, entity: updated };
|
|
231
|
+
}
|
|
65
232
|
setBlock(x, y, z, block, states = EMPTY_BLOCK_STATES) {
|
|
66
233
|
const key = coordinateKey(x, y, z);
|
|
67
234
|
const existing = this.blocks.get(key);
|
|
@@ -71,7 +238,7 @@ export class SimulatedDimension {
|
|
|
71
238
|
sameStates(existing?.states ?? EMPTY_BLOCK_STATES, normalized)) {
|
|
72
239
|
return previous;
|
|
73
240
|
}
|
|
74
|
-
const isRemoval = block.key === AIR.key &&
|
|
241
|
+
const isRemoval = block.key === AIR.key && isEmptyStates(normalized);
|
|
75
242
|
if (!existing &&
|
|
76
243
|
!isRemoval &&
|
|
77
244
|
this.blocks.size >= WORLD_LIMITS.maxModifiedBlocks) {
|
|
@@ -83,8 +250,8 @@ export class SimulatedDimension {
|
|
|
83
250
|
else {
|
|
84
251
|
this.blocks.set(key, { x, y, z, block, states: normalized });
|
|
85
252
|
}
|
|
86
|
-
this.
|
|
87
|
-
this.
|
|
253
|
+
this.sortedBlocksCache = null;
|
|
254
|
+
this.markDirty();
|
|
88
255
|
return previous;
|
|
89
256
|
}
|
|
90
257
|
async flush() {
|
|
@@ -100,7 +267,10 @@ export class SimulatedDimension {
|
|
|
100
267
|
return;
|
|
101
268
|
}
|
|
102
269
|
this.dirty = false;
|
|
103
|
-
const snapshot =
|
|
270
|
+
const snapshot = {
|
|
271
|
+
blocks: this.placedBlocks,
|
|
272
|
+
entities: this.placedEntities
|
|
273
|
+
};
|
|
104
274
|
const run = this.queue.then(async () => {
|
|
105
275
|
await this.store.save(this.info.type, snapshot);
|
|
106
276
|
});
|
|
@@ -115,6 +285,10 @@ export class SimulatedDimension {
|
|
|
115
285
|
this.savePromise = null;
|
|
116
286
|
}
|
|
117
287
|
}
|
|
288
|
+
markDirty() {
|
|
289
|
+
this.dirty = true;
|
|
290
|
+
this.scheduleFlush();
|
|
291
|
+
}
|
|
118
292
|
scheduleFlush() {
|
|
119
293
|
if (this.timer !== null) {
|
|
120
294
|
return;
|
|
@@ -122,7 +296,7 @@ export class SimulatedDimension {
|
|
|
122
296
|
this.timer = setTimeout(() => {
|
|
123
297
|
this.timer = null;
|
|
124
298
|
void this.flush().catch((error) => {
|
|
125
|
-
|
|
299
|
+
logger.error(`Failed to persist ${this.info.key}: ${toErrorMessage(error)}`);
|
|
126
300
|
});
|
|
127
301
|
}, this.writeDelayMs);
|
|
128
302
|
}
|
|
@@ -3,11 +3,22 @@ import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { WORLD_LIMITS } from '../../config/world-limits.js';
|
|
5
5
|
import { toErrorMessage } from '../../utils/error-message.js';
|
|
6
|
+
import { logger } from '../../utils/logger.js';
|
|
6
7
|
import { fromSerialized, toSerialized } from './serialized-block.js';
|
|
7
|
-
|
|
8
|
+
import { fromSerializedEntity, toSerializedEntity } from './serialized-entity.js';
|
|
9
|
+
const FILE_FORMAT = 'lzimul:minecraft/dimension';
|
|
8
10
|
const MAX_SNAPSHOT_BYTES = 128 * 1024 * 1024;
|
|
9
11
|
function warn(message) {
|
|
10
|
-
|
|
12
|
+
logger.warn(message);
|
|
13
|
+
}
|
|
14
|
+
function emptySnapshot() {
|
|
15
|
+
return { blocks: [], entities: [] };
|
|
16
|
+
}
|
|
17
|
+
function compareIds(a, b) {
|
|
18
|
+
if (a.id === b.id) {
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
return a.id < b.id ? -1 : 1;
|
|
11
22
|
}
|
|
12
23
|
export class FileWorldStore {
|
|
13
24
|
directory;
|
|
@@ -31,13 +42,13 @@ export class FileWorldStore {
|
|
|
31
42
|
const info = await stat(file);
|
|
32
43
|
if (info.size > MAX_SNAPSHOT_BYTES) {
|
|
33
44
|
await this.quarantine(file, `snapshot is larger than ${MAX_SNAPSHOT_BYTES} bytes`);
|
|
34
|
-
return
|
|
45
|
+
return emptySnapshot();
|
|
35
46
|
}
|
|
36
47
|
raw = await readFile(file, 'utf8');
|
|
37
48
|
}
|
|
38
49
|
catch (error) {
|
|
39
50
|
if (error.code === 'ENOENT') {
|
|
40
|
-
return
|
|
51
|
+
return emptySnapshot();
|
|
41
52
|
}
|
|
42
53
|
throw new Error(`Cannot read world snapshot ${file}: ${toErrorMessage(error)}`, { cause: error });
|
|
43
54
|
}
|
|
@@ -47,17 +58,29 @@ export class FileWorldStore {
|
|
|
47
58
|
}
|
|
48
59
|
catch {
|
|
49
60
|
await this.quarantine(file, 'invalid JSON');
|
|
50
|
-
return
|
|
61
|
+
return emptySnapshot();
|
|
51
62
|
}
|
|
52
63
|
if (!parsed || !Array.isArray(parsed.blocks)) {
|
|
53
64
|
await this.quarantine(file, 'missing blocks array');
|
|
54
|
-
return
|
|
65
|
+
return emptySnapshot();
|
|
55
66
|
}
|
|
56
67
|
if (parsed.blocks.length > WORLD_LIMITS.maxModifiedBlocks) {
|
|
57
68
|
await this.quarantine(file, `${parsed.blocks.length} blocks exceed the limit of ${WORLD_LIMITS.maxModifiedBlocks}`);
|
|
58
|
-
return
|
|
69
|
+
return emptySnapshot();
|
|
59
70
|
}
|
|
60
|
-
|
|
71
|
+
let serializedEntities = [];
|
|
72
|
+
if (parsed.entities !== void 0) {
|
|
73
|
+
if (!Array.isArray(parsed.entities)) {
|
|
74
|
+
await this.quarantine(file, 'invalid entities array');
|
|
75
|
+
return emptySnapshot();
|
|
76
|
+
}
|
|
77
|
+
if (parsed.entities.length > WORLD_LIMITS.maxEntitiesPerDimension) {
|
|
78
|
+
await this.quarantine(file, `${parsed.entities.length} entities exceed the limit of ${WORLD_LIMITS.maxEntitiesPerDimension}`);
|
|
79
|
+
return emptySnapshot();
|
|
80
|
+
}
|
|
81
|
+
serializedEntities = parsed.entities;
|
|
82
|
+
}
|
|
83
|
+
const blocks = parsed.blocks.flatMap(entry => {
|
|
61
84
|
const block = fromSerialized(entry);
|
|
62
85
|
if (block) {
|
|
63
86
|
return [block];
|
|
@@ -65,24 +88,48 @@ export class FileWorldStore {
|
|
|
65
88
|
warn(`Skipped malformed block entry in ${file}`);
|
|
66
89
|
return [];
|
|
67
90
|
});
|
|
91
|
+
const entities = serializedEntities.flatMap(entry => {
|
|
92
|
+
try {
|
|
93
|
+
const entity = fromSerializedEntity(entry, type);
|
|
94
|
+
if (entity) {
|
|
95
|
+
return [entity];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
}
|
|
100
|
+
warn(`Skipped malformed entity entry in ${file}`);
|
|
101
|
+
return [];
|
|
102
|
+
});
|
|
103
|
+
return { blocks, entities };
|
|
68
104
|
}
|
|
69
|
-
async save(type,
|
|
70
|
-
if (blocks.length > WORLD_LIMITS.maxModifiedBlocks) {
|
|
71
|
-
throw new RangeError(`World snapshot has ${blocks.length} blocks, above the limit of ${WORLD_LIMITS.maxModifiedBlocks}`);
|
|
105
|
+
async save(type, snapshot) {
|
|
106
|
+
if (snapshot.blocks.length > WORLD_LIMITS.maxModifiedBlocks) {
|
|
107
|
+
throw new RangeError(`World snapshot has ${snapshot.blocks.length} blocks, above the limit of ${WORLD_LIMITS.maxModifiedBlocks}`);
|
|
108
|
+
}
|
|
109
|
+
if (snapshot.entities.length > WORLD_LIMITS.maxEntitiesPerDimension) {
|
|
110
|
+
throw new RangeError(`World snapshot has ${snapshot.entities.length} entities, above the limit of ${WORLD_LIMITS.maxEntitiesPerDimension}`);
|
|
72
111
|
}
|
|
73
112
|
const file = this.fileFor(type);
|
|
113
|
+
const sortedBlocks = snapshot.blocks.toSorted((a, b) => a.x - b.x || a.y - b.y || a.z - b.z);
|
|
114
|
+
const sortedEntities = snapshot.entities.toSorted(compareIds);
|
|
115
|
+
const payload = {
|
|
116
|
+
format: FILE_FORMAT,
|
|
117
|
+
dimension: `minecraft:${type}`,
|
|
118
|
+
updatedAt: new Date().toISOString(),
|
|
119
|
+
modifiedBlockCount: sortedBlocks.length,
|
|
120
|
+
entityCount: sortedEntities.length,
|
|
121
|
+
blocks: sortedBlocks.map(toSerialized),
|
|
122
|
+
entities: sortedEntities.map(toSerializedEntity)
|
|
123
|
+
};
|
|
124
|
+
const text = `${JSON.stringify(payload)}\n`;
|
|
125
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
126
|
+
if (bytes > MAX_SNAPSHOT_BYTES) {
|
|
127
|
+
throw new RangeError(`World snapshot for ${type} would be ${bytes} bytes, above the load limit of ${MAX_SNAPSHOT_BYTES} bytes. Remove blocks, block states or entities and retry.`);
|
|
128
|
+
}
|
|
74
129
|
try {
|
|
75
130
|
await this.ensureDirectory();
|
|
76
|
-
const sorted = blocks.toSorted((a, b) => a.x - b.x || a.y - b.y || a.z - b.z);
|
|
77
|
-
const payload = {
|
|
78
|
-
format: FILE_FORMAT,
|
|
79
|
-
dimension: `minecraft:${type}`,
|
|
80
|
-
updatedAt: new Date().toISOString(),
|
|
81
|
-
modifiedBlockCount: sorted.length,
|
|
82
|
-
blocks: sorted.map(toSerialized)
|
|
83
|
-
};
|
|
84
131
|
const temporary = `${file}.tmp`;
|
|
85
|
-
await writeFile(temporary,
|
|
132
|
+
await writeFile(temporary, text, 'utf8');
|
|
86
133
|
await rename(temporary, file);
|
|
87
134
|
}
|
|
88
135
|
catch (error) {
|
|
@@ -3,12 +3,20 @@ export class MemoryWorldStore {
|
|
|
3
3
|
worlds = new Map();
|
|
4
4
|
async load(type) {
|
|
5
5
|
const stored = this.worlds.get(type);
|
|
6
|
-
return stored
|
|
6
|
+
return stored
|
|
7
|
+
? { blocks: [...stored.blocks], entities: [...stored.entities] }
|
|
8
|
+
: { blocks: [], entities: [] };
|
|
7
9
|
}
|
|
8
|
-
async save(type,
|
|
9
|
-
if (blocks.length > WORLD_LIMITS.maxModifiedBlocks) {
|
|
10
|
-
throw new RangeError(`World snapshot has ${blocks.length} blocks, above the limit of ${WORLD_LIMITS.maxModifiedBlocks}`);
|
|
10
|
+
async save(type, snapshot) {
|
|
11
|
+
if (snapshot.blocks.length > WORLD_LIMITS.maxModifiedBlocks) {
|
|
12
|
+
throw new RangeError(`World snapshot has ${snapshot.blocks.length} blocks, above the limit of ${WORLD_LIMITS.maxModifiedBlocks}`);
|
|
11
13
|
}
|
|
12
|
-
|
|
14
|
+
if (snapshot.entities.length > WORLD_LIMITS.maxEntitiesPerDimension) {
|
|
15
|
+
throw new RangeError(`World snapshot has ${snapshot.entities.length} entities, above the limit of ${WORLD_LIMITS.maxEntitiesPerDimension}`);
|
|
16
|
+
}
|
|
17
|
+
this.worlds.set(type, {
|
|
18
|
+
blocks: [...snapshot.blocks],
|
|
19
|
+
entities: [...snapshot.entities]
|
|
20
|
+
});
|
|
13
21
|
}
|
|
14
22
|
}
|
|
@@ -1,21 +1,16 @@
|
|
|
1
1
|
import { getBlockByName } from '../../data/blocks.js';
|
|
2
|
-
import { normalizeStates } from '../block-states.js';
|
|
2
|
+
import { isEmptyStates, normalizeStates } from '../block-states.js';
|
|
3
3
|
export function toSerialized(placed) {
|
|
4
|
-
const states =
|
|
5
|
-
if (
|
|
6
|
-
return {
|
|
7
|
-
x: placed.x,
|
|
8
|
-
y: placed.y,
|
|
9
|
-
z: placed.z,
|
|
10
|
-
block: placed.block.key
|
|
11
|
-
};
|
|
4
|
+
const { x, y, z, block, states } = placed;
|
|
5
|
+
if (isEmptyStates(states)) {
|
|
6
|
+
return { x, y, z, block: block.key };
|
|
12
7
|
}
|
|
13
8
|
return {
|
|
14
|
-
x
|
|
15
|
-
y
|
|
16
|
-
z
|
|
17
|
-
block:
|
|
18
|
-
states
|
|
9
|
+
x,
|
|
10
|
+
y,
|
|
11
|
+
z,
|
|
12
|
+
block: block.key,
|
|
13
|
+
states: normalizeStates(states)
|
|
19
14
|
};
|
|
20
15
|
}
|
|
21
16
|
function readStates(value) {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { getEntityTypeByName } from '../../data/entities.js';
|
|
2
|
+
import { EEntityKind } from '../../enums/entity-kind.enum.js';
|
|
3
|
+
import { createEmptyInventory, normalizeInventory } from '../entity-inventory.js';
|
|
4
|
+
function serializeStack(stack) {
|
|
5
|
+
return stack ? { item: stack.item, count: stack.count } : null;
|
|
6
|
+
}
|
|
7
|
+
function serializeInventory(inventory) {
|
|
8
|
+
return {
|
|
9
|
+
hotbar: inventory.hotbar.map(serializeStack),
|
|
10
|
+
inventory: inventory.inventory.map(serializeStack)
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function hasItems(inventory) {
|
|
14
|
+
return (inventory.hotbar.some(stack => stack !== null) ||
|
|
15
|
+
inventory.inventory.some(stack => stack !== null));
|
|
16
|
+
}
|
|
17
|
+
export function toSerializedEntity(entity) {
|
|
18
|
+
const payload = {
|
|
19
|
+
id: entity.id,
|
|
20
|
+
kind: entity.kind,
|
|
21
|
+
type: entity.type.key,
|
|
22
|
+
name: entity.name,
|
|
23
|
+
x: entity.x,
|
|
24
|
+
y: entity.y,
|
|
25
|
+
z: entity.z,
|
|
26
|
+
createdAt: entity.createdAt
|
|
27
|
+
};
|
|
28
|
+
if (hasItems(entity.inventory)) {
|
|
29
|
+
return { ...payload, inventory: serializeInventory(entity.inventory) };
|
|
30
|
+
}
|
|
31
|
+
return payload;
|
|
32
|
+
}
|
|
33
|
+
function isCoordinate(value) {
|
|
34
|
+
return typeof value === 'number' && Number.isInteger(value);
|
|
35
|
+
}
|
|
36
|
+
export function fromSerializedEntity(entry, dimension) {
|
|
37
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const record = entry;
|
|
41
|
+
const { id, type, name, x, y, z, createdAt, inventory } = record;
|
|
42
|
+
if (typeof id !== 'string' ||
|
|
43
|
+
id.trim().length === 0 ||
|
|
44
|
+
id.length > 128 ||
|
|
45
|
+
typeof type !== 'string' ||
|
|
46
|
+
typeof name !== 'string' ||
|
|
47
|
+
name.trim().length === 0 ||
|
|
48
|
+
name.length > 64 ||
|
|
49
|
+
!isCoordinate(x) ||
|
|
50
|
+
!isCoordinate(y) ||
|
|
51
|
+
!isCoordinate(z)) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const resolved = getEntityTypeByName(type.trim().toLowerCase());
|
|
55
|
+
if (!resolved) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
id: id.trim(),
|
|
60
|
+
kind: resolved.name === 'player' ? EEntityKind.Player : EEntityKind.Entity,
|
|
61
|
+
type: resolved,
|
|
62
|
+
name: name.trim(),
|
|
63
|
+
x,
|
|
64
|
+
y,
|
|
65
|
+
z,
|
|
66
|
+
dimension,
|
|
67
|
+
inventory: inventory === void 0
|
|
68
|
+
? createEmptyInventory()
|
|
69
|
+
: normalizeInventory(inventory),
|
|
70
|
+
createdAt: typeof createdAt === 'string' && createdAt.length > 0
|
|
71
|
+
? createdAt
|
|
72
|
+
: new Date().toISOString()
|
|
73
|
+
};
|
|
74
|
+
}
|