Compare commits

..

3 Commits

Author SHA1 Message Date
Matt 572671f1d4 Merge pull request 'econ: sell shop + bounty engine + player commands + FTB Ranks 13-tier' (#67) from feature/v0.33.0-economy-foundation into main 2026-06-06 23:55:43 +00:00
Matt ffff3bc78d econ: sell shop + bounty engine + player commands + FTB Ranks 13-tier
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>
2026-06-06 23:55:42 +00:00
Matt b4cd66036b econ: tier system foundation (00_tier_system.js)
KubeJS module that owns the 13-tier civic ladder per economy doc §4a.
Authoritative source for TIER_CONFIG (per-tier perks: cost, wilderness
chunks, plot slots, daily bounty slots, shop fee discount, chat
colour, join broadcast).

Player tier stored as int in player.persistentData.bnsTier; default
1 (Peasant), set on first login.

Admin commands (op level 2):
  /bns admin tier get <player>
  /bns admin tier set <player> <1-13>
  /bns admin tier list

Module-local pattern (KubeJS Rhino runs scripts in strict mode -- no
globalThis, no bare-assignment globals). Other economy scripts will
duplicate TIER_CONFIG and use player.persistentData.bnsTier directly
to read tier rather than trying to import. See feedback-kubejs-rhino-
gotchas memory.

FTB Ranks will mirror this once the bnstoolkit mod ships -- ranks
will carry the FTB Chunks permission nodes. For now the NBT is the
single source of truth and admin promotes manually with /bns admin
tier set.

Verified: server boots clean, KubeJS loads with 0 errors. First-join
hook initialises new players to tier 1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 23:38:30 +00:00
7 changed files with 1055 additions and 17 deletions
@@ -0,0 +1,170 @@
// Brass and Sigil -- tier system (foundation)
//
// 13-tier civic ladder per docs/economy-system.md §4a. This file owns:
// - The TIER_CONFIG table (single source of truth for tier perks)
// - Helper functions: getTier(player), setTier, getTierConfig
// - Admin commands: /bns admin set-tier, get-tier
//
// Storage: player.persistentData.bnsTier (int, 1-13). Default 1 (Peasant).
//
// Eventually FTB Ranks will mirror this — when the bnstoolkit mod ships,
// rank promotion will set both the NBT tier here AND the FTB Ranks rank
// (which carries the FTB-Chunks permission nodes for wilderness caps).
// For now this NBT is the authoritative source.
//
// All other economy scripts read tier perks through this module's
// exposed globals.
// ─── The ladder ─────────────────────────────────────────────────────
// Names + costs + perks per tier. Edit here to rebalance.
// Sub-fields:
// wildernessChunks — FTB Chunks claim cap (also enforced via FTB Ranks
// permission node once configured; this is the
// fallback / source-of-truth value)
// plotSlots — how many spawn plots the player can own at once
// dailyBountySlots — how many bounties they can have active at once
// shopFeeDiscount — % discount on the 25% base Bazaar fee (negative %)
// chatColour — name colour in chat
// joinBroadcast — broadcast level on login
const TIER_CONFIG = Object.freeze([
null, // tiers are 1-indexed, slot 0 reserved
{ id: 1, name: 'Peasant', cost: 0, wildernessChunks: 9, plotSlots: 0, dailyBountySlots: 3, shopFeeDiscount: 0, chatColour: 'gray', joinBroadcast: 'none' },
{ id: 2, name: 'Farmer', cost: 200, wildernessChunks: 18, plotSlots: 0, dailyBountySlots: 3, shopFeeDiscount: 0, chatColour: 'gray', joinBroadcast: 'none' },
{ id: 3, name: 'Citizen', cost: 750, wildernessChunks: 35, plotSlots: 1, dailyBountySlots: 3, shopFeeDiscount: 0, chatColour: 'white', joinBroadcast: 'none' },
{ id: 4, name: 'Merchant', cost: 2500, wildernessChunks: 60, plotSlots: 1, dailyBountySlots: 4, shopFeeDiscount: -2, chatColour: 'yellow', joinBroadcast: 'none' },
{ id: 5, name: 'Knight', cost: 6500, wildernessChunks: 100, plotSlots: 2, dailyBountySlots: 4, shopFeeDiscount: -5, chatColour: 'green', joinBroadcast: 'simple' },
{ id: 6, name: 'Baron', cost: 15000, wildernessChunks: 150, plotSlots: 2, dailyBountySlots: 5, shopFeeDiscount: -7, chatColour: 'aqua', joinBroadcast: 'simple' },
{ id: 7, name: 'Viscount', cost: 35000, wildernessChunks: 220, plotSlots: 3, dailyBountySlots: 5, shopFeeDiscount: -9, chatColour: 'blue', joinBroadcast: 'fancy' },
{ id: 8, name: 'Earl', cost: 75000, wildernessChunks: 300, plotSlots: 3, dailyBountySlots: 6, shopFeeDiscount: -11, chatColour: 'dark_purple', joinBroadcast: 'fancy' },
{ id: 9, name: 'Marquess', cost: 150000, wildernessChunks: 400, plotSlots: 4, dailyBountySlots: 6, shopFeeDiscount: -13, chatColour: 'gold', joinBroadcast: 'fancy' },
{ id: 10, name: 'Duke', cost: 300000, wildernessChunks: 525, plotSlots: 5, dailyBountySlots: 7, shopFeeDiscount: -15, chatColour: 'gold', joinBroadcast: 'epic' },
{ id: 11, name: 'Archduke', cost: 700000, wildernessChunks: 700, plotSlots: 6, dailyBountySlots: 7, shopFeeDiscount: -17, chatColour: 'red', joinBroadcast: 'epic' },
{ id: 12, name: 'Grand Duke', cost: 1500000, wildernessChunks: 900, plotSlots: 7, dailyBountySlots: 8, shopFeeDiscount: -19, chatColour: 'red', joinBroadcast: 'epic_particles' },
{ id: 13, name: 'Sovereign', cost: 10000000, wildernessChunks: 1500, plotSlots: 10, dailyBountySlots: 10, shopFeeDiscount: -25, chatColour: 'light_purple', joinBroadcast: 'epic_spectacular' },
]);
const MAX_TIER = 13;
// Bazaar's base sell fee, before tier discount. A Peasant pays 25%; a
// Sovereign with -25% discount pays 0%. Read by the sell-shop module.
const BAZAAR_BASE_FEE_PCT = 25;
// ─── Tier accessors ─────────────────────────────────────────────────
// Module-local helpers. Each consumer file declares its own TIER_CONFIG
// copy because KubeJS Rhino strict-mode doesn't allow cross-script globals.
// See feedback-kubejs-rhino-gotchas memory note.
const bnsTier = {
// get the player's current tier (1-13). Defaults to 1 if unset.
get: function(player) {
const data = player.persistentData;
if (!data.contains('bnsTier')) return 1;
const t = data.getInt('bnsTier');
return (t >= 1 && t <= MAX_TIER) ? t : 1;
},
// set the player's tier (1-13). Clamps to range.
set: function(player, tier) {
const clamped = Math.max(1, Math.min(MAX_TIER, tier|0));
player.persistentData.putInt('bnsTier', clamped);
return clamped;
},
// get the full config row for a tier (1-13).
config: function(tier) {
if (tier < 1 || tier > MAX_TIER) return null;
return TIER_CONFIG[tier];
},
// get config for the player's current tier
playerConfig: function(player) {
return TIER_CONFIG[this.get(player)];
},
// expose the table read-only
table: function() { return TIER_CONFIG; },
// bazaar base fee accessor for the sell-shop module
bazaarBaseFee: function() { return BAZAAR_BASE_FEE_PCT; },
// effective shop-fee percentage for the player (base + discount).
// Returns 0..25, never negative.
effectiveShopFeePct: function(player) {
const cfg = this.playerConfig(player);
return Math.max(0, BAZAAR_BASE_FEE_PCT + cfg.shopFeeDiscount);
},
MAX: MAX_TIER,
};
// ─── Admin commands ──────────────────────────────────────────────────
// /bns admin tier get <player>
// /bns admin tier set <player> <tier>
// /bns admin tier list (show the full table)
ServerEvents.commandRegistry(event => {
const { commands: Commands, arguments: Arguments } = event;
const tierCmd = Commands.literal('bns')
.then(Commands.literal('admin')
.requires(src => src.hasPermission(2))
.then(Commands.literal('tier')
.then(Commands.literal('get')
.then(Commands.argument('target', Arguments.PLAYER.create(event))
.executes(ctx => {
const t = Arguments.PLAYER.getResult(ctx, 'target');
const tier = bnsTier.get(t);
const cfg = bnsTier.config(tier);
ctx.source.sendSystemMessage(Text.aqua(
`${t.username}: tier ${tier} (${cfg.name})`
));
return 1;
})))
.then(Commands.literal('set')
.then(Commands.argument('target', Arguments.PLAYER.create(event))
.then(Commands.argument('tier', Arguments.INTEGER.create(event))
.executes(ctx => {
const t = Arguments.PLAYER.getResult(ctx, 'target');
const requested = Arguments.INTEGER.getResult(ctx, 'tier');
if (requested < 1 || requested > MAX_TIER) {
ctx.source.sendSystemMessage(Text.red(
`Tier must be 1..${MAX_TIER} (got ${requested})`
));
return 0;
}
const actual = bnsTier.set(t, requested);
const cfg = bnsTier.config(actual);
ctx.source.sendSystemMessage(Text.gold(
`Set ${t.username} -> tier ${actual} (${cfg.name})`
));
t.tell(Text.gold(`You have been promoted to ${cfg.name}.`));
console.info(`[bns/tier] admin set ${t.username} tier=${actual} (${cfg.name})`);
return 1;
}))))
.then(Commands.literal('list').executes(ctx => {
ctx.source.sendSystemMessage(Text.aqua('--- Tier ladder ---'));
for (let i = 1; i <= MAX_TIER; i++) {
const c = TIER_CONFIG[i];
ctx.source.sendSystemMessage(Text.gray(
` ${i}. ${c.name} cost ${c.cost} chunks ${c.wildernessChunks} plots ${c.plotSlots} bounties ${c.dailyBountySlots} fee ${c.shopFeeDiscount}% ${c.chatColour}`
));
}
return 1;
}))
)
);
event.register(tierCmd);
});
// ─── First-join: ensure tier defaults to 1 ──────────────────────────
// Doesn't conflict with welcome.js (different flag).
PlayerEvents.loggedIn(event => {
const p = event.player;
if (!p.persistentData.contains('bnsTier')) {
bnsTier.set(p, 1);
console.info(`[bns/tier] initialised ${p.username} to tier 1 (Peasant)`);
}
});
console.info('[bns/tier_system] loaded — 13 tiers configured');
@@ -0,0 +1,197 @@
// Brass and Sigil -- /bns sell shop (Phase 1 of the economy)
//
// Players sell raw commodities to "the Bazaar" for spurs. Implementation:
// - Command-driven for now: /bns sell <item> [count]
// - When the bnstoolkit mod ships, a Numismatics shop block in the
// Merchant's Bazaar will call the same backend.
//
// Mechanics per docs/economy-system.md §11.4:
// - Base price per item is configured below in SELL_PRICES
// - Bazaar charges a base 25% fee, reduced by the player's tier
// discount (read from player.persistentData.bnsTier).
// - Diminishing returns: each player has a per-item lifetime-sales
// counter. Price reduces 10% per 64 sold, floored at 50% of base.
//
// Storage:
// player.persistentData.bnsSales -- compound map: item_id -> int count
// ─── Sell catalogue ─────────────────────────────────────────────────
// Add/remove items here. Prices are pre-fee, pre-DR — "list price."
const SELL_PRICES = {
'minecraft:diamond': 10,
'minecraft:netherite_ingot': 100,
'minecraft:emerald': 1,
'minecraft:gold_ingot': 2,
'minecraft:iron_ingot': 1, // small unit value; sell in stacks
'minecraft:lapis_lazuli': 1,
'minecraft:redstone': 1,
'minecraft:coal': 1, // 4 coal = 1 spur after DR + fee
'minecraft:ancient_debris': 500, // raw, rare
};
// Constants like BAZAAR_BASE_FEE_PCT live in 00_tier_system.js.
// Coin handling (COIN_VALUES, DENOMS_DESC, giveCoins) lives in plots.js.
// All top-level `const`/`function` declarations are in the shared
// Rhino global scope, so we just call them directly here.
const DR_STEP = 64; // every N sold => price drops a step
const DR_DECAY = 0.10; // 10% drop per step
const DR_FLOOR = 0.50; // never below 50% of base
// ─── Sales tracker ─────────────────────────────────────────────────
function getSalesMap(player) {
const data = player.persistentData;
if (!data.contains('bnsSales')) {
data.put('bnsSales', {});
}
return data.getCompound('bnsSales');
}
function lifetimeSold(player, itemId) {
const map = getSalesMap(player);
return map.contains(itemId) ? map.getInt(itemId) : 0;
}
function recordSale(player, itemId, count) {
const map = getSalesMap(player);
const prev = map.contains(itemId) ? map.getInt(itemId) : 0;
map.putInt(itemId, prev + count);
}
// ─── Pricing ───────────────────────────────────────────────────────
// Effective per-unit payout for the player after DR + fee.
// Worked example for a Peasant selling their first diamond:
// base = 10
// DR multiplier = max(0.50, 1 - 0 * 0.10) = 1.0
// pre-fee per unit = 10
// tier discount = 0, effective fee = 25%
// payout = floor(10 * 0.75) = 7
function effectivePayout(player, itemId, count) {
const base = SELL_PRICES[itemId];
if (!base) return 0;
const sold = lifetimeSold(player, itemId);
// average DR multiplier across the units being sold this transaction.
// Approximated by midpoint of the range.
const midSteps = Math.floor((sold + count / 2) / DR_STEP);
const drMult = Math.max(DR_FLOOR, 1 - midSteps * DR_DECAY);
const feePct = bnsTier.effectiveShopFeePct(player);
const grossPerUnit = base * drMult;
const netPerUnit = grossPerUnit * (1 - feePct / 100);
return Math.floor(netPerUnit * count);
}
// Display-only single-unit price (for /bns sell list)
function singleUnitPriceDisplay(player, itemId) {
const base = SELL_PRICES[itemId];
if (!base) return '-';
const sold = lifetimeSold(player, itemId);
const steps = Math.floor(sold / DR_STEP);
const drMult = Math.max(DR_FLOOR, 1 - steps * DR_DECAY);
const feePct = bnsTier.effectiveShopFeePct(player);
const net = base * drMult * (1 - feePct / 100);
return Math.floor(net).toString();
}
// ─── Inventory helpers ─────────────────────────────────────────────
function takeFromInventory(player, itemId, count) {
let remaining = count;
const inv = player.inventory;
for (let i = 0; i < inv.containerSize && remaining > 0; i++) {
const stack = inv.getItem(i);
if (stack.id !== itemId) continue;
const take = Math.min(stack.count, remaining);
stack.shrink(take);
remaining -= take;
}
return count - remaining; // how many were actually taken
}
function countInInventory(player, itemId) {
let total = 0;
const inv = player.inventory;
for (let i = 0; i < inv.containerSize; i++) {
const stack = inv.getItem(i);
if (stack.id === itemId) total += stack.count;
}
return total;
}
// ─── Commands ──────────────────────────────────────────────────────
ServerEvents.commandRegistry(event => {
const { commands: Commands, arguments: Arguments } = event;
const sellCmd = Commands.literal('bns')
.then(Commands.literal('sell')
// /bns sell list
.then(Commands.literal('list').executes(ctx => {
const player = ctx.source.player;
if (!player) { ctx.source.sendSystemMessage(Text.red('Run from a player context.')); return 0; }
const feeNow = bnsTier.effectiveShopFeePct(player);
ctx.source.sendSystemMessage(Text.aqua(`--- Bazaar sell prices (your effective fee: ${feeNow}%) ---`));
for (const itemId of Object.keys(SELL_PRICES)) {
const base = SELL_PRICES[itemId];
const now = singleUnitPriceDisplay(player, itemId);
const sold = lifetimeSold(player, itemId);
ctx.source.sendSystemMessage(Text.gray(
` ${itemId} base ${base} -> you get ${now} (you've sold ${sold})`
));
}
ctx.source.sendSystemMessage(Text.gray('Sell: /bns sell <item-id> [count] (omit count to sell 1)'));
return 1;
}))
// /bns sell <item-id> [count]
.then(Commands.argument('item', Arguments.STRING.create(event))
.executes(ctx => {
return doSell(ctx, Arguments.STRING.getResult(ctx, 'item'), 1);
})
.then(Commands.argument('count', Arguments.INTEGER.create(event))
.executes(ctx => {
const item = Arguments.STRING.getResult(ctx, 'item');
const count = Arguments.INTEGER.getResult(ctx, 'count');
return doSell(ctx, item, count);
})))
);
event.register(sellCmd);
});
function doSell(ctx, itemId, count) {
const player = ctx.source.player;
if (!player) { ctx.source.sendSystemMessage(Text.red('Run from a player context.')); return 0; }
if (!SELL_PRICES[itemId]) {
player.tell(Text.red(`The Bazaar doesn't buy '${itemId}'. Try /bns sell list.`));
return 0;
}
if (count < 1) {
player.tell(Text.red('Count must be at least 1.'));
return 0;
}
const have = countInInventory(player, itemId);
if (have < count) {
player.tell(Text.red(`You only have ${have}x ${itemId} (need ${count}).`));
return 0;
}
const payout = effectivePayout(player, itemId, count);
if (payout <= 0) {
player.tell(Text.red(`Payout would be 0 spurs (DR + fee). Wait or upgrade your tier.`));
return 0;
}
const taken = takeFromInventory(player, itemId, count);
if (taken !== count) {
// Defensive: shouldn't happen — countInInventory said we had enough
player.tell(Text.red(`Failed to remove ${count}x ${itemId} (took ${taken}). Aborting.`));
return 0;
}
recordSale(player, itemId, count);
giveCoins(player, payout); // shared from plots.js
player.tell(Text.gold(`Sold ${count}x ${itemId} for ${payout} spurs.`));
console.info(`[bns/sell] ${player.username} sold ${count}x ${itemId} for ${payout} spurs (lifetime ${lifetimeSold(player, itemId)})`);
return 1;
}
console.info('[bns/sell_shop] loaded — 9 items in catalogue, DR + tier-fee active');
@@ -0,0 +1,347 @@
// Brass and Sigil -- bounty engine V1 (command-driven)
//
// Per docs/economy-system.md §4-5:
// Each player has a per-day pool of available bounties drawn randomly
// from BOUNTY_POOL. They can accept up to TIER.dailyBountySlots active
// at once. Completing the objective marks the bounty READY -- they
// then turn it in to claim the spur reward. Cancellation applies the
// same cooldown as completion (no RNG shopping).
//
// V1 turn-in is a command. When the bnstoolkit mod ships, the Adventurer's
// Guild NPC will be the turn-in surface and this command becomes a
// fallback/debug path.
//
// Player commands:
// /bns bounty list -- today's available bounties
// /bns bounty active -- your accepted bounties
// /bns bounty accept <id>
// /bns bounty cancel <id>
// /bns bounty turnin <id>
// /bns bounty completed -- your completed bounties + cooldowns
//
// Bounty state per player (in player.persistentData):
// bnsBountyPool compound { id -> 1 } -- today's available 5
// bnsBountyPoolRefresh long -- epoch ms of last roll
// bnsBountyActive compound { id -> { progress, target, reward, source, ready? } }
// bnsBountyCompleted compound { id -> cooldown_until_ms }
const BOUNTY_POOL_SIZE = 5;
const BOUNTY_REFRESH_MS = 24 * 60 * 60 * 1000; // 24h
const BOUNTY_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24h cooldown after complete or cancel
// ─── Bounty catalogue ───────────────────────────────────────────────
// Each bounty: { id, name, target, count, type, reward, source }
// target = entity id ('minecraft:zombie') OR resource for non-kill bounties
// type = 'kill' (V1 only supports kill bounties)
// reward = spurs paid on turn-in
// source = guild name (for flavour in messages)
const BOUNTY_POOL = [
// ── Easy: novice-tier mobs ──
{ id: 'b_zombie_10', name: 'Slay 10 Zombies', target: 'minecraft:zombie', count: 10, type: 'kill', reward: 15, source: "Adventurer's Guild" },
{ id: 'b_skeleton_10', name: 'Slay 10 Skeletons', target: 'minecraft:skeleton', count: 10, type: 'kill', reward: 15, source: "Adventurer's Guild" },
{ id: 'b_spider_8', name: 'Slay 8 Spiders', target: 'minecraft:spider', count: 8, type: 'kill', reward: 12, source: "Adventurer's Guild" },
{ id: 'b_creeper_5', name: 'Slay 5 Creepers', target: 'minecraft:creeper', count: 5, type: 'kill', reward: 20, source: "Adventurer's Guild" },
{ id: 'b_drowned_8', name: 'Slay 8 Drowned', target: 'minecraft:drowned', count: 8, type: 'kill', reward: 18, source: "Adventurer's Guild" },
{ id: 'b_husk_8', name: 'Slay 8 Husks', target: 'minecraft:husk', count: 8, type: 'kill', reward: 18, source: "Adventurer's Guild" },
// ── Medium: nether basic ──
{ id: 'b_blaze_5', name: 'Slay 5 Blazes', target: 'minecraft:blaze', count: 5, type: 'kill', reward: 50, source: "Adventurer's Guild" },
{ id: 'b_piglin_8', name: 'Slay 8 Piglins', target: 'minecraft:piglin', count: 8, type: 'kill', reward: 40, source: "Adventurer's Guild" },
{ id: 'b_magma_6', name: 'Slay 6 Magma Cubes', target: 'minecraft:magma_cube', count: 6, type: 'kill', reward: 30, source: "Adventurer's Guild" },
{ id: 'b_ghast_3', name: 'Slay 3 Ghasts', target: 'minecraft:ghast', count: 3, type: 'kill', reward: 60, source: "Adventurer's Guild" },
// ── Medium: modded ─
{ id: 'b_plunderer_5', name: 'Hunt 5 Plunderers', target: 'supplementaries:plunderer', count: 5, type: 'kill', reward: 75, source: "Adventurer's Guild" },
// ── Hard: nether elites + end ──
{ id: 'b_witch_3', name: 'Slay 3 Witches', target: 'minecraft:witch', count: 3, type: 'kill', reward: 80, source: "Adventurer's Guild" },
{ id: 'b_enderman_5', name: 'Slay 5 Endermen', target: 'minecraft:enderman', count: 5, type: 'kill', reward: 90, source: "Adventurer's Guild" },
{ id: 'b_wither_skel_3', name: 'Slay 3 Wither Skeletons', target: 'minecraft:wither_skeleton', count: 3, type: 'kill', reward: 150, source: "Adventurer's Guild" },
{ id: 'b_shulker_3', name: 'Slay 3 Shulkers', target: 'minecraft:shulker', count: 3, type: 'kill', reward: 200, source: "Adventurer's Guild" },
// ── Boss tier (rare picks) ──
{ id: 'b_warden_1', name: 'Slay the Warden', target: 'minecraft:warden', count: 1, type: 'kill', reward: 800, source: "Adventurer's Guild" },
{ id: 'b_voidworm_1', name: 'Destroy a Void Worm', target: 'alexsmobs:void_worm', count: 1, type: 'kill', reward: 1200, source: "Adventurer's Guild" },
{ id: 'b_warped_mosco_1', name: 'Slay a Warped Mosco', target: 'alexsmobs:warped_mosco', count: 1, type: 'kill', reward: 700, source: "Adventurer's Guild" },
{ id: 'b_farseer_1', name: 'Slay the Farseer', target: 'alexsmobs:farseer', count: 1, type: 'kill', reward: 900, source: "Adventurer's Guild" },
{ id: 'b_skreecher_1', name: 'Slay a Skreecher', target: 'alexsmobs:skreecher', count: 1, type: 'kill', reward: 400, source: "Adventurer's Guild" },
];
// Index pool by id for O(1) lookup
const BOUNTY_INDEX = {};
for (const b of BOUNTY_POOL) BOUNTY_INDEX[b.id] = b;
// ─── Player tier → slot cap ─────────────────────────────────────────
const TIER_BOUNTY_SLOTS = [0, 3, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 10];
function playerBountySlotCap(player) {
const data = player.persistentData;
const tier = data.contains('bnsTier') ? data.getInt('bnsTier') : 1;
const safe = (tier >= 1 && tier <= 13) ? tier : 1;
return TIER_BOUNTY_SLOTS[safe];
}
// ─── Compound storage helpers ───────────────────────────────────────
function getActiveMap(player) {
const d = player.persistentData;
if (!d.contains('bnsBountyActive')) d.put('bnsBountyActive', {});
return d.getCompound('bnsBountyActive');
}
function getCompletedMap(player) {
const d = player.persistentData;
if (!d.contains('bnsBountyCompleted')) d.put('bnsBountyCompleted', {});
return d.getCompound('bnsBountyCompleted');
}
function getPoolList(player) {
const d = player.persistentData;
if (!d.contains('bnsBountyPool')) d.put('bnsBountyPool', {});
return d.getCompound('bnsBountyPool');
}
// ─── Daily pool refresh ─────────────────────────────────────────────
function nowMs() { return Date.now(); }
function rollDailyPool(player) {
const eligible = BOUNTY_POOL.slice(); // shallow copy
// Filter out bounties still on cooldown
const completed = getCompletedMap(player);
const now = nowMs();
const available = eligible.filter(b => {
if (!completed.contains(b.id)) return true;
return completed.getLong(b.id) <= now;
});
// Shuffle and pick top BOUNTY_POOL_SIZE
for (let i = available.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const t = available[i]; available[i] = available[j]; available[j] = t;
}
const picked = available.slice(0, BOUNTY_POOL_SIZE);
const pool = getPoolList(player);
// Clear old pool entries
for (const k of pool.getAllKeys()) pool.remove(k);
for (const b of picked) pool.putInt(b.id, 1);
player.persistentData.putLong('bnsBountyPoolRefresh', now);
console.info(`[bns/bounty] rolled pool for ${player.username}: ${picked.map(b=>b.id).join(', ')}`);
}
function ensurePoolFresh(player) {
const d = player.persistentData;
const lastRoll = d.contains('bnsBountyPoolRefresh') ? d.getLong('bnsBountyPoolRefresh') : 0;
if (nowMs() - lastRoll > BOUNTY_REFRESH_MS) {
rollDailyPool(player);
}
}
// ─── Active-bounty management ───────────────────────────────────────
function activeBountyCount(player) {
return getActiveMap(player).getAllKeys().size();
}
function addActiveBounty(player, bountyDef) {
const map = getActiveMap(player);
const entry = {};
entry['progress'] = 0;
entry['target'] = bountyDef.count;
entry['reward'] = bountyDef.reward;
entry['source'] = bountyDef.source;
entry['name'] = bountyDef.name;
entry['ready'] = false;
entry['targetId'] = bountyDef.target;
entry['type'] = bountyDef.type;
map.put(bountyDef.id, entry);
}
function removeActiveBounty(player, bountyId) {
getActiveMap(player).remove(bountyId);
}
function markCooldown(player, bountyId) {
getCompletedMap(player).putLong(bountyId, nowMs() + BOUNTY_COOLDOWN_MS);
}
// ─── Progress hook: increment matching active bounties on kill ──────
EntityEvents.death(event => {
const src = event.source.player;
if (!src) return;
const player = src;
const killedId = event.entity.type;
const active = getActiveMap(player);
let updated = false;
for (const id of active.getAllKeys()) {
const entry = active.getCompound(id);
if (entry.getString('type') !== 'kill') continue;
if (entry.getString('targetId') !== killedId) continue;
if (entry.getBoolean('ready')) continue;
const newProgress = entry.getInt('progress') + 1;
entry.putInt('progress', newProgress);
if (newProgress >= entry.getInt('target')) {
entry.putBoolean('ready', true);
player.tell(Text.gold(`Bounty READY: ${entry.getString('name')}. Turn in with /bns bounty turnin ${id}`));
} else {
// Light progress notification every few kills to not spam
if (newProgress === 1 || newProgress === entry.getInt('target') - 1 || newProgress % 5 === 0) {
player.tell(Text.gray(`Bounty progress: ${entry.getString('name')}${newProgress}/${entry.getInt('target')}`));
}
}
updated = true;
}
});
// ─── Commands ──────────────────────────────────────────────────────
ServerEvents.commandRegistry(event => {
const { commands: Commands, arguments: Arguments } = event;
const bountyCmd = Commands.literal('bns')
.then(Commands.literal('bounty')
// /bns bounty list
.then(Commands.literal('list').executes(ctx => {
const player = ctx.source.player;
if (!player) { ctx.source.sendSystemMessage(Text.red('Run from a player context.')); return 0; }
ensurePoolFresh(player);
const pool = getPoolList(player);
const cap = playerBountySlotCap(player);
const active = activeBountyCount(player);
player.tell(Text.aqua(`--- Today's bounties (active: ${active}/${cap}) ---`));
if (pool.getAllKeys().size() === 0) {
player.tell(Text.gray(' (No bounties currently available — check back in 24h)'));
return 1;
}
for (const id of pool.getAllKeys()) {
const b = BOUNTY_INDEX[id];
if (!b) continue;
const accepted = getActiveMap(player).contains(id);
const tag = accepted ? '§a[ACCEPTED]§7' : '§7';
player.tell(Text.gray(` ${tag} ${id} "${b.name}" reward: ${b.reward} spurs`));
}
player.tell(Text.gray('Accept: /bns bounty accept <id>'));
return 1;
}))
// /bns bounty active
.then(Commands.literal('active').executes(ctx => {
const player = ctx.source.player;
if (!player) { ctx.source.sendSystemMessage(Text.red('Run from a player context.')); return 0; }
const active = getActiveMap(player);
const cap = playerBountySlotCap(player);
player.tell(Text.aqua(`--- Active bounties (${active.getAllKeys().size()}/${cap}) ---`));
if (active.getAllKeys().size() === 0) {
player.tell(Text.gray(' (No active bounties — accept some with /bns bounty list)'));
return 1;
}
for (const id of active.getAllKeys()) {
const e = active.getCompound(id);
const flag = e.getBoolean('ready') ? '§e[READY]§7' : '§7';
player.tell(Text.gray(
` ${flag} ${id} "${e.getString('name')}" ${e.getInt('progress')}/${e.getInt('target')} reward: ${e.getInt('reward')} spurs`
));
}
player.tell(Text.gray('Turn in: /bns bounty turnin <id> Cancel: /bns bounty cancel <id>'));
return 1;
}))
// /bns bounty accept <id>
.then(Commands.literal('accept').then(Commands.argument('id', Arguments.STRING.create(event))
.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');
ensurePoolFresh(player);
const pool = getPoolList(player);
if (!pool.contains(id)) {
player.tell(Text.red(`Bounty ${id} isn't in your pool today. Use /bns bounty list.`));
return 0;
}
if (getActiveMap(player).contains(id)) {
player.tell(Text.red(`You've already accepted ${id}.`));
return 0;
}
const cap = playerBountySlotCap(player);
if (activeBountyCount(player) >= cap) {
player.tell(Text.red(`You're at your bounty cap (${cap}). Turn in or cancel one first.`));
return 0;
}
const b = BOUNTY_INDEX[id];
addActiveBounty(player, b);
player.tell(Text.gold(`Accepted: ${b.name}. Reward: ${b.reward} spurs on turn-in.`));
console.info(`[bns/bounty] ${player.username} accepted ${id}`);
return 1;
})))
// /bns bounty cancel <id>
.then(Commands.literal('cancel').then(Commands.argument('id', Arguments.STRING.create(event))
.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 active = getActiveMap(player);
if (!active.contains(id)) {
player.tell(Text.red(`You haven't accepted ${id}.`));
return 0;
}
removeActiveBounty(player, id);
markCooldown(player, id);
player.tell(Text.gold(`Cancelled ${id}. Cooldown applied (24h) — quest will return to your pool tomorrow.`));
console.info(`[bns/bounty] ${player.username} cancelled ${id}`);
return 1;
})))
// /bns bounty turnin <id>
.then(Commands.literal('turnin').then(Commands.argument('id', Arguments.STRING.create(event))
.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 active = getActiveMap(player);
if (!active.contains(id)) {
player.tell(Text.red(`You haven't accepted ${id}.`));
return 0;
}
const entry = active.getCompound(id);
if (!entry.getBoolean('ready')) {
player.tell(Text.red(
`${entry.getString('name')} isn't complete yet (${entry.getInt('progress')}/${entry.getInt('target')}).`
));
return 0;
}
const reward = entry.getInt('reward');
removeActiveBounty(player, id);
markCooldown(player, id);
giveCoins(player, reward); // shared helper from plots.js
player.tell(Text.gold(`Turn-in: ${entry.getString('name')} — paid ${reward} spurs.`));
console.info(`[bns/bounty] ${player.username} turned in ${id} for ${reward} spurs`);
return 1;
})))
// /bns bounty completed
.then(Commands.literal('completed').executes(ctx => {
const player = ctx.source.player;
if (!player) { ctx.source.sendSystemMessage(Text.red('Run from a player context.')); return 0; }
const completed = getCompletedMap(player);
const now = nowMs();
player.tell(Text.aqua(`--- Completed / cooldown bounties ---`));
if (completed.getAllKeys().size() === 0) {
player.tell(Text.gray(' (None yet)'));
return 1;
}
for (const id of completed.getAllKeys()) {
const until = completed.getLong(id);
const b = BOUNTY_INDEX[id];
const name = b ? b.name : id;
if (until <= now) {
player.tell(Text.gray(` ${id} "${name}" cooldown expired`));
} else {
const hrs = Math.floor((until - now) / 3600000);
const min = Math.floor(((until - now) % 3600000) / 60000);
player.tell(Text.gray(` ${id} "${name}" cooldown ${hrs}h ${min}m`));
}
}
return 1;
}))
);
event.register(bountyCmd);
});
console.info('[bns/bounty_engine] loaded — ' + BOUNTY_POOL.length + ' bounties in catalogue');
@@ -0,0 +1,146 @@
// Brass and Sigil -- player-facing economy commands
//
// /bns help overview of every /bns subcommand
// /bns me quick economic status (tier, balance, plots, bounties)
// /bns tier upgrade pay spurs to advance to the next tier
//
// Player-facing tier upgrade pays spurs, increments the NBT tier, and
// runs `ftbranks add <player> <rankId>` so FTB Ranks applies the rank's
// permission nodes (ftbchunks.max_claimed_chunks etc.).
// FTB Ranks rank ID per tier number (must match
// pack/overrides/world/serverconfig/ftbranks/ranks.snbt)
const TIER_RANK_ID = [
null, // 0 unused
'peasant',
'farmer',
'citizen',
'merchant',
'knight',
'baron',
'viscount',
'earl',
'marquess',
'duke',
'archduke',
'grand_duke',
'sovereign',
];
ServerEvents.commandRegistry(event => {
const { commands: Commands } = event;
const playerCmds = Commands.literal('bns')
// /bns help
.then(Commands.literal('help').executes(ctx => {
const lines = [
'§6═══ Brass and Sigil — economy commands ═══',
'§eGeneral:',
'§7 /bns help this list',
'§7 /bns me your economy status',
'§eCivic tier:',
'§7 /bns tier upgrade pay spurs to advance to next tier',
'§eSpawn plots:',
'§7 /bns plot list browse available plots',
'§7 /bns plot info <id> details for one plot',
'§7 /bns plot buy <id> purchase a plot (uses a tier slot)',
'§7 /bns plot release <id> release a plot (50% refund)',
'§7 /bns plot mine list plots you own',
'§eBazaar (sell raw commodities):',
'§7 /bns sell list prices for items you can sell',
'§7 /bns sell <item-id> [count] sell items from your inventory',
'§eBounties:',
'§7 /bns bounty list today\'s available bounties',
'§7 /bns bounty active your accepted bounties',
'§7 /bns bounty accept <id> accept a bounty',
'§7 /bns bounty cancel <id> drop a bounty (cooldown applies)',
'§7 /bns bounty turnin <id> claim reward for a completed bounty',
'§7 /bns bounty completed bounties on cooldown',
'§eAdmin (op level 2):',
'§7 /bns admin info your raw NBT state',
'§7 /bns admin tier get|set|list',
'§7 /bns admin waystone-count|waystone-set|reset-welcome',
];
for (const l of lines) ctx.source.sendSystemMessage(Text.of(l));
return 1;
}))
// /bns me — quick status
.then(Commands.literal('me').executes(ctx => {
const player = ctx.source.player;
if (!player) { ctx.source.sendSystemMessage(Text.red('Run from a player context.')); return 0; }
const tier = bnsTier.get(player);
const cfg = bnsTier.config(tier);
const balance = countCoins(player); // shared helper from plots.js
const ownedPlots = plotsOwnedBy(player.uuid.toString());
const plotCap = playerPlotSlotCap(player);
const activeBounties = getActiveMap(player);
const bountyCap = playerBountySlotCap(player);
const feePct = bnsTier.effectiveShopFeePct(player);
const lines = [
'§6═══ ' + player.username + ' — economy status ═══',
'§eCivic tier: §f' + tier + '. ' + cfg.name,
'§eSpur balance: §f' + balance,
'§ePlots owned: §f' + ownedPlots.length + ' / ' + plotCap,
'§eActive bounties: §f' + activeBounties.getAllKeys().size() + ' / ' + bountyCap,
'§eBazaar fee: §f' + feePct + '%',
'§eWilderness cap: §f' + cfg.wildernessChunks + ' chunks',
];
if (tier < 13) {
const next = bnsTier.config(tier + 1);
lines.push('§eNext tier: §f' + next.name + ' (' + next.cost + ' spurs to upgrade)');
} else {
lines.push('§eNext tier: §f— you are Sovereign, the peak of the realm.');
}
for (const l of lines) player.tell(Text.of(l));
return 1;
}))
// /bns tier upgrade
.then(Commands.literal('tier').then(Commands.literal('upgrade').executes(ctx => {
const player = ctx.source.player;
if (!player) { ctx.source.sendSystemMessage(Text.red('Run from a player context.')); return 0; }
const cur = bnsTier.get(player);
if (cur >= bnsTier.MAX) {
player.tell(Text.gold('You\'re already Sovereign. There is no higher rank.'));
return 0;
}
const nextTier = cur + 1;
const nextCfg = bnsTier.config(nextTier);
const balance = countCoins(player);
if (balance < nextCfg.cost) {
player.tell(Text.red(
'Upgrade to ' + nextCfg.name + ' costs ' + nextCfg.cost + ' spurs. You have ' + balance + '.'
));
return 0;
}
const took = takeCoins(player, nextCfg.cost);
if (took < nextCfg.cost) {
giveCoins(player, took);
player.tell(Text.red('Coin removal failed — refunded. Try again.'));
return 0;
}
// Promote via FTB Ranks
const rankId = TIER_RANK_ID[nextTier];
Utils.server.runCommandSilent('ftbranks add ' + player.username + ' ' + rankId);
// Mirror to NBT
bnsTier.set(player, nextTier);
player.tell(Text.gold(
'╔══ You have been elevated to ' + nextCfg.name + '! ══╗'
));
player.tell(Text.gold(
' ' + nextCfg.wildernessChunks + ' wilderness chunks · ' +
nextCfg.plotSlots + ' plot slots · ' +
nextCfg.dailyBountySlots + ' bounty slots · ' +
(Math.max(0, 25 + nextCfg.shopFeeDiscount)) + '% Bazaar fee'
));
console.info('[bns/tier] ' + player.username + ' upgraded to tier ' + nextTier + ' (' + nextCfg.name + ') for ' + nextCfg.cost + ' spurs');
return 1;
})));
event.register(playerCmds);
});
console.info('[bns/player_commands] loaded — /bns help, /bns me, /bns tier upgrade');
+71 -15
View File
@@ -135,12 +135,25 @@ function ownerOf(plotId) {
return map.contains(plotId) ? map.getString(plotId) : null;
}
function plotOwnedBy(playerUuid) {
function plotsOwnedBy(playerUuid) {
const map = getOwnerMap();
const owned = [];
for (const key of map.getAllKeys()) {
if (map.getString(key) === playerUuid) return key;
if (map.getString(key) === playerUuid) owned.push(key);
}
return null;
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) {
@@ -247,10 +260,18 @@ ServerEvents.commandRegistry(event => {
player.tell(Text.red(`Plot ${id} is already owned.`));
return 0;
}
const existing = plotOwnedBy(player.uuid.toString());
if (existing) {
// Tier-based slot cap.
const owned = plotsOwnedBy(player.uuid.toString());
const cap = playerPlotSlotCap(player);
if (cap <= 0) {
player.tell(Text.red(
`You already own plot ${existing}. Release it first with /bns plot release.`
`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;
}
@@ -271,33 +292,68 @@ ServerEvents.commandRegistry(event => {
recordPurchase(id, player.uuid.toString());
claimChunksForPlayer(plot, player);
player.tell(Text.gold(
`Plot ${id} (${plot.name}) is now yours. Paid ${plot.price} spurs.`
`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`);
console.info(`[bns/plots] ${player.username} bought ${id} for ${plot.price} spurs (slot ${owned.length + 1}/${cap})`);
return 1;
})))
// /bns plot release
.then(Commands.literal('release').executes(ctx => {
// /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 = plotOwnedBy(player.uuid.toString());
if (!id) {
player.tell(Text.red(`You don't own a plot.`));
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}).`
`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`);
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;
}))
@@ -0,0 +1,122 @@
{
# Brass and Sigil 13-tier civic ladder.
# Permission nodes:
# ftbchunks.max_claimed_chunks wilderness chunk allowance
# ftbchunks.max_force_loaded_chunks force-load allowance (~15% of claim cap)
# ftbranks.name_format chat colour for this rank
# Mirrors TIER_CONFIG in pack/overrides/kubejs/server_scripts/economy/00_tier_system.js.
# Tier upgrade flow: KubeJS sets player.persistentData.bnsTier AND runs
# /ftbranks add <player> <rankId> to apply this rank's permission nodes.
peasant: {
name: "Peasant"
power: 1
condition: "always_active"
ftbchunks.max_claimed_chunks: 9
ftbchunks.max_force_loaded_chunks: 1
ftbranks.name_format: "&7{name}"
}
farmer: {
name: "Farmer"
power: 2
ftbchunks.max_claimed_chunks: 18
ftbchunks.max_force_loaded_chunks: 2
ftbranks.name_format: "&7{name}"
}
citizen: {
name: "Citizen"
power: 3
ftbchunks.max_claimed_chunks: 35
ftbchunks.max_force_loaded_chunks: 4
ftbranks.name_format: "&f{name}"
}
merchant: {
name: "Merchant"
power: 4
ftbchunks.max_claimed_chunks: 60
ftbchunks.max_force_loaded_chunks: 8
ftbranks.name_format: "&e{name}"
}
knight: {
name: "Knight"
power: 5
ftbchunks.max_claimed_chunks: 100
ftbchunks.max_force_loaded_chunks: 12
ftbranks.name_format: "&a{name}"
}
baron: {
name: "Baron"
power: 6
ftbchunks.max_claimed_chunks: 150
ftbchunks.max_force_loaded_chunks: 18
ftbranks.name_format: "&b{name}"
}
viscount: {
name: "Viscount"
power: 7
ftbchunks.max_claimed_chunks: 220
ftbchunks.max_force_loaded_chunks: 26
ftbranks.name_format: "&9{name}"
}
earl: {
name: "Earl"
power: 8
ftbchunks.max_claimed_chunks: 300
ftbchunks.max_force_loaded_chunks: 36
ftbranks.name_format: "&5{name}"
}
marquess: {
name: "Marquess"
power: 9
ftbchunks.max_claimed_chunks: 400
ftbchunks.max_force_loaded_chunks: 50
ftbranks.name_format: "&6{name}"
}
duke: {
name: "Duke"
power: 10
ftbchunks.max_claimed_chunks: 525
ftbchunks.max_force_loaded_chunks: 65
ftbranks.name_format: "&6{name}"
}
archduke: {
name: "Archduke"
power: 11
ftbchunks.max_claimed_chunks: 700
ftbchunks.max_force_loaded_chunks: 85
ftbranks.name_format: "&c{name}"
}
grand_duke: {
name: "Grand Duke"
power: 12
ftbchunks.max_claimed_chunks: 900
ftbchunks.max_force_loaded_chunks: 110
ftbranks.name_format: "&c{name}"
}
sovereign: {
name: "Sovereign"
power: 13
ftbchunks.max_claimed_chunks: 1500
ftbchunks.max_force_loaded_chunks: 200
ftbranks.name_format: "&d{name}"
}
admin: {
name: "Admin"
power: 1000
condition: "op"
ftbranks.name_format: "&2{name}"
}
}
+2 -2
View File
@@ -1,13 +1,13 @@
{
"$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.32.0",
"version": "0.33.0",
"minecraft": "1.21.1",
"loader": {
"type": "neoforge",
"version": "21.1.228"
},
"lockedAt": "2026-06-06T23:21:32Z",
"lockedAt": "2026-06-06T23:34:21Z",
"mods": [
{
"source": "modrinth",