Merge: redstone-link addon, family-goals tracking, custom-block geometry fix
Deploy Addons / deploy (push) Successful in 14s

Brings in four commits:
  * redstone-link-addon -- wireless redstone (TX/RX paired by channel name)
  * family-goals-addon  -- was live on jamie but in no commit on any branch
  * block textures generated through scripts/build-textures.py
  * minecraft:geometry fix for the 7 custom blocks that rendered untextured

All of it is already deployed by hand and verified on the live stack, so a
CI deploy from this merge is a no-op content-wise. docker-compose.yml here is
byte-identical to the host, and deploy.yml's PATHS now covers every directory
the compose file mounts.

--no-ff so the whole change can be reverted as one unit if a live world
misbehaves.

* feat/redstone-link-and-family-goals:
  fix(addons): add minecraft:geometry to the 7 untextured custom blocks
  fix(redstone-link): add minecraft:geometry so material_instances applies
  feat(redstone-link): real block textures via build-textures.py, add build.sh
  feat(redstone-link): wireless redstone addon; track family-goals
This commit is contained in:
sysadmin
2026-08-28 12:36:17 +01:00
30 changed files with 1477 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
@@ -12,6 +12,7 @@
"explosion_resistance": 1.0
},
"minecraft:map_color": "#547A4E",
"minecraft:geometry": "minecraft:geometry.full_block",
"minecraft:material_instances": {
"*": {
"texture": "tent_canvas",
+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.");
});
@@ -16,6 +16,7 @@
"explosion_resistance": 200.0
},
"minecraft:map_color": "#C0703A",
"minecraft:geometry": "minecraft:geometry.full_block",
"minecraft:material_instances": {
"*": {
"texture": "home_sign",
@@ -17,6 +17,7 @@
},
"minecraft:light_emission": 10,
"minecraft:map_color": "#5C6D74",
"minecraft:geometry": "minecraft:geometry.full_block",
"minecraft:material_instances": {
"*": {
"texture": "portal_frame",
@@ -17,6 +17,7 @@
},
"minecraft:light_emission": 10,
"minecraft:map_color": "#2D9C2D",
"minecraft:geometry": "minecraft:geometry.full_block",
"minecraft:material_instances": {
"*": {
"texture": "portal_jamie",
@@ -17,6 +17,7 @@
},
"minecraft:light_emission": 10,
"minecraft:map_color": "#9B59B6",
"minecraft:geometry": "minecraft:geometry.full_block",
"minecraft:material_instances": {
"*": {
"texture": "portal_lyla",
@@ -17,6 +17,7 @@
},
"minecraft:light_emission": 10,
"minecraft:map_color": "#4FC1E9",
"minecraft:geometry": "minecraft:geometry.full_block",
"minecraft:material_instances": {
"*": {
"texture": "portal_mya",
+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.
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build"
rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR"
# Each pack is zipped from INSIDE its own directory so that manifest.json sits
# at the archive root. An extra nesting level makes Minecraft reject the import.
# No -x exclude here, unlike addon/build.sh: these pack dirs contain no
# dotfiles, and dropping it keeps the script working with the 7-Zip `zip`
# shim on the Windows workstation as well as Info-ZIP on the CI runner.
echo "Packaging Behavior Pack..."
cd "$SCRIPT_DIR/redstone_link_BP"
zip -rq "$BUILD_DIR/redstone_link_BP.mcpack" .
echo "Packaging Resource Pack..."
cd "$SCRIPT_DIR/redstone_link_RP"
zip -rq "$BUILD_DIR/redstone_link_RP.mcpack" .
echo "Creating .mcaddon bundle..."
cd "$BUILD_DIR"
zip -rq "$BUILD_DIR/redstone_link.mcaddon" redstone_link_BP.mcpack redstone_link_RP.mcpack
echo ""
echo "Build complete!"
echo " Output: $BUILD_DIR/redstone_link.mcaddon"
echo ""
echo "To install:"
echo " - Double-click the .mcaddon file on Windows to auto-import"
echo " - Or copy packs to com.mojang/development_behavior_packs/ and development_resource_packs/"
echo ""
echo "Note: import BOTH packs. The behavior pack alone gives you working"
echo "logic with missing textures and untranslated block names."
@@ -0,0 +1,28 @@
{
"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:geometry": "minecraft:geometry.full_block",
"minecraft:material_instances": {
"*": {
"texture": "redstone_link_rx",
"render_method": "opaque"
}
}
}
}
}
@@ -0,0 +1,28 @@
{
"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:geometry": "minecraft:geometry.full_block",
"minecraft:material_instances": {
"*": {
"texture": "redstone_link_tx",
"render_method": "opaque"
}
}
}
}
}
@@ -0,0 +1,58 @@
{
"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,
1
],
"min_engine_version": [
1,
21,
0
]
},
"modules": [
{
"type": "data",
"uuid": "e4f1a7c2-6b95-4d38-a7e0-3c81d5f2b002",
"version": [
1,
0,
1
]
},
{
"type": "script",
"language": "javascript",
"uuid": "e4f1a7c2-6b95-4d38-a7e0-3c81d5f2b003",
"version": [
1,
0,
1
],
"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,
1
]
}
]
}
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, 1],
"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: 291 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 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"
}
}
}
+87
View File
@@ -397,12 +397,99 @@ def portal_field() -> Image.Image:
return img
# ── Redstone Link transmitter / receiver ───────────────────
# Machine faces, not icons. Following the mailbox lesson: the motif has to
# reach the edges or a cube of it reads as a placeholder. Both share a gunmetal
# plate with corner rivets and a beveled frame; the transmitter carries redstone
# traces radiating OUT to all four edges, the receiver carries traces running IN
# to a collector dish. Same silhouette, mirrored behaviour, different accent.
LINK_PLATE = (62, 66, 74)
LINK_PLATE_HL = (92, 97, 107)
LINK_PLATE_SH = (38, 41, 47)
LINK_RIVET = (140, 146, 158)
TX_TRACE = (168, 30, 28)
TX_TRACE_HOT = REDSTONE_BRIGHT
TX_CORE = (255, 140, 110)
RX_TRACE = (34, 96, 150)
RX_TRACE_HOT = (92, 178, 240)
RX_CORE = (200, 240, 255)
def _link_plate() -> Image.Image:
"""Shared gunmetal base: beveled frame + corner rivets, fully opaque."""
img = Image.new("RGBA", (16, 16), LINK_PLATE)
# Bevel: lit top/left, shadowed bottom/right
rect(img, 0, 0, 15, 0, LINK_PLATE_HL)
rect(img, 0, 0, 0, 15, LINK_PLATE_HL)
rect(img, 0, 15, 15, 15, LINK_PLATE_SH)
rect(img, 15, 0, 15, 15, LINK_PLATE_SH)
# Corner rivets
for cx, cy in ((2, 2), (13, 2), (2, 13), (13, 13)):
px(img, cx, cy, LINK_RIVET)
px(img, cx, cy + 1, LINK_PLATE_SH)
return img
def redstone_link_tx() -> Image.Image:
img = _link_plate()
# Traces running from the core out to all four edges: this is what makes a
# cube of it read as wiring rather than a centred sprite.
rect(img, 7, 0, 8, 15, TX_TRACE)
rect(img, 0, 7, 15, 8, TX_TRACE)
# Diagonal spurs toward the corners
for i in range(3, 7):
px(img, i, i, TX_TRACE)
px(img, 15 - i, i, TX_TRACE)
px(img, i, 15 - i, TX_TRACE)
px(img, 15 - i, 15 - i, TX_TRACE)
# Emitter core, hot centre
rect(img, 6, 6, 9, 9, TX_TRACE_HOT)
rect(img, 7, 7, 8, 8, TX_CORE)
px(img, 7, 7, (255, 235, 220))
# Trace highlight so the cross reads as raised
rect(img, 7, 1, 7, 5, TX_TRACE_HOT)
rect(img, 1, 7, 5, 7, TX_TRACE_HOT)
return img
def redstone_link_rx() -> Image.Image:
img = _link_plate()
# Collector rings, broken at the axes so the in-running traces read clearly
for x in range(3, 13):
px(img, x, 3, RX_TRACE)
px(img, x, 12, RX_TRACE)
for y in range(3, 13):
px(img, 3, y, RX_TRACE)
px(img, 12, y, RX_TRACE)
for x in range(5, 11):
px(img, x, 5, RX_TRACE_HOT)
px(img, x, 10, RX_TRACE_HOT)
for y in range(5, 11):
px(img, 5, y, RX_TRACE_HOT)
px(img, 10, y, RX_TRACE_HOT)
# Feed lines from the edges into the dish
rect(img, 7, 0, 8, 2, RX_TRACE)
rect(img, 7, 13, 8, 15, RX_TRACE)
rect(img, 0, 7, 2, 8, RX_TRACE)
rect(img, 13, 7, 15, 8, RX_TRACE)
# Collector core
rect(img, 7, 7, 8, 8, RX_TRACE_HOT)
px(img, 7, 7, RX_CORE)
px(img, 8, 8, RX_CORE)
return img
def main() -> None:
save(smart_crafting_table(), "smart-crafting-addon/smart_crafting_RP/textures/blocks/smart_crafting_table.png")
save(post_office_block(), "postal-service-addon/postal_service_RP/textures/blocks/post_office.png")
save(mailbox_block(), "postal-service-addon/postal_service_RP/textures/blocks/mailbox.png")
save(tent_canvas(), "camping-supplies-addon/camping_supplies_RP/textures/blocks/tent_canvas.png")
save(portal_field(), "lobby-addon/lobby_transfer_RP/textures/blocks/portal_field.png")
save(redstone_link_tx(), "redstone-link-addon/redstone_link_RP/textures/blocks/redstone_link_tx.png")
save(redstone_link_rx(), "redstone-link-addon/redstone_link_RP/textures/blocks/redstone_link_rx.png")
if __name__ == "__main__":
@@ -16,6 +16,7 @@
"explosion_resistance": 20.0
},
"minecraft:map_color": "#D4AF37",
"minecraft:geometry": "minecraft:geometry.full_block",
"minecraft:material_instances": {
"*": {
"texture": "smart_crafting_table",