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
+8 -1
View File
@@ -14,6 +14,8 @@ on:
- 'keep-inventory-addon/**'
- 'postal-service-addon/**'
- 'camping-supplies-addon/**'
- 'family-goals-addon/**'
- 'redstone-link-addon/**'
- 'docker-compose.yml'
- 'scripts/**'
@@ -31,7 +33,12 @@ jobs:
script: |
set -e
APP_DIR="$HOME/minecraft-multiworld"
PATHS="addon/ lobby-addon/ hub-return-addon/ village-evolution-addon/ monkey-addon/ private-chest-addon/ home-sign-addon/ keep-inventory-addon/ postal-service-addon/ camping-supplies-addon/ docker-compose.yml"
# Anything mounted in docker-compose.yml but missing here is simply
# never refreshed by CI. family-goals-addon and redstone-link-addon
# are listed because docker-compose.yml mounts them; omitting either
# while checking out docker-compose.yml would leave a mount pointing
# at a directory this job never delivers.
PATHS="addon/ lobby-addon/ hub-return-addon/ village-evolution-addon/ monkey-addon/ private-chest-addon/ home-sign-addon/ keep-inventory-addon/ postal-service-addon/ camping-supplies-addon/ family-goals-addon/ redstone-link-addon/ docker-compose.yml"
# First run: clone. Subsequent: pull.
if [ ! -d "$APP_DIR/.git" ]; then
+3
View File
@@ -38,6 +38,8 @@ services:
- ./dynamite-addon/dynamite_RP:/data/resource_packs/dynamite_RP
- ./tow-boat-addon/tow_boat_BP:/data/behavior_packs/tow_boat_BP
- ./tow-boat-addon/tow_boat_RP:/data/resource_packs/tow_boat_RP
- ./redstone-link-addon/redstone_link_BP:/data/behavior_packs/redstone_link_BP
- ./redstone-link-addon/redstone_link_RP:/data/resource_packs/redstone_link_RP
restart: unless-stopped
# Cap each Bedrock service so a runaway/hung server can't OOM-kill its
# neighbours. Host has 8 GB; 4 × 1500 MB leaves headroom for the OS and
@@ -81,6 +83,7 @@ services:
- ./trees-features-addon/trees_features_RP:/data/resource_packs/trees_features_RP
- ./hemp-addon/hemp_BP:/data/behavior_packs/hemp_BP
- ./hemp-addon/hemp_RP:/data/resource_packs/hemp_RP
- ./family-goals-addon/family_goals_BP:/data/behavior_packs/family_goals_BP
restart: unless-stopped
mem_limit: 1500m
memswap_limit: 2500m
@@ -0,0 +1,33 @@
{
"format_version": 2,
"header": {
"name": "Family Goals",
"description": "Shared goals, family bank, loot share, pity timer, and PvP-off for the family survival world",
"uuid": "f1a2b3c4-d5e6-4789-90ab-cdef12345001",
"version": [1, 0, 6],
"min_engine_version": [1, 21, 0]
},
"modules": [
{
"type": "script",
"language": "javascript",
"uuid": "f1a2b3c4-d5e6-4789-90ab-cdef12345002",
"version": [1, 0, 6],
"entry": "scripts/main.js"
}
],
"dependencies": [
{
"module_name": "@minecraft/server",
"version": "1.17.0"
},
{
"module_name": "@minecraft/server-ui",
"version": "1.3.0"
},
{
"module_name": "@minecraft/server-admin",
"version": "1.0.0-beta"
}
]
}
@@ -0,0 +1,479 @@
// Family Goals Addon
//
// Lives in jamie's world (the shared family survival world). Provides:
// • PvP-off (siblings can't hurt each other)
// • Family roster ( !family setup adds online players )
// • Family Bank ( !family bank registers a vanilla chest as shared )
// • Five shared goals with per-player contribution tracking
// • Loot share within 32 blocks (sibling helpers get a kill bonus)
// • Pity timer (idle siblings get an encouragement bundle every 60 min)
//
// All persistent state lives in world dynamic properties so it survives
// container restarts. No resource pack — vanilla items + titleraw + actionforms.
import { world, system, ItemStack } from "@minecraft/server";
import { ActionFormData } from "@minecraft/server-ui";
// We don't actually use @minecraft/server-admin, but importing from it flips
// the beta-API gate for this pack so world.beforeEvents.chatSend resolves.
// If we ever drop this import, fall back to afterEvents.chatSend (no cancel).
import { variables } from "@minecraft/server-admin";
void variables; // silence unused warning
// ─── Tunables ──────────────────────────────────────────────────
const FAMILY_PROP = "family_roster_v1"; // { members: [<playerId>, ...] }
const GOALS_PROP = "family_goals_v1"; // see initGoals() for shape
const BANK_PROP = "family_bank_v1"; // { x, y, z, dim } | null
const PROGRESS_PROP = "family_last_progress_at"; // per-player dyn prop, Date.now()
const VANILLA_CHEST = "minecraft:chest";
const LOOT_SHARE_RADIUS = 32;
const PITY_THRESHOLD_MS = 60 * 60 * 1000; // 60 min of no progress
const PITY_TICK_INTERVAL = 6000; // poll every 5 min (20 tps)
const NIGHTS_TICK_INTERVAL = 200; // every 10s
const GOAL_DEFS = [
{ id: "logs", label: "Logs Chopped", target: 5000 },
{ id: "diamonds", label: "Diamonds Mined", target: 50 },
{ id: "mobs", label: "Monsters Defeated", target: 500 },
{ id: "crops", label: "Crops Harvested", target: 1000 },
{ id: "nights", label: "Nights Survived", target: 30 },
];
const LOG_IDS = new Set([
"minecraft:oak_log", "minecraft:spruce_log", "minecraft:birch_log",
"minecraft:jungle_log", "minecraft:acacia_log", "minecraft:dark_oak_log",
"minecraft:cherry_log", "minecraft:mangrove_log",
"minecraft:stripped_oak_log", "minecraft:stripped_spruce_log",
"minecraft:stripped_birch_log", "minecraft:stripped_jungle_log",
"minecraft:stripped_acacia_log", "minecraft:stripped_dark_oak_log",
"minecraft:stripped_cherry_log", "minecraft:stripped_mangrove_log",
]);
const DIAMOND_IDS = new Set([
"minecraft:diamond_ore", "minecraft:deepslate_diamond_ore",
]);
// Block id → "growth"-like state value at full maturity
const CROP_AGES = {
"minecraft:wheat": 7,
"minecraft:carrots": 7,
"minecraft:potatoes": 7,
"minecraft:beetroot": 3,
};
// ─── In-memory mirror of dynamic properties ────────────────────
let family = { members: [] };
let goals = null;
let bank = null;
let lastDay = -1; // tracks day-rollover for the "nights survived" goal
// ─── Persistence ───────────────────────────────────────────────
function loadState() {
try {
const f = world.getDynamicProperty(FAMILY_PROP);
if (typeof f === "string") family = JSON.parse(f);
} catch (_) { family = { members: [] }; }
try {
const g = world.getDynamicProperty(GOALS_PROP);
if (typeof g === "string") goals = JSON.parse(g);
} catch (_) { goals = null; }
if (!goals || !goals.goals) goals = initGoals();
// Backfill any newly added goal definitions on upgrade
for (const def of GOAL_DEFS) {
if (!goals.goals[def.id]) {
goals.goals[def.id] = { current: 0, target: def.target, completed: false, contributions: {} };
}
}
try {
const b = world.getDynamicProperty(BANK_PROP);
if (typeof b === "string") bank = JSON.parse(b);
} catch (_) { bank = null; }
}
function initGoals() {
const g = { goals: {} };
for (const def of GOAL_DEFS) {
g.goals[def.id] = { current: 0, target: def.target, completed: false, contributions: {} };
}
return g;
}
function saveFamily() {
try { world.setDynamicProperty(FAMILY_PROP, JSON.stringify(family)); } catch (_) {}
}
function saveGoals() {
try {
const json = JSON.stringify(goals);
world.setDynamicProperty(GOALS_PROP, json);
const verify = world.getDynamicProperty(GOALS_PROP);
console.warn(`[Family] saveGoals: wrote ${json.length} chars, readback typeof=${typeof verify} len=${typeof verify === "string" ? verify.length : "n/a"}`);
} catch (e) { console.warn(`[Family] saveGoals FAILED: ${e}`); }
}
function saveBank() {
try { world.setDynamicProperty(BANK_PROP, bank ? JSON.stringify(bank) : undefined); } catch (_) {}
}
function isFamily(player) {
if (!player || !family.members) return false;
return family.members.includes(player.id);
}
// ─── Goal mutation ─────────────────────────────────────────────
function incrGoal(goalId, player, n = 1) {
if (!isFamily(player)) return;
const g = goals.goals[goalId];
if (!g || g.completed) return;
g.current += n;
g.contributions[player.id] = (g.contributions[player.id] || 0) + n;
try { player.setDynamicProperty(PROGRESS_PROP, Date.now()); } catch (_) {}
if (g.current >= g.target) {
g.completed = true;
g.current = g.target;
fireGoalReward(goalId);
}
saveGoals();
}
function fireGoalReward(goalId) {
const def = GOAL_DEFS.find(d => d.id === goalId);
const label = def ? def.label : goalId;
for (const p of world.getAllPlayers()) {
try {
p.onScreenDisplay.setTitle(`§6✦ Family Goal Complete!`, {
subtitle: `§e${label}`,
fadeInDuration: 10,
stayDuration: 80,
fadeOutDuration: 20,
});
p.dimension.runCommand(`summon firework_rocket ${p.location.x} ${p.location.y + 2} ${p.location.z}`);
// v1 trophy: a named gold ingot. Easy to tell apart in inventory.
p.runCommand(`give @s gold_ingot 1`);
} catch (_) {}
}
world.sendMessage(`§6[Family] §eGoal complete: §f${label}§e! §7Trophy gold awarded.`);
}
// ─── Family roster command ─────────────────────────────────────
function familySetup(player) {
const online = world.getAllPlayers();
const before = new Set(family.members);
let added = 0;
for (const p of online) {
if (!before.has(p.id)) {
family.members.push(p.id);
added += 1;
}
}
saveFamily();
const names = online.map(p => p.name).join(", ");
if (added === 0) {
player.sendMessage(`§7[Family] §fAll currently-online players are already in the roster (${family.members.length} members).`);
} else {
player.sendMessage(`§a[Family] §fAdded ${added} new member(s). Roster (${family.members.length}): §e${names}§f.`);
world.sendMessage(`§a[Family] §fRoster updated by §e${player.name}§f.`);
}
}
function familyReset(player) {
family = { members: [] };
saveFamily();
player.sendMessage("§c[Family] §fRoster cleared. Run §e!family setup§f again to repopulate.");
}
// ─── Family Bank command ───────────────────────────────────────
function familyBank(player) {
if (!isFamily(player)) {
player.sendMessage("§c[Family] §7Only family members can register the bank — run §e!family setup§7 first while everyone's online.");
return;
}
let view;
try {
view = player.getBlockFromViewDirection({ maxDistance: 6 });
} catch (e) {
player.sendMessage(`§c[Family] §7Couldn't look at a block: ${e.message}`);
return;
}
const target = view?.block;
if (!target || target.typeId !== VANILLA_CHEST) {
player.sendMessage("§c[Family] §7Look at a vanilla chest within 6 blocks, then re-run §e!family bank§7.");
return;
}
bank = {
x: target.location.x,
y: target.location.y,
z: target.location.z,
dim: target.dimension.id,
};
saveBank();
player.sendMessage(`§a[Family] §fBank registered at §e${bank.x}, ${bank.y}, ${bank.z}§f. All family members can now open it.`);
}
// ─── Bank gating (interact + break) ────────────────────────────
function isBankAt(loc, dimId) {
if (!bank) return false;
return Math.floor(loc.x) === bank.x
&& Math.floor(loc.y) === bank.y
&& Math.floor(loc.z) === bank.z
&& bank.dim === dimId;
}
try {
world.beforeEvents.playerInteractWithBlock.subscribe((event) => {
const block = event.block;
if (block.typeId !== VANILLA_CHEST) return;
if (!isBankAt(block.location, block.dimension.id)) return;
if (isFamily(event.player)) return;
event.cancel = true;
const playerRef = event.player;
system.run(() => playerRef.sendMessage("§c[Family Bank] §7Only family members can open this chest."));
});
} catch (e) {
console.warn(`[Family] beforeEvents.playerInteractWithBlock unavailable: ${e}`);
}
try {
world.beforeEvents.playerBreakBlock.subscribe((event) => {
const block = event.block;
if (block.typeId !== VANILLA_CHEST) return;
if (!isBankAt(block.location, block.dimension.id)) return;
if (isFamily(event.player)) {
// Family member breaking the bank → un-register so it stops being protected
bank = null;
saveBank();
const p = event.player;
system.run(() => p.sendMessage("§7[Family Bank] §fBank chest broken — un-registered. Place a new chest and run §e!family bank§f to re-register."));
return;
}
event.cancel = true;
const playerRef = event.player;
system.run(() => playerRef.sendMessage("§c[Family Bank] §7Only family members can break this chest."));
});
} catch (e) {
console.warn(`[Family] beforeEvents.playerBreakBlock unavailable: ${e}`);
}
// ─── PvP off (heal damage between siblings) ────────────────────
// Bedrock has no server.properties pvp toggle — we hook the after-event
// and immediately re-heal. Net visible effect: zero damage between players.
world.afterEvents.entityHurt.subscribe((event) => {
const source = event.damageSource?.damagingEntity;
const victim = event.hurtEntity;
if (!source || !victim) return;
if (source.typeId !== "minecraft:player" || victim.typeId !== "minecraft:player") return;
try {
const health = victim.getComponent("minecraft:health");
if (!health) return;
const restored = Math.min(health.effectiveMax, health.currentValue + event.damage);
health.setCurrentValue(restored);
} catch (_) {}
});
// ─── Goal triggers ─────────────────────────────────────────────
world.afterEvents.playerBreakBlock.subscribe((event) => {
const player = event.player;
if (!isFamily(player)) return;
const id = event.brokenBlockPermutation?.type?.id;
if (!id) return;
if (LOG_IDS.has(id)) {
incrGoal("logs", player);
return;
}
if (DIAMOND_IDS.has(id)) {
incrGoal("diamonds", player);
return;
}
if (id in CROP_AGES) {
// Only fully grown crops count
let states = null;
try { states = event.brokenBlockPermutation.getAllStates(); } catch (_) {}
const age = states && (states["growth"] ?? states["age"]);
if (typeof age === "number" && age >= CROP_AGES[id]) {
incrGoal("crops", player);
}
}
});
world.afterEvents.entityDie.subscribe((event) => {
const dead = event.deadEntity;
if (!dead || dead.typeId === "minecraft:player") return;
const source = event.damageSource?.damagingEntity;
if (!source || source.typeId !== "minecraft:player") return;
if (!isFamily(source)) return;
// Goal: monsters only
let isMonster = false;
try {
const fam = dead.getComponent("minecraft:type_family");
if (fam && typeof fam.hasTypeFamily === "function" && fam.hasTypeFamily("monster")) {
isMonster = true;
}
} catch (_) {}
if (isMonster) incrGoal("mobs", source);
// Loot share: every family member in 32 blocks (other than killer) gets cooked beef.
// Sharing helpers, not real drop mirroring — keeps the drop pipeline untouched.
try {
const k = source.location;
const r2 = LOOT_SHARE_RADIUS * LOOT_SHARE_RADIUS;
for (const other of world.getAllPlayers()) {
if (other.id === source.id) continue;
if (!isFamily(other)) continue;
if (other.dimension.id !== source.dimension.id) continue;
const dx = other.location.x - k.x;
const dy = other.location.y - k.y;
const dz = other.location.z - k.z;
if (dx*dx + dy*dy + dz*dz <= r2) {
other.runCommand("give @s cooked_beef 2");
}
}
} catch (_) {}
});
// ─── Nights survived ───────────────────────────────────────────
system.runInterval(() => {
let day;
try { day = world.getDay(); } catch (_) { return; }
if (typeof day !== "number") return;
if (lastDay === -1) { lastDay = day; return; }
if (day > lastDay) {
const diff = day - lastDay;
lastDay = day;
for (const p of world.getAllPlayers()) {
if (isFamily(p)) incrGoal("nights", p, diff);
}
}
}, NIGHTS_TICK_INTERVAL);
// ─── Pity timer ────────────────────────────────────────────────
system.runInterval(() => {
const now = Date.now();
for (const p of world.getAllPlayers()) {
if (!isFamily(p)) continue;
let last;
try { last = p.getDynamicProperty(PROGRESS_PROP); } catch (_) { last = undefined; }
if (typeof last !== "number") {
// First time we've seen this family member contribute (or first session) — initialize, don't pity yet
try { p.setDynamicProperty(PROGRESS_PROP, now); } catch (_) {}
continue;
}
if (now - last < PITY_THRESHOLD_MS) continue;
// Drop encouragement bundle directly into inventory
try {
p.runCommand("give @s diamond 1");
p.runCommand("give @s cooked_beef 16");
p.runCommand("give @s iron_pickaxe 1");
p.sendMessage("§6[Family] §fA little boost to keep you going! §7(family pity bundle)");
p.setDynamicProperty(PROGRESS_PROP, now); // reset so we don't spam every 5 min
} catch (_) {}
}
}, PITY_TICK_INTERVAL);
// ─── Goal display ──────────────────────────────────────────────
function makeBar(pct) {
const filled = Math.max(0, Math.min(10, Math.floor(pct / 10)));
return "§a" + "▰".repeat(filled) + "§8" + "▰".repeat(10 - filled);
}
async function showGoals(player, perPlayerMode) {
const lines = [];
for (const def of GOAL_DEFS) {
const g = goals.goals[def.id];
if (!g) continue;
const cur = Math.min(g.current, g.target);
const pct = Math.floor((cur / g.target) * 100);
const bar = makeBar(pct);
const status = g.completed ? " §a✔" : "";
if (perPlayerMode) {
const my = g.contributions[player.id] || 0;
const myPct = cur > 0 ? Math.floor((my / cur) * 100) : 0;
lines.push(`§e${def.label}${status}\n§7 ${bar}§7 §f${cur}§7/§f${g.target}\n§7 You: §f${my}§7 (${myPct}% of progress)`);
} else {
lines.push(`§e${def.label}${status}\n§7 ${bar}§7 §f${cur}§7/§f${g.target}`);
}
}
if (!lines.length) lines.push("§7No goals configured.");
const form = new ActionFormData()
.title(perPlayerMode ? "My Goal Contributions" : "Family Goals")
.body(lines.join("\n\n"))
.button("§7OK");
try { await form.show(player); } catch (_) {}
}
// ─── Family menu (entered via scriptevent from hub-return compass) ─────
function showRoster(player) {
if (family.members.length === 0) {
player.sendMessage("§7[Family] §fNo roster set yet — open §e🏆 Family§f from your compass and pick §eAdd me to the family§f.");
return;
}
const names = world.getAllPlayers()
.filter(p => family.members.includes(p.id))
.map(p => p.name);
player.sendMessage(`§a[Family] §fRoster: §e${family.members.length}§f members. Online: §e${names.join(", ") || "(none)"}§f.`);
}
async function openFamilyMenu(player) {
const inFamily = isFamily(player);
const form = new ActionFormData()
.title("🏆 Family")
.body(`§7Roster: §f${family.members.length}§7 members${inFamily ? " §a(you're in)" : " §c(you're not)"}\n§7Bank: ${bank ? `§f${bank.x}, ${bank.y}, ${bank.z}` : "§cnot set"}`);
const actions = [];
form.button("🎯 Family Goals"); actions.push("goals");
form.button("📝 My Contributions"); actions.push("mygoals");
form.button("👥 Show Roster"); actions.push("roster");
form.button(" Add me / Add online players"); actions.push("setup");
form.button("🏦 Register Family Bank (look at chest)"); actions.push("bank");
form.button("♻ Reset Roster (danger)"); actions.push("reset");
form.button("§7Cancel"); actions.push("cancel");
let res;
try { res = await form.show(player); } catch (_) { return; }
if (res.canceled || res.selection === undefined) return;
const a = actions[res.selection];
if (a === "goals") return showGoals(player, false);
if (a === "mygoals") return showGoals(player, true);
if (a === "roster") return showRoster(player);
if (a === "setup") return familySetup(player);
if (a === "bank") return familyBank(player);
if (a === "reset") return familyReset(player);
}
// scriptEventReceive is the cross-pack dispatch surface. Hub-return's compass
// menu fires `scriptevent family:menu` to enter our UI. Direct ids are also
// handled so an opped player can run e.g. `/scriptevent family:setup`.
try {
system.afterEvents.scriptEventReceive.subscribe((event) => {
const id = event.id;
if (!id || !id.startsWith("family:")) return;
const player = event.sourceEntity;
if (!player || player.typeId !== "minecraft:player") return;
switch (id) {
case "family:menu": system.run(() => openFamilyMenu(player)); break;
case "family:goals": system.run(() => showGoals(player, false)); break;
case "family:mygoals": system.run(() => showGoals(player, true)); break;
case "family:roster": showRoster(player); break;
case "family:setup": familySetup(player); break;
case "family:bank": familyBank(player); break;
case "family:reset": familyReset(player); break;
}
});
} catch (e) {
console.warn(`[Family] scriptEventReceive unavailable: ${e}`);
}
// ─── Boot ──────────────────────────────────────────────────────
system.run(() => {
loadState();
saveGoals(); // persist any backfilled goal defs from upgrade
world.sendMessage("§6[Family] §fLoaded. Right-click your §dHub Compass§f to find the §6🏆 Family§f menu.");
});
+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"
}
}
}