Three mid-session fixes to the hemp progression mechanics: - Trader buyback now operates on the held stack only. Earlier code walked the entire inventory to count and consume hemp items, so right-clicking with a stack of 64 buds while having more in other slots traded everything. Reproduced by user holding 64, ended up losing all stacks. Fixed: count = stack.amount, consume modifies only the selected hotbar slot. Other stacks of the same item stay intact. - Chest loot handler removed the `if (stack) return` guard. It was meant to skip placement events but blocked nearly every legitimate chest open (players almost always hold an item). Now every fresh chest open rolls; per-chest seeded set still prevents repeats. Added a chat ping when seeds drop so players notice. - Added a script-side wild hemp scatter on a 60s tick to bridge the worldgen gap. Bedrock features only fire on FRESH chunk generation, so legacy worlds where players have already explored never see the feature_rule patches. Scatter picks a random online overworld player every minute (40% chance), finds a grass surface within 32 blocks, places 2-4 mature hemp_crop in a small cluster. Runs in parallel with the worldgen feature_rule for new chunks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
813 lines
32 KiB
JavaScript
813 lines
32 KiB
JavaScript
import { world, system, ItemStack, BlockPermutation } from "@minecraft/server";
|
||
|
||
const CROP = "silverlabs:hemp_crop";
|
||
const SUN_LAMP = "silverlabs:sun_lamp";
|
||
const SEEDS = "silverlabs:hemp_seeds";
|
||
const BUD = "silverlabs:hemp_bud";
|
||
const TINCTURE = "silverlabs:hemp_tincture";
|
||
const BROWNIE = "silverlabs:hemp_brownie";
|
||
|
||
const AGE = "silverlabs:hemp_age";
|
||
const TOP = "silverlabs:hemp_top";
|
||
|
||
const GROWTH_INTERVAL_TICKS = 100; // 5s between growth ticks
|
||
const RABBIT_INTERVAL_TICKS = 100; // 5s between rabbit checks
|
||
const SCAN_RADIUS = 5; // ±5 horizontal — dense scan, every block looked at
|
||
const SCAN_VERT = 2; // ±2 vertical
|
||
const SUN_LAMP_RANGE = 4; // blocks searched for a lamp around indoor crop
|
||
const GROWTH_CHANCE_OUTDOOR = 0.017; // per 5s tick — ~20 min for 0→4 (prime)
|
||
const GROWTH_CHANCE_INDOOR = 0.011; // per 5s tick — ~30 min for 0→4 (sun lamp required)
|
||
const OVERRIPE_CHANCE_MULT = 0.33; // age 4→5 transition is 3x slower so prime lingers
|
||
|
||
function rand(n) { return Math.floor(Math.random() * n); }
|
||
function chance(p) { return Math.random() < p; }
|
||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
||
|
||
function getInv(player) {
|
||
return player.getComponent("minecraft:inventory")?.container ?? null;
|
||
}
|
||
|
||
function consumeOneOfType(player, typeId) {
|
||
const inv = getInv(player);
|
||
if (!inv) return false;
|
||
const sel = player.selectedSlotIndex;
|
||
const cur = inv.getItem(sel);
|
||
if (cur && cur.typeId === typeId) {
|
||
if (cur.amount > 1) { cur.amount -= 1; inv.setItem(sel, cur); }
|
||
else inv.setItem(sel, undefined);
|
||
return true;
|
||
}
|
||
for (let i = 0; i < inv.size; i++) {
|
||
const it = inv.getItem(i);
|
||
if (it && it.typeId === typeId) {
|
||
if (it.amount > 1) { it.amount -= 1; inv.setItem(i, it); }
|
||
else inv.setItem(i, undefined);
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function giveItem(player, typeId, count = 1) {
|
||
const inv = getInv(player);
|
||
if (!inv) return false;
|
||
try {
|
||
inv.addItem(new ItemStack(typeId, count));
|
||
return true;
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Block ids that don't block sky access — the plant grows around these like
|
||
// a vanilla tree pushes through leaves. Anything else above stops growth.
|
||
function isSkyTransparent(typeId) {
|
||
if (!typeId) return true;
|
||
if (typeId === CROP) return true; // ignore our own top half
|
||
if (typeId === "minecraft:air") return true;
|
||
// Glass / panes / bars
|
||
if (typeId.includes("glass")) return true;
|
||
if (typeId === "minecraft:iron_bars") return true;
|
||
// Leaves and saplings (canopy-style obstructions)
|
||
if (typeId.includes("leaves")) return true;
|
||
if (typeId.includes("sapling")) return true;
|
||
// Other plants / vines / hanging stuff
|
||
if (typeId === "minecraft:vine" || typeId === "minecraft:weeping_vines"
|
||
|| typeId === "minecraft:twisting_vines") return true;
|
||
return false;
|
||
}
|
||
|
||
function isAirAbove(dim, loc) {
|
||
// Walk up from y+1; sky-transparent blocks don't count as obstructions.
|
||
for (let dy = 1; dy < 64; dy++) {
|
||
let b;
|
||
try { b = dim.getBlock({ x: loc.x, y: loc.y + dy, z: loc.z }); } catch (_) { return true; }
|
||
if (!b) return true;
|
||
if (b.isAir || b.isLiquid) continue;
|
||
if (isSkyTransparent(b.typeId)) continue;
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function findSunLampNear(dim, loc) {
|
||
const r = SUN_LAMP_RANGE;
|
||
for (let dy = -1; dy <= r; dy++) {
|
||
for (let dx = -r; dx <= r; dx++) {
|
||
for (let dz = -r; dz <= r; dz++) {
|
||
let b;
|
||
try { b = dim.getBlock({ x: loc.x + dx, y: loc.y + dy, z: loc.z + dz }); } catch (_) { continue; }
|
||
if (b && b.typeId === SUN_LAMP) {
|
||
try {
|
||
if (b.permutation.getState("silverlabs:powered") === true) return true;
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function setAge(block, newAge, outdoor) {
|
||
try {
|
||
const perm = BlockPermutation.resolve(CROP, {
|
||
[AGE]: clamp(newAge, 0, 5),
|
||
[TOP]: false,
|
||
});
|
||
block.setPermutation(perm);
|
||
if (outdoor && newAge >= 3) {
|
||
// Place top half above for the visual "tall outdoor crop"
|
||
const above = block.dimension.getBlock({ x: block.location.x, y: block.location.y + 1, z: block.location.z });
|
||
if (above && above.isAir) {
|
||
try {
|
||
const topPerm = BlockPermutation.resolve(CROP, { [AGE]: clamp(newAge, 0, 5), [TOP]: true });
|
||
above.setPermutation(topPerm);
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
function clearTopAbove(block) {
|
||
const above = block.dimension.getBlock({ x: block.location.x, y: block.location.y + 1, z: block.location.z });
|
||
if (above && above.typeId === CROP) {
|
||
try { above.setType("minecraft:air"); } catch (_) {}
|
||
}
|
||
}
|
||
|
||
// --- Growth: dense scan of an 11×11×5 box around each player ---
|
||
// Every hemp_crop base in range gets a roll on every tick — no random sampling.
|
||
system.runInterval(() => {
|
||
for (const player of world.getAllPlayers()) {
|
||
const dim = player.dimension;
|
||
const px = Math.floor(player.location.x);
|
||
const py = Math.floor(player.location.y);
|
||
const pz = Math.floor(player.location.z);
|
||
for (let dy = -SCAN_VERT; dy <= SCAN_VERT; dy++) {
|
||
for (let dx = -SCAN_RADIUS; dx <= SCAN_RADIUS; dx++) {
|
||
for (let dz = -SCAN_RADIUS; dz <= SCAN_RADIUS; dz++) {
|
||
let b;
|
||
try { b = dim.getBlock({ x: px + dx, y: py + dy, z: pz + dz }); } catch (_) { continue; }
|
||
if (!b || b.typeId !== CROP) continue;
|
||
if (b.permutation.getState(TOP) === true) continue; // only base ticks
|
||
const age = b.permutation.getState(AGE) ?? 0;
|
||
if (age >= 5) continue; // overripe stops
|
||
const outdoor = isAirAbove(dim, b.location);
|
||
let baseChance = 0;
|
||
if (outdoor) baseChance = GROWTH_CHANCE_OUTDOOR;
|
||
else if (findSunLampNear(dim, b.location)) baseChance = GROWTH_CHANCE_INDOOR;
|
||
else continue;
|
||
// Prime (age 4) lingers — slow the transition into overripe
|
||
const c = age === 4 ? baseChance * OVERRIPE_CHANCE_MULT : baseChance;
|
||
if (chance(c)) setAge(b, age + 1, outdoor);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}, GROWTH_INTERVAL_TICKS);
|
||
|
||
// --- Redstone power: scan known sources adjacent to a sun_lamp ---
|
||
const REDSTONE_FACES = [
|
||
[1, 0, 0], [-1, 0, 0],
|
||
[0, 1, 0], [0, -1, 0],
|
||
[0, 0, 1], [0, 0, -1],
|
||
];
|
||
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") {
|
||
// torch lit state
|
||
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") || t.includes(":wooden_button") || t.includes(":stone_button")) {
|
||
try { return b.permutation.getState("button_pressed_bit") === true; } 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;
|
||
}
|
||
function lampShouldBePowered(lamp) {
|
||
// Try the script API redstone power first (newer servers expose it)
|
||
try {
|
||
const p = lamp.getRedstonePower();
|
||
if (typeof p === "number" && p > 0) return true;
|
||
} catch (_) {}
|
||
const dim = lamp.dimension;
|
||
for (const [dx, dy, dz] of REDSTONE_FACES) {
|
||
let n;
|
||
try { n = dim.getBlock({ x: lamp.location.x + dx, y: lamp.location.y + dy, z: lamp.location.z + dz }); } catch (_) { continue; }
|
||
if (isPowerSource(n)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
const LAMP_SCAN_INTERVAL_TICKS = 20; // 1s
|
||
system.runInterval(() => {
|
||
for (const player of world.getAllPlayers()) {
|
||
const dim = player.dimension;
|
||
const px = Math.floor(player.location.x);
|
||
const py = Math.floor(player.location.y);
|
||
const pz = Math.floor(player.location.z);
|
||
for (let dy = -SCAN_VERT; dy <= SCAN_VERT; dy++) {
|
||
for (let dx = -SCAN_RADIUS; dx <= SCAN_RADIUS; dx++) {
|
||
for (let dz = -SCAN_RADIUS; dz <= SCAN_RADIUS; dz++) {
|
||
let b;
|
||
try { b = dim.getBlock({ x: px + dx, y: py + dy, z: pz + dz }); } catch (_) { continue; }
|
||
if (!b || b.typeId !== SUN_LAMP) continue;
|
||
let cur = false;
|
||
try { cur = b.permutation.getState("silverlabs:powered") === true; } catch (_) {}
|
||
const want = lampShouldBePowered(b);
|
||
if (cur !== want) {
|
||
try {
|
||
const perm = BlockPermutation.resolve(SUN_LAMP, { "silverlabs:powered": want });
|
||
b.setPermutation(perm);
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}, LAMP_SCAN_INTERVAL_TICKS);
|
||
|
||
// --- Rabbit threat: damage outdoor hemp ---
|
||
system.runInterval(() => {
|
||
for (const player of world.getAllPlayers()) {
|
||
const dim = player.dimension;
|
||
let rabbits;
|
||
try {
|
||
rabbits = dim.getEntities({ type: "minecraft:rabbit", location: player.location, maxDistance: 48 });
|
||
} catch (_) { continue; }
|
||
for (const rabbit of rabbits) {
|
||
// For each rabbit, look at a small box around its feet for hemp_crop.
|
||
const rx = Math.floor(rabbit.location.x);
|
||
const ry = Math.floor(rabbit.location.y);
|
||
const rz = Math.floor(rabbit.location.z);
|
||
for (let dx = -1; dx <= 1; dx++) {
|
||
for (let dz = -1; dz <= 1; dz++) {
|
||
let b;
|
||
try { b = dim.getBlock({ x: rx + dx, y: ry, z: rz + dz }); } catch (_) { continue; }
|
||
if (!b || b.typeId !== CROP) continue;
|
||
const isTop = b.permutation.getState(TOP) === true;
|
||
if (isTop) continue;
|
||
if (chance(0.35)) {
|
||
const age = b.permutation.getState(AGE) ?? 0;
|
||
if (age <= 0) {
|
||
clearTopAbove(b);
|
||
try { b.setType("minecraft:air"); } catch (_) {}
|
||
} else {
|
||
clearTopAbove(b);
|
||
setAge(b, age - 1, isAirAbove(dim, b.location));
|
||
}
|
||
try { dim.runCommand(`particle minecraft:crit_particle ${rx + dx + 0.5} ${ry + 0.5} ${rz + dz + 0.5}`); } catch (_) {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}, RABBIT_INTERVAL_TICKS);
|
||
|
||
// --- Pillager raid threat: illagers carrying a banner steal hemp during raids ---
|
||
// Gate on either an active raid (detected via a nearby ominous_banner) or a
|
||
// pillager_captain so isolated tower pillagers don't constantly cull farms
|
||
// in render distance. When triggered, drops the crop's age by 1 (or removes
|
||
// it if age 0) and spawns 1 hemp_bud at the illager's feet so the player
|
||
// can recover the loot by killing the raider.
|
||
const RAID_INTERVAL_TICKS = 100;
|
||
const ILLAGER_TYPES = new Set([
|
||
"minecraft:pillager",
|
||
"minecraft:vindicator",
|
||
"minecraft:evocation_illager",
|
||
]);
|
||
const ILLAGER_HEMP_STEAL_CHANCE = 0.30;
|
||
|
||
function illagerIsRaiding(dim, ill) {
|
||
// Cheapest: check if the illager is wearing/carrying an ominous_banner
|
||
// (raid captain marker). Also accept any nearby ominous_banner block within
|
||
// 8 blocks as evidence of an ongoing raid the illager is participating in.
|
||
const equip = (() => { try { return ill.getComponent("minecraft:equippable"); } catch (_) { return null; } })();
|
||
if (equip) {
|
||
try {
|
||
const head = equip.getEquipmentSlot("Head")?.getItem();
|
||
if (head && head.typeId === "minecraft:ominous_banner") return true;
|
||
} catch (_) {}
|
||
}
|
||
const ix = Math.floor(ill.location.x);
|
||
const iy = Math.floor(ill.location.y);
|
||
const iz = Math.floor(ill.location.z);
|
||
for (let dx = -8; dx <= 8; dx += 4) {
|
||
for (let dz = -8; dz <= 8; dz += 4) {
|
||
let b;
|
||
try { b = dim.getBlock({ x: ix + dx, y: iy, z: iz + dz }); } catch (_) { continue; }
|
||
if (b && (b.typeId === "minecraft:standing_banner" || b.typeId === "minecraft:wall_banner")) {
|
||
// Banners can be ominous via their NBT; we can't read it, so any banner
|
||
// within 8 blocks of an illager is treated as raid evidence. Still rare.
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
system.runInterval(() => {
|
||
for (const player of world.getAllPlayers()) {
|
||
const dim = player.dimension;
|
||
let illagers;
|
||
try {
|
||
illagers = dim.getEntities({ families: [], location: player.location, maxDistance: 48 });
|
||
} catch (_) { continue; }
|
||
for (const ill of illagers) {
|
||
if (!ILLAGER_TYPES.has(ill.typeId)) continue;
|
||
if (!illagerIsRaiding(dim, ill)) continue;
|
||
const ix = Math.floor(ill.location.x);
|
||
const iy = Math.floor(ill.location.y);
|
||
const iz = Math.floor(ill.location.z);
|
||
for (let dx = -1; dx <= 1; dx++) {
|
||
for (let dz = -1; dz <= 1; dz++) {
|
||
let b;
|
||
try { b = dim.getBlock({ x: ix + dx, y: iy, z: iz + dz }); } catch (_) { continue; }
|
||
if (!b || b.typeId !== CROP) continue;
|
||
if (b.permutation.getState(TOP) === true) continue;
|
||
if (!chance(ILLAGER_HEMP_STEAL_CHANCE)) continue;
|
||
const age = b.permutation.getState(AGE) ?? 0;
|
||
const outdoor = isAirAbove(dim, b.location);
|
||
if (age <= 0) {
|
||
clearTopAbove(b);
|
||
try { b.setType("minecraft:air"); } catch (_) {}
|
||
} else {
|
||
clearTopAbove(b);
|
||
setAge(b, age - 1, outdoor);
|
||
}
|
||
// Drop a hemp_bud at the illager's feet — recoverable when killed.
|
||
spawnDrop(dim, ill.location, BUD, 1);
|
||
try { dim.runCommand(`particle minecraft:villager_angry ${ix + 0.5} ${iy + 1.0} ${iz + 0.5}`); } catch (_) {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}, RAID_INTERVAL_TICKS);
|
||
|
||
// --- Wandering trader buyback: hand off hemp products for emeralds ---
|
||
// Right-click a wandering_trader while holding hemp_bud / hemp_tincture /
|
||
// hemp_seeds. Avoids overriding vanilla trade tables (which would pin us to
|
||
// one Bedrock version) by acting as an off-menu interaction. Trader animates
|
||
// a "happy villager" particle and the player's inventory swaps the items
|
||
// for emeralds at the rates below.
|
||
const TRADER_BUYBACK = {
|
||
[BUD]: { perTrade: 2, emeralds: 1 },
|
||
[TINCTURE]: { perTrade: 1, emeralds: 3 },
|
||
[SEEDS]: { perTrade: 8, emeralds: 1 },
|
||
};
|
||
|
||
world.beforeEvents.playerInteractWithEntity.subscribe((event) => {
|
||
const target = event.target;
|
||
if (!target || target.typeId !== "minecraft:wandering_trader") return;
|
||
const stack = event.itemStack;
|
||
if (!stack) return;
|
||
const deal = TRADER_BUYBACK[stack.typeId];
|
||
if (!deal) return;
|
||
event.cancel = true; // suppress the vanilla trade window for this interaction
|
||
const player = event.player;
|
||
const held = stack.amount;
|
||
system.run(() => {
|
||
if (held < deal.perTrade) {
|
||
player.sendMessage(`§7[Trader] §fBring me at least §a${deal.perTrade}§f of those and I'll pay in emeralds.`);
|
||
return;
|
||
}
|
||
const inv = getInv(player);
|
||
if (!inv) return;
|
||
// Trade scope is the held stack only — never walk the rest of the inventory
|
||
const sel = player.selectedSlotIndex;
|
||
const cur = inv.getItem(sel);
|
||
if (!cur || cur.typeId !== stack.typeId) {
|
||
// Player swapped item out between the click and this run() — abort safely
|
||
return;
|
||
}
|
||
const trades = Math.floor(held / deal.perTrade);
|
||
const consumed = trades * deal.perTrade;
|
||
const remaining = held - consumed;
|
||
if (remaining > 0) {
|
||
cur.amount = remaining;
|
||
inv.setItem(sel, cur);
|
||
} else {
|
||
inv.setItem(sel, undefined);
|
||
}
|
||
giveItem(player, "minecraft:emerald", trades * deal.emeralds);
|
||
const loc = target.location;
|
||
try { target.dimension.runCommand(`particle minecraft:villager_happy ${loc.x} ${loc.y + 1.5} ${loc.z}`); } catch (_) {}
|
||
try { target.dimension.runCommand(`playsound mob.villager.yes @a ${loc.x} ${loc.y} ${loc.z} 0.8 1.1`); } catch (_) {}
|
||
player.sendMessage(`§a[Trader] §fTraded §e${consumed}§f for §a${trades * deal.emeralds} emerald${trades * deal.emeralds === 1 ? "" : "s"}§f.`);
|
||
});
|
||
});
|
||
|
||
// --- Outdoor detection on placement ---
|
||
world.afterEvents.playerPlaceBlock.subscribe((event) => {
|
||
const block = event.block;
|
||
if (!block || block.typeId !== CROP) return;
|
||
// newly-placed crop is age 0 by default — we don't need to set anything,
|
||
// outdoor status is computed live during ticks.
|
||
});
|
||
|
||
// --- Cleanup: breaking either half of a tall plant removes the other ---
|
||
// Also: rare hemp_seeds drop from breaking vanilla grass / tall_grass /
|
||
// short_grass — gives players a way to bootstrap into hemp without
|
||
// already having seeds. Mirrors how vanilla wheat seeds work.
|
||
const GRASS_BLOCK_IDS = new Set([
|
||
"minecraft:short_grass",
|
||
"minecraft:tall_grass",
|
||
"minecraft:fern",
|
||
"minecraft:large_fern",
|
||
// Pre-1.21 alias kept for safety on mixed-version worlds
|
||
"minecraft:grass",
|
||
]);
|
||
const GRASS_HEMP_SEED_CHANCE = 0.04; // ~1 in 25 grass tufts
|
||
|
||
world.afterEvents.playerBreakBlock.subscribe((event) => {
|
||
const brokenType = event.brokenBlockPermutation?.type?.id;
|
||
|
||
// Hemp_crop tall-plant cleanup
|
||
if (brokenType === CROP) {
|
||
const block = event.block;
|
||
const dim = block.dimension;
|
||
const wasTop = event.brokenBlockPermutation.getState(TOP) === true;
|
||
const dy = wasTop ? -1 : 1;
|
||
let neighbor;
|
||
try { neighbor = dim.getBlock({ x: block.location.x, y: block.location.y + dy, z: block.location.z }); } catch (_) { return; }
|
||
if (!neighbor || neighbor.typeId !== CROP) return;
|
||
const neighborIsTop = neighbor.permutation.getState(TOP) === true;
|
||
if (wasTop === neighborIsTop) return;
|
||
try { neighbor.setType("minecraft:air"); } catch (_) {}
|
||
return;
|
||
}
|
||
|
||
// Hemp seed bootstrap from grass blocks
|
||
if (brokenType && GRASS_BLOCK_IDS.has(brokenType)) {
|
||
if (!chance(GRASS_HEMP_SEED_CHANCE)) return;
|
||
const block = event.block;
|
||
const loc = block.location;
|
||
spawnDrop(block.dimension, loc, SEEDS, 1);
|
||
}
|
||
});
|
||
|
||
// Per-player debounce so a single click that fires the event twice
|
||
// (a known Bedrock quirk for some interact paths) only does work once.
|
||
const recentInteract = new Map(); // key: playerId|x|y|z -> system.currentTick
|
||
function consumeInteractToken(player, block) {
|
||
const key = `${player.id}|${block.location.x}|${block.location.y}|${block.location.z}`;
|
||
const last = recentInteract.get(key) ?? -999;
|
||
const now = system.currentTick;
|
||
if (now - last < 4) return false; // <200ms — treat as duplicate
|
||
recentInteract.set(key, now);
|
||
// Lazy cleanup: prune any entries older than ~4s on each successful claim
|
||
if (recentInteract.size > 64) {
|
||
for (const [k, t] of recentInteract) {
|
||
if (now - t > 80) recentInteract.delete(k);
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// --- Bonemeal on hemp_crop bumps growth ---
|
||
world.beforeEvents.playerInteractWithBlock.subscribe((event) => {
|
||
const block = event.block;
|
||
const stack = event.itemStack;
|
||
const player = event.player;
|
||
if (!block || block.typeId !== CROP) return;
|
||
|
||
// Sheers harvest path
|
||
if (stack && stack.typeId === "minecraft:shears") {
|
||
event.cancel = true;
|
||
if (!consumeInteractToken(player, block)) return;
|
||
system.run(() => harvestWithShears(player, block, stack));
|
||
return;
|
||
}
|
||
|
||
// Bone meal path
|
||
if (stack && stack.typeId === "minecraft:bone_meal") {
|
||
event.cancel = true;
|
||
if (!consumeInteractToken(player, block)) return;
|
||
system.run(() => {
|
||
// If the player clicked the top half, redirect to the base
|
||
let target = block;
|
||
if (block.permutation.getState(TOP) === true) {
|
||
const below = block.dimension.getBlock({ x: block.location.x, y: block.location.y - 1, z: block.location.z });
|
||
if (below && below.typeId === CROP) target = below;
|
||
else return;
|
||
}
|
||
const age = target.permutation.getState(AGE) ?? 0;
|
||
// Cap bone meal at age 4 (prime) — overripe (5) only via neglect
|
||
if (age >= 4) {
|
||
player.sendMessage("§e[Hemp] Already mature — harvest with shears.");
|
||
return;
|
||
}
|
||
// Always consume the bone meal, but only 50% chance to advance
|
||
consumeOneOfType(player, "minecraft:bone_meal");
|
||
const loc = target.location;
|
||
try { target.dimension.runCommand(`particle minecraft:crop_growth_emitter ${loc.x + 0.5} ${loc.y + 0.5} ${loc.z + 0.5}`); } catch (_) {}
|
||
if (!chance(0.5)) return;
|
||
const outdoor = isAirAbove(target.dimension, loc);
|
||
setAge(target, age + 1, outdoor);
|
||
try { target.dimension.runCommand(`playsound random.fizz @a ${loc.x + 0.5} ${loc.y + 0.5} ${loc.z + 0.5} 0.6 1.6`); } catch (_) {}
|
||
});
|
||
return;
|
||
}
|
||
});
|
||
|
||
function harvestWithShears(player, block, shearsStack) {
|
||
if (!block || block.typeId !== CROP) return; // already harvested / replaced
|
||
// Read state on the BASE block (not top)
|
||
let base = block;
|
||
if (block.permutation.getState(TOP) === true) {
|
||
const below = block.dimension.getBlock({ x: block.location.x, y: block.location.y - 1, z: block.location.z });
|
||
if (below && below.typeId === CROP) base = below;
|
||
}
|
||
const age = base.permutation.getState(AGE) ?? 0;
|
||
const dim = base.dimension;
|
||
const loc = base.location;
|
||
const outdoor = isAirAbove(dim, loc);
|
||
|
||
// Too early — leave the plant alone, don't damage shears
|
||
if (age <= 1) {
|
||
player.sendMessage("§7[Hemp] Too early — leave it to grow.");
|
||
return;
|
||
}
|
||
|
||
const yields = computeYield(age, outdoor);
|
||
if (yields.msg) player.sendMessage(yields.msg);
|
||
|
||
if (yields.bud > 0) spawnDrop(dim, loc, BUD, yields.bud);
|
||
if (yields.seeds > 0) spawnDrop(dim, loc, SEEDS, yields.seeds);
|
||
|
||
// Fully remove the plant — player replants from seeds
|
||
clearTopAbove(base);
|
||
try { base.setType("minecraft:air"); } catch (_) {}
|
||
|
||
damageShears(player, shearsStack);
|
||
try { dim.runCommand(`playsound mob.sheep.shear @a ${loc.x + 0.5} ${loc.y + 0.5} ${loc.z + 0.5}`); } catch (_) {}
|
||
}
|
||
|
||
function computeYield(age, outdoor) {
|
||
// Outdoor gets a bud bonus; indoor (sun-lamp grown) is baseline.
|
||
const out = outdoor ? 1 : 0;
|
||
switch (age) {
|
||
case 2: return { bud: 1, seeds: 1 + out };
|
||
case 3: return { bud: 2 + out, seeds: 1 + out };
|
||
case 4: return { bud: 3 + out * 2, seeds: 1 + out, msg: "§a[Hemp] Prime harvest." }; // peak
|
||
case 5: return { bud: rand(3), seeds: 3 + rand(3), msg: "§7[Hemp] Overripe — mostly seeds now." };
|
||
default: return { bud: 0, seeds: 0 };
|
||
}
|
||
}
|
||
|
||
function spawnDrop(dim, loc, typeId, count) {
|
||
try {
|
||
const it = new ItemStack(typeId, count);
|
||
dim.spawnItem(it, { x: loc.x + 0.5, y: loc.y + 0.5, z: loc.z + 0.5 });
|
||
} catch (_) {}
|
||
}
|
||
|
||
function damageShears(player, shearsStack) {
|
||
try {
|
||
const equippable = player.getComponent("minecraft:equippable");
|
||
if (!equippable) return;
|
||
const slot = equippable.getEquipmentSlot("Mainhand");
|
||
if (!slot) return;
|
||
const item = slot.getItem();
|
||
if (!item || item.typeId !== "minecraft:shears") return;
|
||
const dur = item.getComponent("minecraft:durability");
|
||
if (!dur) return;
|
||
const damage = dur.damage + 1;
|
||
if (damage >= dur.maxDurability) {
|
||
slot.setItem(undefined);
|
||
try { player.dimension.runCommand(`playsound random.break @a ${player.location.x} ${player.location.y} ${player.location.z}`); } catch (_) {}
|
||
} else {
|
||
dur.damage = damage;
|
||
slot.setItem(item);
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
// --- Cauldron tincture brewing: hemp_bud + water cauldron + glass bottle => tincture ---
|
||
world.afterEvents.itemUse.subscribe((event) => {
|
||
const player = event.source;
|
||
const stack = event.itemStack;
|
||
if (!stack || stack.typeId !== BUD) return;
|
||
system.run(() => {
|
||
const target = player.getBlockFromViewDirection({ maxDistance: 6 });
|
||
const block = target?.block;
|
||
if (!block) return;
|
||
const isCauldron = block.typeId === "minecraft:cauldron" || block.typeId === "minecraft:water_cauldron";
|
||
if (!isCauldron) return;
|
||
|
||
// Need at least 3 buds total in inventory and 1 glass bottle
|
||
const inv = getInv(player);
|
||
if (!inv) return;
|
||
let budCount = 0, bottleCount = 0;
|
||
for (let i = 0; i < inv.size; i++) {
|
||
const it = inv.getItem(i);
|
||
if (!it) continue;
|
||
if (it.typeId === BUD) budCount += it.amount;
|
||
else if (it.typeId === "minecraft:glass_bottle") bottleCount += it.amount;
|
||
}
|
||
if (budCount < 3) {
|
||
player.sendMessage("§c[Hemp] Need 3 hemp buds to brew tincture.");
|
||
return;
|
||
}
|
||
if (bottleCount < 1) {
|
||
player.sendMessage("§c[Hemp] Need an empty glass bottle.");
|
||
return;
|
||
}
|
||
|
||
// Check water level (water cauldron has fill_level state 1-3; vanilla cauldron = empty)
|
||
let level = 0;
|
||
try { level = block.permutation.getState("fill_level") ?? 0; } catch (_) {}
|
||
if (block.typeId === "minecraft:cauldron") {
|
||
player.sendMessage("§c[Hemp] Cauldron must contain water.");
|
||
return;
|
||
}
|
||
if (level <= 0) {
|
||
player.sendMessage("§c[Hemp] Cauldron is empty.");
|
||
return;
|
||
}
|
||
|
||
// Consume 3 buds, 1 bottle
|
||
for (let i = 0; i < 3; i++) consumeOneOfType(player, BUD);
|
||
consumeOneOfType(player, "minecraft:glass_bottle");
|
||
|
||
// Decrement water level
|
||
try {
|
||
const newLevel = level - 1;
|
||
if (newLevel <= 0) {
|
||
block.setType("minecraft:cauldron");
|
||
} else {
|
||
const perm = BlockPermutation.resolve("minecraft:water_cauldron", { fill_level: newLevel });
|
||
block.setPermutation(perm);
|
||
}
|
||
} catch (_) {}
|
||
|
||
// Give tincture
|
||
giveItem(player, TINCTURE, 1);
|
||
const loc = block.location;
|
||
try { block.dimension.runCommand(`particle minecraft:water_splash_particle_manual ${loc.x + 0.5} ${loc.y + 1.0} ${loc.z + 0.5}`); } catch (_) {}
|
||
try { block.dimension.runCommand(`playsound bucket.fill_water @a ${loc.x + 0.5} ${loc.y + 0.5} ${loc.z + 0.5}`); } catch (_) {}
|
||
player.sendMessage("§a[Hemp] Hemp tincture brewed.");
|
||
});
|
||
});
|
||
|
||
// --- Consumption effects ---
|
||
world.afterEvents.itemCompleteUse.subscribe((event) => {
|
||
const player = event.source;
|
||
const stack = event.itemStack;
|
||
if (!stack) return;
|
||
if (stack.typeId === TINCTURE) {
|
||
try {
|
||
player.addEffect("regeneration", 100, { amplifier: 1, showParticles: true });
|
||
player.addEffect("slowness", 200, { amplifier: 0, showParticles: true });
|
||
} catch (_) {}
|
||
player.sendMessage("§a[Hemp] You feel a warm, calming wave.");
|
||
} else if (stack.typeId === BROWNIE) {
|
||
try {
|
||
player.addEffect("regeneration", 200, { amplifier: 0, showParticles: true });
|
||
player.addEffect("slowness", 400, { amplifier: 0, showParticles: true });
|
||
} catch (_) {}
|
||
player.sendMessage("§a[Hemp] Mmm. You feel relaxed.");
|
||
}
|
||
});
|
||
|
||
// --- Chest loot injection: first-time-open chests sometimes contain seeds ---
|
||
// Bedrock has no merge-loot-table mechanism, so we hook chest opens and
|
||
// deposit hemp_seeds with low probability into a random empty slot.
|
||
// Tracking is per-chest via a world dynamic property holding a JSON list
|
||
// of "dim:x:y:z" keys; pruned to a rolling cap to bound storage.
|
||
const CHEST_TYPES = new Set(["minecraft:chest", "minecraft:trapped_chest"]);
|
||
const CHEST_SEED_CHANCE = 0.08;
|
||
const SEEDED_PROP = "hemp_seeded_chests_v1";
|
||
const SEEDED_CAP = 500;
|
||
|
||
function chestKey(block) {
|
||
const l = block.location;
|
||
return `${block.dimension.id}:${l.x}:${l.y}:${l.z}`;
|
||
}
|
||
function loadSeededChests() {
|
||
try {
|
||
const raw = world.getDynamicProperty(SEEDED_PROP);
|
||
return new Set(raw ? JSON.parse(raw) : []);
|
||
} catch (_) { return new Set(); }
|
||
}
|
||
function saveSeededChests(set) {
|
||
// Prune oldest if over cap (Set preserves insertion order in JS)
|
||
if (set.size > SEEDED_CAP) {
|
||
const arr = Array.from(set).slice(set.size - SEEDED_CAP);
|
||
set = new Set(arr);
|
||
}
|
||
try {
|
||
world.setDynamicProperty(SEEDED_PROP, JSON.stringify(Array.from(set)));
|
||
} catch (_) {}
|
||
}
|
||
|
||
world.afterEvents.playerInteractWithBlock.subscribe((event) => {
|
||
const block = event.block;
|
||
if (!block || !CHEST_TYPES.has(block.typeId)) return;
|
||
// Run on every chest interaction; the per-chest seeded set prevents repeats.
|
||
// Earlier `if (stack) return` was over-aggressive — players almost always
|
||
// hold an item when opening chests, so it suppressed nearly all opens.
|
||
const key = chestKey(block);
|
||
const seeded = loadSeededChests();
|
||
if (seeded.has(key)) return;
|
||
seeded.add(key);
|
||
saveSeededChests(seeded);
|
||
if (!chance(CHEST_SEED_CHANCE)) return;
|
||
// Try to insert into a random empty slot
|
||
let inv;
|
||
try { inv = block.getComponent("minecraft:inventory")?.container; } catch (_) { return; }
|
||
if (!inv) return;
|
||
const empties = [];
|
||
for (let i = 0; i < inv.size; i++) if (!inv.getItem(i)) empties.push(i);
|
||
if (empties.length === 0) return;
|
||
const slot = empties[rand(empties.length)];
|
||
const count = 1 + rand(3); // 1-3 seeds
|
||
try { inv.setItem(slot, new ItemStack(SEEDS, count)); } catch (_) {}
|
||
try { event.player.sendMessage("§a[Hemp] §7You spot some §dhemp seeds§7 tucked inside the chest."); } catch (_) {}
|
||
});
|
||
|
||
// --- Wild hemp scatter: bridge until worldgen patches show up ---
|
||
// Bedrock features only fire on FRESH chunk generation. Pre-existing
|
||
// chunks (where the player has already been) won't have patches even
|
||
// after we ship the feature_rule. This periodic scatter seeds mature
|
||
// hemp_crop on suitable grass blocks in the player's vicinity so the
|
||
// mechanic is discoverable in legacy worlds. Cap one patch per minute
|
||
// per player to avoid flooding.
|
||
const SCATTER_INTERVAL_TICKS = 1200; // 60 s
|
||
const SCATTER_CHANCE_PER_TICK = 0.4; // 40% per minute → ~1 patch every 2.5 min
|
||
const SCATTER_RADIUS = 32;
|
||
const SCATTER_PATCH_SIZE = [2, 4]; // 2-4 plants per patch
|
||
function tryScatterHempNearPlayer(player) {
|
||
const dim = player.dimension;
|
||
if (dim.id !== "minecraft:overworld") return;
|
||
const px = Math.floor(player.location.x);
|
||
const py = Math.floor(player.location.y);
|
||
const pz = Math.floor(player.location.z);
|
||
// Sample up to 12 candidate locations; pick the first viable grass block
|
||
for (let attempt = 0; attempt < 12; attempt++) {
|
||
const dx = rand(SCATTER_RADIUS * 2 + 1) - SCATTER_RADIUS;
|
||
const dz = rand(SCATTER_RADIUS * 2 + 1) - SCATTER_RADIUS;
|
||
// Search vertically: top-down within ±6 of player y for the surface
|
||
let surface = null;
|
||
for (let dy = 6; dy >= -6; dy--) {
|
||
let b;
|
||
try { b = dim.getBlock({ x: px + dx, y: py + dy, z: pz + dz }); } catch (_) { continue; }
|
||
if (!b) continue;
|
||
if (b.typeId === "minecraft:grass_block") {
|
||
// Check above is air (and skylit-ish — not a cave with grass_path glitches)
|
||
let above;
|
||
try { above = dim.getBlock({ x: px + dx, y: py + dy + 1, z: pz + dz }); } catch (_) { continue; }
|
||
if (above && above.isAir) { surface = { x: px + dx, y: py + dy + 1, z: pz + dz }; break; }
|
||
}
|
||
}
|
||
if (!surface) continue;
|
||
// Place 2-4 mature hemp_crop in a small cluster around surface
|
||
const count = SCATTER_PATCH_SIZE[0] + rand(SCATTER_PATCH_SIZE[1] - SCATTER_PATCH_SIZE[0] + 1);
|
||
let placed = 0;
|
||
for (let i = 0; i < count * 3 && placed < count; i++) {
|
||
const ox = rand(5) - 2;
|
||
const oz = rand(5) - 2;
|
||
const tx = surface.x + ox, ty = surface.y, tz = surface.z + oz;
|
||
let target, ground;
|
||
try {
|
||
target = dim.getBlock({ x: tx, y: ty, z: tz });
|
||
ground = dim.getBlock({ x: tx, y: ty - 1, z: tz });
|
||
} catch (_) { continue; }
|
||
if (!target || !ground) continue;
|
||
if (!target.isAir) continue;
|
||
if (ground.typeId !== "minecraft:grass_block" && ground.typeId !== "minecraft:dirt") continue;
|
||
try {
|
||
const perm = BlockPermutation.resolve(CROP, { [AGE]: 4, [TOP]: false });
|
||
target.setPermutation(perm);
|
||
placed++;
|
||
} catch (_) {}
|
||
}
|
||
return; // one patch per tick
|
||
}
|
||
}
|
||
system.runInterval(() => {
|
||
const players = world.getAllPlayers();
|
||
if (players.length === 0) return;
|
||
const player = players[rand(players.length)];
|
||
if (!chance(SCATTER_CHANCE_PER_TICK)) return;
|
||
tryScatterHempNearPlayer(player);
|
||
}, SCATTER_INTERVAL_TICKS);
|
||
|
||
system.run(() => {
|
||
world.sendMessage("§a[Hemp] §7Hemp pack loaded.");
|
||
});
|