feat(postal): multi-mailbox per player with labels, redirect flow, icon refresh
All checks were successful
Deploy Addons / deploy (push) Successful in 14s
All checks were successful
Deploy Addons / deploy (push) Successful in 14s
Players can now place up to 5 mailboxes, each labelled like a lodestone waypoint. Sending mail picks recipient then mailbox; redirect collapses one of your own mailboxes into another and removes the source. - v1 -> v2 schema migration runs once on boot; existing claims default to label "Mailbox". - Two-step send picker (skipped when recipient has only one mailbox). - Post office root menu adds Redirect option. - Per-entry break handling so removing one mailbox keeps the others claimed. - minecraft:icon component + 16x16 inventory icons for both blocks. - Refreshed pack_icon. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -25,3 +25,6 @@ server-data/
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
# Art workspace (edit-in-place mirror; real textures live in each RP)
|
||||
/art/
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"texture": "mailbox",
|
||||
"render_method": "opaque"
|
||||
}
|
||||
}
|
||||
},
|
||||
"minecraft:icon": "mailbox_icon"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"texture": "post_office",
|
||||
"render_method": "opaque"
|
||||
}
|
||||
}
|
||||
},
|
||||
"minecraft:icon": "post_office_icon"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"type": "script",
|
||||
"language": "javascript",
|
||||
"uuid": "d7a4b9c1-2e3f-4a5b-8c6d-1f2e3d4c5b62",
|
||||
"version": [1, 0, 0],
|
||||
"version": [1, 1, 0],
|
||||
"entry": "scripts/main.js"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,38 +1,101 @@
|
||||
import { world, system, ItemStack } from "@minecraft/server";
|
||||
import { ActionFormData, MessageFormData } from "@minecraft/server-ui";
|
||||
import { ActionFormData, MessageFormData, ModalFormData } 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";
|
||||
const PROP_KEY_V1 = "postal_state_v1";
|
||||
const PROP_KEY_V2 = "postal_state_v2";
|
||||
const MAX_MAILBOXES_PER_PLAYER = 5;
|
||||
const LABEL_MAX_LEN = 24;
|
||||
|
||||
// ─── 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
|
||||
// mailboxes: { "x,y,z,dim": { ownerId, ownerName, mailboxId, label } }
|
||||
// registry: { [ownerId]: { name, mailboxes: [{ mailboxId, label, x, y, z, dim }] } }
|
||||
// pending: { [ownerId]: [{ from, itemSummary, mailboxLabel, ts }] }
|
||||
let state = { mailboxes: {}, registry: {}, pending: {} };
|
||||
|
||||
function uuid() {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
function loadState() {
|
||||
// Try v2 first
|
||||
try {
|
||||
const raw = world.getDynamicProperty(PROP_KEY);
|
||||
if (raw && typeof raw === "string") {
|
||||
const parsed = JSON.parse(raw);
|
||||
const rawV2 = world.getDynamicProperty(PROP_KEY_V2);
|
||||
if (rawV2 && typeof rawV2 === "string") {
|
||||
const parsed = JSON.parse(rawV2);
|
||||
state = {
|
||||
mailboxes: parsed.mailboxes || {},
|
||||
registry: parsed.registry || {},
|
||||
pending: parsed.pending || {},
|
||||
};
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
world.sendMessage(`§c[Postal] Failed to load state: ${e.message}`);
|
||||
state = { mailboxes: {}, registry: {}, pending: {} };
|
||||
world.sendMessage(`§c[Postal] Failed to load v2 state: ${e.message}`);
|
||||
}
|
||||
|
||||
// One-shot v1 → v2 migration
|
||||
try {
|
||||
const rawV1 = world.getDynamicProperty(PROP_KEY_V1);
|
||||
if (rawV1 && typeof rawV1 === "string") {
|
||||
const v1 = JSON.parse(rawV1);
|
||||
const next = { mailboxes: {}, registry: {}, pending: v1.pending || {} };
|
||||
|
||||
// Each v1 mailbox entry → v2 entry with synthetic mailboxId + default label
|
||||
for (const [k, entry] of Object.entries(v1.mailboxes || {})) {
|
||||
next.mailboxes[k] = {
|
||||
ownerId: entry.ownerId,
|
||||
ownerName: entry.ownerName,
|
||||
mailboxId: uuid(),
|
||||
label: "Mailbox",
|
||||
};
|
||||
}
|
||||
|
||||
// v1 registry shape: { [ownerId]: { name, x, y, z, dim } }
|
||||
for (const [ownerId, reg] of Object.entries(v1.registry || {})) {
|
||||
const k = `${reg.x},${reg.y},${reg.z},${reg.dim}`;
|
||||
const mb = next.mailboxes[k];
|
||||
if (!mb) continue; // orphaned
|
||||
next.registry[ownerId] = {
|
||||
name: reg.name,
|
||||
mailboxes: [
|
||||
{
|
||||
mailboxId: mb.mailboxId,
|
||||
label: "Mailbox",
|
||||
x: reg.x,
|
||||
y: reg.y,
|
||||
z: reg.z,
|
||||
dim: reg.dim,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
state = next;
|
||||
saveState();
|
||||
try {
|
||||
world.setDynamicProperty(PROP_KEY_V1, undefined);
|
||||
} catch (_) {}
|
||||
world.sendMessage(`§6[Postal] §7Migrated mail data to v2 (multi-mailbox).`);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
world.sendMessage(`§c[Postal] Migration failed: ${e.message}`);
|
||||
}
|
||||
|
||||
state = { mailboxes: {}, registry: {}, pending: {} };
|
||||
}
|
||||
|
||||
function saveState() {
|
||||
try {
|
||||
world.setDynamicProperty(PROP_KEY, JSON.stringify(state));
|
||||
world.setDynamicProperty(PROP_KEY_V2, JSON.stringify(state));
|
||||
} catch (e) {
|
||||
world.sendMessage(`§c[Postal] Failed to save state: ${e.message}`);
|
||||
}
|
||||
@@ -46,16 +109,41 @@ function getMailboxOwner(loc, dimensionId) {
|
||||
return state.mailboxes[keyOf(loc, dimensionId)] || null;
|
||||
}
|
||||
|
||||
function claimMailbox(loc, dimensionId, player) {
|
||||
function claimMailbox(loc, dimensionId, player, label) {
|
||||
const k = keyOf(loc, dimensionId);
|
||||
state.mailboxes[k] = { ownerId: player.id, ownerName: player.name };
|
||||
state.registry[player.id] = {
|
||||
name: player.name,
|
||||
const mailboxId = uuid();
|
||||
state.mailboxes[k] = {
|
||||
ownerId: player.id,
|
||||
ownerName: player.name,
|
||||
mailboxId,
|
||||
label,
|
||||
};
|
||||
if (!state.registry[player.id]) {
|
||||
state.registry[player.id] = { name: player.name, mailboxes: [] };
|
||||
}
|
||||
state.registry[player.id].name = player.name;
|
||||
state.registry[player.id].mailboxes.push({
|
||||
mailboxId,
|
||||
label,
|
||||
x: Math.floor(loc.x),
|
||||
y: Math.floor(loc.y),
|
||||
z: Math.floor(loc.z),
|
||||
dim: dimensionId,
|
||||
};
|
||||
});
|
||||
saveState();
|
||||
return mailboxId;
|
||||
}
|
||||
|
||||
function renameMailbox(loc, dimensionId, label) {
|
||||
const k = keyOf(loc, dimensionId);
|
||||
const entry = state.mailboxes[k];
|
||||
if (!entry) return;
|
||||
entry.label = label;
|
||||
const reg = state.registry[entry.ownerId];
|
||||
if (reg) {
|
||||
const m = reg.mailboxes.find((m) => m.mailboxId === entry.mailboxId);
|
||||
if (m) m.label = label;
|
||||
}
|
||||
saveState();
|
||||
}
|
||||
|
||||
@@ -65,15 +153,20 @@ function releaseMailbox(loc, dimensionId) {
|
||||
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) {
|
||||
reg.mailboxes = reg.mailboxes.filter((m) => m.mailboxId !== entry.mailboxId);
|
||||
if (reg.mailboxes.length === 0) {
|
||||
delete state.registry[entry.ownerId];
|
||||
}
|
||||
}
|
||||
saveState();
|
||||
}
|
||||
|
||||
function hasClaim(playerId) {
|
||||
return !!state.registry[playerId];
|
||||
function countMailboxes(playerId) {
|
||||
return state.registry[playerId]?.mailboxes?.length || 0;
|
||||
}
|
||||
|
||||
function getPlayerMailboxes(playerId) {
|
||||
return state.registry[playerId]?.mailboxes || [];
|
||||
}
|
||||
|
||||
// ─── Chest facing from player rotation ──────────────────────
|
||||
@@ -96,8 +189,8 @@ world.afterEvents.playerPlaceBlock.subscribe((event) => {
|
||||
const loc = block.location;
|
||||
const dim = block.dimension;
|
||||
|
||||
if (hasClaim(player.id)) {
|
||||
// Revert placement and refund the item
|
||||
const currentCount = countMailboxes(player.id);
|
||||
if (currentCount >= MAX_MAILBOXES_PER_PLAYER) {
|
||||
try {
|
||||
dim.runCommand(`setblock ${loc.x} ${loc.y} ${loc.z} air`);
|
||||
dim.spawnItem(new ItemStack(MAILBOX_BLOCK, 1), {
|
||||
@@ -106,7 +199,9 @@ world.afterEvents.playerPlaceBlock.subscribe((event) => {
|
||||
z: loc.z + 0.5,
|
||||
});
|
||||
} catch (_) {}
|
||||
player.sendMessage(`§c[Postal] §7You already have a mailbox in this world.`);
|
||||
player.sendMessage(
|
||||
`§c[Postal] §7Max of ${MAX_MAILBOXES_PER_PLAYER} mailboxes per player — break one first.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -121,20 +216,44 @@ world.afterEvents.playerPlaceBlock.subscribe((event) => {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
claimMailbox(loc, dim.id, player);
|
||||
player.sendMessage(`§6[Postal] §7Mailbox locked to you. Only you can open or break it.`);
|
||||
const defaultLabel = `Mailbox ${currentCount + 1}`;
|
||||
claimMailbox(loc, dim.id, player, defaultLabel);
|
||||
|
||||
system.run(() => promptForLabel(player, loc, dim.id, defaultLabel));
|
||||
});
|
||||
|
||||
async function promptForLabel(player, loc, dimId, defaultLabel) {
|
||||
const form = new ModalFormData()
|
||||
.title("Name this Mailbox")
|
||||
.textField("Label", "e.g. Home, Mine, Base", defaultLabel);
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await form.show(player);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (res.canceled) {
|
||||
player.sendMessage(`§6[Postal] §7Mailbox locked to you as §d${defaultLabel}§7.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = (res.formValues && res.formValues[0]) || "";
|
||||
const label = String(raw).trim().slice(0, LABEL_MAX_LEN) || defaultLabel;
|
||||
renameMailbox(loc, dimId, label);
|
||||
player.sendMessage(`§6[Postal] §7Mailbox locked to you as §d${label}§7.`);
|
||||
}
|
||||
|
||||
// ─── 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)
|
||||
// Post-office block: open menu (cancel default interact)
|
||||
if (block.typeId === POST_OFFICE_BLOCK) {
|
||||
event.cancel = true;
|
||||
const player = event.player;
|
||||
system.run(() => openSendForm(player));
|
||||
system.run(() => openPostOfficeMenu(player));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -146,7 +265,9 @@ world.beforeEvents.playerInteractWithBlock.subscribe((event) => {
|
||||
event.cancel = true;
|
||||
const playerRef = event.player;
|
||||
system.run(() =>
|
||||
playerRef.sendMessage(`§c[Postal] §7This mailbox belongs to §f${owner.ownerName}§7.`)
|
||||
playerRef.sendMessage(
|
||||
`§c[Postal] §7This mailbox belongs to §f${owner.ownerName}§7.`
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -162,7 +283,9 @@ world.beforeEvents.playerBreakBlock.subscribe((event) => {
|
||||
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.`)
|
||||
player.sendMessage(
|
||||
`§c[Postal] §7This mailbox belongs to §f${owner.ownerName}§7. You can't break it.`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -170,6 +293,7 @@ world.beforeEvents.playerBreakBlock.subscribe((event) => {
|
||||
event.cancel = true;
|
||||
const loc = { x: block.location.x, y: block.location.y, z: block.location.z };
|
||||
const dim = block.dimension;
|
||||
const label = owner.label;
|
||||
|
||||
system.run(() => {
|
||||
try {
|
||||
@@ -191,10 +315,38 @@ world.beforeEvents.playerBreakBlock.subscribe((event) => {
|
||||
player.sendMessage(`§c[Postal] Error during break: ${e.message}`);
|
||||
}
|
||||
releaseMailbox(loc, dim.id);
|
||||
player.sendMessage(`§6[Postal] §7Removed §d${label}§7.`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Post Office: send form ─────────────────────────────────
|
||||
// ─── Post Office: root menu (router) ────────────────────────
|
||||
async function openPostOfficeMenu(player) {
|
||||
const myCount = countMailboxes(player.id);
|
||||
|
||||
// No mailboxes of their own → no redirect option, jump straight to send
|
||||
if (myCount === 0) {
|
||||
return openSendForm(player);
|
||||
}
|
||||
|
||||
const form = new ActionFormData()
|
||||
.title("Post Office")
|
||||
.body("What would you like to do?");
|
||||
form.button("§fSend Mail\n§7Deliver an item to another player");
|
||||
form.button("§fRedirect Mailbox\n§7Move one of your mailboxes' contents into another");
|
||||
form.button("§cClose");
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await form.show(player);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (res.canceled || res.selection === undefined) return;
|
||||
if (res.selection === 0) return openSendForm(player);
|
||||
if (res.selection === 1) return openRedirectForm(player);
|
||||
}
|
||||
|
||||
// ─── Held item helpers ──────────────────────────────────────
|
||||
function getHeldItem(player) {
|
||||
try {
|
||||
const inv = player.getComponent("inventory")?.container;
|
||||
@@ -212,16 +364,20 @@ function itemSummary(item) {
|
||||
return `${item.amount} × ${niceName}`;
|
||||
}
|
||||
|
||||
// ─── Send form: Stage A (recipient) ─────────────────────────
|
||||
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.`);
|
||||
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;
|
||||
if (!reg.mailboxes || reg.mailboxes.length === 0) continue;
|
||||
candidates.push({ ownerId, reg });
|
||||
}
|
||||
|
||||
@@ -231,50 +387,111 @@ async function openSendForm(player) {
|
||||
}
|
||||
|
||||
const form = new ActionFormData()
|
||||
.title("Post Office")
|
||||
.title("Post Office — Recipient")
|
||||
.body(`Sending §f${itemSummary(item)}§r\n\nChoose a recipient:`);
|
||||
for (const c of candidates) form.button(c.reg.name);
|
||||
for (const c of candidates) {
|
||||
const n = c.reg.mailboxes.length;
|
||||
form.button(`§f${c.reg.name}\n§7${n} mailbox${n === 1 ? "" : "es"}`);
|
||||
}
|
||||
form.button("§cCancel");
|
||||
|
||||
let response;
|
||||
let res;
|
||||
try {
|
||||
response = await form.show(player);
|
||||
res = await form.show(player);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (response.canceled || response.selection === undefined) return;
|
||||
if (response.selection >= candidates.length) return; // cancel button
|
||||
if (res.canceled || res.selection === undefined) return;
|
||||
if (res.selection >= candidates.length) return;
|
||||
|
||||
const chosen = candidates[response.selection];
|
||||
const chosen = candidates[res.selection];
|
||||
|
||||
// Single mailbox → skip Stage B
|
||||
if (chosen.reg.mailboxes.length === 1) {
|
||||
return confirmAndSend(player, chosen, chosen.reg.mailboxes[0], item, slot, inv);
|
||||
}
|
||||
|
||||
return openMailboxPicker(player, chosen, item, slot, inv);
|
||||
}
|
||||
|
||||
// ─── Send form: Stage B (mailbox) ───────────────────────────
|
||||
async function openMailboxPicker(player, chosen, item, slot, inv) {
|
||||
const form = new ActionFormData()
|
||||
.title(`Send to ${chosen.reg.name}`)
|
||||
.body(`Sending §f${itemSummary(item)}§r\n\nChoose which mailbox:`);
|
||||
for (const m of chosen.reg.mailboxes) {
|
||||
form.button(`§f${m.label}\n§7${m.x}, ${m.y}, ${m.z}`);
|
||||
}
|
||||
form.button("§cBack");
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await form.show(player);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (res.canceled || res.selection === undefined) return;
|
||||
if (res.selection >= chosen.reg.mailboxes.length) {
|
||||
return openSendForm(player);
|
||||
}
|
||||
|
||||
const mailbox = chosen.reg.mailboxes[res.selection];
|
||||
return confirmAndSend(player, chosen, mailbox, item, slot, inv);
|
||||
}
|
||||
|
||||
// ─── Send form: Stage C (confirm) ───────────────────────────
|
||||
async function confirmAndSend(player, chosen, mailbox, item, slot, inv) {
|
||||
const confirm = new MessageFormData()
|
||||
.title("Confirm Send")
|
||||
.body(`Send §f${itemSummary(item)}§r\nto §f${chosen.reg.name}§r?`)
|
||||
.body(
|
||||
`Send §f${itemSummary(item)}§r\nto §f${chosen.reg.name}§r's §d${mailbox.label}§r?`
|
||||
)
|
||||
.button1("Send")
|
||||
.button2("Cancel");
|
||||
|
||||
let conf;
|
||||
let res;
|
||||
try {
|
||||
conf = await confirm.show(player);
|
||||
res = await confirm.show(player);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (conf.canceled || conf.selection !== 0) return;
|
||||
if (res.canceled || res.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) {
|
||||
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);
|
||||
deliver(
|
||||
player,
|
||||
chosen.ownerId,
|
||||
chosen.reg.name,
|
||||
mailbox,
|
||||
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 };
|
||||
function deliver(
|
||||
senderPlayer,
|
||||
recipientId,
|
||||
recipientName,
|
||||
mailbox,
|
||||
itemStack,
|
||||
slot,
|
||||
inv,
|
||||
retry = 0
|
||||
) {
|
||||
const dim = world.getDimension(mailbox.dim);
|
||||
const loc = { x: mailbox.x, y: mailbox.y, z: mailbox.z };
|
||||
let block;
|
||||
try {
|
||||
block = dim.getBlock(loc);
|
||||
@@ -285,19 +502,30 @@ function deliver(senderPlayer, recipientId, reg, itemStack, slot, inv, retry = 0
|
||||
if (!block) {
|
||||
if (retry >= 1) {
|
||||
senderPlayer.sendMessage(
|
||||
`§c[Postal] §7Couldn't reach §f${reg.name}§7's mailbox (chunk not loaded). Try again later.`
|
||||
`§c[Postal] §7Couldn't reach §f${recipientName}§7's §d${mailbox.label}§7 (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);
|
||||
const taName = `postal_d_${(mailbox.mailboxId || "").slice(0, 8)}`.replace(
|
||||
/[^a-zA-Z0-9_]/g,
|
||||
"_"
|
||||
);
|
||||
try {
|
||||
dim.runCommand(
|
||||
`tickingarea add ${reg.x} ${reg.y} ${reg.z} ${reg.x} ${reg.y} ${reg.z} ${taName}`
|
||||
`tickingarea add ${loc.x} ${loc.y} ${loc.z} ${loc.x} ${loc.y} ${loc.z} ${taName}`
|
||||
);
|
||||
} catch (_) {}
|
||||
system.runTimeout(() => {
|
||||
deliver(senderPlayer, recipientId, reg, itemStack, slot, inv, retry + 1);
|
||||
deliver(
|
||||
senderPlayer,
|
||||
recipientId,
|
||||
recipientName,
|
||||
mailbox,
|
||||
itemStack,
|
||||
slot,
|
||||
inv,
|
||||
retry + 1
|
||||
);
|
||||
try {
|
||||
dim.runCommand(`tickingarea remove ${taName}`);
|
||||
} catch (_) {}
|
||||
@@ -306,19 +534,18 @@ function deliver(senderPlayer, recipientId, reg, itemStack, slot, inv, retry = 0
|
||||
}
|
||||
|
||||
if (block.typeId !== VANILLA_CHEST) {
|
||||
// Mailbox was destroyed externally — clean up registry
|
||||
releaseMailbox(loc, reg.dim);
|
||||
releaseMailbox(loc, mailbox.dim);
|
||||
senderPlayer.sendMessage(
|
||||
`§c[Postal] §f${reg.name}§7's mailbox is missing. Send cancelled.`
|
||||
`§c[Postal] §f${recipientName}§7's §d${mailbox.label}§7 is missing. Send cancelled.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const owner = getMailboxOwner(loc, reg.dim);
|
||||
const owner = getMailboxOwner(loc, mailbox.dim);
|
||||
if (!owner || owner.ownerId !== recipientId) {
|
||||
releaseMailbox(loc, reg.dim);
|
||||
releaseMailbox(loc, mailbox.dim);
|
||||
senderPlayer.sendMessage(
|
||||
`§c[Postal] §f${reg.name}§7's mailbox is no longer claimed. Send cancelled.`
|
||||
`§c[Postal] §f${recipientName}§7's §d${mailbox.label}§7 is no longer claimed. Send cancelled.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -331,40 +558,240 @@ function deliver(senderPlayer, recipientId, reg, itemStack, slot, inv, retry = 0
|
||||
|
||||
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);
|
||||
senderPlayer.dimension.spawnItem(leftover, senderPlayer.location);
|
||||
} catch (_) {}
|
||||
senderPlayer.sendMessage(
|
||||
`§c[Postal] §f${reg.name}§7's mailbox is full — partial delivery. Leftover dropped at your feet.`
|
||||
`§c[Postal] §f${recipientName}§7's §d${mailbox.label}§7 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.`);
|
||||
senderPlayer.sendMessage(
|
||||
`§6[Postal] §7Sent §f${summary}§7 to §f${recipientName}§7's §d${mailbox.label}§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})`);
|
||||
onlineRecipient.sendMessage(
|
||||
`§6[Postal] §7You've got mail from §f${senderPlayer.name}§7 in §d${mailbox.label}§7! (${summary})`
|
||||
);
|
||||
} else {
|
||||
if (!state.pending[recipientId]) state.pending[recipientId] = [];
|
||||
state.pending[recipientId].push({
|
||||
from: senderPlayer.name,
|
||||
itemSummary: summary,
|
||||
mailboxLabel: mailbox.label,
|
||||
ts: Date.now(),
|
||||
});
|
||||
saveState();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Redirect: Stage 1 (source) ─────────────────────────────
|
||||
async function openRedirectForm(player) {
|
||||
const myMailboxes = getPlayerMailboxes(player.id);
|
||||
if (myMailboxes.length < 2) {
|
||||
player.sendMessage(`§c[Postal] §7You need at least two mailboxes to redirect.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const form = new ActionFormData()
|
||||
.title("Redirect — Source")
|
||||
.body("Choose the mailbox to empty and remove:");
|
||||
for (const m of myMailboxes) {
|
||||
form.button(`§f${m.label}\n§7${m.x}, ${m.y}, ${m.z}`);
|
||||
}
|
||||
form.button("§cCancel");
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await form.show(player);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (res.canceled || res.selection === undefined) return;
|
||||
if (res.selection >= myMailboxes.length) return;
|
||||
|
||||
const source = myMailboxes[res.selection];
|
||||
return redirectStep2(player, source);
|
||||
}
|
||||
|
||||
// ─── Redirect: Stage 2 (destination) ────────────────────────
|
||||
async function redirectStep2(player, source) {
|
||||
const dests = getPlayerMailboxes(player.id).filter(
|
||||
(m) => m.mailboxId !== source.mailboxId
|
||||
);
|
||||
if (dests.length === 0) {
|
||||
player.sendMessage(`§c[Postal] §7No other mailboxes to redirect into.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const form = new ActionFormData()
|
||||
.title("Redirect — Destination")
|
||||
.body(`Source: §d${source.label}§r\n\nChoose where to move its contents:`);
|
||||
for (const m of dests) {
|
||||
form.button(`§f${m.label}\n§7${m.x}, ${m.y}, ${m.z}`);
|
||||
}
|
||||
form.button("§cBack");
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await form.show(player);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (res.canceled || res.selection === undefined) return;
|
||||
if (res.selection >= dests.length) return openRedirectForm(player);
|
||||
|
||||
const dest = dests[res.selection];
|
||||
return redirectConfirm(player, source, dest);
|
||||
}
|
||||
|
||||
// ─── Redirect: Stage 3 (confirm) ────────────────────────────
|
||||
async function redirectConfirm(player, source, dest) {
|
||||
const confirm = new MessageFormData()
|
||||
.title("Confirm Redirect")
|
||||
.body(
|
||||
`Redirect §d${source.label}§r → §d${dest.label}§r?\n\n§7The source mailbox will be destroyed and all its contents moved to the destination.`
|
||||
)
|
||||
.button1("Redirect")
|
||||
.button2("Cancel");
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await confirm.show(player);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (res.canceled || res.selection !== 0) return;
|
||||
|
||||
executeRedirect(player, source, dest);
|
||||
}
|
||||
|
||||
// ─── Redirect: execute ──────────────────────────────────────
|
||||
function executeRedirect(player, source, dest, retry = 0) {
|
||||
const sDim = world.getDimension(source.dim);
|
||||
const dDim = world.getDimension(dest.dim);
|
||||
const sLoc = { x: source.x, y: source.y, z: source.z };
|
||||
const dLoc = { x: dest.x, y: dest.y, z: dest.z };
|
||||
|
||||
let sBlock, dBlock;
|
||||
try {
|
||||
sBlock = sDim.getBlock(sLoc);
|
||||
} catch (_) {}
|
||||
try {
|
||||
dBlock = dDim.getBlock(dLoc);
|
||||
} catch (_) {}
|
||||
|
||||
if (!sBlock || !dBlock) {
|
||||
if (retry >= 1) {
|
||||
player.sendMessage(
|
||||
`§c[Postal] §7Couldn't reach mailbox chunks. Try again later.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const taS = `postal_rs_${(source.mailboxId || "").slice(0, 6)}`.replace(
|
||||
/[^a-zA-Z0-9_]/g,
|
||||
"_"
|
||||
);
|
||||
const taD = `postal_rd_${(dest.mailboxId || "").slice(0, 6)}`.replace(
|
||||
/[^a-zA-Z0-9_]/g,
|
||||
"_"
|
||||
);
|
||||
try {
|
||||
sDim.runCommand(
|
||||
`tickingarea add ${sLoc.x} ${sLoc.y} ${sLoc.z} ${sLoc.x} ${sLoc.y} ${sLoc.z} ${taS}`
|
||||
);
|
||||
} catch (_) {}
|
||||
try {
|
||||
dDim.runCommand(
|
||||
`tickingarea add ${dLoc.x} ${dLoc.y} ${dLoc.z} ${dLoc.x} ${dLoc.y} ${dLoc.z} ${taD}`
|
||||
);
|
||||
} catch (_) {}
|
||||
system.runTimeout(() => {
|
||||
executeRedirect(player, source, dest, retry + 1);
|
||||
try {
|
||||
sDim.runCommand(`tickingarea remove ${taS}`);
|
||||
} catch (_) {}
|
||||
try {
|
||||
dDim.runCommand(`tickingarea remove ${taD}`);
|
||||
} catch (_) {}
|
||||
}, 20);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sBlock.typeId !== VANILLA_CHEST) {
|
||||
releaseMailbox(sLoc, source.dim);
|
||||
player.sendMessage(
|
||||
`§c[Postal] §7Source mailbox §d${source.label}§7 is missing. Aborted.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (dBlock.typeId !== VANILLA_CHEST) {
|
||||
releaseMailbox(dLoc, dest.dim);
|
||||
player.sendMessage(
|
||||
`§c[Postal] §7Destination mailbox §d${dest.label}§7 is missing. Aborted.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const sOwner = getMailboxOwner(sLoc, source.dim);
|
||||
const dOwner = getMailboxOwner(dLoc, dest.dim);
|
||||
if (!sOwner || sOwner.ownerId !== player.id) {
|
||||
player.sendMessage(`§c[Postal] §7You no longer own the source mailbox. Aborted.`);
|
||||
return;
|
||||
}
|
||||
if (!dOwner || dOwner.ownerId !== player.id) {
|
||||
player.sendMessage(
|
||||
`§c[Postal] §7You no longer own the destination mailbox. Aborted.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const sContainer = sBlock.getComponent("inventory")?.container;
|
||||
const dContainer = dBlock.getComponent("inventory")?.container;
|
||||
if (!sContainer || !dContainer) {
|
||||
player.sendMessage(`§c[Postal] §7Couldn't access mailbox contents.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const leftovers = [];
|
||||
for (let i = 0; i < sContainer.size; i++) {
|
||||
const it = sContainer.getItem(i);
|
||||
if (!it) continue;
|
||||
const lo = dContainer.addItem(it);
|
||||
if (lo) leftovers.push(lo);
|
||||
sContainer.setItem(i, undefined);
|
||||
}
|
||||
|
||||
// Destroy source block (no item drop — pure consolidation)
|
||||
try {
|
||||
sDim.runCommand(`setblock ${sLoc.x} ${sLoc.y} ${sLoc.z} air`);
|
||||
} catch (_) {}
|
||||
releaseMailbox(sLoc, source.dim);
|
||||
|
||||
if (leftovers.length) {
|
||||
for (const lo of leftovers) {
|
||||
try {
|
||||
player.dimension.spawnItem(lo, player.location);
|
||||
} catch (_) {}
|
||||
}
|
||||
player.sendMessage(
|
||||
`§e[Postal] §7Destination was full — leftovers dropped at your feet.`
|
||||
);
|
||||
}
|
||||
|
||||
player.sendMessage(
|
||||
`§6[Postal] §7Redirected §d${source.label}§7 → §d${dest.label}§7.`
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Flush pending notifications on login ───────────────────
|
||||
world.afterEvents.playerSpawn.subscribe((event) => {
|
||||
if (!event.initialSpawn) return;
|
||||
@@ -373,10 +800,15 @@ world.afterEvents.playerSpawn.subscribe((event) => {
|
||||
if (!queue || queue.length === 0) return;
|
||||
|
||||
system.runTimeout(() => {
|
||||
player.sendMessage(`§6[Postal] §7You have §f${queue.length}§7 new mail notification(s):`);
|
||||
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}`);
|
||||
const where = n.mailboxLabel ? ` in §d${n.mailboxLabel}§7` : "";
|
||||
player.sendMessage(
|
||||
`§7[${when}] §7from §f${n.from}§7${where} — ${n.itemSummary}`
|
||||
);
|
||||
}
|
||||
delete state.pending[player.id];
|
||||
saveState();
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 971 B After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"resource_pack_name": "postal_service_RP",
|
||||
"texture_name": "atlas.items",
|
||||
"texture_data": {
|
||||
"mailbox_icon": {
|
||||
"textures": "textures/items/mailbox"
|
||||
},
|
||||
"post_office_icon": {
|
||||
"textures": "textures/items/post_office"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 245 B |
Binary file not shown.
|
After Width: | Height: | Size: 328 B |
Reference in New Issue
Block a user