35cbd84146
Drops Alex's Mobs from the pack (105 -> 103 mods total). The 4 AM-mob bounty entries are replaced with vanilla 1.21.x mob bounties (Breeze, Bogged, Evoker, Vindicator, Piglin Brute, Ravager). Bounty board bug fix: KubeJS PlayerEvents.loggedIn now ensures the daily pool is rolled on first login. Previously the pool only rolled when a player ran /bns bounty list, so the bounty board screen showed empty for anyone who'd never typed that command. /bns tier upgrade now uses combined cash+bank deduction via NumismaticsBridge.spend (Java). Players who've sold a fortune into their bank can now spend it on tier upgrades directly. Every player gets a bound white_card on first login — no more "I need to craft + bind a card before I can spend at a Vendor block" friction. bnstoolkit 0.6.2 -> 0.7.0. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
360 lines
19 KiB
JavaScript
360 lines
19 KiB
JavaScript
// 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) — all vanilla 1.21.x ──
|
|
{ id: 'b_warden_1', name: 'Slay the Warden', target: 'minecraft:warden', count: 1, type: 'kill', reward: 800, source: "Adventurer's Guild" },
|
|
{ id: 'b_breeze_3', name: 'Hunt 3 Breezes', target: 'minecraft:breeze', count: 3, type: 'kill', reward: 250, source: "Adventurer's Guild" },
|
|
{ id: 'b_bogged_5', name: 'Slay 5 Bogged', target: 'minecraft:bogged', count: 5, type: 'kill', reward: 120, source: "Adventurer's Guild" },
|
|
{ id: 'b_evoker_2', name: 'Slay 2 Evokers', target: 'minecraft:evoker', count: 2, type: 'kill', reward: 350, source: "Adventurer's Guild" },
|
|
{ id: 'b_vindicator_4', name: 'Slay 4 Vindicators', target: 'minecraft:vindicator', count: 4, type: 'kill', reward: 180, source: "Adventurer's Guild" },
|
|
{ id: 'b_piglin_brute_2', name: 'Slay 2 Piglin Brutes', target: 'minecraft:piglin_brute', count: 2, type: 'kill', reward: 400, source: "Adventurer's Guild" },
|
|
{ id: 'b_ravager_1', name: 'Hunt down a Ravager', target: 'minecraft:ravager', count: 1, type: 'kill', reward: 500, 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);
|
|
});
|
|
|
|
// ─── First-login pool roll ─────────────────────────────────────────
|
|
// Without this, new players see an empty Bounty Board until they type
|
|
// /bns bounty list (which triggers ensurePoolFresh). The board reads
|
|
// bnsBountyPool from NBT; bnsBountyPool stays empty until rollDailyPool
|
|
// runs. This hook closes the gap so the screen is populated the first
|
|
// time it's opened.
|
|
PlayerEvents.loggedIn(event => {
|
|
ensurePoolFresh(event.player);
|
|
});
|
|
|
|
console.info('[bns/bounty_engine] loaded — ' + BOUNTY_POOL.length + ' bounties in catalogue');
|