@lzimul/minecraft-mcp 1.0.3 → 1.0.5
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 -21
- package/README.md +126 -67
- package/SECURITY.md +38 -21
- package/build/config/server-info.js +5 -4
- package/build/config/storage.js +2 -1
- package/build/config/world-limits.js +6 -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/enums/move-direction.enum.js +9 -0
- package/build/index.js +4 -3
- 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 +24 -7
- 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 +638 -0
- package/build/tools/index.js +4 -0
- package/build/tools/result.js +3 -5
- package/build/tools/world-tools.js +526 -0
- package/build/utils/logger.js +53 -0
- package/build/world/block-spec.js +8 -6
- package/build/world/block-states.js +23 -4
- package/build/world/entity-inventory.js +101 -0
- package/build/world/item-spec.js +25 -0
- package/build/world/simulated-dimension.js +314 -14
- package/build/world/stores/file-world-store.js +84 -29
- package/build/world/stores/memory-world-store.js +18 -5
- package/build/world/stores/serialized-block.js +9 -14
- package/build/world/stores/serialized-entity.js +75 -0
- package/build/world/stores/serialized-snapshot.js +35 -0
- package/build/world/world.js +33 -1
- package/lib/app/server.d.ts.map +1 -1
- package/lib/config/dimensions.d.ts.map +1 -1
- package/lib/config/server-info.d.ts.map +1 -1
- package/lib/config/storage.d.ts.map +1 -1
- package/lib/config/world-limits.d.ts +5 -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/bounding-box.enum.d.ts.map +1 -1
- package/lib/enums/dimension-type.enum.d.ts.map +1 -1
- package/lib/enums/entity-kind.enum.d.ts +5 -0
- package/lib/enums/entity-kind.enum.d.ts.map +1 -0
- package/lib/enums/icon-theme.enum.d.ts.map +1 -1
- package/lib/enums/inventory-section.enum.d.ts +5 -0
- package/lib/enums/inventory-section.enum.d.ts.map +1 -0
- package/lib/enums/move-direction.enum.d.ts +9 -0
- package/lib/enums/move-direction.enum.d.ts.map +1 -0
- package/lib/interfaces/block.interface.d.ts.map +1 -1
- package/lib/interfaces/data-entry.interface.d.ts.map +1 -1
- package/lib/interfaces/dimension.interface.d.ts.map +1 -1
- 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/item.interface.d.ts.map +1 -1
- package/lib/interfaces/placed-block.interface.d.ts.map +1 -1
- package/lib/interfaces/world-store.interface.d.ts +8 -2
- package/lib/interfaces/world-store.interface.d.ts.map +1 -1
- package/lib/resources/block-resources.d.ts.map +1 -1
- package/lib/resources/data-resources.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/resources/item-resources.d.ts.map +1 -1
- package/lib/schemas/block-schema.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/schemas/item-schema.d.ts.map +1 -1
- 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/item-tools.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-spec.d.ts.map +1 -1
- 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 +54 -0
- package/lib/world/simulated-dimension.d.ts.map +1 -1
- package/lib/world/stores/file-world-store.d.ts +4 -4
- package/lib/world/stores/file-world-store.d.ts.map +1 -1
- package/lib/world/stores/index.d.ts.map +1 -1
- package/lib/world/stores/memory-world-store.d.ts +4 -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/stores/serialized-snapshot.d.ts +6 -0
- package/lib/world/stores/serialized-snapshot.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 +16 -12
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
let used = 0;
|
|
78
|
+
for (const stack of inventory.hotbar) {
|
|
79
|
+
if (stack !== null) {
|
|
80
|
+
used += 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const stack of inventory.inventory) {
|
|
84
|
+
if (stack !== null) {
|
|
85
|
+
used += 1;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return used;
|
|
89
|
+
}
|
|
90
|
+
export function sectionUsedSlots(inventory, section) {
|
|
91
|
+
const slots = section === EInventorySection.Hotbar
|
|
92
|
+
? inventory.hotbar
|
|
93
|
+
: inventory.inventory;
|
|
94
|
+
let used = 0;
|
|
95
|
+
for (const stack of slots) {
|
|
96
|
+
if (stack !== null) {
|
|
97
|
+
used += 1;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return used;
|
|
101
|
+
}
|
|
@@ -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,62 @@
|
|
|
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';
|
|
7
|
+
import { EMoveDirection } from '../enums/move-direction.enum.js';
|
|
3
8
|
import { toErrorMessage } from '../utils/error-message.js';
|
|
4
|
-
import {
|
|
9
|
+
import { logger } from '../utils/logger.js';
|
|
10
|
+
import { EMPTY_BLOCK_STATES, isEmptyStates, normalizeStates, sameStates } from './block-states.js';
|
|
11
|
+
import { createEmptyInventory, getInventorySlot, inventoryUsedSlots, isSlotInRange, sameItemStack, slotCount, withInventorySlot } from './entity-inventory.js';
|
|
5
12
|
const AIR = getBlockByName('air') ?? MINECRAFT_BLOCKS[0];
|
|
13
|
+
function directionDelta(direction) {
|
|
14
|
+
switch (direction) {
|
|
15
|
+
case EMoveDirection.Forward:
|
|
16
|
+
return { x: 0, y: 0, z: 1 };
|
|
17
|
+
case EMoveDirection.Back:
|
|
18
|
+
return { x: 0, y: 0, z: -1 };
|
|
19
|
+
case EMoveDirection.Left:
|
|
20
|
+
return { x: -1, y: 0, z: 0 };
|
|
21
|
+
case EMoveDirection.Right:
|
|
22
|
+
return { x: 1, y: 0, z: 0 };
|
|
23
|
+
case EMoveDirection.Up:
|
|
24
|
+
return { x: 0, y: 1, z: 0 };
|
|
25
|
+
case EMoveDirection.Down:
|
|
26
|
+
return { x: 0, y: -1, z: 0 };
|
|
27
|
+
default:
|
|
28
|
+
throw new Error(`Unknown movement direction "${direction}".`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
6
31
|
function coordinateKey(x, y, z) {
|
|
7
32
|
return `${x},${y},${z}`;
|
|
8
33
|
}
|
|
34
|
+
function playerEntityType() {
|
|
35
|
+
const type = getEntityTypeByName('minecraft:player');
|
|
36
|
+
if (!type) {
|
|
37
|
+
throw new Error('The entity registry is missing minecraft:player.');
|
|
38
|
+
}
|
|
39
|
+
return type;
|
|
40
|
+
}
|
|
41
|
+
function compareEntityIds(a, b) {
|
|
42
|
+
if (a.id === b.id) {
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
return a.id < b.id ? -1 : 1;
|
|
46
|
+
}
|
|
9
47
|
export class SimulatedDimension {
|
|
10
48
|
info;
|
|
11
49
|
blocks = new Map();
|
|
50
|
+
entities = new Map();
|
|
51
|
+
playersByName = new Map();
|
|
12
52
|
store;
|
|
13
53
|
writeDelayMs;
|
|
14
54
|
timer = null;
|
|
15
55
|
queue = Promise.resolve();
|
|
16
56
|
savePromise = null;
|
|
17
57
|
dirty = false;
|
|
58
|
+
sortedBlocksCache = null;
|
|
59
|
+
sortedEntitiesCache = null;
|
|
18
60
|
constructor(info, store, writeDelayMs = 25) {
|
|
19
61
|
this.info = info;
|
|
20
62
|
this.store = store;
|
|
@@ -23,25 +65,81 @@ export class SimulatedDimension {
|
|
|
23
65
|
get modifiedBlockCount() {
|
|
24
66
|
return this.blocks.size;
|
|
25
67
|
}
|
|
68
|
+
get entityCount() {
|
|
69
|
+
return this.entities.size;
|
|
70
|
+
}
|
|
71
|
+
get playerCount() {
|
|
72
|
+
return this.playersByName.size;
|
|
73
|
+
}
|
|
26
74
|
get placedBlocks() {
|
|
27
75
|
return [...this.blocks.values()];
|
|
28
76
|
}
|
|
77
|
+
get sortedPlacedBlocks() {
|
|
78
|
+
if (!this.sortedBlocksCache) {
|
|
79
|
+
this.sortedBlocksCache = this.placedBlocks.toSorted((a, b) => a.x - b.x || a.y - b.y || a.z - b.z);
|
|
80
|
+
}
|
|
81
|
+
return this.sortedBlocksCache;
|
|
82
|
+
}
|
|
83
|
+
get placedEntities() {
|
|
84
|
+
return [...this.entities.values()];
|
|
85
|
+
}
|
|
86
|
+
get sortedPlacedEntities() {
|
|
87
|
+
if (!this.sortedEntitiesCache) {
|
|
88
|
+
this.sortedEntitiesCache = this.placedEntities.toSorted(compareEntityIds);
|
|
89
|
+
}
|
|
90
|
+
return this.sortedEntitiesCache;
|
|
91
|
+
}
|
|
29
92
|
async load() {
|
|
93
|
+
if (this.timer !== null) {
|
|
94
|
+
clearTimeout(this.timer);
|
|
95
|
+
this.timer = null;
|
|
96
|
+
}
|
|
97
|
+
await this.queue;
|
|
98
|
+
this.dirty = false;
|
|
30
99
|
const stored = await this.store.load(this.info.type);
|
|
31
|
-
|
|
32
|
-
|
|
100
|
+
this.blocks.clear();
|
|
101
|
+
this.entities.clear();
|
|
102
|
+
this.playersByName.clear();
|
|
103
|
+
this.sortedBlocksCache = null;
|
|
104
|
+
this.sortedEntitiesCache = null;
|
|
105
|
+
let skippedBlocks = 0;
|
|
106
|
+
for (const placed of stored.blocks) {
|
|
33
107
|
const key = coordinateKey(placed.x, placed.y, placed.z);
|
|
34
|
-
const isNew = !this.blocks.has(key);
|
|
35
108
|
const outOfRange = !this.contains(placed.x, placed.y, placed.z);
|
|
36
|
-
const overLimit =
|
|
109
|
+
const overLimit = !this.blocks.has(key) &&
|
|
110
|
+
this.blocks.size >= WORLD_LIMITS.maxModifiedBlocks;
|
|
37
111
|
if (outOfRange || overLimit) {
|
|
38
|
-
|
|
112
|
+
skippedBlocks++;
|
|
39
113
|
continue;
|
|
40
114
|
}
|
|
41
115
|
this.blocks.set(key, placed);
|
|
42
116
|
}
|
|
43
|
-
|
|
44
|
-
|
|
117
|
+
let skippedEntities = 0;
|
|
118
|
+
for (const entity of stored.entities) {
|
|
119
|
+
const duplicateId = this.entities.has(entity.id);
|
|
120
|
+
const outOfRange = !this.contains(entity.x, entity.y, entity.z) ||
|
|
121
|
+
entity.dimension !== this.info.type;
|
|
122
|
+
const overLimit = !duplicateId &&
|
|
123
|
+
this.entities.size >= WORLD_LIMITS.maxEntitiesPerDimension;
|
|
124
|
+
if (duplicateId || outOfRange || overLimit) {
|
|
125
|
+
skippedEntities++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (entity.kind === EEntityKind.Player) {
|
|
129
|
+
const playerName = entity.name.trim().toLowerCase();
|
|
130
|
+
if (this.playersByName.has(playerName)) {
|
|
131
|
+
skippedEntities++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
this.playersByName.set(playerName, entity);
|
|
135
|
+
}
|
|
136
|
+
this.entities.set(entity.id, entity);
|
|
137
|
+
}
|
|
138
|
+
if (skippedBlocks > 0) {
|
|
139
|
+
logger.warn(`Skipped ${skippedBlocks} invalid block(s) while loading ${this.info.key}`);
|
|
140
|
+
}
|
|
141
|
+
if (skippedEntities > 0) {
|
|
142
|
+
logger.warn(`Skipped ${skippedEntities} invalid entity/player record(s) while loading ${this.info.key}`);
|
|
45
143
|
}
|
|
46
144
|
}
|
|
47
145
|
contains(x, y, z) {
|
|
@@ -56,12 +154,203 @@ export class SimulatedDimension {
|
|
|
56
154
|
return (this.blocks.has(coordinateKey(x, y, z)) ||
|
|
57
155
|
this.blocks.size < WORLD_LIMITS.maxModifiedBlocks);
|
|
58
156
|
}
|
|
157
|
+
canAddEntity() {
|
|
158
|
+
return this.entities.size < WORLD_LIMITS.maxEntitiesPerDimension;
|
|
159
|
+
}
|
|
59
160
|
getBlock(x, y, z) {
|
|
60
161
|
return this.blocks.get(coordinateKey(x, y, z))?.block ?? AIR;
|
|
61
162
|
}
|
|
62
163
|
getStates(x, y, z) {
|
|
63
164
|
return (this.blocks.get(coordinateKey(x, y, z))?.states ?? EMPTY_BLOCK_STATES);
|
|
64
165
|
}
|
|
166
|
+
getBlockState(x, y, z) {
|
|
167
|
+
const placed = this.blocks.get(coordinateKey(x, y, z));
|
|
168
|
+
return placed
|
|
169
|
+
? { block: placed.block, states: placed.states }
|
|
170
|
+
: { block: AIR, states: EMPTY_BLOCK_STATES };
|
|
171
|
+
}
|
|
172
|
+
getEntity(id) {
|
|
173
|
+
return this.entities.get(id);
|
|
174
|
+
}
|
|
175
|
+
findPlayer(name) {
|
|
176
|
+
return this.playersByName.get(name.trim().toLowerCase());
|
|
177
|
+
}
|
|
178
|
+
removeDuplicatePlayers(claimedNames) {
|
|
179
|
+
let removed = 0;
|
|
180
|
+
for (const entity of this.entities.values()) {
|
|
181
|
+
if (entity.kind !== EEntityKind.Player) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const name = entity.name.trim().toLowerCase();
|
|
185
|
+
if (claimedNames.has(name)) {
|
|
186
|
+
this.entities.delete(entity.id);
|
|
187
|
+
this.playersByName.delete(name);
|
|
188
|
+
removed++;
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
claimedNames.add(name);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (removed > 0) {
|
|
195
|
+
this.sortedEntitiesCache = null;
|
|
196
|
+
}
|
|
197
|
+
return removed;
|
|
198
|
+
}
|
|
199
|
+
createEntity(type, name, x, y, z) {
|
|
200
|
+
if (type.name === 'player') {
|
|
201
|
+
throw new Error('Use createPlayer to create a player; createEntity only creates non-player entities.');
|
|
202
|
+
}
|
|
203
|
+
return this.addEntity(EEntityKind.Entity, type, name, x, y, z);
|
|
204
|
+
}
|
|
205
|
+
createPlayer(name, x, y, z) {
|
|
206
|
+
if (this.findPlayer(name)) {
|
|
207
|
+
throw new Error(`A player named "${name}" already exists in ${this.info.displayName}. Player names are case-insensitive.`);
|
|
208
|
+
}
|
|
209
|
+
return this.addEntity(EEntityKind.Player, playerEntityType(), name, x, y, z);
|
|
210
|
+
}
|
|
211
|
+
addEntity(kind, type, name, x, y, z) {
|
|
212
|
+
if (!this.canAddEntity()) {
|
|
213
|
+
throw new RangeError(`The ${this.info.displayName} world already holds the maximum of ${WORLD_LIMITS.maxEntitiesPerDimension} entities.`);
|
|
214
|
+
}
|
|
215
|
+
const entity = {
|
|
216
|
+
id: randomUUID(),
|
|
217
|
+
kind,
|
|
218
|
+
type,
|
|
219
|
+
name,
|
|
220
|
+
x,
|
|
221
|
+
y,
|
|
222
|
+
z,
|
|
223
|
+
dimension: this.info.type,
|
|
224
|
+
inventory: createEmptyInventory(),
|
|
225
|
+
createdAt: new Date().toISOString()
|
|
226
|
+
};
|
|
227
|
+
this.entities.set(entity.id, entity);
|
|
228
|
+
if (kind === EEntityKind.Player) {
|
|
229
|
+
this.playersByName.set(entity.name.trim().toLowerCase(), entity);
|
|
230
|
+
}
|
|
231
|
+
this.sortedEntitiesCache = null;
|
|
232
|
+
this.markDirty();
|
|
233
|
+
return entity;
|
|
234
|
+
}
|
|
235
|
+
setInventorySlot(id, section, slot, stack) {
|
|
236
|
+
const entity = this.entities.get(id);
|
|
237
|
+
if (!entity) {
|
|
238
|
+
throw new RangeError(`Unknown entity "${id}" in ${this.info.displayName}.`);
|
|
239
|
+
}
|
|
240
|
+
if (!isSlotInRange(section, slot)) {
|
|
241
|
+
throw new RangeError(`Slot ${slot} is out of range for the ${section} (0-${slotCount(section) - 1}).`);
|
|
242
|
+
}
|
|
243
|
+
const previous = getInventorySlot(entity.inventory, section, slot);
|
|
244
|
+
if (sameItemStack(previous, stack)) {
|
|
245
|
+
return { previous, entity };
|
|
246
|
+
}
|
|
247
|
+
const updated = {
|
|
248
|
+
...entity,
|
|
249
|
+
inventory: withInventorySlot(entity.inventory, section, slot, stack)
|
|
250
|
+
};
|
|
251
|
+
this.entities.set(id, updated);
|
|
252
|
+
if (updated.kind === EEntityKind.Player) {
|
|
253
|
+
this.playersByName.set(updated.name.trim().toLowerCase(), updated);
|
|
254
|
+
}
|
|
255
|
+
this.sortedEntitiesCache = null;
|
|
256
|
+
this.markDirty();
|
|
257
|
+
return { previous, entity: updated };
|
|
258
|
+
}
|
|
259
|
+
deleteEntity(id) {
|
|
260
|
+
const entity = this.entities.get(id);
|
|
261
|
+
if (!entity) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
this.entities.delete(id);
|
|
265
|
+
if (entity.kind === EEntityKind.Player) {
|
|
266
|
+
this.playersByName.delete(entity.name.trim().toLowerCase());
|
|
267
|
+
}
|
|
268
|
+
this.sortedEntitiesCache = null;
|
|
269
|
+
this.markDirty();
|
|
270
|
+
return entity;
|
|
271
|
+
}
|
|
272
|
+
clearInventory(id) {
|
|
273
|
+
const entity = this.entities.get(id);
|
|
274
|
+
if (!entity) {
|
|
275
|
+
throw new RangeError(`Unknown entity "${id}" in ${this.info.displayName}.`);
|
|
276
|
+
}
|
|
277
|
+
const clearedSlots = inventoryUsedSlots(entity.inventory);
|
|
278
|
+
if (clearedSlots === 0) {
|
|
279
|
+
return { clearedSlots, entity };
|
|
280
|
+
}
|
|
281
|
+
const updated = {
|
|
282
|
+
...entity,
|
|
283
|
+
inventory: createEmptyInventory()
|
|
284
|
+
};
|
|
285
|
+
this.entities.set(id, updated);
|
|
286
|
+
if (updated.kind === EEntityKind.Player) {
|
|
287
|
+
this.playersByName.set(updated.name.trim().toLowerCase(), updated);
|
|
288
|
+
}
|
|
289
|
+
this.sortedEntitiesCache = null;
|
|
290
|
+
this.markDirty();
|
|
291
|
+
return { clearedSlots, entity: updated };
|
|
292
|
+
}
|
|
293
|
+
swapInventorySlots(id, fromSection, fromSlot, toSection, toSlot) {
|
|
294
|
+
const entity = this.entities.get(id);
|
|
295
|
+
if (!entity) {
|
|
296
|
+
throw new RangeError(`Unknown entity "${id}" in ${this.info.displayName}.`);
|
|
297
|
+
}
|
|
298
|
+
if (!isSlotInRange(fromSection, fromSlot)) {
|
|
299
|
+
throw new RangeError(`Slot ${fromSlot} is out of range for the ${fromSection} (0-${slotCount(fromSection) - 1}).`);
|
|
300
|
+
}
|
|
301
|
+
if (!isSlotInRange(toSection, toSlot)) {
|
|
302
|
+
throw new RangeError(`Slot ${toSlot} is out of range for the ${toSection} (0-${slotCount(toSection) - 1}).`);
|
|
303
|
+
}
|
|
304
|
+
const previousFrom = getInventorySlot(entity.inventory, fromSection, fromSlot);
|
|
305
|
+
const previousTo = getInventorySlot(entity.inventory, toSection, toSlot);
|
|
306
|
+
const sameSlot = fromSection === toSection && fromSlot === toSlot;
|
|
307
|
+
if (sameSlot || sameItemStack(previousFrom, previousTo)) {
|
|
308
|
+
return { previousFrom, previousTo, entity };
|
|
309
|
+
}
|
|
310
|
+
let inventory = withInventorySlot(entity.inventory, fromSection, fromSlot, previousTo);
|
|
311
|
+
inventory = withInventorySlot(inventory, toSection, toSlot, previousFrom);
|
|
312
|
+
const updated = { ...entity, inventory };
|
|
313
|
+
this.entities.set(id, updated);
|
|
314
|
+
if (updated.kind === EEntityKind.Player) {
|
|
315
|
+
this.playersByName.set(updated.name.trim().toLowerCase(), updated);
|
|
316
|
+
}
|
|
317
|
+
this.sortedEntitiesCache = null;
|
|
318
|
+
this.markDirty();
|
|
319
|
+
return { previousFrom, previousTo, entity: updated };
|
|
320
|
+
}
|
|
321
|
+
moveEntity(id, direction) {
|
|
322
|
+
const entity = this.entities.get(id);
|
|
323
|
+
if (!entity) {
|
|
324
|
+
throw new RangeError(`Unknown entity "${id}" in ${this.info.displayName}.`);
|
|
325
|
+
}
|
|
326
|
+
const delta = directionDelta(direction);
|
|
327
|
+
return this.setEntityPosition(id, entity.x + delta.x, entity.y + delta.y, entity.z + delta.z);
|
|
328
|
+
}
|
|
329
|
+
setEntityPosition(id, x, y, z) {
|
|
330
|
+
const entity = this.entities.get(id);
|
|
331
|
+
if (!entity) {
|
|
332
|
+
throw new RangeError(`Unknown entity "${id}" in ${this.info.displayName}.`);
|
|
333
|
+
}
|
|
334
|
+
if (!this.contains(x, y, z)) {
|
|
335
|
+
throw new RangeError(`Position (${x}, ${y}, ${z}) is out of range in ${this.info.displayName} (Y: ${this.info.minY} to ${this.info.maxY - 1}).`);
|
|
336
|
+
}
|
|
337
|
+
const previous = {
|
|
338
|
+
x: entity.x,
|
|
339
|
+
y: entity.y,
|
|
340
|
+
z: entity.z
|
|
341
|
+
};
|
|
342
|
+
if (entity.x === x && entity.y === y && entity.z === z) {
|
|
343
|
+
return { previous, entity };
|
|
344
|
+
}
|
|
345
|
+
const updated = { ...entity, x, y, z };
|
|
346
|
+
this.entities.set(id, updated);
|
|
347
|
+
if (updated.kind === EEntityKind.Player) {
|
|
348
|
+
this.playersByName.set(updated.name.trim().toLowerCase(), updated);
|
|
349
|
+
}
|
|
350
|
+
this.sortedEntitiesCache = null;
|
|
351
|
+
this.markDirty();
|
|
352
|
+
return { previous, entity: updated };
|
|
353
|
+
}
|
|
65
354
|
setBlock(x, y, z, block, states = EMPTY_BLOCK_STATES) {
|
|
66
355
|
const key = coordinateKey(x, y, z);
|
|
67
356
|
const existing = this.blocks.get(key);
|
|
@@ -71,7 +360,7 @@ export class SimulatedDimension {
|
|
|
71
360
|
sameStates(existing?.states ?? EMPTY_BLOCK_STATES, normalized)) {
|
|
72
361
|
return previous;
|
|
73
362
|
}
|
|
74
|
-
const isRemoval = block.key === AIR.key &&
|
|
363
|
+
const isRemoval = block.key === AIR.key && isEmptyStates(normalized);
|
|
75
364
|
if (!existing &&
|
|
76
365
|
!isRemoval &&
|
|
77
366
|
this.blocks.size >= WORLD_LIMITS.maxModifiedBlocks) {
|
|
@@ -83,10 +372,14 @@ export class SimulatedDimension {
|
|
|
83
372
|
else {
|
|
84
373
|
this.blocks.set(key, { x, y, z, block, states: normalized });
|
|
85
374
|
}
|
|
86
|
-
this.
|
|
87
|
-
this.
|
|
375
|
+
this.sortedBlocksCache = null;
|
|
376
|
+
this.markDirty();
|
|
88
377
|
return previous;
|
|
89
378
|
}
|
|
379
|
+
async readRaw() {
|
|
380
|
+
await this.flush();
|
|
381
|
+
return this.store.readRaw(this.info.type);
|
|
382
|
+
}
|
|
90
383
|
async flush() {
|
|
91
384
|
if (this.timer !== null) {
|
|
92
385
|
clearTimeout(this.timer);
|
|
@@ -100,7 +393,10 @@ export class SimulatedDimension {
|
|
|
100
393
|
return;
|
|
101
394
|
}
|
|
102
395
|
this.dirty = false;
|
|
103
|
-
const snapshot =
|
|
396
|
+
const snapshot = {
|
|
397
|
+
blocks: this.sortedPlacedBlocks,
|
|
398
|
+
entities: this.sortedPlacedEntities
|
|
399
|
+
};
|
|
104
400
|
const run = this.queue.then(async () => {
|
|
105
401
|
await this.store.save(this.info.type, snapshot);
|
|
106
402
|
});
|
|
@@ -115,14 +411,18 @@ export class SimulatedDimension {
|
|
|
115
411
|
this.savePromise = null;
|
|
116
412
|
}
|
|
117
413
|
}
|
|
414
|
+
markDirty() {
|
|
415
|
+
this.dirty = true;
|
|
416
|
+
this.scheduleFlush();
|
|
417
|
+
}
|
|
118
418
|
scheduleFlush() {
|
|
119
419
|
if (this.timer !== null) {
|
|
120
420
|
return;
|
|
121
421
|
}
|
|
122
422
|
this.timer = setTimeout(() => {
|
|
123
423
|
this.timer = null;
|
|
124
|
-
void this.flush().catch(
|
|
125
|
-
|
|
424
|
+
void this.flush().catch(error => {
|
|
425
|
+
logger.error(`Failed to persist ${this.info.key}: ${toErrorMessage(error)}`);
|
|
126
426
|
});
|
|
127
427
|
}, this.writeDelayMs);
|
|
128
428
|
}
|
|
@@ -3,11 +3,15 @@ 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 {
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
import { logger } from '../../utils/logger.js';
|
|
7
|
+
import { fromSerialized } from './serialized-block.js';
|
|
8
|
+
import { fromSerializedEntity } from './serialized-entity.js';
|
|
9
|
+
import { MAX_SNAPSHOT_BYTES, serializeSnapshot } from './serialized-snapshot.js';
|
|
9
10
|
function warn(message) {
|
|
10
|
-
|
|
11
|
+
logger.warn(message);
|
|
12
|
+
}
|
|
13
|
+
function emptySnapshot() {
|
|
14
|
+
return { blocks: [], entities: [] };
|
|
11
15
|
}
|
|
12
16
|
export class FileWorldStore {
|
|
13
17
|
directory;
|
|
@@ -31,13 +35,13 @@ export class FileWorldStore {
|
|
|
31
35
|
const info = await stat(file);
|
|
32
36
|
if (info.size > MAX_SNAPSHOT_BYTES) {
|
|
33
37
|
await this.quarantine(file, `snapshot is larger than ${MAX_SNAPSHOT_BYTES} bytes`);
|
|
34
|
-
return
|
|
38
|
+
return emptySnapshot();
|
|
35
39
|
}
|
|
36
40
|
raw = await readFile(file, 'utf8');
|
|
37
41
|
}
|
|
38
42
|
catch (error) {
|
|
39
43
|
if (error.code === 'ENOENT') {
|
|
40
|
-
return
|
|
44
|
+
return emptySnapshot();
|
|
41
45
|
}
|
|
42
46
|
throw new Error(`Cannot read world snapshot ${file}: ${toErrorMessage(error)}`, { cause: error });
|
|
43
47
|
}
|
|
@@ -47,42 +51,93 @@ export class FileWorldStore {
|
|
|
47
51
|
}
|
|
48
52
|
catch {
|
|
49
53
|
await this.quarantine(file, 'invalid JSON');
|
|
50
|
-
return
|
|
54
|
+
return emptySnapshot();
|
|
51
55
|
}
|
|
52
|
-
if (!parsed ||
|
|
56
|
+
if (!parsed ||
|
|
57
|
+
typeof parsed !== 'object' ||
|
|
58
|
+
Array.isArray(parsed) ||
|
|
59
|
+
!Array.isArray(parsed.blocks)) {
|
|
53
60
|
await this.quarantine(file, 'missing blocks array');
|
|
54
|
-
return
|
|
61
|
+
return emptySnapshot();
|
|
62
|
+
}
|
|
63
|
+
const stored = parsed;
|
|
64
|
+
if (stored.blocks.length > WORLD_LIMITS.maxModifiedBlocks) {
|
|
65
|
+
await this.quarantine(file, `${stored.blocks.length} blocks exceed the limit of ${WORLD_LIMITS.maxModifiedBlocks}`);
|
|
66
|
+
return emptySnapshot();
|
|
55
67
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
68
|
+
let serializedEntities = [];
|
|
69
|
+
if (typeof stored.entities !== 'undefined') {
|
|
70
|
+
if (!Array.isArray(stored.entities)) {
|
|
71
|
+
await this.quarantine(file, 'invalid entities array');
|
|
72
|
+
return emptySnapshot();
|
|
73
|
+
}
|
|
74
|
+
if (stored.entities.length > WORLD_LIMITS.maxEntitiesPerDimension) {
|
|
75
|
+
await this.quarantine(file, `${stored.entities.length} entities exceed the limit of ${WORLD_LIMITS.maxEntitiesPerDimension}`);
|
|
76
|
+
return emptySnapshot();
|
|
77
|
+
}
|
|
78
|
+
serializedEntities = stored.entities;
|
|
59
79
|
}
|
|
60
|
-
|
|
80
|
+
const blocks = [];
|
|
81
|
+
for (const entry of stored.blocks) {
|
|
61
82
|
const block = fromSerialized(entry);
|
|
62
83
|
if (block) {
|
|
63
|
-
|
|
84
|
+
blocks.push(block);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
warn(`Skipped malformed block entry in ${file}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const entities = [];
|
|
91
|
+
for (const entry of serializedEntities) {
|
|
92
|
+
try {
|
|
93
|
+
const entity = fromSerializedEntity(entry, type);
|
|
94
|
+
if (entity) {
|
|
95
|
+
entities.push(entity);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
}
|
|
101
|
+
warn(`Skipped malformed entity entry in ${file}`);
|
|
102
|
+
}
|
|
103
|
+
return { blocks, entities };
|
|
104
|
+
}
|
|
105
|
+
async readRaw(type) {
|
|
106
|
+
const file = this.fileFor(type);
|
|
107
|
+
try {
|
|
108
|
+
const info = await stat(file);
|
|
109
|
+
if (info.size > MAX_SNAPSHOT_BYTES) {
|
|
110
|
+
throw new RangeError(`World snapshot ${file} is larger than ${MAX_SNAPSHOT_BYTES} bytes`);
|
|
111
|
+
}
|
|
112
|
+
return await readFile(file, 'utf8');
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (error.code === 'ENOENT') {
|
|
116
|
+
return serializeSnapshot(type, emptySnapshot());
|
|
117
|
+
}
|
|
118
|
+
if (error instanceof RangeError) {
|
|
119
|
+
throw error;
|
|
64
120
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
});
|
|
121
|
+
throw new Error(`Cannot read world snapshot ${file}: ${toErrorMessage(error)}`, { cause: error });
|
|
122
|
+
}
|
|
68
123
|
}
|
|
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}`);
|
|
124
|
+
async save(type, snapshot) {
|
|
125
|
+
if (snapshot.blocks.length > WORLD_LIMITS.maxModifiedBlocks) {
|
|
126
|
+
throw new RangeError(`World snapshot has ${snapshot.blocks.length} blocks, above the limit of ${WORLD_LIMITS.maxModifiedBlocks}`);
|
|
127
|
+
}
|
|
128
|
+
if (snapshot.entities.length > WORLD_LIMITS.maxEntitiesPerDimension) {
|
|
129
|
+
throw new RangeError(`World snapshot has ${snapshot.entities.length} entities, above the limit of ${WORLD_LIMITS.maxEntitiesPerDimension}`);
|
|
72
130
|
}
|
|
73
131
|
const file = this.fileFor(type);
|
|
132
|
+
const text = serializeSnapshot(type, snapshot);
|
|
133
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
134
|
+
if (bytes > MAX_SNAPSHOT_BYTES) {
|
|
135
|
+
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.`);
|
|
136
|
+
}
|
|
74
137
|
try {
|
|
75
138
|
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
139
|
const temporary = `${file}.tmp`;
|
|
85
|
-
await writeFile(temporary,
|
|
140
|
+
await writeFile(temporary, text, 'utf8');
|
|
86
141
|
await rename(temporary, file);
|
|
87
142
|
}
|
|
88
143
|
catch (error) {
|