ffff3bc78d
Foundation economy stack — fully command-driven, ready for the custom
UI mod to layer on top later. Built tonight as a single feature batch.
KubeJS scripts:
pack/overrides/kubejs/server_scripts/economy/
00_tier_system.js — 13-tier ladder + admin commands
02_sell_shop.js — /bns sell, 9 items, DR + tier-fee
03_bounty_engine.js — /bns bounty, 20 bounties, state machine
04_player_commands.js — /bns help, /bns me, /bns tier upgrade
plots.js: updated to support multi-plot ownership based on tier slots
- plotsOwnedBy() returns list, slot cap from TIER_PLOT_SLOTS table
- /bns plot release now requires plot id (multiple plots possible)
- /bns plot mine added
FTB Ranks server config:
pack/overrides/world/serverconfig/ftbranks/ranks.snbt
13 ranks with ftbchunks.max_claimed_chunks scaling per tier:
Peasant 9 -> Sovereign 1500 (chunks)
Peasant 1 -> Sovereign 200 (force-loaded)
Name format colour codes match the design doc.
Player command surface (everything callable from chat or Telegram bridge):
/bns help full command listing
/bns me tier, balance, plots, bounties
/bns tier upgrade pay spurs, advance to next tier
/bns plot list|info|buy|release|mine
/bns sell list|<item> [count]
/bns bounty list|active|accept|cancel|turnin|completed
/bns admin tier get|set|list (op level 2)
/bns admin info|waystone-*|reset-welcome (pre-existing)
Tier upgrade flow:
1. Player runs /bns tier upgrade
2. KubeJS takes spurs from inventory
3. KubeJS sets player.persistentData.bnsTier += 1
4. KubeJS runs `ftbranks add <player> <rank>` so FTB Ranks applies
the permission nodes (chunk allowance auto-flips)
Cross-file scope: KubeJS 2101 runs all server_scripts in shared Rhino
global scope. Constants (TIER_CONFIG, COIN_VALUES, bnsTier object,
shared functions like giveCoins/countCoins/takeCoins) are declared
once in their owning file and referenced from siblings. Don't redeclare.
Blocked tonight (deferred):
- Villager trade rebalance: KubeJS 2101.7.2 doesn't ship a
villagerTrades event. ServerEvents only exposes command/loaded/
recipes/registry/tags/tick/unloaded. Verified by grepping the
KubeJS jar's ServerEvents class. No KubeJS-villager-trade addon
on Modrinth for NeoForge 1.21.1. Either build into the custom
mod or add a third-party trade-overrides mod (research pending).
- Custom mod UI screens: no Java toolchain on host, and these
need visual iteration with the user anyway.
Verified live: server boots clean, all 4 economy scripts log loaded,
port 25565 binds, no EcmaError or EvaluatorException in KubeJS log.
FTB Ranks reload via rcon confirms "Ranks reloaded from disk!"
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
376 lines
16 KiB
JavaScript
376 lines
16 KiB
JavaScript
// 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 ────────────────────────────────────────
|
|
// 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 plotsOwnedBy(playerUuid) {
|
|
const map = getOwnerMap();
|
|
const owned = [];
|
|
for (const key of map.getAllKeys()) {
|
|
if (map.getString(key) === playerUuid) owned.push(key);
|
|
}
|
|
return owned;
|
|
}
|
|
|
|
// Per-tier plot slot cap. Index = tier 1-13. Mirrors TIER_CONFIG.plotSlots
|
|
// in 00_tier_system.js — kept duplicated here because KubeJS Rhino doesn't
|
|
// allow cross-script globals (strict mode). See feedback-kubejs-rhino-gotchas.
|
|
const TIER_PLOT_SLOTS = [0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 5, 6, 7, 10];
|
|
|
|
function playerPlotSlotCap(player) {
|
|
const data = player.persistentData;
|
|
const tier = data.contains('bnsTier') ? data.getInt('bnsTier') : 1;
|
|
const safe = (tier >= 1 && tier <= 13) ? tier : 1;
|
|
return TIER_PLOT_SLOTS[safe];
|
|
}
|
|
|
|
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;
|
|
}
|
|
// Tier-based slot cap.
|
|
const owned = plotsOwnedBy(player.uuid.toString());
|
|
const cap = playerPlotSlotCap(player);
|
|
if (cap <= 0) {
|
|
player.tell(Text.red(
|
|
`Your current tier doesn't allow plot ownership. Upgrade your civic tier first.`
|
|
));
|
|
return 0;
|
|
}
|
|
if (owned.length >= cap) {
|
|
player.tell(Text.red(
|
|
`You're at your plot-slot cap (${owned.length}/${cap}). Release one with /bns plot release <id>, or upgrade your tier for more slots.`
|
|
));
|
|
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. Slots used: ${owned.length + 1}/${cap}.`
|
|
));
|
|
console.info(`[bns/plots] ${player.username} bought ${id} for ${plot.price} spurs (slot ${owned.length + 1}/${cap})`);
|
|
return 1;
|
|
})))
|
|
|
|
// /bns plot release <id>
|
|
// Requires the plot id explicitly. If a player owns multiple plots
|
|
// (high-tier), they must say which one. The old "release with no
|
|
// arg" shortcut is gone -- explicitness wins for safety.
|
|
.then(Commands.literal('release').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 ownerUuid = ownerOf(id);
|
|
if (ownerUuid !== player.uuid.toString()) {
|
|
player.tell(Text.red(`You don't own plot ${id}.`));
|
|
return 0;
|
|
}
|
|
const plot = PLOT_DEFS.get(id);
|
|
if (!plot) {
|
|
player.tell(Text.red(`Plot ${id} has no definition -- something's wrong.`));
|
|
return 0;
|
|
}
|
|
const refund = Math.floor(plot.price * REFUND_FRACTION);
|
|
unclaimChunksForPlayer(plot, player);
|
|
clearOwner(id);
|
|
giveCoins(player, refund);
|
|
const owned = plotsOwnedBy(player.uuid.toString());
|
|
const cap = playerPlotSlotCap(player);
|
|
player.tell(Text.gold(
|
|
`Released plot ${id}. Refunded ${refund} spurs (${Math.round(REFUND_FRACTION*100)}% of ${plot.price}). Slots used: ${owned.length}/${cap}.`
|
|
));
|
|
console.info(`[bns/plots] ${player.username} released ${id}, refunded ${refund} spurs (now ${owned.length}/${cap})`);
|
|
return 1;
|
|
})))
|
|
|
|
// /bns plot mine -- show plots the current player owns
|
|
.then(Commands.literal('mine').executes(ctx => {
|
|
const player = ctx.source.player;
|
|
if (!player) {
|
|
ctx.source.sendSystemMessage(Text.red('Run from a player context.'));
|
|
return 0;
|
|
}
|
|
const owned = plotsOwnedBy(player.uuid.toString());
|
|
const cap = playerPlotSlotCap(player);
|
|
if (owned.length === 0) {
|
|
player.tell(Text.gray(`You own no plots. Available slots at your tier: ${cap}`));
|
|
return 1;
|
|
}
|
|
player.tell(Text.aqua(`You own ${owned.length}/${cap} plots:`));
|
|
for (const pid of owned) {
|
|
const plot = PLOT_DEFS.get(pid);
|
|
if (plot) {
|
|
player.tell(Text.gray(` ${pid} "${plot.name}" ${plot.size} ${plot.price} spurs`));
|
|
} else {
|
|
player.tell(Text.red(` ${pid} (orphaned -- plot definition missing)`));
|
|
}
|
|
}
|
|
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');
|
|
});
|