// 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 // /scriptevent rlink:unregister // /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 "); 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 "); 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."); });