diff --git a/pack/overrides/kubejs/server_scripts/plots.js b/pack/overrides/kubejs/server_scripts/plots.js new file mode 100644 index 0000000..d4b3852 --- /dev/null +++ b/pack/overrides/kubejs/server_scripts/plots.js @@ -0,0 +1,316 @@ +// 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 ──────────────────────────────────────── +const COIN_VALUES = { + 'numismatics:spur': 1, + 'numismatics:bevel': 8, + 'numismatics:cog': 64, + 'numismatics:crown': 512, + 'numismatics:sun': 4096, +}; +const DENOMS_DESC = ['numismatics:sun', 'numismatics:crown', 'numismatics:cog', + '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'); +}); diff --git a/pack/pack.lock.json b/pack/pack.lock.json index c065e7b..156fb6e 100644 --- a/pack/pack.lock.json +++ b/pack/pack.lock.json @@ -1,7 +1,7 @@ { "$schema": "Brass-and-Sigil pack.lock.json - generated, do not edit by hand unless you know what you are doing", "name": "Brass and Sigil", - "version": "0.20.1", + "version": "0.21.0", "minecraft": "1.21.1", "loader": { "type": "neoforge",