Files
minecraft-aiworld/postal-service-addon/postal_service_BP/scripts/main.js
SysAdmin f7aa71e9eb
All checks were successful
Deploy Addons / deploy (push) Successful in 16s
feat(postal): add postal service addon and bundle pending addon work
- 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>
2026-04-20 20:07:39 +01:00

391 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.");
});