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>
This commit is contained in:
Matt
2026-06-06 23:55:42 +00:00
parent b4cd66036b
commit ffff3bc78d
5 changed files with 883 additions and 15 deletions
@@ -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');