feat(postal): add postal service addon and bundle pending addon work
All checks were successful
Deploy Addons / deploy (push) Successful in 16s
All checks were successful
Deploy Addons / deploy (push) Successful in 16s
- New postal-service-addon: per-player mailboxes + post-office send block (ActionForm recipient picker, offline notification queue, chunk-load retry via tickingarea) - Commit previously untracked private-chest, home-sign, keep-inventory addons and their docker-compose mounts - Deploy workflow: add postal + previously unwired addons to path filter and checkout list; drop easter-egg from deployment - enabled_packs.json: register postal UUIDs for Lyla + Mya Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
27
postal-service-addon/postal_service_BP/blocks/mailbox.json
Normal file
27
postal-service-addon/postal_service_BP/blocks/mailbox.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"format_version": "1.21.0",
|
||||
"minecraft:block": {
|
||||
"description": {
|
||||
"identifier": "silverlabs:mailbox",
|
||||
"menu_category": {
|
||||
"category": "items",
|
||||
"group": "itemGroup.name.chest"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"minecraft:destructible_by_mining": {
|
||||
"seconds_to_destroy": 2.0
|
||||
},
|
||||
"minecraft:destructible_by_explosion": {
|
||||
"explosion_resistance": 1200.0
|
||||
},
|
||||
"minecraft:map_color": "#C83232",
|
||||
"minecraft:material_instances": {
|
||||
"*": {
|
||||
"texture": "mailbox",
|
||||
"render_method": "opaque"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"format_version": "1.21.0",
|
||||
"minecraft:block": {
|
||||
"description": {
|
||||
"identifier": "silverlabs:post_office",
|
||||
"menu_category": {
|
||||
"category": "items",
|
||||
"group": "itemGroup.name.chest"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"minecraft:destructible_by_mining": {
|
||||
"seconds_to_destroy": 2.5
|
||||
},
|
||||
"minecraft:destructible_by_explosion": {
|
||||
"explosion_resistance": 1200.0
|
||||
},
|
||||
"minecraft:map_color": "#8B5A2B",
|
||||
"minecraft:material_instances": {
|
||||
"*": {
|
||||
"texture": "post_office",
|
||||
"render_method": "opaque"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
postal-service-addon/postal_service_BP/manifest.json
Normal file
38
postal-service-addon/postal_service_BP/manifest.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"format_version": 2,
|
||||
"header": {
|
||||
"name": "Postal Service",
|
||||
"description": "Player-to-player mail: personal mailboxes and a post office send block",
|
||||
"uuid": "d7a4b9c1-2e3f-4a5b-8c6d-1f2e3d4c5b60",
|
||||
"version": [1, 0, 0],
|
||||
"min_engine_version": [1, 21, 0]
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"type": "data",
|
||||
"uuid": "d7a4b9c1-2e3f-4a5b-8c6d-1f2e3d4c5b61",
|
||||
"version": [1, 0, 0]
|
||||
},
|
||||
{
|
||||
"type": "script",
|
||||
"language": "javascript",
|
||||
"uuid": "d7a4b9c1-2e3f-4a5b-8c6d-1f2e3d4c5b62",
|
||||
"version": [1, 0, 0],
|
||||
"entry": "scripts/main.js"
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
{
|
||||
"module_name": "@minecraft/server",
|
||||
"version": "1.17.0"
|
||||
},
|
||||
{
|
||||
"module_name": "@minecraft/server-ui",
|
||||
"version": "1.3.0"
|
||||
},
|
||||
{
|
||||
"uuid": "d7a4b9c1-2e3f-4a5b-8c6d-1f2e3d4c5b63",
|
||||
"version": [1, 0, 0]
|
||||
}
|
||||
]
|
||||
}
|
||||
18
postal-service-addon/postal_service_BP/recipes/mailbox.json
Normal file
18
postal-service-addon/postal_service_BP/recipes/mailbox.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"format_version": "1.21.0",
|
||||
"minecraft:recipe_shapeless": {
|
||||
"description": {
|
||||
"identifier": "silverlabs:mailbox_recipe"
|
||||
},
|
||||
"tags": ["crafting_table"],
|
||||
"unlock": { "context": "AlwaysUnlocked" },
|
||||
"ingredients": [
|
||||
{ "item": "minecraft:chest" },
|
||||
{ "item": "minecraft:gold_ingot" }
|
||||
],
|
||||
"result": {
|
||||
"item": "silverlabs:mailbox",
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"format_version": "1.21.0",
|
||||
"minecraft:recipe_shaped": {
|
||||
"description": {
|
||||
"identifier": "silverlabs:post_office_recipe"
|
||||
},
|
||||
"tags": ["crafting_table"],
|
||||
"unlock": { "context": "AlwaysUnlocked" },
|
||||
"pattern": [
|
||||
"III",
|
||||
"PCP",
|
||||
"PPP"
|
||||
],
|
||||
"key": {
|
||||
"I": { "item": "minecraft:iron_ingot" },
|
||||
"P": { "item": "minecraft:oak_planks" },
|
||||
"C": { "item": "minecraft:chest" }
|
||||
},
|
||||
"result": {
|
||||
"item": "silverlabs:post_office",
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
390
postal-service-addon/postal_service_BP/scripts/main.js
Normal file
390
postal-service-addon/postal_service_BP/scripts/main.js
Normal file
@@ -0,0 +1,390 @@
|
||||
import { world, system, ItemStack } from "@minecraft/server";
|
||||
import { ActionFormData, MessageFormData } from "@minecraft/server-ui";
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────
|
||||
const MAILBOX_BLOCK = "silverlabs:mailbox";
|
||||
const POST_OFFICE_BLOCK = "silverlabs:post_office";
|
||||
const VANILLA_CHEST = "minecraft:chest";
|
||||
const PROP_KEY = "postal_state_v1";
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────
|
||||
// mailboxes: { "x,y,z,dim": { ownerId, ownerName } }
|
||||
// registry: { [ownerId]: { name, x, y, z, dim } } — reverse lookup for recipient picker
|
||||
// pending: { [ownerId]: [{ from, itemSummary, ts }] } — queued offline notifications
|
||||
let state = { mailboxes: {}, registry: {}, pending: {} };
|
||||
|
||||
function loadState() {
|
||||
try {
|
||||
const raw = world.getDynamicProperty(PROP_KEY);
|
||||
if (raw && typeof raw === "string") {
|
||||
const parsed = JSON.parse(raw);
|
||||
state = {
|
||||
mailboxes: parsed.mailboxes || {},
|
||||
registry: parsed.registry || {},
|
||||
pending: parsed.pending || {},
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
world.sendMessage(`§c[Postal] Failed to load state: ${e.message}`);
|
||||
state = { mailboxes: {}, registry: {}, pending: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function saveState() {
|
||||
try {
|
||||
world.setDynamicProperty(PROP_KEY, JSON.stringify(state));
|
||||
} catch (e) {
|
||||
world.sendMessage(`§c[Postal] Failed to save state: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function keyOf(loc, dimensionId) {
|
||||
return `${Math.floor(loc.x)},${Math.floor(loc.y)},${Math.floor(loc.z)},${dimensionId}`;
|
||||
}
|
||||
|
||||
function getMailboxOwner(loc, dimensionId) {
|
||||
return state.mailboxes[keyOf(loc, dimensionId)] || null;
|
||||
}
|
||||
|
||||
function claimMailbox(loc, dimensionId, player) {
|
||||
const k = keyOf(loc, dimensionId);
|
||||
state.mailboxes[k] = { ownerId: player.id, ownerName: player.name };
|
||||
state.registry[player.id] = {
|
||||
name: player.name,
|
||||
x: Math.floor(loc.x),
|
||||
y: Math.floor(loc.y),
|
||||
z: Math.floor(loc.z),
|
||||
dim: dimensionId,
|
||||
};
|
||||
saveState();
|
||||
}
|
||||
|
||||
function releaseMailbox(loc, dimensionId) {
|
||||
const k = keyOf(loc, dimensionId);
|
||||
const entry = state.mailboxes[k];
|
||||
delete state.mailboxes[k];
|
||||
if (entry && state.registry[entry.ownerId]) {
|
||||
const reg = state.registry[entry.ownerId];
|
||||
if (reg.x === Math.floor(loc.x) && reg.y === Math.floor(loc.y) && reg.z === Math.floor(loc.z) && reg.dim === dimensionId) {
|
||||
delete state.registry[entry.ownerId];
|
||||
}
|
||||
}
|
||||
saveState();
|
||||
}
|
||||
|
||||
function hasClaim(playerId) {
|
||||
return !!state.registry[playerId];
|
||||
}
|
||||
|
||||
// ─── Chest facing from player rotation ──────────────────────
|
||||
function chestFacing(yaw) {
|
||||
let y = yaw;
|
||||
while (y > 180) y -= 360;
|
||||
while (y < -180) y += 360;
|
||||
if (y >= -45 && y < 45) return "north";
|
||||
if (y >= 45 && y < 135) return "east";
|
||||
if (y >= -135 && y < -45) return "west";
|
||||
return "south";
|
||||
}
|
||||
|
||||
// ─── Mailbox placement ──────────────────────────────────────
|
||||
world.afterEvents.playerPlaceBlock.subscribe((event) => {
|
||||
const block = event.block;
|
||||
if (block.typeId !== MAILBOX_BLOCK) return;
|
||||
|
||||
const player = event.player;
|
||||
const loc = block.location;
|
||||
const dim = block.dimension;
|
||||
|
||||
if (hasClaim(player.id)) {
|
||||
// Revert placement and refund the item
|
||||
try {
|
||||
dim.runCommand(`setblock ${loc.x} ${loc.y} ${loc.z} air`);
|
||||
dim.spawnItem(new ItemStack(MAILBOX_BLOCK, 1), {
|
||||
x: loc.x + 0.5,
|
||||
y: loc.y + 0.5,
|
||||
z: loc.z + 0.5,
|
||||
});
|
||||
} catch (_) {}
|
||||
player.sendMessage(`§c[Postal] §7You already have a mailbox in this world.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const facing = chestFacing(player.getRotation().y);
|
||||
try {
|
||||
dim.runCommand(
|
||||
`setblock ${loc.x} ${loc.y} ${loc.z} chest ["minecraft:cardinal_direction":"${facing}"]`
|
||||
);
|
||||
} catch (_) {
|
||||
try {
|
||||
dim.runCommand(`setblock ${loc.x} ${loc.y} ${loc.z} chest`);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
claimMailbox(loc, dim.id, player);
|
||||
player.sendMessage(`§6[Postal] §7Mailbox locked to you. Only you can open or break it.`);
|
||||
});
|
||||
|
||||
// ─── Interact: gate mailbox opening for non-owners ──────────
|
||||
world.beforeEvents.playerInteractWithBlock.subscribe((event) => {
|
||||
const block = event.block;
|
||||
if (!block) return;
|
||||
|
||||
// Post-office block: open send UI (cancel default interact)
|
||||
if (block.typeId === POST_OFFICE_BLOCK) {
|
||||
event.cancel = true;
|
||||
const player = event.player;
|
||||
system.run(() => openSendForm(player));
|
||||
return;
|
||||
}
|
||||
|
||||
// Vanilla chest: gate if it's a claimed mailbox
|
||||
if (block.typeId !== VANILLA_CHEST) return;
|
||||
const owner = getMailboxOwner(block.location, block.dimension.id);
|
||||
if (!owner) return;
|
||||
if (owner.ownerId === event.player.id) return;
|
||||
event.cancel = true;
|
||||
const playerRef = event.player;
|
||||
system.run(() =>
|
||||
playerRef.sendMessage(`§c[Postal] §7This mailbox belongs to §f${owner.ownerName}§7.`)
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Break: protect mailboxes; owner break returns custom item ──
|
||||
world.beforeEvents.playerBreakBlock.subscribe((event) => {
|
||||
const block = event.block;
|
||||
if (block.typeId !== VANILLA_CHEST) return;
|
||||
const owner = getMailboxOwner(block.location, block.dimension.id);
|
||||
if (!owner) return;
|
||||
|
||||
const player = event.player;
|
||||
|
||||
if (owner.ownerId !== player.id) {
|
||||
event.cancel = true;
|
||||
system.run(() =>
|
||||
player.sendMessage(`§c[Postal] §7This mailbox belongs to §f${owner.ownerName}§7. You can't break it.`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
event.cancel = true;
|
||||
const loc = { x: block.location.x, y: block.location.y, z: block.location.z };
|
||||
const dim = block.dimension;
|
||||
|
||||
system.run(() => {
|
||||
try {
|
||||
const inv = dim.getBlock(loc)?.getComponent("inventory");
|
||||
const container = inv?.container;
|
||||
const dropPos = { x: loc.x + 0.5, y: loc.y + 0.5, z: loc.z + 0.5 };
|
||||
if (container) {
|
||||
for (let i = 0; i < container.size; i++) {
|
||||
const item = container.getItem(i);
|
||||
if (item) {
|
||||
dim.spawnItem(item, dropPos);
|
||||
container.setItem(i, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
dim.spawnItem(new ItemStack(MAILBOX_BLOCK, 1), dropPos);
|
||||
dim.runCommand(`setblock ${loc.x} ${loc.y} ${loc.z} air`);
|
||||
} catch (e) {
|
||||
player.sendMessage(`§c[Postal] Error during break: ${e.message}`);
|
||||
}
|
||||
releaseMailbox(loc, dim.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Post Office: send form ─────────────────────────────────
|
||||
function getHeldItem(player) {
|
||||
try {
|
||||
const inv = player.getComponent("inventory")?.container;
|
||||
if (!inv) return { item: undefined, slot: -1 };
|
||||
const slot = player.selectedSlotIndex;
|
||||
const item = inv.getItem(slot);
|
||||
return { item, slot, inv };
|
||||
} catch (_) {
|
||||
return { item: undefined, slot: -1 };
|
||||
}
|
||||
}
|
||||
|
||||
function itemSummary(item) {
|
||||
const niceName = item.typeId.replace(/^minecraft:/, "").replace(/_/g, " ");
|
||||
return `${item.amount} × ${niceName}`;
|
||||
}
|
||||
|
||||
async function openSendForm(player) {
|
||||
const { item, slot, inv } = getHeldItem(player);
|
||||
if (!item) {
|
||||
player.sendMessage(`§c[Postal] §7Hold the item you want to send in your hotbar first.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
for (const [ownerId, reg] of Object.entries(state.registry)) {
|
||||
if (ownerId === player.id) continue;
|
||||
candidates.push({ ownerId, reg });
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
player.sendMessage(`§c[Postal] §7No other players have claimed a mailbox yet.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const form = new ActionFormData()
|
||||
.title("Post Office")
|
||||
.body(`Sending §f${itemSummary(item)}§r\n\nChoose a recipient:`);
|
||||
for (const c of candidates) form.button(c.reg.name);
|
||||
form.button("§cCancel");
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await form.show(player);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (response.canceled || response.selection === undefined) return;
|
||||
if (response.selection >= candidates.length) return; // cancel button
|
||||
|
||||
const chosen = candidates[response.selection];
|
||||
|
||||
const confirm = new MessageFormData()
|
||||
.title("Confirm Send")
|
||||
.body(`Send §f${itemSummary(item)}§r\nto §f${chosen.reg.name}§r?`)
|
||||
.button1("Send")
|
||||
.button2("Cancel");
|
||||
|
||||
let conf;
|
||||
try {
|
||||
conf = await confirm.show(player);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (conf.canceled || conf.selection !== 0) return;
|
||||
|
||||
// Re-fetch held item in case it changed while form was open
|
||||
const fresh = getHeldItem(player);
|
||||
if (!fresh.item || fresh.item.typeId !== item.typeId || fresh.item.amount !== item.amount) {
|
||||
player.sendMessage(`§c[Postal] §7Held item changed — send cancelled.`);
|
||||
return;
|
||||
}
|
||||
|
||||
deliver(player, chosen.ownerId, chosen.reg, fresh.item, fresh.slot, fresh.inv);
|
||||
}
|
||||
|
||||
// ─── Delivery ───────────────────────────────────────────────
|
||||
function deliver(senderPlayer, recipientId, reg, itemStack, slot, inv, retry = 0) {
|
||||
const dim = world.getDimension(reg.dim);
|
||||
const loc = { x: reg.x, y: reg.y, z: reg.z };
|
||||
let block;
|
||||
try {
|
||||
block = dim.getBlock(loc);
|
||||
} catch (_) {
|
||||
block = undefined;
|
||||
}
|
||||
|
||||
if (!block) {
|
||||
if (retry >= 1) {
|
||||
senderPlayer.sendMessage(
|
||||
`§c[Postal] §7Couldn't reach §f${reg.name}§7's mailbox (chunk not loaded). Try again later.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Force chunk load briefly, then retry
|
||||
const taName = `postal_tmp_${recipientId}`.replace(/[^a-zA-Z0-9_]/g, "_").slice(0, 20);
|
||||
try {
|
||||
dim.runCommand(
|
||||
`tickingarea add ${reg.x} ${reg.y} ${reg.z} ${reg.x} ${reg.y} ${reg.z} ${taName}`
|
||||
);
|
||||
} catch (_) {}
|
||||
system.runTimeout(() => {
|
||||
deliver(senderPlayer, recipientId, reg, itemStack, slot, inv, retry + 1);
|
||||
try {
|
||||
dim.runCommand(`tickingarea remove ${taName}`);
|
||||
} catch (_) {}
|
||||
}, 20);
|
||||
return;
|
||||
}
|
||||
|
||||
if (block.typeId !== VANILLA_CHEST) {
|
||||
// Mailbox was destroyed externally — clean up registry
|
||||
releaseMailbox(loc, reg.dim);
|
||||
senderPlayer.sendMessage(
|
||||
`§c[Postal] §f${reg.name}§7's mailbox is missing. Send cancelled.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const owner = getMailboxOwner(loc, reg.dim);
|
||||
if (!owner || owner.ownerId !== recipientId) {
|
||||
releaseMailbox(loc, reg.dim);
|
||||
senderPlayer.sendMessage(
|
||||
`§c[Postal] §f${reg.name}§7's mailbox is no longer claimed. Send cancelled.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const container = block.getComponent("inventory")?.container;
|
||||
if (!container) {
|
||||
senderPlayer.sendMessage(`§c[Postal] §7Couldn't access the mailbox contents.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const leftover = container.addItem(itemStack);
|
||||
|
||||
// Remove the full stack from sender's inventory first
|
||||
try {
|
||||
inv.setItem(slot, undefined);
|
||||
} catch (_) {}
|
||||
|
||||
// Refund leftover (mailbox full) back to sender
|
||||
if (leftover) {
|
||||
const p = senderPlayer.location;
|
||||
try {
|
||||
senderPlayer.dimension.spawnItem(leftover, p);
|
||||
} catch (_) {}
|
||||
senderPlayer.sendMessage(
|
||||
`§c[Postal] §f${reg.name}§7's mailbox is full — partial delivery. Leftover dropped at your feet.`
|
||||
);
|
||||
}
|
||||
|
||||
const summary = itemSummary(itemStack);
|
||||
senderPlayer.sendMessage(`§6[Postal] §7Sent §f${summary}§7 to §f${reg.name}§7.`);
|
||||
|
||||
// Notify recipient
|
||||
const onlineRecipient = world.getAllPlayers().find((p) => p.id === recipientId);
|
||||
if (onlineRecipient) {
|
||||
onlineRecipient.sendMessage(`§6[Postal] §7You've got mail from §f${senderPlayer.name}§7! (${summary})`);
|
||||
} else {
|
||||
if (!state.pending[recipientId]) state.pending[recipientId] = [];
|
||||
state.pending[recipientId].push({
|
||||
from: senderPlayer.name,
|
||||
itemSummary: summary,
|
||||
ts: Date.now(),
|
||||
});
|
||||
saveState();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Flush pending notifications on login ───────────────────
|
||||
world.afterEvents.playerSpawn.subscribe((event) => {
|
||||
if (!event.initialSpawn) return;
|
||||
const player = event.player;
|
||||
const queue = state.pending[player.id];
|
||||
if (!queue || queue.length === 0) return;
|
||||
|
||||
system.runTimeout(() => {
|
||||
player.sendMessage(`§6[Postal] §7You have §f${queue.length}§7 new mail notification(s):`);
|
||||
for (const n of queue) {
|
||||
const when = new Date(n.ts).toISOString().replace("T", " ").slice(0, 16);
|
||||
player.sendMessage(`§7[${when}] §7from §f${n.from}§7 — ${n.itemSummary}`);
|
||||
}
|
||||
delete state.pending[player.id];
|
||||
saveState();
|
||||
}, 40);
|
||||
});
|
||||
|
||||
// ─── Boot ──────────────────────────────────────────────────
|
||||
system.run(() => {
|
||||
loadState();
|
||||
world.sendMessage("§6[Postal] §7Postal service loaded.");
|
||||
});
|
||||
Reference in New Issue
Block a user