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
This commit is contained in:
sysadmin
2026-08-28 11:06:46 +01:00
co-authored by Claude Opus 5
parent b28e4d0610
commit 8ded29cbcc
21 changed files with 1323 additions and 1 deletions
@@ -0,0 +1,27 @@
{
"format_version": "1.21.0",
"minecraft:block": {
"description": {
"identifier": "silverlabs:redstone_link_rx",
"menu_category": {
"category": "items",
"group": "itemGroup.name.redstone"
}
},
"components": {
"minecraft:destructible_by_mining": {
"seconds_to_destroy": 1.5
},
"minecraft:destructible_by_explosion": {
"explosion_resistance": 20.0
},
"minecraft:map_color": "#1A4C8C",
"minecraft:material_instances": {
"*": {
"texture": "redstone_link_rx",
"render_method": "opaque"
}
}
}
}
}
@@ -0,0 +1,27 @@
{
"format_version": "1.21.0",
"minecraft:block": {
"description": {
"identifier": "silverlabs:redstone_link_tx",
"menu_category": {
"category": "items",
"group": "itemGroup.name.redstone"
}
},
"components": {
"minecraft:destructible_by_mining": {
"seconds_to_destroy": 1.5
},
"minecraft:destructible_by_explosion": {
"explosion_resistance": 20.0
},
"minecraft:map_color": "#8C1A1A",
"minecraft:material_instances": {
"*": {
"texture": "redstone_link_tx",
"render_method": "opaque"
}
}
}
}
}
@@ -0,0 +1,38 @@
{
"format_version": 2,
"header": {
"name": "Redstone Link",
"description": "Wireless redstone: pair a transmitter and a receiver by channel name, no wire in between",
"uuid": "e4f1a7c2-6b95-4d38-a7e0-3c81d5f2b001",
"version": [1, 0, 0],
"min_engine_version": [1, 21, 0]
},
"modules": [
{
"type": "data",
"uuid": "e4f1a7c2-6b95-4d38-a7e0-3c81d5f2b002",
"version": [1, 0, 0]
},
{
"type": "script",
"language": "javascript",
"uuid": "e4f1a7c2-6b95-4d38-a7e0-3c81d5f2b003",
"version": [1, 0, 0],
"entry": "scripts/main.js"
}
],
"dependencies": [
{
"module_name": "@minecraft/server",
"version": "1.17.0"
},
{
"module_name": "@minecraft/server-ui",
"version": "1.3.0"
},
{
"uuid": "e4f1a7c2-6b95-4d38-a7e0-3c81d5f2b004",
"version": [1, 0, 0]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,19 @@
{
"format_version": "1.21.0",
"minecraft:recipe_shapeless": {
"description": {
"identifier": "silverlabs:redstone_link_rx_recipe"
},
"tags": ["crafting_table"],
"unlock": { "context": "AlwaysUnlocked" },
"ingredients": [
{ "item": "minecraft:redstone_block" },
{ "item": "minecraft:ender_pearl" },
{ "item": "minecraft:iron_ingot" }
],
"result": {
"item": "silverlabs:redstone_link_rx",
"count": 2
}
}
}
@@ -0,0 +1,19 @@
{
"format_version": "1.21.0",
"minecraft:recipe_shapeless": {
"description": {
"identifier": "silverlabs:redstone_link_tx_recipe"
},
"tags": ["crafting_table"],
"unlock": { "context": "AlwaysUnlocked" },
"ingredients": [
{ "item": "minecraft:redstone_block" },
{ "item": "minecraft:ender_pearl" },
{ "item": "minecraft:copper_ingot" }
],
"result": {
"item": "silverlabs:redstone_link_tx",
"count": 2
}
}
}
@@ -0,0 +1,354 @@
// 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.");
});
@@ -0,0 +1,84 @@
// Redstone input detection for the transmitter.
//
// Mirrors the approach already proven in hemp-addon's sun lamp: try the script
// API's getRedstonePower() first, and fall back to inspecting the six
// neighbouring blocks when it is unavailable or reports nothing.
export const REDSTONE_FACES = [
[1, 0, 0], [-1, 0, 0],
[0, 1, 0], [0, -1, 0],
[0, 0, 1], [0, 0, -1],
];
export function isPowerSource(b) {
if (!b) return false;
const t = b.typeId;
if (t === "minecraft:redstone_block") return true;
if (t === "minecraft:lit_redstone_torch" || t === "minecraft:redstone_torch") {
try { return b.permutation.getState("toggle_bit") !== false; } catch (_) {}
return t === "minecraft:lit_redstone_torch";
}
if (t === "minecraft:powered_repeater") return true;
if (t === "minecraft:powered_comparator") return true;
if (t === "minecraft:lever") {
try { return b.permutation.getState("open_bit") === true; } catch (_) { return false; }
}
if (t.endsWith("_button")) {
try { return b.permutation.getState("button_pressed_bit") === true; } catch (_) { return false; }
}
if (t.endsWith("_pressure_plate")) {
try {
const r = b.permutation.getState("redstone_signal") ?? 0;
return r > 0;
} catch (_) { return false; }
}
if (t === "minecraft:redstone_wire") {
try {
const p = b.permutation.getState("redstone_signal") ?? 0;
return p > 0;
} catch (_) { return false; }
}
if (t === "minecraft:daylight_detector_inverted") return true;
return false;
}
/**
* Is this transmitter receiving power?
*
* `ignoreKeys` holds the position keys of receivers on the SAME channel. A
* powered receiver is a real redstone block, so without this guard a
* transmitter sitting next to its own channel's receiver would latch itself on
* forever. We skip those positions, and when one is adjacent we also skip the
* getRedstonePower() fast path, because that call cannot exclude a position.
*/
export function isTransmitterPowered(block, ignoreKeys) {
const dim = block.dimension;
const dimId = dim.id;
const { x, y, z } = block.location;
let adjacentSameChannelReceiver = false;
if (ignoreKeys && ignoreKeys.size > 0) {
for (const [dx, dy, dz] of REDSTONE_FACES) {
if (ignoreKeys.has(`${dimId}|${x + dx}|${y + dy}|${z + dz}`)) {
adjacentSameChannelReceiver = true;
break;
}
}
}
if (!adjacentSameChannelReceiver) {
try {
const p = block.getRedstonePower();
if (typeof p === "number" && p > 0) return true;
} catch (_) {}
}
for (const [dx, dy, dz] of REDSTONE_FACES) {
const pos = { x: x + dx, y: y + dy, z: z + dz };
if (ignoreKeys && ignoreKeys.has(`${dimId}|${pos.x}|${pos.y}|${pos.z}`)) continue;
let nb;
try { nb = dim.getBlock(pos); } catch (_) { continue; }
if (isPowerSource(nb)) return true;
}
return false;
}
@@ -0,0 +1,118 @@
// Persistent node registry for Redstone Link.
//
// A "node" is one placed transmitter or receiver. We have to remember them
// because a powered receiver is physically replaced by a vanilla redstone
// block (Bedrock custom blocks cannot emit redstone power), so the block
// itself stops being evidence that a receiver is there.
//
// State lives in a world dynamic property, the same approach family-goals and
// private-chest use, so it survives container restarts.
import { world } from "@minecraft/server";
const DP_KEY = "rlink:nodes";
// Bounded so the serialised registry stays well inside the dynamic-property
// size budget and the tick loop stays cheap.
export const MAX_NODES = 256;
// node shape: { d: dimensionId, x, y, z, r: "tx" | "rx", c: channel }
let cache = null;
export function load() {
if (cache) return cache;
let raw;
try {
raw = world.getDynamicProperty(DP_KEY);
} catch (_) {
raw = undefined;
}
if (typeof raw !== "string" || raw.length === 0) {
cache = [];
return cache;
}
try {
const parsed = JSON.parse(raw);
cache = Array.isArray(parsed) ? parsed : [];
} catch (_) {
console.warn("[RedstoneLink] registry was corrupt, starting empty");
cache = [];
}
return cache;
}
export function save() {
if (!cache) return;
try {
world.setDynamicProperty(DP_KEY, JSON.stringify(cache));
} catch (e) {
console.warn(`[RedstoneLink] registry save failed: ${e}`);
}
}
export function keyOf(dimensionId, loc) {
return `${dimensionId}|${Math.floor(loc.x)}|${Math.floor(loc.y)}|${Math.floor(loc.z)}`;
}
export function nodeKey(n) {
return `${n.d}|${n.x}|${n.y}|${n.z}`;
}
export function find(dimensionId, loc) {
const k = keyOf(dimensionId, loc);
return load().find((n) => nodeKey(n) === k) ?? null;
}
/** Returns the node, or null if the registry is full. */
export function add(dimensionId, loc, role, channel) {
const list = load();
const k = keyOf(dimensionId, loc);
const existing = list.find((n) => nodeKey(n) === k);
if (existing) {
existing.r = role;
existing.c = channel;
save();
return existing;
}
if (list.length >= MAX_NODES) return null;
const node = {
d: dimensionId,
x: Math.floor(loc.x),
y: Math.floor(loc.y),
z: Math.floor(loc.z),
r: role,
c: channel,
};
list.push(node);
save();
return node;
}
export function remove(dimensionId, loc) {
const list = load();
const k = keyOf(dimensionId, loc);
const i = list.findIndex((n) => nodeKey(n) === k);
if (i < 0) return false;
list.splice(i, 1);
save();
return true;
}
/** Bulk removal by node key; one save for the whole batch. */
export function removeKeys(keys) {
if (!keys || keys.size === 0) return 0;
const list = load();
let removed = 0;
for (let i = list.length - 1; i >= 0; i--) {
if (keys.has(nodeKey(list[i]))) {
list.splice(i, 1);
removed++;
}
}
if (removed > 0) save();
return removed;
}
export function all() {
return load();
}