11 Commits
Author SHA1 Message Date
sysadminandClaude Opus 5 4c8f3b4a28 fix(dynamite): revert thrown-entity geometry; bump RPs to break client cache
Deploy Addons / deploy (push) Failing after 0s
Two problems, one of them mine.

Reverts thrown_banger/bundle/dynamite from geometry.snowball back to
geometry.item_sprite. Syncing these from main to the host produced
"[Geometry][error] geometry not found?" on the client -- geometry.snowball
does not resolve, while the April item_sprite version the host had been
running does. main was newer but had never been deployed, so the change had
never been validated against a running client. Newer is not the same as
correct, and this should have been verified before syncing rather than
assumed from the timestamps.

Separately, the client was reporting "Missing referenced asset" for every
custom block geometry -- tent_panel_l/r, hammock_slab, portal_field,
dragon_basket -- and spamming "A block must have baked material data in order
to tessellate!" 182,683 times, once per frame per unrenderable block. Those
geometry files exist and are valid; the client was serving stale cached copies
of the resource packs that predate them. Dynamite was the one pack that
refreshed, precisely because its version had just been bumped, which is why
dynamite appeared in game and camping supplies did not.

So every RP that ships models/blocks geometry is bumped to force a re-download:
spark_pet, camping_supplies and lobby_transfer, plus dynamite to carry the
geometry revert. Dependent BP manifests follow, and the world pins were set
explicitly rather than by the repin helper, which correctly refused these --
shared/ holds duplicate copies of the same UUIDs at older versions, so
"version on disk" is ambiguous for them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Gf1kZypRDfPL1YiWUS1LC
2026-08-28 13:50:27 +01:00
sysadminandClaude Opus 5 f67249c8db ci: cover every mounted addon in the deploy PATHS
Six addons were bind-mounted in docker-compose.yml but absent from the deploy
workflow, so CI had never deployed them: dynamite, hemp, naturalist-lite,
smart-crafting, tow-boat and trees-features. The host was consequently running
April copies of files that main had moved on from in August -- dynamite's
thrown entities still used item_sprite geometry rather than snowball, and
smart-crafting's recipe still used AlwaysUnlocked rather than unlocking from a
crafting table.

That gap also silently broke dynamite: the world pins had been advanced to RP
1.0.2 while the host stayed on 1.0.1, so the pack was dropped from every world
until the pins were corrected earlier today.

Verified before adding, rather than trusting the workflow: tow-boat and
trees-features are byte-identical between host and main, and for the other
four main is strictly newer (host files date from April, main's from August).
The only host-only files are three strays that checkout -f will not remove --
sloth.bc_ap.json, "secretary_bird.rp_ac .json" (note the space) and
warlus.json -- so nothing is lost by arming the checkout for these paths.

PATHS now covers every directory docker-compose.yml mounts, so a mount can no
longer point at a directory CI never delivers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Gf1kZypRDfPL1YiWUS1LC
2026-08-28 13:23:48 +01:00
sysadminandClaude Opus 5 75219f9d1d fix(naturalist): draw the three missing item icons
Deploy Addons / deploy (push) Failing after 0s
frog_leg, cooked_frog_leg and snake_egg_block are declared in the behavior
pack and mapped in item_texture.json, but the PNGs were never present, so all
three rendered as missing-texture wherever they appeared. All three already
had lang names, so only the art was absent.

Drawn through scripts/build-textures.py to match the pack's existing style --
small sprite on transparency, muted natural palette, single highlight. The
frog legs share one drumstick routine, raw carrying a green skin tint along
the top contour and cooked a browned crust. snake_egg_block is a clutch of
three eggs reusing the palette of the pack's existing single snake_egg.png,
so the block form reads as a group of the item.

RP bumped to 1.0.3 and mya's pin updated to match, otherwise clients keep
serving the cached pack and never fetch the new files.

Note: naturalist-lite-addon is absent from deploy.yml's PATHS, so CI never
deploys it -- this was applied to the host by hand. That absence is also why
the host sat on RP 1.0.1 while main was already at 1.0.2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Gf1kZypRDfPL1YiWUS1LC
2026-08-28 13:14:49 +01:00
sysadminandClaude Opus 5 efac50aba7 fix(lobby): sync BP dependency to bumped RP version; name portal_field
Deploy Addons / deploy (push) Failing after 0s
Two issues an addon-wide audit turned up.

The portal-texture commit bumped lobby_transfer_RP to 1.0.1 but left the
behavior pack's dependency on it pinned at 1.0.0. A dependency whose version
does not match the referenced pack's header is exactly the kind of mismatch
that makes Bedrock drop a pack, so this was a regression waiting to bite.

portal_field carries a menu_category, so it appears in the creative inventory,
but had no lang entry -- it showed as the raw key tile.silverlabs:portal_field
.name. The other four portal blocks were already named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Gf1kZypRDfPL1YiWUS1LC
2026-08-28 13:03:15 +01:00
sysadminandClaude Opus 5 cde6c46bf2 fix(hub-return): recover the uncommitted 1.0.9 from the NAS archive
Deploy Addons / deploy (push) Failing after 0s
hub-return existed on the host at 1.0.9 but had never been committed; main
only had 1.0.5. The deploy workflow's `git checkout -f origin/main -- $PATHS`
reverted the host to 1.0.5, discarding work that existed nowhere in git.

Recovered verbatim from the 2026-06-03 NAS archive. The RP is byte-identical
between the two, and the file sets match, so this is only the BP manifest
version plus scripts/main.js.

What 1.0.9 adds over 1.0.5:
  * a spawn tip pointing players at private chests, since break-protection
    covers chests but not terrain
  * Family Goals integration -- a "Family" button in the hub menu, shown only
    when the family-goals addon is present (sniffed via its world dynamic
    property), dispatching `scriptevent family:menu` as the player so
    family-goals sees the right sourceEntity

That integration pairs with family-goals, which was itself uncommitted until
this branch, so the two were lost and recovered together.

All three child worlds re-pinned to [1,0,9] (jamie already expected it; lyla
and mya were on 1.0.5) and verified loading at 1.0.9 in the pack stack.

Carried over as-is rather than tidied: openHubMenu still emits a
`[Hub][diag]` console.warn on every open, left from mid-development. Worth
removing, but not while recovering -- recovery should be faithful.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Gf1kZypRDfPL1YiWUS1LC
2026-08-28 12:55:42 +01:00
sysadminandClaude Opus 5 50af20981f fix(lobby): ship real portal textures; they pointed at vanilla paths
Deploy Addons / deploy (push) Failing after 0s
The four portal blocks referenced vanilla texture paths from their own pack
(emerald_block, amethyst_block, prismarine_bricks, end_portal_frame_top)
without shipping those files. While the blocks rendered through the legacy
blocks.json path this was invisible; once minecraft:geometry made
material_instances take effect the lookup failed outright and they rendered
as missing-texture.

Ships real 16x16 textures generated through scripts/build-textures.py: a dark
stone frame around a glowing field, one hue per destination -- jamie green,
lyla purple, mya teal, plus a gold-ringed frame. terrain_texture.json now
points at these instead of vanilla paths, so every key in the pack resolves
to a file the pack actually contains.

lobby_transfer_RP bumped to 1.0.1 so clients re-download rather than serving
cached art; the lobby world pin was updated to match.

Known issue, not fixed here: lobby_transfer_RP and hub_return_transfer_BP
share pack UUID b2c3d4e5-1111-2222-3333-fedcba654321. They never load on the
same server today, so the collision is latent, but it makes version bumps on
either pack risky and should be resolved with a fresh UUID plus a pin update.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Gf1kZypRDfPL1YiWUS1LC
2026-08-28 12:49:46 +01:00
sysadmin 57c06dde06 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
2026-08-28 12:36:17 +01:00
sysadminandClaude Opus 5 0fb2b27d7c fix(addons): add minecraft:geometry to the 7 untextured custom blocks
Without a minecraft:geometry component Bedrock renders a custom block
through the legacy blocks.json texture path instead of material_instances.
These packs' blocks.json files only set "sound", with no "textures", so the
client substituted a default and the blocks rendered untextured. Diagnosed
and verified in-game on redstone-link; this applies the same one-line fix to
the rest.

Confirmed in the field for the portal blocks: portal_jamie, portal_lyla and
portal_mya all fell back to the same default, so players had to guess which
portal led to which world. They reference vanilla textures (emerald_block,
amethyst_block, prismarine_bricks), so with geometry set they render green,
purple and teal respectively -- one per child.

Also fixes home_sign, smart_crafting_table, tent_canvas and portal_frame.

full_block is correct for all seven: every custom block model in these packs
(hammock_slab, tent_panel_l/r, portal_field) is already referenced by its own
block, and no orphan model exists for any of these, so they were always meant
to be plain cubes.

No pack versions bumped deliberately. Block definitions live in the behavior
pack and reach clients through the network block palette rather than a cached
resource pack, so a bump is unnecessary -- and bumping would require updating
the matching pin in all four world_behavior_packs.json files, where a
mismatch silently drops the pack from the stack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Gf1kZypRDfPL1YiWUS1LC
2026-08-28 12:27:59 +01:00
sysadminandClaude Opus 5 1e82825f8f fix(redstone-link): add minecraft:geometry so material_instances applies
The blocks rendered as an untextured default. Root cause: with no
minecraft:geometry component, Bedrock renders the block through the legacy
blocks.json texture path instead of material_instances. blocks.json here
only sets "sound", with no "textures", so the client substituted a default.

This produces no error in the content log -- both paths are valid, they just
resolve differently -- which is why the pack looked correct everywhere:
files installed byte-perfect, pack activated, terrain_texture.json valid,
and a clean world with only this addon still showed the wrong texture.

Adding "minecraft:geometry": "minecraft:geometry.full_block" fixes it,
verified in-game.

BP bumped to 1.0.1 so clients cannot reuse the cached copy. The lobby world
pin is updated to match; a mismatch silently drops the pack from the stack.

Note: 7 of the repo's other custom blocks have the same defect -- home_sign,
smart_crafting_table, tent_canvas, and all four lobby portal blocks. Most
blocks here DO set geometry, so those seven look like oversights rather than
intent. Not fixed in this commit; they touch every live world.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Gf1kZypRDfPL1YiWUS1LC
2026-08-28 12:22:22 +01:00
sysadminandClaude Opus 5 db8931784e feat(redstone-link): real block textures via build-textures.py, add build.sh
The first textures were a motif floating on a flat slab. That hits the
lesson already documented on mailbox_block(): an icon-like sprite "reads
as a placeholder when applied to all 6 faces of a cube". Replaced with
machine faces whose traces run out to the edges, so they connect across
tile boundaries and a wall of them reads as continuous circuitry --
transmitter radiating out, receiver feeding in.

Generated through scripts/build-textures.py using the existing palette and
rect/px/save helpers rather than an ad-hoc script, so they regenerate with
everything else. Running the pipeline also rewrites the other five
textures, but pixel-identically -- that churn is only a newer Pillow
re-encoding, so those files are left alone.

RP header bumped to 1.0.1 (with the BP dependency to match) because clients
cache packs by uuid+version and would otherwise keep serving the old art.
The lobby world pin was updated to match; a mismatch silently drops the
pack from the stack.

Adds build.sh to package the .mcaddon, mirroring addon/build.sh. It omits
that script's `-x` exclude: these packs have no dotfiles, and dropping it
keeps the script working with the 7-Zip `zip` shim on the workstation as
well as Info-ZIP on the CI runner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Gf1kZypRDfPL1YiWUS1LC
2026-08-28 11:40:16 +01:00
sysadminandClaude Opus 5 8ded29cbcc feat(redstone-link): wireless redstone addon; track family-goals
Adds redstone-link-addon: a transmitter/receiver pair bound by channel
name, giving redstone that runs anywhere without wire, support blocks or
line of sight. Bedrock custom blocks can read redstone power but cannot
emit it, so a live receiver is swapped to a vanilla redstone_block and
back; receiver positions persist in a world dynamic property, with a
reconcile pass at boot to repair anything a crash left inconsistent.
Deployed and proven end-to-end on lobby (Hub World).

Also commits family-goals-addon, which was running on jamie -- mounted in
docker-compose.yml and pinned in the world -- while existing in no commit
on any branch. It was unbacked-up production code.

That omission was load-bearing for deploys: deploy.yml checks out
docker-compose.yml from origin/main, so a deploy would have replaced the
host compose with a version lacking the family-goals mount and silently
killed the addon. Both new directories are added to the workflow's PATHS
and push-trigger list, so nothing is mounted that CI does not deliver.

The mount set in this file now matches the live host exactly.

Note for a follow-up: dynamite-, hemp-, naturalist-lite-, smart-crafting-,
tow-boat- and trees-features-addon are mounted but absent from PATHS, so
CI never refreshes them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Gf1kZypRDfPL1YiWUS1LC
2026-08-28 11:06:46 +01:00
57 changed files with 1796 additions and 43 deletions
+14 -1
View File
@@ -14,6 +14,14 @@ on:
- 'keep-inventory-addon/**'
- 'postal-service-addon/**'
- 'camping-supplies-addon/**'
- 'family-goals-addon/**'
- 'redstone-link-addon/**'
- 'dynamite-addon/**'
- 'hemp-addon/**'
- 'naturalist-lite-addon/**'
- 'smart-crafting-addon/**'
- 'tow-boat-addon/**'
- 'trees-features-addon/**'
- 'docker-compose.yml'
- 'scripts/**'
@@ -31,7 +39,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/ dynamite-addon/ hemp-addon/ naturalist-lite-addon/ smart-crafting-addon/ tow-boat-addon/ trees-features-addon/ docker-compose.yml"
# First run: clone. Subsequent: pull.
if [ ! -d "$APP_DIR/.git" ]; then
+1 -1
View File
@@ -32,7 +32,7 @@
"version": [
1,
0,
1
2
]
}
]
+20 -4
View File
@@ -4,20 +4,36 @@
"name": "Pets 4 Jamie's STARS Resources",
"description": "Textures, models and animations for Pets 4 Jamie's STARS",
"uuid": "5f25d547-00bb-49ce-8be3-d86cd3941c9b",
"version": [1, 0, 1],
"min_engine_version": [1, 21, 0]
"version": [
1,
0,
2
],
"min_engine_version": [
1,
21,
0
]
},
"modules": [
{
"type": "resources",
"uuid": "6adc20cd-25a5-4ca5-aa12-f5e43ee9ea22",
"version": [1, 0, 1]
"version": [
1,
0,
2
]
}
],
"dependencies": [
{
"uuid": "7cf924ce-e246-4c8c-998c-f420edb26451",
"version": [1, 0, 0]
"version": [
1,
0,
0
]
}
]
}
@@ -12,6 +12,7 @@
"explosion_resistance": 1.0
},
"minecraft:map_color": "#547A4E",
"minecraft:geometry": "minecraft:geometry.full_block",
"minecraft:material_instances": {
"*": {
"texture": "tent_canvas",
@@ -47,7 +47,7 @@
"version": [
1,
0,
1
2
]
}
]
@@ -4,14 +4,26 @@
"name": "Camping Supplies Resources",
"description": "Textures and lang for camping supplies (tent, hammock, canvas, cloth)",
"uuid": "36f12107-10c6-484c-a0f2-b5dd88cd5baa",
"version": [1, 0, 1],
"min_engine_version": [1, 21, 0]
"version": [
1,
0,
2
],
"min_engine_version": [
1,
21,
0
]
},
"modules": [
{
"type": "resources",
"uuid": "c9ee429f-9374-4083-843b-4b195e8db130",
"version": [1, 0, 1]
"version": [
1,
0,
2
]
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 427 B

After

Width:  |  Height:  |  Size: 381 B

+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
+1 -1
View File
@@ -32,7 +32,7 @@
"version": [
1,
0,
2
3
]
}
]
@@ -3,10 +3,18 @@
"minecraft:client_entity": {
"description": {
"identifier": "silverlabs:thrown_banger",
"materials": { "default": "entity_alphatest" },
"textures": { "default": "textures/entity/thrown_banger" },
"geometry": { "default": "geometry.snowball" },
"render_controllers": [ "controller.render.snowball" ]
"materials": {
"default": "entity_alphatest"
},
"textures": {
"default": "textures/entity/thrown_banger"
},
"geometry": {
"default": "geometry.item_sprite"
},
"render_controllers": [
"controller.render.item_sprite"
]
}
}
}
@@ -3,10 +3,18 @@
"minecraft:client_entity": {
"description": {
"identifier": "silverlabs:thrown_bundle",
"materials": { "default": "entity_alphatest" },
"textures": { "default": "textures/entity/thrown_bundle" },
"geometry": { "default": "geometry.snowball" },
"render_controllers": [ "controller.render.snowball" ]
"materials": {
"default": "entity_alphatest"
},
"textures": {
"default": "textures/entity/thrown_bundle"
},
"geometry": {
"default": "geometry.item_sprite"
},
"render_controllers": [
"controller.render.item_sprite"
]
}
}
}
@@ -3,10 +3,18 @@
"minecraft:client_entity": {
"description": {
"identifier": "silverlabs:thrown_dynamite",
"materials": { "default": "entity_alphatest" },
"textures": { "default": "textures/entity/thrown_dynamite" },
"geometry": { "default": "geometry.snowball" },
"render_controllers": [ "controller.render.snowball" ]
"materials": {
"default": "entity_alphatest"
},
"textures": {
"default": "textures/entity/thrown_dynamite"
},
"geometry": {
"default": "geometry.item_sprite"
},
"render_controllers": [
"controller.render.item_sprite"
]
}
}
}
+15 -3
View File
@@ -4,14 +4,26 @@
"name": "Dynamite Resources",
"description": "Textures and language for the Dynamite addon.",
"uuid": "a18bdde1-53f8-49aa-b06d-6f0ec6c45b46",
"version": [1, 0, 2],
"min_engine_version": [1, 21, 0]
"version": [
1,
0,
3
],
"min_engine_version": [
1,
21,
0
]
},
"modules": [
{
"type": "resources",
"uuid": "587281f2-f159-4ad9-85a6-d20ff4899717",
"version": [1, 0, 2]
"version": [
1,
0,
3
]
}
]
}
@@ -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",
@@ -4,7 +4,7 @@
"name": "Hub Return Transfer",
"description": "Transfers players back to lobby when they step on the return portal",
"uuid": "b2c3d4e5-1111-2222-3333-fedcba654321",
"version": [1, 0, 5],
"version": [1, 0, 9],
"min_engine_version": [1, 21, 0]
},
"modules": [
@@ -12,7 +12,7 @@
"type": "script",
"language": "javascript",
"uuid": "b2c3d4e5-4444-5555-6666-fedcba987654",
"version": [1, 0, 5],
"version": [1, 0, 9],
"entry": "scripts/main.js"
}
],
@@ -190,6 +190,8 @@ world.afterEvents.playerSpawn.subscribe((event) => {
player.runCommand(`titleraw @s title {"rawtext":[{"text":"§6Welcome!"}]}`);
player.runCommand(`titleraw @s subtitle {"rawtext":[{"text":"§7You are now in §e${WORLD_NAME}"}]}`);
giveCompassIfMissing(player);
// Light social reminder — break-protection only covers chests, not terrain.
player.sendMessage("§7[Tip] §fUse a §dprivate chest§f for valuables — break-protection only covers chests.");
} catch (e) {
// Player may have disconnected
}
@@ -244,6 +246,27 @@ async function openCompassMenu(player) {
form.button("\uD83E\uDDED Get a marker block");
actions.push({ kind: "give_marker" });
// Family Goals integration — only surfaces if the family-goals addon is loaded
// in this world. We sniff its world dynamic property; absence = silent skip.
let familyLoaded = false;
let famDebug = "?";
let idsList = "?";
try {
const v = world.getDynamicProperty("family_goals_v1");
famDebug = `${typeof v}:${v === undefined ? "undef" : String(v).slice(0, 30)}`;
familyLoaded = v !== undefined;
if (typeof world.getDynamicPropertyIds === "function") {
idsList = world.getDynamicPropertyIds().join(",");
} else {
idsList = "(getDynamicPropertyIds missing)";
}
} catch (e) { famDebug = `err:${e.message}`; }
console.warn(`[Hub][diag] familyLoaded=${familyLoaded} probe=${famDebug} ids=${idsList}`);
if (familyLoaded) {
form.button("\uD83C\uDFC6 Family");
actions.push({ kind: "family_menu" });
}
form.button("§7Cancel");
actions.push({ kind: "cancel" });
@@ -282,6 +305,14 @@ async function openCompassMenu(player) {
giveMarkerBlock(player);
return;
}
if (a.kind === "family_menu") {
// Hand off to family-goals addon. The scriptevent is dispatched as the
// player so family-goals' subscriber sees event.sourceEntity = this player.
try { player.runCommand("scriptevent family:menu"); } catch (e) {
player.sendMessage(`§c[Hub] Couldn't open Family menu: ${e.message}`);
}
return;
}
}
function giveMarkerBlock(player) {
@@ -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",
+25 -5
View File
@@ -4,20 +4,36 @@
"name": "Lobby Portal Transfer",
"description": "Auto-transfers players when they step into portal areas",
"uuid": "a1b2c3d4-1111-2222-3333-abcdef123456",
"version": [1, 1, 0],
"min_engine_version": [1, 21, 0]
"version": [
1,
1,
0
],
"min_engine_version": [
1,
21,
0
]
},
"modules": [
{
"type": "data",
"uuid": "a1b2c3d4-7777-8888-9999-abcdef345678",
"version": [1, 1, 0]
"version": [
1,
1,
0
]
},
{
"type": "script",
"language": "javascript",
"uuid": "a1b2c3d4-4444-5555-6666-abcdef789012",
"version": [1, 1, 0],
"version": [
1,
1,
0
],
"entry": "scripts/main.js"
}
],
@@ -32,7 +48,11 @@
},
{
"uuid": "b2c3d4e5-1111-2222-3333-fedcba654321",
"version": [1, 0, 0]
"version": [
1,
0,
2
]
}
]
}
+15 -3
View File
@@ -4,14 +4,26 @@
"name": "Lobby Portal Transfer Resources",
"description": "Textures and lang for craftable portal blocks",
"uuid": "b2c3d4e5-1111-2222-3333-fedcba654321",
"version": [1, 0, 0],
"min_engine_version": [1, 21, 0]
"version": [
1,
0,
2
],
"min_engine_version": [
1,
21,
0
]
},
"modules": [
{
"type": "resources",
"uuid": "b2c3d4e5-4444-5555-6666-fedcba987654",
"version": [1, 0, 0]
"version": [
1,
0,
2
]
}
]
}
@@ -2,3 +2,4 @@ tile.silverlabs:portal_frame.name=Portal Frame
tile.silverlabs:portal_jamie.name=Jamie's World Portal
tile.silverlabs:portal_lyla.name=Lyla's World Portal
tile.silverlabs:portal_mya.name=Mya's World Portal
tile.silverlabs:portal_field.name=Portal Field
Binary file not shown.

After

Width:  |  Height:  |  Size: 194 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

@@ -5,16 +5,16 @@
"num_mip_levels": 4,
"texture_data": {
"portal_frame": {
"textures": "textures/blocks/end_portal_frame_top"
"textures": "textures/blocks/portal_frame"
},
"portal_jamie": {
"textures": "textures/blocks/emerald_block"
"textures": "textures/blocks/portal_jamie"
},
"portal_lyla": {
"textures": "textures/blocks/amethyst_block"
"textures": "textures/blocks/portal_lyla"
},
"portal_mya": {
"textures": "textures/blocks/prismarine_bricks"
"textures": "textures/blocks/portal_mya"
},
"portal_field": {
"textures": "textures/blocks/portal_field"
@@ -4,14 +4,26 @@
"name": "Naturalist Lite Resources",
"description": "Textures, models, and animations for Naturalist Lite mobs.",
"uuid": "de1b016a-3cf3-4f75-8076-5a5508ab8b74",
"version": [1, 0, 2],
"min_engine_version": [1, 21, 0]
"version": [
1,
0,
3
],
"min_engine_version": [
1,
21,
0
]
},
"modules": [
{
"type": "resources",
"uuid": "29da80f3-cffc-4c3b-997d-34f0820cc65b",
"version": [1, 0, 2]
"version": [
1,
0,
3
]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 177 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 B

After

Width:  |  Height:  |  Size: 220 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 B

After

Width:  |  Height:  |  Size: 295 B

+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"
}
}
}
+218
View File
@@ -397,12 +397,230 @@ 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
# ── Lobby portal blocks ────────────────────────────────────
# These previously pointed terrain_texture.json at vanilla paths
# (emerald_block, amethyst_block, prismarine_bricks, end_portal_frame_top)
# which a custom pack cannot resolve, so they rendered as missing-texture
# once minecraft:geometry made material_instances take effect. Ship real
# textures instead: a dark stone frame around a glowing field, one hue per
# child, so the three destinations are distinguishable at a glance.
PORTAL_STONE = (58, 60, 68)
PORTAL_STONE_HL = (86, 89, 99)
PORTAL_STONE_SH = (34, 35, 41)
def _portal_face(core, bright, glow):
img = Image.new("RGBA", (16, 16), PORTAL_STONE)
# chiselled frame
rect(img, 0, 0, 15, 0, PORTAL_STONE_HL)
rect(img, 0, 0, 0, 15, PORTAL_STONE_HL)
rect(img, 0, 15, 15, 15, PORTAL_STONE_SH)
rect(img, 15, 0, 15, 15, PORTAL_STONE_SH)
rect(img, 2, 2, 13, 13, PORTAL_STONE_SH)
# glowing field
rect(img, 3, 3, 12, 12, core)
rect(img, 4, 4, 11, 11, bright)
rect(img, 6, 6, 9, 9, glow)
# corner sparks so the field reads as energy, not flat paint
for cx, cy in ((4, 4), (11, 4), (4, 11), (11, 11)):
px(img, cx, cy, glow)
# dark centre seam
rect(img, 7, 7, 8, 8, core)
return img
def portal_jamie(): return _portal_face((22, 92, 40), (46, 156, 66), (140, 240, 150))
def portal_lyla(): return _portal_face((78, 40, 130), (126, 74, 190), (214, 168, 255))
def portal_mya(): return _portal_face((22, 96, 104), (46, 152, 160), (150, 238, 244))
def portal_frame():
img = Image.new("RGBA", (16, 16), PORTAL_STONE)
rect(img, 0, 0, 15, 0, PORTAL_STONE_HL)
rect(img, 0, 0, 0, 15, PORTAL_STONE_HL)
rect(img, 0, 15, 15, 15, PORTAL_STONE_SH)
rect(img, 15, 0, 15, 15, PORTAL_STONE_SH)
# inset panel with a dull gold ring, echoing an end portal frame
rect(img, 3, 3, 12, 12, (48, 50, 57))
for x in range(4, 12):
px(img, x, 4, GOLD); px(img, x, 11, GOLD)
for y in range(4, 12):
px(img, 4, y, GOLD); px(img, 11, y, GOLD)
rect(img, 6, 6, 9, 9, (70, 66, 40))
px(img, 7, 7, GOLD_BRIGHT); px(img, 8, 8, GOLD_BRIGHT)
return img
# ── Naturalist Lite: three items shipped without icons ─────
# frog_leg, cooked_frog_leg and snake_egg_block are declared in the BP and
# mapped in item_texture.json, but the PNGs were never present, so they
# rendered as missing-texture. Drawn to match the pack's existing style:
# small sprite on transparency, muted natural palette, single highlight.
FROG_RAW = (176, 122, 128)
FROG_RAW_HI = (214, 164, 168)
FROG_RAW_SH = (126, 82, 92)
FROG_SKIN = (122, 148, 96)
FROG_COOK = (156, 104, 56)
FROG_COOK_HI = (198, 146, 88)
FROG_COOK_SH = (104, 64, 32)
BONE = (232, 226, 206)
BONE_SH = (176, 170, 152)
EGG_SHELL = (222, 214, 196)
EGG_SHELL_HI = (244, 240, 228)
EGG_SHELL_SH = (170, 162, 144)
EGG_SPOT = (150, 138, 112)
def _frog_leg(meat, hi, sh, skin=None):
"""Drumstick: meat mass upper-left, bone stub lower-right."""
img = Image.new("RGBA", (16, 16), (0, 0, 0, 0))
# meat mass
rect(img, 4, 2, 10, 8, meat)
rect(img, 3, 3, 3, 7, meat)
rect(img, 11, 3, 11, 7, meat)
rect(img, 5, 1, 9, 1, meat)
rect(img, 5, 9, 9, 9, meat)
# shading
rect(img, 4, 8, 10, 9, sh)
rect(img, 11, 4, 11, 7, sh)
# highlight
rect(img, 5, 2, 7, 3, hi)
px(img, 5, 4, hi)
# raw frog keeps a green tinge along the top contour rather than a patch,
# so it reads as skin on the meat instead of a floating square
if skin:
for sx, sy in ((6, 1), (7, 1), (8, 1), (9, 2), (10, 3), (5, 2)):
px(img, sx, sy, skin)
px(img, 8, 2, (140, 166, 112))
# bone stub running to lower-right
rect(img, 9, 10, 10, 11, BONE)
rect(img, 11, 12, 12, 13, BONE)
px(img, 12, 14, BONE)
px(img, 10, 11, BONE_SH)
px(img, 12, 13, BONE_SH)
px(img, 13, 14, BONE_SH)
return img
def frog_leg(): return _frog_leg(FROG_RAW, FROG_RAW_HI, FROG_RAW_SH, FROG_SKIN)
def cooked_frog_leg(): return _frog_leg(FROG_COOK, FROG_COOK_HI, FROG_COOK_SH)
def snake_egg_block():
"""A clutch of eggs, so it reads as the block form of the single egg item."""
img = Image.new("RGBA", (16, 16), (0, 0, 0, 0))
def egg(cx, cy, w, h):
rect(img, cx, cy, cx + w, cy + h, EGG_SHELL)
rect(img, cx, cy + h, cx + w, cy + h, EGG_SHELL_SH)
rect(img, cx + w, cy + 1, cx + w, cy + h, EGG_SHELL_SH)
px(img, cx + 1, cy + 1, EGG_SHELL_HI)
egg(2, 6, 4, 6)
egg(9, 5, 4, 6)
egg(5, 8, 4, 5)
# speckles
for sx, sy in ((3, 9), (11, 8), (6, 11), (4, 7), (12, 10)):
px(img, sx, sy, EGG_SPOT)
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(portal_frame(), "lobby-addon/lobby_transfer_RP/textures/blocks/portal_frame.png")
save(portal_jamie(), "lobby-addon/lobby_transfer_RP/textures/blocks/portal_jamie.png")
save(portal_lyla(), "lobby-addon/lobby_transfer_RP/textures/blocks/portal_lyla.png")
save(portal_mya(), "lobby-addon/lobby_transfer_RP/textures/blocks/portal_mya.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")
NAT = "naturalist-lite-addon/naturalist_lite_RP/textures/sf/nba/items/"
save(frog_leg(), NAT + "frog_leg.png")
save(cooked_frog_leg(), NAT + "cooked_frog_leg.png")
save(snake_egg_block(), NAT + "snake_egg_block.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",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 407 B

After

Width:  |  Height:  |  Size: 366 B