diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 88808fb..3a4eec2 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/camping-supplies-addon/camping_supplies_BP/blocks/tent_canvas.json b/camping-supplies-addon/camping_supplies_BP/blocks/tent_canvas.json index 12998ba..086526d 100644 --- a/camping-supplies-addon/camping_supplies_BP/blocks/tent_canvas.json +++ b/camping-supplies-addon/camping_supplies_BP/blocks/tent_canvas.json @@ -12,6 +12,7 @@ "explosion_resistance": 1.0 }, "minecraft:map_color": "#547A4E", + "minecraft:geometry": "minecraft:geometry.full_block", "minecraft:material_instances": { "*": { "texture": "tent_canvas", diff --git a/docker-compose.yml b/docker-compose.yml index 04bda63..c72b019 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/family-goals-addon/family_goals_BP/manifest.json b/family-goals-addon/family_goals_BP/manifest.json new file mode 100644 index 0000000..a4097dd --- /dev/null +++ b/family-goals-addon/family_goals_BP/manifest.json @@ -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" + } + ] +} diff --git a/family-goals-addon/family_goals_BP/scripts/main.js b/family-goals-addon/family_goals_BP/scripts/main.js new file mode 100644 index 0000000..8d03576 --- /dev/null +++ b/family-goals-addon/family_goals_BP/scripts/main.js @@ -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: [, ...] } +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."); +}); diff --git a/home-sign-addon/home_sign_BP/blocks/home_sign.json b/home-sign-addon/home_sign_BP/blocks/home_sign.json index 3c59f85..df7eca2 100644 --- a/home-sign-addon/home_sign_BP/blocks/home_sign.json +++ b/home-sign-addon/home_sign_BP/blocks/home_sign.json @@ -16,6 +16,7 @@ "explosion_resistance": 200.0 }, "minecraft:map_color": "#C0703A", + "minecraft:geometry": "minecraft:geometry.full_block", "minecraft:material_instances": { "*": { "texture": "home_sign", diff --git a/lobby-addon/lobby_transfer_BP/blocks/portal_frame.json b/lobby-addon/lobby_transfer_BP/blocks/portal_frame.json index b37beda..9127cac 100644 --- a/lobby-addon/lobby_transfer_BP/blocks/portal_frame.json +++ b/lobby-addon/lobby_transfer_BP/blocks/portal_frame.json @@ -17,6 +17,7 @@ }, "minecraft:light_emission": 10, "minecraft:map_color": "#5C6D74", + "minecraft:geometry": "minecraft:geometry.full_block", "minecraft:material_instances": { "*": { "texture": "portal_frame", diff --git a/lobby-addon/lobby_transfer_BP/blocks/portal_jamie.json b/lobby-addon/lobby_transfer_BP/blocks/portal_jamie.json index e7075a9..b7797c4 100644 --- a/lobby-addon/lobby_transfer_BP/blocks/portal_jamie.json +++ b/lobby-addon/lobby_transfer_BP/blocks/portal_jamie.json @@ -17,6 +17,7 @@ }, "minecraft:light_emission": 10, "minecraft:map_color": "#2D9C2D", + "minecraft:geometry": "minecraft:geometry.full_block", "minecraft:material_instances": { "*": { "texture": "portal_jamie", diff --git a/lobby-addon/lobby_transfer_BP/blocks/portal_lyla.json b/lobby-addon/lobby_transfer_BP/blocks/portal_lyla.json index 9b995ad..2929a4d 100644 --- a/lobby-addon/lobby_transfer_BP/blocks/portal_lyla.json +++ b/lobby-addon/lobby_transfer_BP/blocks/portal_lyla.json @@ -17,6 +17,7 @@ }, "minecraft:light_emission": 10, "minecraft:map_color": "#9B59B6", + "minecraft:geometry": "minecraft:geometry.full_block", "minecraft:material_instances": { "*": { "texture": "portal_lyla", diff --git a/lobby-addon/lobby_transfer_BP/blocks/portal_mya.json b/lobby-addon/lobby_transfer_BP/blocks/portal_mya.json index eb80320..8ad14a0 100644 --- a/lobby-addon/lobby_transfer_BP/blocks/portal_mya.json +++ b/lobby-addon/lobby_transfer_BP/blocks/portal_mya.json @@ -17,6 +17,7 @@ }, "minecraft:light_emission": 10, "minecraft:map_color": "#4FC1E9", + "minecraft:geometry": "minecraft:geometry.full_block", "minecraft:material_instances": { "*": { "texture": "portal_mya", diff --git a/redstone-link-addon/README.md b/redstone-link-addon/README.md new file mode 100644 index 0000000..d6a1b5a --- /dev/null +++ b/redstone-link-addon/README.md @@ -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 +/scriptevent rlink:unregister +/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. diff --git a/redstone-link-addon/build.sh b/redstone-link-addon/build.sh new file mode 100644 index 0000000..56830b5 --- /dev/null +++ b/redstone-link-addon/build.sh @@ -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." diff --git a/redstone-link-addon/redstone_link_BP/blocks/redstone_link_rx.json b/redstone-link-addon/redstone_link_BP/blocks/redstone_link_rx.json new file mode 100644 index 0000000..57235cc --- /dev/null +++ b/redstone-link-addon/redstone_link_BP/blocks/redstone_link_rx.json @@ -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" + } + } + } + } +} diff --git a/redstone-link-addon/redstone_link_BP/blocks/redstone_link_tx.json b/redstone-link-addon/redstone_link_BP/blocks/redstone_link_tx.json new file mode 100644 index 0000000..80b4e17 --- /dev/null +++ b/redstone-link-addon/redstone_link_BP/blocks/redstone_link_tx.json @@ -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" + } + } + } + } +} diff --git a/redstone-link-addon/redstone_link_BP/manifest.json b/redstone-link-addon/redstone_link_BP/manifest.json new file mode 100644 index 0000000..3ced366 --- /dev/null +++ b/redstone-link-addon/redstone_link_BP/manifest.json @@ -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 + ] + } + ] +} diff --git a/redstone-link-addon/redstone_link_BP/pack_icon.png b/redstone-link-addon/redstone_link_BP/pack_icon.png new file mode 100644 index 0000000..f030e4a Binary files /dev/null and b/redstone-link-addon/redstone_link_BP/pack_icon.png differ diff --git a/redstone-link-addon/redstone_link_BP/recipes/redstone_link_rx.json b/redstone-link-addon/redstone_link_BP/recipes/redstone_link_rx.json new file mode 100644 index 0000000..1d6e0f7 --- /dev/null +++ b/redstone-link-addon/redstone_link_BP/recipes/redstone_link_rx.json @@ -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 + } + } +} diff --git a/redstone-link-addon/redstone_link_BP/recipes/redstone_link_tx.json b/redstone-link-addon/redstone_link_BP/recipes/redstone_link_tx.json new file mode 100644 index 0000000..82e0dfa --- /dev/null +++ b/redstone-link-addon/redstone_link_BP/recipes/redstone_link_tx.json @@ -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 + } + } +} diff --git a/redstone-link-addon/redstone_link_BP/scripts/main.js b/redstone-link-addon/redstone_link_BP/scripts/main.js new file mode 100644 index 0000000..cf8291f --- /dev/null +++ b/redstone-link-addon/redstone_link_BP/scripts/main.js @@ -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 +// /scriptevent rlink:unregister +// /scriptevent rlink:status + +function announce(text) { + try { world.getDimension("overworld").runCommand(`say ${text}`); } catch (_) {} + console.warn(text); +} + +function handleRegister(args) { + if (args.length < 4) { + announce("[RedstoneLink] usage: rlink:register "); + return; + } + const x = Number(args[0]), y = Number(args[1]), z = Number(args[2]); + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) { + announce("[RedstoneLink] register: coordinates must be numbers"); + return; + } + const channel = sanitiseChannel(args.slice(3).join(" ")); + if (!channel) { + announce("[RedstoneLink] register: channel name required"); + return; + } + const loc = { x: Math.floor(x), y: Math.floor(y), z: Math.floor(z) }; + let block; + try { block = world.getDimension("overworld").getBlock(loc); } catch (_) { block = undefined; } + if (!block) { + announce(`[RedstoneLink] register: no loaded block at ${loc.x},${loc.y},${loc.z}`); + return; + } + const role = roleOf(block.typeId); + if (!role) { + announce(`[RedstoneLink] register: ${block.typeId} is not a link block`); + return; + } + const saved = registry.add("minecraft:overworld", loc, role, channel); + if (!saved) { + announce(`[RedstoneLink] register: registry full (max ${registry.MAX_NODES})`); + return; + } + announce(`[RedstoneLink] registered ${role} at ${loc.x},${loc.y},${loc.z} on channel ${channel}`); +} + +function handleUnregister(args) { + if (args.length < 3) { + announce("[RedstoneLink] usage: rlink:unregister "); + return; + } + const loc = { + x: Math.floor(Number(args[0])), + y: Math.floor(Number(args[1])), + z: Math.floor(Number(args[2])), + }; + const gone = registry.remove("minecraft:overworld", loc); + announce(`[RedstoneLink] unregister ${loc.x},${loc.y},${loc.z}: ${gone ? "removed" : "not found"}`); +} + +function handleStatus() { + const nodes = registry.all(); + const tx = nodes.filter((n) => n.r === "tx").length; + const rx = nodes.filter((n) => n.r === "rx").length; + const channels = [...new Set(nodes.map((n) => n.c).filter(Boolean))]; + announce(`[RedstoneLink] alive: ${nodes.length} node(s), ${tx} tx, ${rx} rx, channels=[${channels.join(",")}]`); +} + +try { + system.afterEvents.scriptEventReceive.subscribe((event) => { + const id = event.id; + if (!id || !id.startsWith("rlink:")) return; + const args = (event.message || "").trim().split(/\s+/).filter(Boolean); + try { + switch (id) { + case "rlink:register": handleRegister(args); break; + case "rlink:unregister": handleUnregister(args); break; + case "rlink:status": handleStatus(); break; + default: announce(`[RedstoneLink] unknown command ${id}`); + } + } catch (e) { + announce(`[RedstoneLink] ${id} failed: ${e}`); + } + }); +} catch (e) { + console.warn(`[RedstoneLink] scriptEventReceive unavailable: ${e}`); +} + +// --- Boot ----------------------------------------------------------------- + +system.run(() => { + try { reconcile(); } catch (e) { console.warn(`[RedstoneLink] reconcile failed: ${e}`); } + try { + system.runInterval(() => { + try { tick(); } catch (e) { console.warn(`[RedstoneLink] tick failed: ${e}`); } + }, TICK_INTERVAL); + } catch (e) { + console.warn(`[RedstoneLink] could not start tick loop: ${e}`); + } + world.sendMessage("§c[Redstone Link] §7Wireless redstone loaded."); +}); diff --git a/redstone-link-addon/redstone_link_BP/scripts/power.js b/redstone-link-addon/redstone_link_BP/scripts/power.js new file mode 100644 index 0000000..bd8b8cb --- /dev/null +++ b/redstone-link-addon/redstone_link_BP/scripts/power.js @@ -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; +} diff --git a/redstone-link-addon/redstone_link_BP/scripts/registry.js b/redstone-link-addon/redstone_link_BP/scripts/registry.js new file mode 100644 index 0000000..1676056 --- /dev/null +++ b/redstone-link-addon/redstone_link_BP/scripts/registry.js @@ -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(); +} diff --git a/redstone-link-addon/redstone_link_RP/blocks.json b/redstone-link-addon/redstone_link_RP/blocks.json new file mode 100644 index 0000000..2b4511f --- /dev/null +++ b/redstone-link-addon/redstone_link_RP/blocks.json @@ -0,0 +1,5 @@ +{ + "format_version": [1, 1, 0], + "silverlabs:redstone_link_tx": { "sound": "stone" }, + "silverlabs:redstone_link_rx": { "sound": "stone" } +} diff --git a/redstone-link-addon/redstone_link_RP/manifest.json b/redstone-link-addon/redstone_link_RP/manifest.json new file mode 100644 index 0000000..93add19 --- /dev/null +++ b/redstone-link-addon/redstone_link_RP/manifest.json @@ -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] + } + ] +} diff --git a/redstone-link-addon/redstone_link_RP/pack_icon.png b/redstone-link-addon/redstone_link_RP/pack_icon.png new file mode 100644 index 0000000..f030e4a Binary files /dev/null and b/redstone-link-addon/redstone_link_RP/pack_icon.png differ diff --git a/redstone-link-addon/redstone_link_RP/texts/en_US.lang b/redstone-link-addon/redstone_link_RP/texts/en_US.lang new file mode 100644 index 0000000..a820b43 --- /dev/null +++ b/redstone-link-addon/redstone_link_RP/texts/en_US.lang @@ -0,0 +1,2 @@ +tile.silverlabs:redstone_link_tx.name=Redstone Link Transmitter +tile.silverlabs:redstone_link_rx.name=Redstone Link Receiver diff --git a/redstone-link-addon/redstone_link_RP/textures/blocks/redstone_link_rx.png b/redstone-link-addon/redstone_link_RP/textures/blocks/redstone_link_rx.png new file mode 100644 index 0000000..d070425 Binary files /dev/null and b/redstone-link-addon/redstone_link_RP/textures/blocks/redstone_link_rx.png differ diff --git a/redstone-link-addon/redstone_link_RP/textures/blocks/redstone_link_tx.png b/redstone-link-addon/redstone_link_RP/textures/blocks/redstone_link_tx.png new file mode 100644 index 0000000..a112e87 Binary files /dev/null and b/redstone-link-addon/redstone_link_RP/textures/blocks/redstone_link_tx.png differ diff --git a/redstone-link-addon/redstone_link_RP/textures/terrain_texture.json b/redstone-link-addon/redstone_link_RP/textures/terrain_texture.json new file mode 100644 index 0000000..77784ab --- /dev/null +++ b/redstone-link-addon/redstone_link_RP/textures/terrain_texture.json @@ -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" + } + } +} diff --git a/scripts/build-textures.py b/scripts/build-textures.py index c30a64c..35455f2 100644 --- a/scripts/build-textures.py +++ b/scripts/build-textures.py @@ -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__": diff --git a/smart-crafting-addon/smart_crafting_BP/blocks/smart_crafting_table.json b/smart-crafting-addon/smart_crafting_BP/blocks/smart_crafting_table.json index 448bcea..66e075f 100644 --- a/smart-crafting-addon/smart_crafting_BP/blocks/smart_crafting_table.json +++ b/smart-crafting-addon/smart_crafting_BP/blocks/smart_crafting_table.json @@ -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",