fix(hemp): trader scope to held stack + chest loot guard + wild scatter bridge

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>
This commit is contained in:
2026-04-28 01:07:27 +01:00
parent fd73ac55ec
commit b28e4d0610

View File

@@ -379,32 +379,35 @@ world.beforeEvents.playerInteractWithEntity.subscribe((event) => {
if (!deal) return; if (!deal) return;
event.cancel = true; // suppress the vanilla trade window for this interaction event.cancel = true; // suppress the vanilla trade window for this interaction
const player = event.player; const player = event.player;
const held = stack.amount;
system.run(() => { system.run(() => {
// Count what the player has of this item across the inventory if (held < deal.perTrade) {
const inv = getInv(player); player.sendMessage(`§7[Trader] §fBring me at least §a${deal.perTrade}§f of those and I'll pay in emeralds.`);
if (!inv) return;
let have = 0;
for (let i = 0; i < inv.size; i++) {
const it = inv.getItem(i);
if (it && it.typeId === stack.typeId) have += it.amount;
}
if (have < deal.perTrade) {
player.sendMessage(`§7[Trader] Brings me at least §f${deal.perTrade}§7 of those and I'll pay you in emeralds.`);
return; return;
} }
const trades = Math.floor(have / deal.perTrade); const inv = getInv(player);
let consumed = 0; if (!inv) return;
for (let n = 0; n < trades * deal.perTrade; n++) { // Trade scope is the held stack only — never walk the rest of the inventory
if (!consumeOneOfType(player, stack.typeId)) break; const sel = player.selectedSlotIndex;
consumed++; 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 actualTrades = Math.floor(consumed / deal.perTrade); const trades = Math.floor(held / deal.perTrade);
if (actualTrades <= 0) return; const consumed = trades * deal.perTrade;
giveItem(player, "minecraft:emerald", actualTrades * deal.emeralds); 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; 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(`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 (_) {} 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${actualTrades * deal.perTrade}§f for §a${actualTrades * deal.emeralds} emerald${actualTrades * deal.emeralds === 1 ? "" : "s"}§f.`); player.sendMessage(`§a[Trader] §fTraded §e${consumed}§f for §a${trades * deal.emeralds} emerald${trades * deal.emeralds === 1 ? "" : "s"}§f.`);
}); });
}); });
@@ -715,9 +718,9 @@ function saveSeededChests(set) {
world.afterEvents.playerInteractWithBlock.subscribe((event) => { world.afterEvents.playerInteractWithBlock.subscribe((event) => {
const block = event.block; const block = event.block;
if (!block || !CHEST_TYPES.has(block.typeId)) return; if (!block || !CHEST_TYPES.has(block.typeId)) return;
// Only on the open hand (not while holding an item, to avoid placing-into events firing twice) // Run on every chest interaction; the per-chest seeded set prevents repeats.
const stack = event.itemStack; // Earlier `if (stack) return` was over-aggressive — players almost always
if (stack) return; // hold an item when opening chests, so it suppressed nearly all opens.
const key = chestKey(block); const key = chestKey(block);
const seeded = loadSeededChests(); const seeded = loadSeededChests();
if (seeded.has(key)) return; if (seeded.has(key)) return;
@@ -734,9 +737,76 @@ world.afterEvents.playerInteractWithBlock.subscribe((event) => {
const slot = empties[rand(empties.length)]; const slot = empties[rand(empties.length)];
const count = 1 + rand(3); // 1-3 seeds const count = 1 + rand(3); // 1-3 seeds
try { inv.setItem(slot, new ItemStack(SEEDS, count)); } catch (_) {} try { inv.setItem(slot, new ItemStack(SEEDS, count)); } catch (_) {}
// No chat ping — let the player discover the seeds organically. 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(() => { system.run(() => {
world.sendMessage("§a[Hemp] §7Hemp pack loaded."); world.sendMessage("§a[Hemp] §7Hemp pack loaded.");
}); });