Files
sysadminandClaude Opus 5 8ded29cbcc feat(redstone-link): wireless redstone addon; track family-goals
Adds redstone-link-addon: a transmitter/receiver pair bound by channel
name, giving redstone that runs anywhere without wire, support blocks or
line of sight. Bedrock custom blocks can read redstone power but cannot
emit it, so a live receiver is swapped to a vanilla redstone_block and
back; receiver positions persist in a world dynamic property, with a
reconcile pass at boot to repair anything a crash left inconsistent.
Deployed and proven end-to-end on lobby (Hub World).

Also commits family-goals-addon, which was running on jamie -- mounted in
docker-compose.yml and pinned in the world -- while existing in no commit
on any branch. It was unbacked-up production code.

That omission was load-bearing for deploys: deploy.yml checks out
docker-compose.yml from origin/main, so a deploy would have replaced the
host compose with a version lacking the family-goals mount and silently
killed the addon. Both new directories are added to the workflow's PATHS
and push-trigger list, so nothing is mounted that CI does not deliver.

The mount set in this file now matches the live host exactly.

Note for a follow-up: dynamite-, hemp-, naturalist-lite-, smart-crafting-,
tow-boat- and trees-features-addon are mounted but absent from PATHS, so
CI never refreshes them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Gf1kZypRDfPL1YiWUS1LC
2026-08-28 11:06:46 +01:00

355 lines
12 KiB
JavaScript

// Redstone Link Addon
//
// Wireless redstone for the SilverLABS multiworld. Place a transmitter next to
// any redstone source and a receiver anywhere else, give both the same channel
// name, and the receiver emits full-strength power whenever the transmitter is
// powered. No wire, no line of sight, no support block, any distance.
//
// Why a block swap: Bedrock custom blocks can READ redstone power but cannot
// EMIT it -- there is no script API for that. So a live receiver is physically
// replaced by a vanilla redstone block (strength 15, omnidirectional, needs no
// support) and swapped back when the channel goes quiet. registry.js remembers
// where the receivers are, because while powered the block no longer says so.
//
// Known limits, by design:
// * Both ends must be in loaded chunks. Out-of-range links simply do nothing.
// * ~2 tick latency. Not for sub-tick contraptions.
// * Binary, not graded: output is always 15.
import { world, system, ItemStack, BlockPermutation } from "@minecraft/server";
import { ModalFormData } from "@minecraft/server-ui";
import { isTransmitterPowered } from "./power.js";
import * as registry from "./registry.js";
const TX_ID = "silverlabs:redstone_link_tx";
const RX_ID = "silverlabs:redstone_link_rx";
const LIVE_ID = "minecraft:redstone_block";
const TICK_INTERVAL = 2;
const MAX_CHANNEL_LEN = 24;
const DEFAULT_CHANNEL = "default";
function roleOf(typeId) {
if (typeId === TX_ID) return "tx";
if (typeId === RX_ID) return "rx";
return null;
}
function sanitiseChannel(raw) {
if (typeof raw !== "string") return "";
return raw.trim().toLowerCase().slice(0, MAX_CHANNEL_LEN);
}
function blockAt(node) {
// Returns undefined when the chunk is not loaded -- callers must treat that
// as "unknown", never as "gone", or we would delete live nodes.
let dim;
try { dim = world.getDimension(node.d); } catch (_) { return undefined; }
if (!dim) return undefined;
try { return dim.getBlock({ x: node.x, y: node.y, z: node.z }); } catch (_) { return undefined; }
}
function setBlockTo(block, typeId) {
try {
block.setPermutation(BlockPermutation.resolve(typeId));
return true;
} catch (e) {
console.warn(`[RedstoneLink] failed to set ${typeId}: ${e}`);
return false;
}
}
// --- The link engine ------------------------------------------------------
function tick() {
const nodes = registry.all();
if (nodes.length === 0) return;
// Receiver positions grouped by channel, so a transmitter can ignore power
// coming from its own channel's receivers (self-latch guard).
const rxKeysByChannel = new Map();
for (const n of nodes) {
if (n.r !== "rx" || !n.c) continue;
let set = rxKeysByChannel.get(n.c);
if (!set) { set = new Set(); rxKeysByChannel.set(n.c, set); }
set.add(registry.nodeKey(n));
}
const stale = new Set();
const live = new Set();
// Pass 1 -- which channels are being driven?
for (const n of nodes) {
if (n.r !== "tx") continue;
const b = blockAt(n);
if (b === undefined) continue; // chunk unloaded: unknown
if (b.typeId !== TX_ID) { stale.add(registry.nodeKey(n)); continue; }
if (!n.c || live.has(n.c)) continue; // unset, or already known live
if (isTransmitterPowered(b, rxKeysByChannel.get(n.c))) live.add(n.c);
}
// Pass 2 -- make every receiver match its channel.
for (const n of nodes) {
if (n.r !== "rx") continue;
const b = blockAt(n);
if (b === undefined) continue;
const isOn = b.typeId === LIVE_ID;
const isOff = b.typeId === RX_ID;
if (!isOn && !isOff) { stale.add(registry.nodeKey(n)); continue; }
const want = Boolean(n.c) && live.has(n.c);
if (want && !isOn) setBlockTo(b, LIVE_ID);
else if (!want && !isOff) setBlockTo(b, RX_ID);
}
if (stale.size > 0) registry.removeKeys(stale);
}
/**
* Startup pass. Anything that got out of sync while the world was unloaded --
* a receiver left as a redstone block after a crash, a node whose block was
* removed by an explosion -- is corrected or forgotten here.
*/
function reconcile() {
const nodes = registry.all();
if (nodes.length === 0) return;
const stale = new Set();
let restored = 0;
for (const n of nodes) {
const b = blockAt(n);
if (b === undefined) continue;
if (n.r === "tx") {
if (b.typeId !== TX_ID) stale.add(registry.nodeKey(n));
continue;
}
if (b.typeId === LIVE_ID) {
// No transmitter has been evaluated yet, so nothing is live: park it off.
if (setBlockTo(b, RX_ID)) restored++;
} else if (b.typeId !== RX_ID) {
stale.add(registry.nodeKey(n));
}
}
const dropped = registry.removeKeys(stale);
if (restored || dropped) {
console.warn(`[RedstoneLink] reconcile: ${restored} receiver(s) reset, ${dropped} stale node(s) dropped`);
}
}
// --- Channel UI -----------------------------------------------------------
async function openChannelForm(player, block, node) {
const role = roleOf(block.typeId);
const label = role === "tx" ? "Transmitter" : "Receiver";
const current = node?.c ?? "";
const form = new ModalFormData()
.title(`§6Redstone Link §7${label}`)
.textField(
"Channel name\n§7Any transmitter and receiver sharing a channel are linked.",
"e.g. front-door",
current
);
let res;
try { res = await form.show(player); } catch (_) { return; }
if (res.canceled) return;
const channel = sanitiseChannel(res.formValues?.[0]);
if (!channel) {
player.sendMessage("§c[Redstone Link] §7Channel name cannot be empty.");
return;
}
const saved = registry.add(block.dimension.id, block.location, role, channel);
if (!saved) {
player.sendMessage(`§c[Redstone Link] §7Too many links in this world (max ${registry.MAX_NODES}).`);
return;
}
player.sendMessage(`§a[Redstone Link] §7${label} set to channel §f${channel}§7.`);
}
// --- Events ---------------------------------------------------------------
try {
world.beforeEvents.playerInteractWithBlock.subscribe((event) => {
const block = event.block;
if (!block) return;
const role = roleOf(block.typeId);
if (!role) return;
event.cancel = true;
const playerRef = event.player;
const dimId = block.dimension.id;
const loc = { x: block.location.x, y: block.location.y, z: block.location.z };
system.run(() => {
const node = registry.find(dimId, loc);
openChannelForm(playerRef, block, node);
});
});
} catch (e) {
console.warn(`[RedstoneLink] beforeEvents.playerInteractWithBlock unavailable: ${e}`);
}
try {
world.afterEvents.playerPlaceBlock.subscribe((event) => {
const block = event.block;
if (!block) return;
const role = roleOf(block.typeId);
if (!role) return;
const added = registry.add(block.dimension.id, block.location, role, DEFAULT_CHANNEL);
if (!added) {
event.player?.sendMessage(
`§c[Redstone Link] §7Too many links in this world (max ${registry.MAX_NODES}). This one will not work.`
);
return;
}
event.player?.sendMessage(
`§7[Redstone Link] On channel §f${DEFAULT_CHANNEL}§7. Interact to rename it.`
);
});
} catch (e) {
console.warn(`[RedstoneLink] afterEvents.playerPlaceBlock unavailable: ${e}`);
}
try {
world.beforeEvents.playerBreakBlock.subscribe((event) => {
const block = event.block;
if (!block) return;
const dimId = block.dimension.id;
const loc = { x: block.location.x, y: block.location.y, z: block.location.z };
const node = registry.find(dimId, loc);
if (!node) return;
if (block.typeId === LIVE_ID) {
// A live receiver. Left alone, vanilla would hand out a free redstone
// block, so intercept: drop the receiver item instead.
event.cancel = true;
const playerRef = event.player;
system.run(() => {
registry.remove(dimId, loc);
let dim;
try { dim = world.getDimension(dimId); } catch (_) { return; }
try { dim.getBlock(loc)?.setPermutation(BlockPermutation.resolve("minecraft:air")); } catch (_) {}
let creative = false;
try { creative = playerRef?.getGameMode?.() === "creative"; } catch (_) { creative = false; }
if (creative) return;
try {
dim.spawnItem(new ItemStack(RX_ID, 1), { x: loc.x + 0.5, y: loc.y + 0.5, z: loc.z + 0.5 });
} catch (_) {}
});
return;
}
// Unpowered node: vanilla break already returns the right item.
system.run(() => registry.remove(dimId, loc));
});
} catch (e) {
console.warn(`[RedstoneLink] beforeEvents.playerBreakBlock unavailable: ${e}`);
}
// --- Console / bridge control --------------------------------------------
//
// Blocks placed by command rather than by hand -- /setblock, /fill, or the
// mc-ai-bridge MCP build tools -- never fire playerPlaceBlock, so they are not
// in the registry and stay inert. These script events give the console and the
// bridge a way to register them. sourceEntity is null when run via RCON or the
// bridge, so we never require a player here.
//
// /scriptevent rlink:register <x> <y> <z> <channel>
// /scriptevent rlink:unregister <x> <y> <z>
// /scriptevent rlink:status
function announce(text) {
try { world.getDimension("overworld").runCommand(`say ${text}`); } catch (_) {}
console.warn(text);
}
function handleRegister(args) {
if (args.length < 4) {
announce("[RedstoneLink] usage: rlink:register <x> <y> <z> <channel>");
return;
}
const x = Number(args[0]), y = Number(args[1]), z = Number(args[2]);
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
announce("[RedstoneLink] register: coordinates must be numbers");
return;
}
const channel = sanitiseChannel(args.slice(3).join(" "));
if (!channel) {
announce("[RedstoneLink] register: channel name required");
return;
}
const loc = { x: Math.floor(x), y: Math.floor(y), z: Math.floor(z) };
let block;
try { block = world.getDimension("overworld").getBlock(loc); } catch (_) { block = undefined; }
if (!block) {
announce(`[RedstoneLink] register: no loaded block at ${loc.x},${loc.y},${loc.z}`);
return;
}
const role = roleOf(block.typeId);
if (!role) {
announce(`[RedstoneLink] register: ${block.typeId} is not a link block`);
return;
}
const saved = registry.add("minecraft:overworld", loc, role, channel);
if (!saved) {
announce(`[RedstoneLink] register: registry full (max ${registry.MAX_NODES})`);
return;
}
announce(`[RedstoneLink] registered ${role} at ${loc.x},${loc.y},${loc.z} on channel ${channel}`);
}
function handleUnregister(args) {
if (args.length < 3) {
announce("[RedstoneLink] usage: rlink:unregister <x> <y> <z>");
return;
}
const loc = {
x: Math.floor(Number(args[0])),
y: Math.floor(Number(args[1])),
z: Math.floor(Number(args[2])),
};
const gone = registry.remove("minecraft:overworld", loc);
announce(`[RedstoneLink] unregister ${loc.x},${loc.y},${loc.z}: ${gone ? "removed" : "not found"}`);
}
function handleStatus() {
const nodes = registry.all();
const tx = nodes.filter((n) => n.r === "tx").length;
const rx = nodes.filter((n) => n.r === "rx").length;
const channels = [...new Set(nodes.map((n) => n.c).filter(Boolean))];
announce(`[RedstoneLink] alive: ${nodes.length} node(s), ${tx} tx, ${rx} rx, channels=[${channels.join(",")}]`);
}
try {
system.afterEvents.scriptEventReceive.subscribe((event) => {
const id = event.id;
if (!id || !id.startsWith("rlink:")) return;
const args = (event.message || "").trim().split(/\s+/).filter(Boolean);
try {
switch (id) {
case "rlink:register": handleRegister(args); break;
case "rlink:unregister": handleUnregister(args); break;
case "rlink:status": handleStatus(); break;
default: announce(`[RedstoneLink] unknown command ${id}`);
}
} catch (e) {
announce(`[RedstoneLink] ${id} failed: ${e}`);
}
});
} catch (e) {
console.warn(`[RedstoneLink] scriptEventReceive unavailable: ${e}`);
}
// --- Boot -----------------------------------------------------------------
system.run(() => {
try { reconcile(); } catch (e) { console.warn(`[RedstoneLink] reconcile failed: ${e}`); }
try {
system.runInterval(() => {
try { tick(); } catch (e) { console.warn(`[RedstoneLink] tick failed: ${e}`); }
}, TICK_INTERVAL);
} catch (e) {
console.warn(`[RedstoneLink] could not start tick loop: ${e}`);
}
world.sendMessage("§c[Redstone Link] §7Wireless redstone loaded.");
});