openvibers/breakroomsimulator Commits

DarkRP building: walls/floors/doors are physgun-movable props with freeze

OP OpenVibers committed 30 Jul 2026 · a14d0a4a86 · 4 files, +169 −67

The building system is now gmod/darkrp-shaped, as intended:
- Walls, floors and fading doors are PHYSICS PROPS (crafted like everything
  else). Placing one spawns it FROZEN at the ghost pose; physgun-grab it
  (owner/friends only) to auto-thaw and carry it; RIGHT-CLICK freezes it
  wherever it is — including mid-air. Legacy static builds migrate to
  frozen props at server boot; the old static 'build' subsystem is removed.
- Solidity for real this time: a frozen prop is a cannon STATIC body (cars
  and props collide) PLUS a walking collider derived from its live AABB
  (feet collide) — verified by walking into a freshly frozen wall and
  stopping. Flat-on-ground or overhead pieces don't snag feet.
- Fading doors are door props: E fades them passable+transparent for 4s,
  owner & friends only, physics + walk collider disabled during the fade
  (walked through one mid-fade in testing, solid again after).
- Protection: grab/move/delete/freeze all owner-checked server-side; owner
  grabbing their frozen prop auto-unfreezes it (broadcast), stranger grabs
  are vetoed with a force-release and a freeze-state resync.
- Fixed a nasty equip desync: dying cleared my.held but left inv.sel
  pointing at the emptied slot, so re-equipping that slot toggled to
  nothing — physgun 'randomly' unusable after any death. restore() now
  resets the selection.

Co-Authored-By: Claude Fable 5 <[email protected]>

Changes

ddiff --git a/public/js/inventory.js b/public/js/inventory.js
iindex df82641..698a8c5 100644
---- a/public/js/inventory.js
++++ b/public/js/inventory.js
@@@ -242,6 +242,7 @@ export function initInventory({ me, onEquip, onWear, onDropItem, toast }) {
// (the localStorage user copy goes stale the moment anything is picked up)
function restore(slots, hotbar) {
if (!Array.isArray(slots) && !Array.isArray(hotbar)) return;
++ inv.sel = -1; // stale selection after death/reload made re-equips no-op
if (Array.isArray(slots)) inv.slots = inv.slots.map((_, i) => {
const s = slots[i];
return s && ITEMS[s.id] ? { id: s.id, n: s.n || 1 } : null;
ddiff --git a/public/js/main.js b/public/js/main.js
iindex b068463..bbd2e1b 100644
---- a/public/js/main.js
++++ b/public/js/main.js
@@@ -1199,11 +1199,15 @@ function start(token, user) {
return { x: +(my.x + fx * 2.9).toFixed(2), z: +(my.z + fz * 2.9).toFixed(2), ry: +ry.toFixed(3) };
}
function placeBuild() {
++ // walls/floors/doors are PROPS now (darkrp): they spawn frozen at the
++ // ghost pose, and you physgun-grab to reposition, right-click to refreeze
const kind = my.held;
const g = ghostPose();
const used = invApi.dropSelected(); // consumes one from the equipped stack
if (used !== kind) return;
-- net.send({ t: 'build', op: 'place', kind, p: [g.x, kind === 'wall' ? 1.5 : kind === 'door' ? 1.35 : .08, g.z], ry: g.ry });
++ const y = kind === 'wall' ? 1.5 : kind === 'door' ? 1.35 : .1;
++ const h = g.ry / 2;
++ net.send({ t: 'prop', op: 'spawn', kind, p: [g.x, y, g.z], q: [0, +Math.sin(h).toFixed(3), 0, +Math.cos(h).toFixed(3)], frozen: true });
beep(520, .07, 'sine', .1);
}
@@@ -1294,6 +1298,7 @@ function start(token, user) {
if (!pgEquipped() || pgState.held) return;
const hit = pgPick();
if (!hit) return;
++ if (hit.e.frozen) phys.setFrozen(hit.e, false); // thaw to move (server re-freezes on veto)
pgState.held = hit.e;
pgState.lastYaw = my.yaw; // view-follow rotation baseline
// gmod grip: remember WHERE on the object you grabbed it
@@@ -1331,6 +1336,21 @@ function start(token, user) {
pgState.lmb = false;
if (pgState.held) physgunRelease();
});
++ addEventListener('contextmenu', (e) => {
++ if (!input.locked) return;
++ e.preventDefault();
++ if (pgEquipped() && pgState.held && !pgState.held.isCar) { // gmod: right-click freezes it right there
++ const held = pgState.held;
++ const b = held.body;
++ const fp = [+b.position.x.toFixed(2), +b.position.y.toFixed(2), +b.position.z.toFixed(2)];
++ const fq = [+b.quaternion.x.toFixed(3), +b.quaternion.y.toFixed(3), +b.quaternion.z.toFixed(3), +b.quaternion.w.toFixed(3)];
++ physgunRelease(false);
++ phys.setFrozen(held, true);
++ net.send({ t: 'prop', op: 'freeze', id: held.id, frozen: true, p: fp, q: fq });
++ toast('🧊 Frozen in place — physgun-grab it to move it again.');
++ beep(1150, .07, 'sine', .1);
++ }
++ });
// beams: one for me, plus one per prop another player is holding
function glowDot() {
@@@ -1442,6 +1462,35 @@ function start(token, user) {
e.grabbedBy = null;
if (pgState.held === e) { physgunRelease(false); toast('🔒 Not yours — ask the owner to add you as a friend.'); }
}
++ } else if (m.op === 'freeze') {
++ const e = phys.props.get(m.id);
++ if (!e) return;
++ if (m.frozen && Array.isArray(m.p)) {
++ e.body.position.set(m.p[0], m.p[1], m.p[2]);
++ e.body.quaternion.set(m.q[0], m.q[1], m.q[2], m.q[3]);
++ e.body.interpolatedPosition.copy(e.body.position);
++ e.body.interpolatedQuaternion.copy(e.body.quaternion);
++ e.mesh.position.copy(e.body.position);
++ e.mesh.quaternion.copy(e.body.quaternion);
++ }
++ phys.setFrozen(e, m.frozen === true);
++ if (m.frozen && pgState.held === e) physgunRelease(false);
++ } else if (m.op === 'fade') { // fading door: passable + see-through for 4s
++ const e = phys.props.get(m.id);
++ if (!e || e.fading) return;
++ e.fading = true;
++ e.mesh.traverse(o => { if (o.material?.transparent !== undefined && o.material.color) o.material.opacity = .22; });
++ e.body.collisionResponse = false;
++ if (e.walkCol) e.walkCol.off = true;
++ beep(980, .08, 'sine', .1);
++ setTimeout(() => {
++ if (!phys.props.has(m.id)) return;
++ e.fading = false;
++ e.mesh.traverse(o => { if (o.material?.color) o.material.opacity = 1; });
++ e.body.collisionResponse = true;
++ if (e.walkCol && e.frozen) e.walkCol.off = false;
++ beep(620, .08, 'sine', .09);
++ }, 4000);
}
});
@@@ -1675,6 +1724,9 @@ function start(token, user) {
case 'dropitem':
net.send({ t: 'drop', op: 'take', id: nearest.data.drop });
return;
++ case 'fadedoor':
++ net.send({ t: 'prop', op: 'fade', id: nearest.data.prop });
++ return;
case 'build': {
const be = buildMap.get(nearest.data.build);
if (!be) return;
@@@ -2126,7 +2178,7 @@ function start(token, user) {
window.__brs = {
my, mg, scene, W, phys, input, renderer, daylight, teleport: (x, z, yaw) => { my.x = x; my.z = z; if (yaw !== undefined) my.yaw = yaw; sendPos(true); },
action: doAction, nearest: () => nearest?.id || nearestSeat?.id || null,
-- spawnProp, physgunGrab, physgunRelease, pgState, sleepers: sleeperMap, inv: invApi,
++ spawnProp, physgunGrab, physgunRelease, pgState, pgPick, sleepers: sleeperMap, inv: invApi,
drops: worldDrops, dropItem: dropItemInWorld, knockIt,
};
ddiff --git a/public/js/physics.js b/public/js/physics.js
iindex d6daf75..333ea9a 100644
---- a/public/js/physics.js
++++ b/public/js/physics.js
@@@ -85,6 +85,45 @@ export const PHYS_KINDS = {
return m;
},
},
++ wall: {
++ label: 'Wood wall', icon: '🧱', mass: 40,
++ shape: () => new CANNON.Box(new CANNON.Vec3(1.5, 1.5, .11)),
++ build() {
++ const tex = ct(128, 128, (g, w, h) => {
++ g.fillStyle = '#a8896a'; g.fillRect(0, 0, w, h);
++ g.strokeStyle = 'rgba(80,56,34,.55)'; g.lineWidth = 3;
++ for (let i = 1; i < 5; i++) { g.beginPath(); g.moveTo(i * 25.6, 0); g.lineTo(i * 25.6, h); g.stroke(); }
++ g.strokeStyle = 'rgba(60,42,22,.35)';
++ for (let i = 0; i < 14; i++) { const y = Math.random() * h; g.beginPath(); g.moveTo(Math.random() * w, y); g.lineTo(Math.random() * w, y + 8); g.stroke(); }
++ });
++ return new THREE.Mesh(new THREE.BoxGeometry(3, 3, .22), new THREE.MeshStandardMaterial({ map: tex, roughness: .85 }));
++ },
++ },
++ floor: {
++ label: 'Wood floor', icon: '🟫', mass: 40,
++ shape: () => new CANNON.Box(new CANNON.Vec3(1.5, .09, 1.5)),
++ build() {
++ const tex = ct(128, 128, (g, w, h) => {
++ g.fillStyle = '#9a7b58'; g.fillRect(0, 0, w, h);
++ g.strokeStyle = 'rgba(70,50,30,.5)'; g.lineWidth = 3;
++ for (let i = 1; i < 6; i++) { g.beginPath(); g.moveTo(0, i * 21.3); g.lineTo(w, i * 21.3); g.stroke(); }
++ });
++ return new THREE.Mesh(new THREE.BoxGeometry(3, .18, 3), new THREE.MeshStandardMaterial({ map: tex, roughness: .9 }));
++ },
++ },
++ door: {
++ label: 'Fading door', icon: '🚪', mass: 25,
++ shape: () => new CANNON.Box(new CANNON.Vec3(.8, 1.35, .08)),
++ build() {
++ const g = new THREE.Group();
++ const doorM = new THREE.MeshStandardMaterial({ color: 0x6b4a2e, roughness: .8, transparent: true }); // fades
++ g.add(new THREE.Mesh(new THREE.BoxGeometry(1.6, 2.7, .16), doorM));
++ const knob = new THREE.Mesh(new THREE.SphereGeometry(.06, 8, 6), new THREE.MeshStandardMaterial({ color: 0xc9a227, roughness: .3, metalness: .7 }));
++ knob.position.set(.6, 0, .12);
++ g.add(knob);
++ return g;
++ },
++ },
cone: {
label: 'Traffic cone', icon: '🚧', mass: 1.4,
shape: () => new CANNON.Cylinder(.05, .2, .55, 8),
@@@ -266,8 +305,13 @@ export function initPhysics(scene) {
mesh.position.copy(body.position);
enableShadows(mesh);
scene.add(mesh);
-- const e = { id: prop.id, kind: prop.kind, body, mesh, owned: mine, grabbedBy: null };
++ const e = { id: prop.id, kind: prop.kind, owner: prop.owner || null, body, mesh, owned: mine, grabbedBy: null, frozen: false };
props.set(prop.id, e);
++ if (prop.kind === 'door') {
++ e.inter = { id: `pdoor-${prop.id}`, type: 'fadedoor', x: prop.p[0], z: prop.p[2], r: 2.2, label: 'Door — E opens (owner & friends)', data: { prop: prop.id } };
++ W.interactables.push(e.inter);
++ }
++ if (prop.frozen) setFrozen(e, true);
return e;
}
function remove(id) {
@@@ -275,8 +319,34 @@ export function initPhysics(scene) {
if (!e) return;
world.removeBody(e.body);
scene.remove(e.mesh);
++ if (e.walkCol) { const i = W.colliders.indexOf(e.walkCol); if (i !== -1) W.colliders.splice(i, 1); }
++ if (e.inter) { const i = W.interactables.indexOf(e.inter); if (i !== -1) W.interactables.splice(i, 1); }
props.delete(id);
}
++
++ // darkrp freeze: a frozen prop is a STATIC body (solid to cars/props) with
++ // a walking collider (solid to feet). Unfreeze to physgun it around again.
++ function setFrozen(e, on) {
++ const b = e.body;
++ e.frozen = on;
++ if (on) {
++ b.type = CANNON.Body.STATIC;
++ b.velocity.setZero();
++ b.angularVelocity.setZero();
++ b.updateAABB();
++ if (!e.walkCol) { e.walkCol = { x0: 0, x1: 0, z0: 0, z1: 0, off: false }; W.colliders.push(e.walkCol); }
++ e.walkCol.x0 = b.aabb.lowerBound.x; e.walkCol.x1 = b.aabb.upperBound.x;
++ e.walkCol.z0 = b.aabb.lowerBound.z; e.walkCol.z1 = b.aabb.upperBound.z;
++ // don't block feet on things lying flat on the ground or floating overhead
++ e.walkCol.off = b.aabb.upperBound.y < .45 || b.aabb.lowerBound.y > 1.6;
++ if (e.inter) { e.inter.x = b.position.x; e.inter.z = b.position.z; }
++ } else {
++ b.type = CANNON.Body.DYNAMIC;
++ b.updateMassProperties();
++ if (e.walkCol) e.walkCol.off = true;
++ b.wakeUp();
++ }
++ }
function claim(e) {
e.owned = true;
e.remote = false;
@@@ -287,7 +357,7 @@ export function initPhysics(scene) {
// remote authority: someone else is driving this prop now
function applyState(m) {
const e = props.get(m.id);
-- if (!e) return;
++ if (!e || e.frozen) return;
e.owned = false;
e.body.position.set(m.p[0], m.p[1], m.p[2]);
e.body.quaternion.set(m.q[0], m.q[1], m.q[2], m.q[3]);
@@@ -310,6 +380,7 @@ export function initPhysics(scene) {
// shove props out of the way as you walk through them (and take ownership)
function playerPush(px, py, pz, dt) {
for (const e of props.values()) {
++ if (e.frozen) continue;
const b = e.body;
const dx = b.position.x - px, dz = b.position.z - pz;
const d = Math.hypot(dx, dz);
@@@ -327,6 +398,7 @@ export function initPhysics(scene) {
function smack(px, py, pz, dirX, dirZ) {
let hitAny = false;
for (const e of props.values()) {
++ if (e.frozen) continue;
const b = e.body;
const dx = b.position.x - px, dz = b.position.z - pz;
const d = Math.hypot(dx, dz);
@@@ -413,7 +485,7 @@ export function initPhysics(scene) {
if (sendT > .085) {
sendT = 0;
for (const e of props.values()) {
-- if (e.owned && e.body.sleepState !== CANNON.Body.SLEEPING) sendState(e);
++ if (e.owned && !e.frozen && e.body.sleepState !== CANNON.Body.SLEEPING) sendState(e);
}
for (const e of cars.values()) {
if (e.owned && !e.car.driver && e.body.type === CANNON.Body.DYNAMIC && e.body.sleepState !== CANNON.Body.SLEEPING) sendCarState(e);
@@@ -480,7 +552,7 @@ export function initPhysics(scene) {
return {
world, props, cars, add, remove, claim, applyState, applyCarState, sendState, sendCarState,
-- drive, step, smack, raycast, yawBody, grabLocal, anchorWorld, addStatic, removeStatic, PHYS_KINDS,
++ drive, step, smack, raycast, yawBody, grabLocal, anchorWorld, addStatic, removeStatic, setFrozen, PHYS_KINDS,
setMyId(id) { myId = id; },
};
}
ddiff --git a/server/server.js b/server/server.js
iindex 98be51f..6c232e7 100644
---- a/server/server.js
++++ b/server/server.js
@@@ -104,16 +104,19 @@ for (const [cid, c] of Object.entries(savedWorld.cars || {})) {
}
}
let nextPhysId = [...props.keys()].reduce((m, id) => Math.max(m, Number(String(id).slice(2)) || 0), 0) + 1;
++// legacy static builds become FROZEN props (darkrp-style: everything is a prop)
++for (const b of Object.values(savedWorld.builds || {})) {
++ if (!['wall', 'floor', 'door'].includes(b.kind) || !Array.isArray(b.p)) continue;
++ const h = (b.ry || 0) / 2;
++ const id = 'fp' + nextPhysId++;
++ props.set(id, { id, kind: b.kind, p: b.p, q: [0, +Math.sin(h).toFixed(3), 0, +Math.cos(h).toFixed(3)], owner: b.owner || null, frozen: true });
++}
// dropped items lying on the floor — minecraft style, persisted like all else
const drops = new Map(Object.entries(savedWorld.drops || {})); // id -> {id, item, n, x, y, z}
for (const [id, d] of drops) { // sweep anything that ever ended up outside the world
if (!Number.isFinite(d?.x) || !Number.isFinite(d?.z) || !(d.y >= 0 && d.y <= 8)) drops.delete(id);
}
let nextDropId = [...drops.keys()].reduce((m, id) => Math.max(m, Number(String(id).slice(1)) || 0), 0) + 1;
--// base-building pieces: walls/floors with hp, wood→stone upgrades. Persisted.
--const builds = new Map(Object.entries(savedWorld.builds || {})); // id -> {id, kind, tier, p:[x,y,z], ry, hp, owner}
--let nextBuildId = [...builds.keys()].reduce((m, id) => Math.max(m, Number(String(id).slice(1)) || 0), 0) + 1;
--const BUILD_HP = { wood: 200, stone: 500 };
// permanent marker tags — the "I was here" layer. They outlive everyone.
const tags = Array.isArray(savedWorld.tags) ? savedWorld.tags : [];
let nextTagId = tags.reduce((m, t) => Math.max(m, Number(String(t.id).slice(1)) || 0), 0) + 1;
@@@ -135,7 +138,7 @@ let worldDirty = false;
function saveWorld() {
worldDirty = false;
const tmp = WORLD_FILE + '.tmp';
-- fs.writeFileSync(tmp, JSON.stringify({ sleepers: Object.fromEntries(sleepers), cars, props: Object.fromEntries(props), drops: Object.fromEntries(drops), builds: Object.fromEntries(builds), tags, stats }));
++ fs.writeFileSync(tmp, JSON.stringify({ sleepers: Object.fromEntries(sleepers), cars, props: Object.fromEntries(props), drops: Object.fromEntries(drops), tags, stats }));
fs.renameSync(tmp, WORLD_FILE); // atomic: a crash mid-write can't corrupt the state
}
setInterval(() => { if (worldDirty) saveWorld(); }, 10000);
@@@ -301,7 +304,6 @@ wss.on('connection', (ws, req) => {
hp: p.hp,
tags,
drops: [...drops.values()],
-- builds: [...builds.values()],
mapEdits,
players: [...players.values()].filter(q => q.id !== id).map(pubState),
c4: { a: c4.a.state(), b: c4.b.state() },
@@@ -497,18 +499,20 @@ function handle(p, m) {
if (now - (p.lastSpawn || 0) < 250) return;
p.lastSpawn = now;
if (props.size >= 150) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '📦 Prop limit reached (150) — grab some with the physgun and press X to clear them.' })); return; }
-- const KINDS = ['box', 'crate', 'ball', 'barrel', 'melon', 'cone'];
++ const KINDS = ['box', 'crate', 'ball', 'barrel', 'melon', 'cone', 'wall', 'floor', 'door'];
if (!KINDS.includes(m.kind)) return;
const pos = Array.isArray(m.p) ? m.p.map(Number) : [];
if (pos.length !== 3 || !pos.every(Number.isFinite)) return;
-- const prop = { id: 'fp' + nextPhysId++, kind: m.kind, p: [clampX(pos[0]), Math.max(0, Math.min(30, pos[1])), clampZ(pos[2])], q: [0, 0, 0, 1], owner: p.key };
++ let q = [0, 0, 0, 1];
++ if (Array.isArray(m.q) && m.q.length === 4 && m.q.every(Number.isFinite)) q = m.q.map(n => +(+n).toFixed(3));
++ const prop = { id: 'fp' + nextPhysId++, kind: m.kind, p: [clampX(pos[0]), Math.max(0, Math.min(30, pos[1])), clampZ(pos[2])], q, owner: p.key, frozen: m.frozen === true };
props.set(prop.id, prop);
stats.props++;
worldDirty = true;
broadcast({ t: 'prop', op: 'add', prop, owner: p.id });
} else if (m.op === 'state') {
const pr = props.get(String(m.id));
-- if (!pr) return;
++ if (!pr || pr.frozen) return;
if (pr.owner && !store.isFriend(pr.owner, p.key)) return; // prop protection
const pos = Array.isArray(m.p) ? m.p.map(Number) : [];
const q = Array.isArray(m.q) ? m.q.map(Number) : [];
@@@ -524,9 +528,34 @@ function handle(p, m) {
if (m.op === 'grab' && pr.owner && !store.isFriend(pr.owner, p.key)) {
if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '🔒 That prop belongs to someone else (they can add you as a friend).' }));
broadcast({ t: 'prop', op: 'drop', id: pr.id }, null); // force-release on their client
++ if (pr.frozen) broadcast({ t: 'prop', op: 'freeze', id: pr.id, frozen: true, p: pr.p, q: pr.q }); // resync
return;
}
++ if (m.op === 'grab' && pr.frozen) { // owner grabbing = unfreeze
++ pr.frozen = false;
++ worldDirty = true;
++ broadcast({ t: 'prop', op: 'freeze', id: pr.id, frozen: false, p: pr.p, q: pr.q });
++ }
broadcast({ t: 'prop', op: m.op, id: pr.id, by: p.id }, p.id);
++ } else if (m.op === 'freeze') {
++ const pr = props.get(String(m.id));
++ if (!pr) return;
++ if (pr.owner && !store.isFriend(pr.owner, p.key)) return;
++ pr.frozen = m.frozen === true;
++ const pos = Array.isArray(m.p) ? m.p.map(Number) : null;
++ const q = Array.isArray(m.q) ? m.q.map(Number) : null;
++ if (pos?.length === 3 && pos.every(Number.isFinite)) pr.p = [clampX(+pos[0].toFixed(2)), Math.max(0, Math.min(30, +pos[1].toFixed(2))), clampZ(+pos[2].toFixed(2))];
++ if (q?.length === 4 && q.every(Number.isFinite)) pr.q = q.map(n => +n.toFixed(3));
++ worldDirty = true;
++ broadcast({ t: 'prop', op: 'freeze', id: pr.id, frozen: pr.frozen, p: pr.p, q: pr.q });
++ } else if (m.op === 'fade') { // gmod fading door (prop edition)
++ const pr = props.get(String(m.id));
++ if (!pr || pr.kind !== 'door') return;
++ if (pr.owner && !store.isFriend(pr.owner, p.key)) {
++ if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '🔒 Locked — this door only opens for its owner and their friends.' }));
++ return;
++ }
++ broadcast({ t: 'prop', op: 'fade', id: pr.id });
} else if (m.op === 'del') {
const pr = props.get(String(m.id));
if (!pr) return;
@@@ -575,58 +604,6 @@ function handle(p, m) {
broadcast({ t: 'shoot', id: p.id, from: [+p.x.toFixed(1), 1.5, +p.z.toFixed(1)], to: to.map(n => +n.toFixed(1)) }, p.id);
break;
}
-- case 'build': { // rust-lite base building
-- if (m.op === 'place') {
-- const now = Date.now();
-- if (now - (p.lastBuild || 0) < 400) return;
-- p.lastBuild = now;
-- if (builds.size >= 300) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '🧱 Build limit reached (300).' })); return; }
-- if (!['wall', 'floor', 'door'].includes(m.kind)) return;
-- const pos = Array.isArray(m.p) ? m.p.map(Number) : [];
-- if (pos.length !== 3 || !pos.every(Number.isFinite) || !Number.isFinite(+m.ry)) return;
-- const b = {
-- id: 'b' + nextBuildId++, kind: m.kind, tier: 'wood',
-- p: [clampX(pos[0]), Math.max(0, Math.min(12, pos[1])), clampZ(pos[2])],
-- ry: +(+m.ry).toFixed(3), hp: m.kind === 'door' ? 150 : BUILD_HP.wood, owner: p.key,
-- };
-- builds.set(b.id, b);
-- worldDirty = true;
-- broadcast({ t: 'build', op: 'add', b });
-- } else if (m.op === 'hit') {
-- const b = builds.get(String(m.id));
-- if (!b) return;
-- if (b.owner && !store.isFriend(b.owner, p.key)) { // base protection
-- if (p.ws.readyState === 1 && Math.random() < .3) p.ws.send(JSON.stringify({ t: 'sys', text: '🔒 Protected base — only the owner and their friends can touch it.' }));
-- return;
-- }
-- const BDMG = { axe: 10, stoneaxe: 14, pickaxe: 12, stonepick: 16, pistol: 15, wrench: 8 };
-- const dmg = BDMG[String(m.item)] ?? 4;
-- if (Math.hypot(p.x - b.p[0], p.z - b.p[2]) > (m.item === 'pistol' ? 50 : 4.5)) return;
-- b.hp -= dmg;
-- worldDirty = true;
-- if (b.hp <= 0) {
-- builds.delete(b.id);
-- broadcast({ t: 'build', op: 'del', id: b.id });
-- } else broadcast({ t: 'build', op: 'hp', id: b.id, hp: b.hp });
-- } else if (m.op === 'fade') { // gmod fading door
-- const b = builds.get(String(m.id));
-- if (!b || b.kind !== 'door') return;
-- if (b.owner && !store.isFriend(b.owner, p.key)) {
-- if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ t: 'sys', text: '🔒 Locked — this door only opens for its owner and their friends.' }));
-- return;
-- }
-- broadcast({ t: 'build', op: 'fade', id: b.id });
-- } else if (m.op === 'upgrade') {
-- const b = builds.get(String(m.id));
-- if (!b || b.tier !== 'wood' || b.kind === 'door') return;
-- if (b.owner && !store.isFriend(b.owner, p.key)) return;
-- b.tier = 'stone';
-- b.hp = BUILD_HP.stone;
-- worldDirty = true;
-- broadcast({ t: 'build', op: 'upgrade', id: b.id, tier: 'stone', hp: b.hp });
-- }
-- break;
-- }
case 'friend': { // build/prop protection whitelist
let list = null;
if (m.op === 'add') list = store.addFriend(p.key, m.name);