From ffff3bc78d7ab213534ddfdf74295b6123d6a024 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 6 Jun 2026 23:55:42 +0000 Subject: [PATCH] econ: sell shop + bounty engine + player commands + FTB Ranks 13-tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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| [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 ` 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 --- .../server_scripts/economy/02_sell_shop.js | 197 ++++++++++ .../economy/03_bounty_engine.js | 347 ++++++++++++++++++ .../economy/04_player_commands.js | 146 ++++++++ pack/overrides/kubejs/server_scripts/plots.js | 86 ++++- .../world/serverconfig/ftbranks/ranks.snbt | 122 ++++++ 5 files changed, 883 insertions(+), 15 deletions(-) create mode 100644 pack/overrides/kubejs/server_scripts/economy/02_sell_shop.js create mode 100644 pack/overrides/kubejs/server_scripts/economy/03_bounty_engine.js create mode 100644 pack/overrides/kubejs/server_scripts/economy/04_player_commands.js create mode 100644 pack/overrides/world/serverconfig/ftbranks/ranks.snbt diff --git a/pack/overrides/kubejs/server_scripts/economy/02_sell_shop.js b/pack/overrides/kubejs/server_scripts/economy/02_sell_shop.js new file mode 100644 index 0000000..c63924f --- /dev/null +++ b/pack/overrides/kubejs/server_scripts/economy/02_sell_shop.js @@ -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 [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 [count] (omit count to sell 1)')); + return 1; + })) + + // /bns sell [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'); diff --git a/pack/overrides/kubejs/server_scripts/economy/03_bounty_engine.js b/pack/overrides/kubejs/server_scripts/economy/03_bounty_engine.js new file mode 100644 index 0000000..e29fb43 --- /dev/null +++ b/pack/overrides/kubejs/server_scripts/economy/03_bounty_engine.js @@ -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 +// /bns bounty cancel +// /bns bounty turnin +// /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 ')); + 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 Cancel: /bns bounty cancel ')); + return 1; + })) + + // /bns bounty accept + .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 + .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 + .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'); diff --git a/pack/overrides/kubejs/server_scripts/economy/04_player_commands.js b/pack/overrides/kubejs/server_scripts/economy/04_player_commands.js new file mode 100644 index 0000000..1d5afd8 --- /dev/null +++ b/pack/overrides/kubejs/server_scripts/economy/04_player_commands.js @@ -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 ` 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 details for one plot', + '§7 /bns plot buy purchase a plot (uses a tier slot)', + '§7 /bns plot release 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 [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 accept a bounty', + '§7 /bns bounty cancel drop a bounty (cooldown applies)', + '§7 /bns bounty turnin 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'); diff --git a/pack/overrides/kubejs/server_scripts/plots.js b/pack/overrides/kubejs/server_scripts/plots.js index f6c8557..9e2d69a 100644 --- a/pack/overrides/kubejs/server_scripts/plots.js +++ b/pack/overrides/kubejs/server_scripts/plots.js @@ -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 , 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 + // 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; })) diff --git a/pack/overrides/world/serverconfig/ftbranks/ranks.snbt b/pack/overrides/world/serverconfig/ftbranks/ranks.snbt new file mode 100644 index 0000000..95b27a8 --- /dev/null +++ b/pack/overrides/world/serverconfig/ftbranks/ranks.snbt @@ -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 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}" + } +}