// Brass & Sigil — Plot system V1 // // SPAWN COMMERCIAL PLOTS (not bases). Players' homes go out in the world // with normal FTB Chunks claims. A "plot" is a small commercial chunk- // allocation at spawn for shops. 1 plot per player; release-and-rebuy to // change size. // // Player commands (/bns plot ...): // list show all plots + availability + price // info detailed info for one plot // buy purchase if eligible (no current plot, has coin) // release release current plot (50% refund) // Admin commands (OP, level 2): // reload re-load plot definitions (currently in-script) // // Storage: // Plot definitions: hardcoded `PLOT_DEFS` below; admin edits and reloads. // Path-to-disk loading deferred to V1.1 (KubeJS file // I/O is sandboxed). // Ownership state: server.persistentData.bnsPlots -- compound NBT // keyed by plot id, value = player UUID string. Single // source of truth. // // Integrations (best-effort; verify in playtest): // - Numismatics coin handling: inventory-based. Counts coins by denomination // value, takes greedily by largest first. Assumed values: // spur=1, bevel=8, cog=64, crown=512, sun=4096 // If Numismatics uses different ratios, adjust COIN_VALUES below. // - FTB Chunks claim transfer: runs `/ftbchunks claim ` as the player // for each chunk in the plot, prefixed by `execute in as `. // Release runs `/ftbchunks unclaim`. May need adjusting if FTBC command // syntax differs in this version. // // Performance: // - All event-handler-style work is bounded by player action (command). // No periodic ticks. No event hot-path scans. // - Plot lookup is O(1) via Map. // - Coin inventory scan is O(36) per purchase -- once per player action. // ─── Plot definitions ───────────────────────────────────────────────── // V1 hardcoded. Replace with admin-defined plots once the spawn marketplace // is built. Chunks are [chunkX, chunkZ] pairs in the overworld. const PLOT_DEFS = new Map([ ['p001', { id: 'p001', name: 'Market Stall 1', size: 'small', chunks: [[10, 10]], // 1x1 price: 100, // in spurs dimension: 'minecraft:overworld', }], ['p002', { id: 'p002', name: 'Corner Lot', size: 'medium', chunks: [[12, 10], [12, 11]], // 1x2 price: 250, dimension: 'minecraft:overworld', }], ['p003', { id: 'p003', name: 'Anchor Plot', size: 'large', chunks: [[14, 10], [14, 11], [15, 10], [15, 11]], // 2x2 price: 600, dimension: 'minecraft:overworld', }], ]); const REFUND_FRACTION = 0.5; // released plots refund 50% of purchase price // ─── Numismatics coin handling ──────────────────────────────────────── // Authoritative values from in-game tooltips (verified by user 2026-05-23). // Chain is NON-uniform (x8, x2, x4, x8, x8) -- don't assume otherwise. const COIN_VALUES = { 'numismatics:spur': 1, 'numismatics:bevel': 8, 'numismatics:sprocket': 16, 'numismatics:cog': 64, 'numismatics:crown': 512, 'numismatics:sun': 4096, }; const DENOMS_DESC = ['numismatics:sun', 'numismatics:crown', 'numismatics:cog', 'numismatics:sprocket', 'numismatics:bevel', 'numismatics:spur']; function countCoins(player) { let total = 0; const inv = player.inventory; for (let i = 0; i < inv.containerSize; i++) { const stack = inv.getItem(i); const v = COIN_VALUES[stack.id]; if (v) total += v * stack.count; } return total; } function takeCoins(player, amount) { let remaining = amount; const inv = player.inventory; // Pass 1: take from largest denomination downward (minimises change). for (const id of DENOMS_DESC) { const value = COIN_VALUES[id]; if (remaining < value) continue; for (let i = 0; i < inv.containerSize && remaining >= value; i++) { const stack = inv.getItem(i); if (stack.id !== id) continue; const take = Math.min(stack.count, Math.floor(remaining / value)); stack.shrink(take); remaining -= take * value; } } return amount - remaining; // returns how many spurs actually taken } function giveCoins(player, amount) { // Give back as largest denomination possible to minimise inventory clutter. let remaining = amount; for (const id of DENOMS_DESC) { const value = COIN_VALUES[id]; if (remaining < value) continue; const count = Math.floor(remaining / value); if (count > 0) { player.give(Item.of(id, count)); remaining -= count * value; } } } // ─── Ownership storage (server.persistentData.bnsPlots compound) ────── function getOwnerMap() { // Lazily ensure the compound exists. The compound is a plot_id -> uuid map. const root = Utils.server.persistentData; if (!root.contains('bnsPlots')) { root.put('bnsPlots', {}); } return root.getCompound('bnsPlots'); } function ownerOf(plotId) { const map = getOwnerMap(); return map.contains(plotId) ? map.getString(plotId) : null; } function plotOwnedBy(playerUuid) { const map = getOwnerMap(); for (const key of map.getAllKeys()) { if (map.getString(key) === playerUuid) return key; } return null; } function recordPurchase(plotId, playerUuid) { getOwnerMap().putString(plotId, playerUuid); console.info(`[bns/plots] purchase recorded: ${plotId} -> ${playerUuid}`); } function clearOwner(plotId) { getOwnerMap().remove(plotId); console.info(`[bns/plots] ownership cleared: ${plotId}`); } // ─── FTB Chunks integration (best-effort) ──────────────────────────── // FTB Chunks doesn't expose a KubeJS API for "claim chunk X for player Y", // but its commands (/ftbchunks claim, /ftbchunks unclaim) work as // console-as-player. Issue them with `execute as ` so they claim // to that player's team. function claimChunksForPlayer(plot, player) { for (const [cx, cz] of plot.chunks) { // /ftbchunks claim claims the chunk the executor stands in by default, // but accepts X Z args in most versions. Try with explicit coords first; // if that fails the syntax may need a `position` subcommand variant. const cmd = `execute as ${player.username} in ${plot.dimension} run ftbchunks claim ${cx} ${cz}`; Utils.server.runCommandSilent(cmd); console.info(`[bns/plots] claim: ${cmd}`); } } function unclaimChunksForPlayer(plot, player) { for (const [cx, cz] of plot.chunks) { const cmd = `execute as ${player.username} in ${plot.dimension} run ftbchunks unclaim ${cx} ${cz}`; Utils.server.runCommandSilent(cmd); console.info(`[bns/plots] unclaim: ${cmd}`); } } // ─── Helpers ────────────────────────────────────────────────────────── function formatPlotLine(plot) { const owner = ownerOf(plot.id); const status = owner ? `OWNED (${owner.substring(0,8)}...)` : 'AVAILABLE'; const cn = plot.chunks.length; return `${plot.id} "${plot.name}" ${plot.size} ${cn} chunk${cn>1?'s':''} ${plot.price} spurs [${status}]`; } function sendMulti(source, lines) { for (const line of lines) source.sendSystemMessage(Text.of(line)); } // ─── Command tree ───────────────────────────────────────────────────── ServerEvents.commandRegistry(event => { const { commands: Commands, arguments: Arguments } = event; const plotIdArg = () => Commands.argument('id', Arguments.STRING.create(event)); const tree = Commands.literal('bns') .then(Commands.literal('plot') // /bns plot list .then(Commands.literal('list').executes(ctx => { const lines = ['§7--- Spawn plots ---']; for (const plot of PLOT_DEFS.values()) { lines.push(formatPlotLine(plot)); } lines.push('§7Run §f/bns plot info §7 for details or §f/bns plot buy §7 to purchase.'); sendMulti(ctx.source, lines); return 1; })) // /bns plot info .then(Commands.literal('info').then(plotIdArg().executes(ctx => { const id = Arguments.STRING.getResult(ctx, 'id'); const plot = PLOT_DEFS.get(id); if (!plot) { ctx.source.sendSystemMessage(Text.red(`No such plot: ${id}`)); return 0; } const owner = ownerOf(id); const chunkStr = plot.chunks.map(([x,z]) => `(${x},${z})`).join(', '); sendMulti(ctx.source, [ `§6Plot ${id}: §f${plot.name}`, `§7 Size: §f${plot.size} (${plot.chunks.length} chunk${plot.chunks.length>1?'s':''})`, `§7 Chunks: §f${chunkStr}`, `§7 Dimension: §f${plot.dimension}`, `§7 Price: §f${plot.price} spurs`, `§7 Owner: §f${owner || 'AVAILABLE'}`, ]); return 1; }))) // /bns plot buy .then(Commands.literal('buy').then(plotIdArg().executes(ctx => { const player = ctx.source.player; if (!player) { ctx.source.sendSystemMessage(Text.red('Run from a player context.')); return 0; } const id = Arguments.STRING.getResult(ctx, 'id'); const plot = PLOT_DEFS.get(id); if (!plot) { player.tell(Text.red(`No such plot: ${id}`)); return 0; } if (ownerOf(id)) { player.tell(Text.red(`Plot ${id} is already owned.`)); return 0; } const existing = plotOwnedBy(player.uuid.toString()); if (existing) { player.tell(Text.red( `You already own plot ${existing}. Release it first with /bns plot release.` )); return 0; } const have = countCoins(player); if (have < plot.price) { player.tell(Text.red( `You need ${plot.price} spurs (have ${have}). Earn coins at the bank exchange.` )); return 0; } const took = takeCoins(player, plot.price); if (took < plot.price) { // Shouldn't happen if countCoins agreed, but be safe. giveCoins(player, took); player.tell(Text.red(`Coin removal failed -- refunded. Try again.`)); return 0; } recordPurchase(id, player.uuid.toString()); claimChunksForPlayer(plot, player); player.tell(Text.gold( `Plot ${id} (${plot.name}) is now yours. Paid ${plot.price} spurs.` )); console.info(`[bns/plots] ${player.username} bought ${id} for ${plot.price} spurs`); return 1; }))) // /bns plot release .then(Commands.literal('release').executes(ctx => { const player = ctx.source.player; if (!player) { ctx.source.sendSystemMessage(Text.red('Run from a player context.')); return 0; } const id = plotOwnedBy(player.uuid.toString()); if (!id) { player.tell(Text.red(`You don't own a plot.`)); return 0; } const plot = PLOT_DEFS.get(id); const refund = Math.floor(plot.price * REFUND_FRACTION); unclaimChunksForPlayer(plot, player); clearOwner(id); giveCoins(player, refund); player.tell(Text.gold( `Released plot ${id}. Refunded ${refund} spurs (${Math.round(REFUND_FRACTION*100)}% of ${plot.price}).` )); console.info(`[bns/plots] ${player.username} released ${id}, refunded ${refund} spurs`); return 1; })) // /bns plot reload (OP) .then(Commands.literal('reload').requires(src => src.hasPermission(2)) .executes(ctx => { // V1: PLOT_DEFS is hardcoded; reload is a no-op until V1.1 // (JSON-from-disk loading). Logged so admins know it ran. ctx.source.sendSystemMessage(Text.gold( `Plot defs are hardcoded in plots.js (V1). Edit the file + restart to change.` )); console.info(`[bns/plots] reload called (no-op in V1)`); return 1; })) ); event.register(tree); console.info('[bns/plots] commands registered: list, info, buy, release, reload'); });