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:
@@ -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');
|
||||
Reference in New Issue
Block a user