Files
Matt 35cbd84146 pack: v0.40.0 — drop Alex's Mobs + bounty board fix + Numismatics polish
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>
2026-06-08 17:20:09 +00:00

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');