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