Merge pull request 'v0.21.0: Plot system V1' (#25) from feature/plots-v1 into main

This commit was merged in pull request #25.
This commit is contained in:
2026-05-23 16:02:05 +00:00
2 changed files with 317 additions and 1 deletions
@@ -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 <id> detailed info for one plot
// buy <id> 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 <x> <z>` as the player
// for each chunk in the plot, prefixed by `execute in <dim> as <player>`.
// 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 <player>` 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 <id>§7 for details or §f/bns plot buy <id>§7 to purchase.');
sendMulti(ctx.source, lines);
return 1;
}))
// /bns plot info <id>
.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 <id>
.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');
});
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "Brass-and-Sigil pack.lock.json - generated, do not edit by hand unless you know what you are doing", "$schema": "Brass-and-Sigil pack.lock.json - generated, do not edit by hand unless you know what you are doing",
"name": "Brass and Sigil", "name": "Brass and Sigil",
"version": "0.20.1", "version": "0.21.0",
"minecraft": "1.21.1", "minecraft": "1.21.1",
"loader": { "loader": {
"type": "neoforge", "type": "neoforge",