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
+76
View File
@@ -0,0 +1,76 @@
# Redstone Link
Wireless redstone. Vanilla dust needs a solid block under it and gets cut off by
terrain; this replaces the wire entirely with a transmitter/receiver pair bound
by a channel name.
| Block | What it does |
|---|---|
| `silverlabs:redstone_link_tx` — Redstone Link Transmitter | Reads redstone power from any of its six neighbours. While powered, its channel is live. |
| `silverlabs:redstone_link_rx` — Redstone Link Receiver | While its channel is live, emits full-strength (15) power in every direction. |
Craft either from a redstone block + ender pearl (+ copper for the transmitter,
iron for the receiver). Both give 2.
**Setting a channel:** right-click a block and type a name, e.g. `front-door`.
Every transmitter and receiver sharing that name is linked — many-to-many, any
distance, through any terrain, in any direction. Newly placed blocks start on
channel `default`.
## How it works, and why
Bedrock custom blocks can *read* redstone power but cannot *emit* it — there is
no script API for that. So a live receiver is physically swapped for a vanilla
`minecraft:redstone_block` (strength 15, omnidirectional, needs no support) and
swapped back when the channel goes quiet.
That swap is why `scripts/registry.js` exists: while a receiver is powered the
block itself no longer says it is a receiver, so the positions are kept in a
world dynamic property. It survives container restarts, and a reconcile pass at
boot repairs anything left inconsistent by a crash.
Breaking a powered receiver is intercepted so it returns the receiver block
rather than a free redstone block.
## Limits
These are inherent to the approach, not bugs:
- **Both ends must be in loaded chunks.** A link whose receiver sits in unloaded
terrain simply does not fire. Cover it with a ticking area if it must always work.
- **~2 tick latency.** Fine for doors, lamps and farms; not for sub-tick clocks.
- **Binary, not graded.** Output is always 15, never a decayed signal.
- **Same dimension only.**
- **256 nodes per world.**
A transmitter ignores power coming from its own channel's receivers, so putting
a matched pair side by side will not latch itself on.
## Console / bridge control
Blocks placed by `/setblock`, `/fill`, or the mc-ai-bridge MCP build tools never
fire `playerPlaceBlock`, so they are not registered and stay inert. Register them
explicitly (`sourceEntity` is null over RCON/the bridge, so no player is needed):
```
/scriptevent rlink:register <x> <y> <z> <channel>
/scriptevent rlink:unregister <x> <y> <z>
/scriptevent rlink:status
```
`rlink:status` reports node counts and active channels — the quickest way to
confirm the pack is alive on a headless server.
## Deployment
Bind-mount both packs into the target service in `docker-compose.yml`, then pin
the pack UUIDs into that world's `world_behavior_packs.json` /
`world_resource_packs.json`. **The version arrays must match the manifest header
versions exactly** or the pack is silently dropped from the stack.
| Pack | UUID | Version |
|---|---|---|
| BP header | `e4f1a7c2-6b95-4d38-a7e0-3c81d5f2b001` | `[1, 0, 0]` |
| RP header | `e4f1a7c2-6b95-4d38-a7e0-3c81d5f2b004` | `[1, 0, 0]` |
Currently deployed to **lobby** (`Hub World`) only.
@@ -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();
}
@@ -0,0 +1,5 @@
{
"format_version": [1, 1, 0],
"silverlabs:redstone_link_tx": { "sound": "stone" },
"silverlabs:redstone_link_rx": { "sound": "stone" }
}
@@ -0,0 +1,17 @@
{
"format_version": 2,
"header": {
"name": "Redstone Link Resources",
"description": "Textures and lang for the silverlabs:redstone_link_tx / _rx blocks",
"uuid": "e4f1a7c2-6b95-4d38-a7e0-3c81d5f2b004",
"version": [1, 0, 0],
"min_engine_version": [1, 21, 0]
},
"modules": [
{
"type": "resources",
"uuid": "e4f1a7c2-6b95-4d38-a7e0-3c81d5f2b005",
"version": [1, 0, 0]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,2 @@
tile.silverlabs:redstone_link_tx.name=Redstone Link Transmitter
tile.silverlabs:redstone_link_rx.name=Redstone Link Receiver
Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 B

@@ -0,0 +1,14 @@
{
"resource_pack_name": "redstone_link_RP",
"texture_name": "atlas.terrain",
"padding": 8,
"num_mip_levels": 4,
"texture_data": {
"redstone_link_tx": {
"textures": "textures/blocks/redstone_link_tx"
},
"redstone_link_rx": {
"textures": "textures/blocks/redstone_link_rx"
}
}
}