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>
This commit is contained in:
Matt
2026-06-06 23:38:30 +00:00
parent 3d635d7e59
commit b4cd66036b
2 changed files with 172 additions and 2 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');
+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",