// Family Goals Addon // // Lives in jamie's world (the shared family survival world). Provides: // • PvP-off (siblings can't hurt each other) // • Family roster ( !family setup adds online players ) // • Family Bank ( !family bank registers a vanilla chest as shared ) // • Five shared goals with per-player contribution tracking // • Loot share within 32 blocks (sibling helpers get a kill bonus) // • Pity timer (idle siblings get an encouragement bundle every 60 min) // // All persistent state lives in world dynamic properties so it survives // container restarts. No resource pack — vanilla items + titleraw + actionforms. import { world, system, ItemStack } from "@minecraft/server"; import { ActionFormData } from "@minecraft/server-ui"; // We don't actually use @minecraft/server-admin, but importing from it flips // the beta-API gate for this pack so world.beforeEvents.chatSend resolves. // If we ever drop this import, fall back to afterEvents.chatSend (no cancel). import { variables } from "@minecraft/server-admin"; void variables; // silence unused warning // ─── Tunables ────────────────────────────────────────────────── const FAMILY_PROP = "family_roster_v1"; // { members: [, ...] } const GOALS_PROP = "family_goals_v1"; // see initGoals() for shape const BANK_PROP = "family_bank_v1"; // { x, y, z, dim } | null const PROGRESS_PROP = "family_last_progress_at"; // per-player dyn prop, Date.now() const VANILLA_CHEST = "minecraft:chest"; const LOOT_SHARE_RADIUS = 32; const PITY_THRESHOLD_MS = 60 * 60 * 1000; // 60 min of no progress const PITY_TICK_INTERVAL = 6000; // poll every 5 min (20 tps) const NIGHTS_TICK_INTERVAL = 200; // every 10s const GOAL_DEFS = [ { id: "logs", label: "Logs Chopped", target: 5000 }, { id: "diamonds", label: "Diamonds Mined", target: 50 }, { id: "mobs", label: "Monsters Defeated", target: 500 }, { id: "crops", label: "Crops Harvested", target: 1000 }, { id: "nights", label: "Nights Survived", target: 30 }, ]; const LOG_IDS = new Set([ "minecraft:oak_log", "minecraft:spruce_log", "minecraft:birch_log", "minecraft:jungle_log", "minecraft:acacia_log", "minecraft:dark_oak_log", "minecraft:cherry_log", "minecraft:mangrove_log", "minecraft:stripped_oak_log", "minecraft:stripped_spruce_log", "minecraft:stripped_birch_log", "minecraft:stripped_jungle_log", "minecraft:stripped_acacia_log", "minecraft:stripped_dark_oak_log", "minecraft:stripped_cherry_log", "minecraft:stripped_mangrove_log", ]); const DIAMOND_IDS = new Set([ "minecraft:diamond_ore", "minecraft:deepslate_diamond_ore", ]); // Block id → "growth"-like state value at full maturity const CROP_AGES = { "minecraft:wheat": 7, "minecraft:carrots": 7, "minecraft:potatoes": 7, "minecraft:beetroot": 3, }; // ─── In-memory mirror of dynamic properties ──────────────────── let family = { members: [] }; let goals = null; let bank = null; let lastDay = -1; // tracks day-rollover for the "nights survived" goal // ─── Persistence ─────────────────────────────────────────────── function loadState() { try { const f = world.getDynamicProperty(FAMILY_PROP); if (typeof f === "string") family = JSON.parse(f); } catch (_) { family = { members: [] }; } try { const g = world.getDynamicProperty(GOALS_PROP); if (typeof g === "string") goals = JSON.parse(g); } catch (_) { goals = null; } if (!goals || !goals.goals) goals = initGoals(); // Backfill any newly added goal definitions on upgrade for (const def of GOAL_DEFS) { if (!goals.goals[def.id]) { goals.goals[def.id] = { current: 0, target: def.target, completed: false, contributions: {} }; } } try { const b = world.getDynamicProperty(BANK_PROP); if (typeof b === "string") bank = JSON.parse(b); } catch (_) { bank = null; } } function initGoals() { const g = { goals: {} }; for (const def of GOAL_DEFS) { g.goals[def.id] = { current: 0, target: def.target, completed: false, contributions: {} }; } return g; } function saveFamily() { try { world.setDynamicProperty(FAMILY_PROP, JSON.stringify(family)); } catch (_) {} } function saveGoals() { try { const json = JSON.stringify(goals); world.setDynamicProperty(GOALS_PROP, json); const verify = world.getDynamicProperty(GOALS_PROP); console.warn(`[Family] saveGoals: wrote ${json.length} chars, readback typeof=${typeof verify} len=${typeof verify === "string" ? verify.length : "n/a"}`); } catch (e) { console.warn(`[Family] saveGoals FAILED: ${e}`); } } function saveBank() { try { world.setDynamicProperty(BANK_PROP, bank ? JSON.stringify(bank) : undefined); } catch (_) {} } function isFamily(player) { if (!player || !family.members) return false; return family.members.includes(player.id); } // ─── Goal mutation ───────────────────────────────────────────── function incrGoal(goalId, player, n = 1) { if (!isFamily(player)) return; const g = goals.goals[goalId]; if (!g || g.completed) return; g.current += n; g.contributions[player.id] = (g.contributions[player.id] || 0) + n; try { player.setDynamicProperty(PROGRESS_PROP, Date.now()); } catch (_) {} if (g.current >= g.target) { g.completed = true; g.current = g.target; fireGoalReward(goalId); } saveGoals(); } function fireGoalReward(goalId) { const def = GOAL_DEFS.find(d => d.id === goalId); const label = def ? def.label : goalId; for (const p of world.getAllPlayers()) { try { p.onScreenDisplay.setTitle(`§6✦ Family Goal Complete!`, { subtitle: `§e${label}`, fadeInDuration: 10, stayDuration: 80, fadeOutDuration: 20, }); p.dimension.runCommand(`summon firework_rocket ${p.location.x} ${p.location.y + 2} ${p.location.z}`); // v1 trophy: a named gold ingot. Easy to tell apart in inventory. p.runCommand(`give @s gold_ingot 1`); } catch (_) {} } world.sendMessage(`§6[Family] §eGoal complete: §f${label}§e! §7Trophy gold awarded.`); } // ─── Family roster command ───────────────────────────────────── function familySetup(player) { const online = world.getAllPlayers(); const before = new Set(family.members); let added = 0; for (const p of online) { if (!before.has(p.id)) { family.members.push(p.id); added += 1; } } saveFamily(); const names = online.map(p => p.name).join(", "); if (added === 0) { player.sendMessage(`§7[Family] §fAll currently-online players are already in the roster (${family.members.length} members).`); } else { player.sendMessage(`§a[Family] §fAdded ${added} new member(s). Roster (${family.members.length}): §e${names}§f.`); world.sendMessage(`§a[Family] §fRoster updated by §e${player.name}§f.`); } } function familyReset(player) { family = { members: [] }; saveFamily(); player.sendMessage("§c[Family] §fRoster cleared. Run §e!family setup§f again to repopulate."); } // ─── Family Bank command ─────────────────────────────────────── function familyBank(player) { if (!isFamily(player)) { player.sendMessage("§c[Family] §7Only family members can register the bank — run §e!family setup§7 first while everyone's online."); return; } let view; try { view = player.getBlockFromViewDirection({ maxDistance: 6 }); } catch (e) { player.sendMessage(`§c[Family] §7Couldn't look at a block: ${e.message}`); return; } const target = view?.block; if (!target || target.typeId !== VANILLA_CHEST) { player.sendMessage("§c[Family] §7Look at a vanilla chest within 6 blocks, then re-run §e!family bank§7."); return; } bank = { x: target.location.x, y: target.location.y, z: target.location.z, dim: target.dimension.id, }; saveBank(); player.sendMessage(`§a[Family] §fBank registered at §e${bank.x}, ${bank.y}, ${bank.z}§f. All family members can now open it.`); } // ─── Bank gating (interact + break) ──────────────────────────── function isBankAt(loc, dimId) { if (!bank) return false; return Math.floor(loc.x) === bank.x && Math.floor(loc.y) === bank.y && Math.floor(loc.z) === bank.z && bank.dim === dimId; } try { world.beforeEvents.playerInteractWithBlock.subscribe((event) => { const block = event.block; if (block.typeId !== VANILLA_CHEST) return; if (!isBankAt(block.location, block.dimension.id)) return; if (isFamily(event.player)) return; event.cancel = true; const playerRef = event.player; system.run(() => playerRef.sendMessage("§c[Family Bank] §7Only family members can open this chest.")); }); } catch (e) { console.warn(`[Family] beforeEvents.playerInteractWithBlock unavailable: ${e}`); } try { world.beforeEvents.playerBreakBlock.subscribe((event) => { const block = event.block; if (block.typeId !== VANILLA_CHEST) return; if (!isBankAt(block.location, block.dimension.id)) return; if (isFamily(event.player)) { // Family member breaking the bank → un-register so it stops being protected bank = null; saveBank(); const p = event.player; system.run(() => p.sendMessage("§7[Family Bank] §fBank chest broken — un-registered. Place a new chest and run §e!family bank§f to re-register.")); return; } event.cancel = true; const playerRef = event.player; system.run(() => playerRef.sendMessage("§c[Family Bank] §7Only family members can break this chest.")); }); } catch (e) { console.warn(`[Family] beforeEvents.playerBreakBlock unavailable: ${e}`); } // ─── PvP off (heal damage between siblings) ──────────────────── // Bedrock has no server.properties pvp toggle — we hook the after-event // and immediately re-heal. Net visible effect: zero damage between players. world.afterEvents.entityHurt.subscribe((event) => { const source = event.damageSource?.damagingEntity; const victim = event.hurtEntity; if (!source || !victim) return; if (source.typeId !== "minecraft:player" || victim.typeId !== "minecraft:player") return; try { const health = victim.getComponent("minecraft:health"); if (!health) return; const restored = Math.min(health.effectiveMax, health.currentValue + event.damage); health.setCurrentValue(restored); } catch (_) {} }); // ─── Goal triggers ───────────────────────────────────────────── world.afterEvents.playerBreakBlock.subscribe((event) => { const player = event.player; if (!isFamily(player)) return; const id = event.brokenBlockPermutation?.type?.id; if (!id) return; if (LOG_IDS.has(id)) { incrGoal("logs", player); return; } if (DIAMOND_IDS.has(id)) { incrGoal("diamonds", player); return; } if (id in CROP_AGES) { // Only fully grown crops count let states = null; try { states = event.brokenBlockPermutation.getAllStates(); } catch (_) {} const age = states && (states["growth"] ?? states["age"]); if (typeof age === "number" && age >= CROP_AGES[id]) { incrGoal("crops", player); } } }); world.afterEvents.entityDie.subscribe((event) => { const dead = event.deadEntity; if (!dead || dead.typeId === "minecraft:player") return; const source = event.damageSource?.damagingEntity; if (!source || source.typeId !== "minecraft:player") return; if (!isFamily(source)) return; // Goal: monsters only let isMonster = false; try { const fam = dead.getComponent("minecraft:type_family"); if (fam && typeof fam.hasTypeFamily === "function" && fam.hasTypeFamily("monster")) { isMonster = true; } } catch (_) {} if (isMonster) incrGoal("mobs", source); // Loot share: every family member in 32 blocks (other than killer) gets cooked beef. // Sharing helpers, not real drop mirroring — keeps the drop pipeline untouched. try { const k = source.location; const r2 = LOOT_SHARE_RADIUS * LOOT_SHARE_RADIUS; for (const other of world.getAllPlayers()) { if (other.id === source.id) continue; if (!isFamily(other)) continue; if (other.dimension.id !== source.dimension.id) continue; const dx = other.location.x - k.x; const dy = other.location.y - k.y; const dz = other.location.z - k.z; if (dx*dx + dy*dy + dz*dz <= r2) { other.runCommand("give @s cooked_beef 2"); } } } catch (_) {} }); // ─── Nights survived ─────────────────────────────────────────── system.runInterval(() => { let day; try { day = world.getDay(); } catch (_) { return; } if (typeof day !== "number") return; if (lastDay === -1) { lastDay = day; return; } if (day > lastDay) { const diff = day - lastDay; lastDay = day; for (const p of world.getAllPlayers()) { if (isFamily(p)) incrGoal("nights", p, diff); } } }, NIGHTS_TICK_INTERVAL); // ─── Pity timer ──────────────────────────────────────────────── system.runInterval(() => { const now = Date.now(); for (const p of world.getAllPlayers()) { if (!isFamily(p)) continue; let last; try { last = p.getDynamicProperty(PROGRESS_PROP); } catch (_) { last = undefined; } if (typeof last !== "number") { // First time we've seen this family member contribute (or first session) — initialize, don't pity yet try { p.setDynamicProperty(PROGRESS_PROP, now); } catch (_) {} continue; } if (now - last < PITY_THRESHOLD_MS) continue; // Drop encouragement bundle directly into inventory try { p.runCommand("give @s diamond 1"); p.runCommand("give @s cooked_beef 16"); p.runCommand("give @s iron_pickaxe 1"); p.sendMessage("§6[Family] §fA little boost to keep you going! §7(family pity bundle)"); p.setDynamicProperty(PROGRESS_PROP, now); // reset so we don't spam every 5 min } catch (_) {} } }, PITY_TICK_INTERVAL); // ─── Goal display ────────────────────────────────────────────── function makeBar(pct) { const filled = Math.max(0, Math.min(10, Math.floor(pct / 10))); return "§a" + "▰".repeat(filled) + "§8" + "▰".repeat(10 - filled); } async function showGoals(player, perPlayerMode) { const lines = []; for (const def of GOAL_DEFS) { const g = goals.goals[def.id]; if (!g) continue; const cur = Math.min(g.current, g.target); const pct = Math.floor((cur / g.target) * 100); const bar = makeBar(pct); const status = g.completed ? " §a✔" : ""; if (perPlayerMode) { const my = g.contributions[player.id] || 0; const myPct = cur > 0 ? Math.floor((my / cur) * 100) : 0; lines.push(`§e${def.label}${status}\n§7 ${bar}§7 §f${cur}§7/§f${g.target}\n§7 You: §f${my}§7 (${myPct}% of progress)`); } else { lines.push(`§e${def.label}${status}\n§7 ${bar}§7 §f${cur}§7/§f${g.target}`); } } if (!lines.length) lines.push("§7No goals configured."); const form = new ActionFormData() .title(perPlayerMode ? "My Goal Contributions" : "Family Goals") .body(lines.join("\n\n")) .button("§7OK"); try { await form.show(player); } catch (_) {} } // ─── Family menu (entered via scriptevent from hub-return compass) ───── function showRoster(player) { if (family.members.length === 0) { player.sendMessage("§7[Family] §fNo roster set yet — open §e🏆 Family§f from your compass and pick §eAdd me to the family§f."); return; } const names = world.getAllPlayers() .filter(p => family.members.includes(p.id)) .map(p => p.name); player.sendMessage(`§a[Family] §fRoster: §e${family.members.length}§f members. Online: §e${names.join(", ") || "(none)"}§f.`); } async function openFamilyMenu(player) { const inFamily = isFamily(player); const form = new ActionFormData() .title("🏆 Family") .body(`§7Roster: §f${family.members.length}§7 members${inFamily ? " §a(you're in)" : " §c(you're not)"}\n§7Bank: ${bank ? `§f${bank.x}, ${bank.y}, ${bank.z}` : "§cnot set"}`); const actions = []; form.button("🎯 Family Goals"); actions.push("goals"); form.button("📝 My Contributions"); actions.push("mygoals"); form.button("👥 Show Roster"); actions.push("roster"); form.button("➕ Add me / Add online players"); actions.push("setup"); form.button("🏦 Register Family Bank (look at chest)"); actions.push("bank"); form.button("♻ Reset Roster (danger)"); actions.push("reset"); form.button("§7Cancel"); actions.push("cancel"); let res; try { res = await form.show(player); } catch (_) { return; } if (res.canceled || res.selection === undefined) return; const a = actions[res.selection]; if (a === "goals") return showGoals(player, false); if (a === "mygoals") return showGoals(player, true); if (a === "roster") return showRoster(player); if (a === "setup") return familySetup(player); if (a === "bank") return familyBank(player); if (a === "reset") return familyReset(player); } // scriptEventReceive is the cross-pack dispatch surface. Hub-return's compass // menu fires `scriptevent family:menu` to enter our UI. Direct ids are also // handled so an opped player can run e.g. `/scriptevent family:setup`. try { system.afterEvents.scriptEventReceive.subscribe((event) => { const id = event.id; if (!id || !id.startsWith("family:")) return; const player = event.sourceEntity; if (!player || player.typeId !== "minecraft:player") return; switch (id) { case "family:menu": system.run(() => openFamilyMenu(player)); break; case "family:goals": system.run(() => showGoals(player, false)); break; case "family:mygoals": system.run(() => showGoals(player, true)); break; case "family:roster": showRoster(player); break; case "family:setup": familySetup(player); break; case "family:bank": familyBank(player); break; case "family:reset": familyReset(player); break; } }); } catch (e) { console.warn(`[Family] scriptEventReceive unavailable: ${e}`); } // ─── Boot ────────────────────────────────────────────────────── system.run(() => { loadState(); saveGoals(); // persist any backfilled goal defs from upgrade world.sendMessage("§6[Family] §fLoaded. Right-click your §dHub Compass§f to find the §6🏆 Family§f menu."); });