Compare commits

..

21 Commits

Author SHA1 Message Date
Matt 016985a5fc docs: rewrite bnstoolkit handoff for feature-complete state
The original handoff doc described an empty M0 scaffold with placeholder
screens to "discuss tomorrow". 0.3.1 actually ships the full UI plus
villager trade rebalance, so the doc now is a testing guide for the
working features rather than a discussion-points list.

Includes the architecture diagram for how Java + KubeJS interact, the
key bindings table, a step-by-step testing path, and a known-gaps list
for future iteration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 02:43:42 +00:00
Matt a7f95691bb Merge pull request 'pack: v0.35.0 — bnstoolkit feature-complete UI + villager rebalance' (#72) from feature/v0.35.0-bnstoolkit-ui-full into main 2026-06-07 02:42:43 +00:00
Matt 23efdee804 pack: actually include bnstoolkit-0.3.1.jar in v0.35.0
The previous commit deleted 0.1.1 from the gitignored mods dir but
git swallowed the add for 0.3.1 because pack/overrides/mods is
ignored. Re-adding with -f.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 02:42:06 +00:00
Matt 1859613f1f pack: v0.35.0 — bnstoolkit 0.3.1 (feature-complete UI + villager rebalance)
bnstoolkit jumps from M0 (empty scaffold) straight to feature complete:

UI screens (all FTB Library widgets, opens via keybinds):
  K  Civic tier ladder + upgrade
  J  Bounty board (accept/cancel/turn-in)
  N  Plot office (buy/release)
  H  Bazaar sell shop (1/16/64 quick-sell)
  M  Quest log (status overview)

HUD: top-left tier + spurs balance overlay, refreshed 1Hz server-side.

Villager trade rebalance (NEW): the mod now hooks VillagerTradesEvent
+ WandererTradesEvent and swaps every emerald cost/result for the
Numismatics equivalent (1..7 emeralds -> spurs, 8..63 -> bevels, 64+
-> cogs). This was the blocked Modrinth dep — the third-party mod
options either required a NeoForge bump (numismatics-villager-currency
1.1.0 wants 21.1.230+, we're on 21.1.228) or hand-written JSON for
every trade (data-trades). The in-tree wrapper is cleaner.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 02:41:49 +00:00
Matt a41d8cb531 Merge pull request 'docs: bnstoolkit postmortem' (#71) from docs/bnstoolkit-discussion-postmortem into main 2026-06-07 00:19:21 +00:00
Matt e770cb55cb docs: add bnstoolkit 0.1.0 crash postmortem + manual-restart note
Surfaces the server-needs-Start state and the fix details up top so
when matt wakes up he doesn't have to scroll past the M1+ planning
to find out the box is down.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 00:19:06 +00:00
Matt 338617e855 Merge pull request 'pack: v0.34.1 — bnstoolkit hotfix 0.1.1' (#70) from hotfix/bnstoolkit-0.1.1 into main 2026-06-07 00:18:38 +00:00
Matt ecd5933f9f pack: v0.34.1 — bnstoolkit hotfix 0.1.1 (fixes mod-load crash)
0.1.0 brought the dedicated server down during construct-mods because
the @Mod constructor called NeoForge.EVENT_BUS.register(this) without
any @SubscribeEvent methods on the class — which NeoForge 21.1 treats
as a fatal IllegalArgumentException.

Caught only after live-deploy (gradle build doesn't simulate the
construct-mods step). 0.1.1 removes the bogus register call. The
@SubscribeEvent listeners will come back in M1 alongside the first
real event handler.

Server has been brought down by the crash and is waiting for a manual
start via the web admin panel. The fixed jar is staged in
/srv/fast/brass-sigil-server/server/mods/ already.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 00:18:17 +00:00
Matt 38fbf80bc1 Merge pull request 'pack: v0.34.0 — bnstoolkit M0 scaffold' (#69) from feature/v0.34.0-bnstoolkit-m0 into main 2026-06-07 00:13:28 +00:00
Matt 61dd3d3754 pack: v0.34.0 — bnstoolkit M0 scaffold
Ships the empty bnstoolkit companion mod (0.1.0) into the pack. The mod
loads cleanly on client and server with zero visible surface — it just
proves the deploy pipeline end-to-end so M1-M4 only have to add code,
not figure out tooling.

Per-milestone scope locked in docs/bnstoolkit-discussion-points-2026-06-07.md.

Source: http://10.16.5.102:3000/minecraft/bnstoolkit

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 00:13:05 +00:00
Matt 61a923cbef Merge pull request 'docs: economy V1 overnight handoff' (#68) from docs/overnight-handoff into main 2026-06-06 23:57:24 +00:00
Matt 57b6cd8dea docs: economy V1 overnight handoff
Tomorrow-evening testing guide for the command-driven economy
shipped in PR #67. Covers: what works, how to test each system,
quick reference of every /bns command, what's deferred and why,
recovery steps if anything bricks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 23:57:23 +00:00
Matt 572671f1d4 Merge pull request 'econ: sell shop + bounty engine + player commands + FTB Ranks 13-tier' (#67) from feature/v0.33.0-economy-foundation into main 2026-06-06 23:55:43 +00:00
Matt ffff3bc78d 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>
2026-06-06 23:55:42 +00:00
Matt b4cd66036b econ: tier system foundation (00_tier_system.js)
KubeJS module that owns the 13-tier civic ladder per economy doc §4a.
Authoritative source for TIER_CONFIG (per-tier perks: cost, wilderness
chunks, plot slots, daily bounty slots, shop fee discount, chat
colour, join broadcast).

Player tier stored as int in player.persistentData.bnsTier; default
1 (Peasant), set on first login.

Admin commands (op level 2):
  /bns admin tier get <player>
  /bns admin tier set <player> <1-13>
  /bns admin tier list

Module-local pattern (KubeJS Rhino runs scripts in strict mode -- no
globalThis, no bare-assignment globals). Other economy scripts will
duplicate TIER_CONFIG and use player.persistentData.bnsTier directly
to read tier rather than trying to import. See feedback-kubejs-rhino-
gotchas memory.

FTB Ranks will mirror this once the bnstoolkit mod ships -- ranks
will carry the FTB Chunks permission nodes. For now the NBT is the
single source of truth and admin promotes manually with /bns admin
tier set.

Verified: server boots clean, KubeJS loads with 0 errors. First-join
hook initialises new players to tier 1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 23:38:30 +00:00
Matt 3d635d7e59 Merge pull request 'pack: v0.32.0 — FTB Quests + XMod Compat + Ranks; restrictive travel design' (#66) from feature/v0.32.0-and-restrictive-travel-design into main 2026-06-06 23:22:48 +00:00
Matt 9eb8f540c4 pack: v0.32.0 — FTB Quests + XMod Compat + Ranks; restrictive travel design
Mods added (Phase 0a substrate for the bnstoolkit economy work):
  - FTB Quests 2101.1.25 (progression quest UI for Phase 5+)
  - FTB XMod Compat 21.1.8 (KubeJS hooks for FTB Chunks/Quests events)
  - FTB Ranks 2101.1.3 (tier system via permission nodes)

Design revision: restrictive travel.

This is a Create Aeronautics pack — the journey is the point. Removed
all instant-teleport perks from the tier ladder. Players get one
starter Waystone and use it for spawn↔base; further travel means
building airships, trains, vehicles. Pressure on real movement is
intentional.

Removed perks: sethomes, /back after death.
Removed system: the full sethome subsystem (commands, SavedData,
cooldowns) from the bnstoolkit mod scope.

Tier table reduced from 10 columns to 8: cost, wilderness chunks,
plot slots, daily bounties, shop fee, chat colour, join broadcast.

Mod phasing collapsed from 7 phases to 6 (M2 sethome system removed,
later phases shift up).

Future perk dimensions list updated with two waystone-related
candidates (slot cap, XP cost reduction) as the natural travel-
progression hooks for later tier-perk revisions.

Verified live: all 3 mods discovered + loaded, port 25565 binds,
no FATAL/Crashed/Exception lines in startup log. Server resumed
to 0.32.0 cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 23:22:47 +00:00
Matt 367056e2ba Merge pull request 'docs: bnstoolkit architecture + detailed perk specs' (#65) from docs/bnstoolkit-architecture into main 2026-06-06 23:09:35 +00:00
Matt 25107efcf1 docs: bnstoolkit architecture + detailed perk specifications
New companion-mod design doc covering:

Architecture
- Purpose & scope (what's in the mod vs what stays in KubeJS)
- High-level diagram showing client/server/KubeJS/FTB-stack roles
- Full Java package layout under uk.sijbers.bnstoolkit
- Six GUI screens specified individually with layouts + network calls
  (Quest Log, Bounty Board, Tier Upgrade, Plot Purchase, Shop Browser,
   Quest Progress HUD)
- Three mixin targets into FTB Chunks' ChunkScreenPanel (drawBackground,
  ChunkButton.addMouseOverText, ChunkButton.onClicked) -- no FTB fork
- Network packet contract (7 S2C + 5 C2S packets, versioned)
- Data persistence model (SavedData for plots + sethomes, in-memory
  for death locations, KubeJS for quest state)
- Two config files documented (tiers.json + plots.json) with example
  JSON5 schemas
- KubeJS integration surface (8 /bnstoolkit_internal commands)
- Build & release setup (separate Gitea repo, standard NeoForge
  ModDevGradle, semver)
- 6 mod implementation phases (M0-M6), each independently shippable

Detailed perk specifications
- Wilderness chunks: FTB Ranks permission node, native FTB Chunks
  enforcement, no mod-side work
- Plot slots: SavedData-backed ownership tracking, 50% refund on
  release
- Sethomes: full sethome system built in bnstoolkit (no other sethome
  mod exists in pack). Per-tier cap, 30s cooldown, /sethome /home
  /delhome /listhomes commands
- Daily bounty slots: enforced in KubeJS engine, UI greys Accept when
  at cap
- Shop fee discount: base 25% Bazaar fee reduced by tier discount.
  Only applies to NPC Bazaar shop, not player-to-player Numismatics.
  Worked example showing -25% Sovereign = 0% fee = full 10 spurs/diamond
- Chat colour: full colour ladder per tier (grey to rainbow at
  Sovereign with character-by-character cycling)
- Join broadcast: 6 levels documented (none, simple, fancy, epic,
  epic+particles, epic+spectacular) with example ASCII
- /back after death: in-memory storage, 60s cooldown, dimension-aware
- Future perk dimensions: 7 candidates the schema supports without
  code changes (particle trails, custom join sounds, custom title
  prefix, priority bounty access, tier-gated vendors, daily login
  bonus, keep-inventory chance)

Six open implementation questions flagged for resolution during
build, none blocking.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 23:09:34 +00:00
Matt 56e6006424 Merge pull request 'docs: economy v0.4 — 13-tier ladder + Sovereign 10M + Founder flag' (#64) from docs/economy-v0.4-13-tier-ladder into main 2026-06-06 23:00:45 +00:00
Matt a0aee440c3 docs: economy v0.4 -- 13-tier ladder + Sovereign 10M + Founder flag
Tier ladder extended from 8 -> 13 tiers, with cleaner historical
progression. Replaces the previous Peasant/Citizen/Patron/Notable/
Baron/Noble/Lord/Sovereign ladder which mixed commoner-economic terms
with noble terms.

New 13-tier ladder (Civic + British peerage):
  1  Peasant       free
  2  Farmer        200
  3  Citizen       750
  4  Merchant      2,500
  5  Knight        6,500       (gentry threshold)
  6  Baron         15,000      (lowest noble)
  7  Viscount      35,000
  8  Earl          75,000
  9  Marquess      150,000
  10 Duke          300,000
  11 Archduke      700,000
  12 Grand Duke    1,500,000
  13 Sovereign     10,000,000  (apex, "finished the game")

Rationale:
- Yeoman -> Farmer, Burgher -> Merchant: both replaced with
  recognizable terms while keeping the period feel.
- Earl kept as the British equivalent of Count (Viscount sits below
  Earl correctly because Viscount = vice-count).
- Top tier extended above Duke with Archduke -> Grand Duke ->
  Sovereign. Avoids singular-feeling Emperor/King/Prince titles.
- Sovereign at 10M is intentionally extreme -- months of dedicated
  play to reach, properly mythical.

Full perk table updated to spread cleanly across 13 tiers:
- Plot slots: 0/0/1/1/2/2/3/3/4/5/6/7/10
- Sethomes: 1/1/2/3/4/5/6/8/10/12/16/20/unlimited
- Daily bounties: 3/3/3/4/4/5/5/6/6/7/7/8/10
- Shop fee: 0% down to -25% at Sovereign
- Chat colours: grey -> white -> yellow -> green -> cyan -> blue
  -> purple -> gold -> orange -> red -> bright red -> rainbow

Knight (tier 5) is the gentry threshold -- the first meaningful
"I'm somebody" upgrade with colored chat, join broadcast, and /back.

Founder cosmetic flag added for matt (server owner). Permanent chat
prefix [Founder] alongside the normal rank. No gameplay perks; just
identification. Founder still grinds the regular ladder.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 23:00:45 +00:00
12 changed files with 2077 additions and 29 deletions
+622
View File
@@ -0,0 +1,622 @@
# bnstoolkit — Architecture & Perk Specification
> Status: design draft (v0.1). Not yet implemented.
> Owner: matt
> Last updated: 2026-06-06
`bnstoolkit` is the custom companion mod for the Brass and Sigil economy
system. It provides the UI layer (GUI screens, HUD), the FTB Chunks
map mixins for spawn plot integration, and the data models the economy
needs. All gameplay logic (bounty rotation, state machine, villager
trades, Numismatics integration, etc.) lives in KubeJS and is called
into by the mod via network packets.
This doc is the source of truth for the mod design. It must be
reviewed before any Java is written.
See also: [`economy-system.md`](./economy-system.md) for the gameplay
side of the design.
---
## 1. Purpose & scope
### In scope
- GUI screens built on FTB Library's UI framework (matches FTB Quests / FTB Chunks visual language)
- Mixins into FTB Chunks' map UI for spawn-plot cost overlay, hover tooltips, and click-to-purchase popups
- Custom HUD render layer for quest progress (replaces stacked vanilla bossbars)
- Data persistence for plot ownership (server-side, saved with world)
- Network packets for client↔server state sync
- Admin commands: `/bnstoolkit reload`, debugging commands
- Config-driven tier perks (`tiers.json`) and plot definitions (`plots.json`)
### Out of scope
- Bounty quest engine (lives in KubeJS)
- Villager trade rebalance (lives in KubeJS)
- Numismatics direct integration (lives in KubeJS — mod calls KubeJS commands to debit/credit)
- FTB Quests integration (FTB Quests handles its own UI for the progression campaign)
- FTB Ranks admin (FTB Ranks handles permissions directly; mod just reads them)
- **Sethome / `/back` / fast-travel features** — explicitly removed. The pack uses **Waystones** as the only travel mechanic. Restrictive travel is a design feature for a Create Aeronautics pack: airships, trains, and vehicles are the intended way to cover distance.
---
## 2. High-level architecture
```
┌──────────────────────┐
│ Player (client) │
└──────────┬───────────┘
│ keybind / NPC click / chunk click
┌───────────────────────────────────┐
│ bnstoolkit (client) │
│ • FTB Library UI screens │
│ • FTB Chunks map mixin │
│ • Quest progress HUD │
└───────────────┬───────────────────┘
│ network packets
┌───────────────────────────────────┐
│ bnstoolkit (server) │
│ • Plot ownership data │
│ • Sethome data │
│ • Network packet handlers │
│ • /bnstoolkit reload command │
└─────┬───────────────────────┬─────┘
│ │
reads │ │ calls KubeJS commands /
FTB Ranks / │ │ fires events for KubeJS
FTB Chunks / │ │ to react to
Numismatics ▼ ▼
┌───────────────┐ ┌──────────────────────┐
│ FTB / NeoForge │ │ KubeJS scripts │
│ stack │ │ • Bounty engine │
└───────────────┘ │ • Quest state │
│ • Numismatics calls │
│ • Villager trades │
│ • Tier promotion │
└──────────────────────┘
```
Key invariant: **the mod owns UI + plot data + sethomes. KubeJS owns
gameplay logic.** They communicate via network packets and KubeJS
commands. The mod never directly mutates Numismatics balances or quest
state — it asks KubeJS to do that via commands.
---
## 3. Package layout
```
bnstoolkit/
├── build.gradle NeoForge 1.21.1 + FTB Library dep
├── src/main/java/uk/sijbers/bnstoolkit/
│ ├── BnsToolkit.java Mod entry, lifecycle
│ ├── client/
│ │ ├── ClientEvents.java Keybinds, client setup
│ │ ├── screen/
│ │ │ ├── QuestLogScreen.java
│ │ │ ├── BountyBoardScreen.java
│ │ │ ├── TierUpgradeScreen.java
│ │ │ ├── PlotPurchaseScreen.java
│ │ │ ├── ShopBrowserScreen.java
│ │ │ └── widgets/ (subclass FTB Library widgets)
│ │ ├── hud/
│ │ │ └── QuestProgressHud.java IGuiOverlay
│ │ └── mixin/
│ │ ├── ChunkScreenPanelMixin.java
│ │ └── ChunkButtonMixin.java
│ ├── server/
│ │ ├── command/
│ │ │ └── BnsToolkitCommand.java /bnstoolkit reload, debug
│ │ ├── data/
│ │ │ ├── PlotRegistry.java loads plots.json
│ │ │ └── PlotOwnership.java SavedData, plot owner UUIDs
│ │ ├── event/
│ │ │ ├── PlayerJoinHandler.java tier-based join broadcast
│ │ │ └── ChatColourHandler.java tier-based chat name colouring
│ │ └── net/
│ │ └── (packet implementations — see §6)
│ ├── common/
│ │ ├── data/
│ │ │ ├── TierConfig.java loads tiers.json
│ │ │ ├── BountyDef.java DTO mirrored from KubeJS
│ │ │ ├── QuestStateView.java snapshot for UI display
│ │ │ └── PlotDef.java plot definition (region, price)
│ │ └── net/
│ │ └── BnsPacket.java network channel + payload base
│ └── resources/
│ ├── META-INF/neoforge.mods.toml
│ ├── bnstoolkit.mixins.json
│ └── assets/bnstoolkit/lang/en_us.json
└── src/main/resources/
└── data-config/ packed defaults, copied on first run
├── tiers.json
└── plots.json
```
---
## 4. Screen specifications
All screens extend FTB Library's `BaseScreen`. They share a common
header (player name, current tier, spur balance) so navigation feels
consistent.
### 4.1 Quest Log
**Opened by:** keybind (default Q+B?) or right-clicking a Quest Log
block placed at any guild building.
**Layout:** three tabs across the top — Active / Available / Completed.
- **Active tab:** list of currently-accepted bounties with progress
bars. Each row clickable; click expands a detail panel with
description, source guild, reward, and a `Cancel quest` button.
Cancel triggers a confirmation modal warning about cooldown.
- **Available tab:** list of bounties currently offered (rotated daily
by the KubeJS engine). Each row shows the bounty source, reward,
cooldown remaining (if any). Click → detail panel with `Accept` button.
- **Completed tab:** historical log of completed bounties this week,
for satisfaction value. No interaction beyond viewing.
**Network calls:**
- On open: client requests current quest state from server (`S2CQuestSnapshot`)
- On accept/cancel click: `C2SAcceptBounty(id)` / `C2SCancelQuest(id)`
### 4.2 Bounty Board
**Opened by:** right-clicking the Adventurer's Guild Quartermaster
NPC (Easy NPC dialog action runs `/bnstoolkit openboard adventurers_guild`)
OR clicking an in-world `bnstoolkit:bounty_board` block.
**Layout:** poster-board style — the day's available bounties as
visual "cards" laid out in a grid, each with mob icon, name,
description, reward, accept button. Friendly to non-tech users.
**Network calls:**
- On open: `S2CBountyBoardSnapshot(guild_id)` returns today's bounty list
- On accept: same `C2SAcceptBounty(id)` as Quest Log
### 4.3 Tier Upgrade Screen
**Opened by:** right-clicking the Civic Office Magistrate NPC.
**Layout:** full ladder shown as a vertical or horizontal track. Your
current tier highlighted (glowing badge). Next tier shows cost +
delta perks (e.g. "+50 chunks, +1 plot slot, +2 sethomes, +1 bounty,
-2% shop fee, chat colour: yellow"). Big `Upgrade to <Tier>` button
at the bottom, greyed out if you don't have the spurs.
Hovering any future tier shows its full perk list in a side panel.
**Network calls:**
- On open: `S2CTierSnapshot` returns current tier + balance
- On upgrade click: `C2SRequestTierUpgrade` → server verifies balance,
calls KubeJS `/bnstoolkit_internal upgrade_rank <uuid>` which
charges spurs and runs `ftbranks add <player> <tier>`
### 4.4 Plot Purchase Popup
**Opened by:** clicking an unowned plot region in the FTB Chunks map
UI (via the mod's mixin into `ChunkScreenPanel`).
**Layout:** modal overlay on top of the FTB Chunks map. Shows: plot
ID, plot type (standard/premium), region size in chunks, owner (none),
price. Your current spur balance. Your remaining plot slots (e.g.
"2 of 3 used"). `Purchase` and `Cancel` buttons. If you're at your
slot cap or don't have enough spurs, `Purchase` is greyed out with
the reason shown.
**Network calls:**
- On purchase: `C2SBuyPlot(plot_id)` → server checks slot capacity,
asks KubeJS to charge spurs, on success records ownership in
`PlotOwnership` SavedData.
### 4.5 Shop Browser
**Opened by:** right-clicking the Bazaar Master NPC at the Merchant's
Bazaar (provides a curated view of all currently-listed shop blocks
+ NPC sell shop).
**Layout:** searchable item list with current prices, sort by
category. Click an item to see who's selling it / where the shop
block is located. The NPC sell shop (diamonds, netherite etc.) shows
as a special "Official Bazaar" category with fixed prices and the
current diminishing-return multiplier per item.
**Note:** individual Numismatics shop blocks keep their native UI
when right-clicked — this Bazaar Master is the *curated central
view*, not a replacement for shop block interaction.
**Network calls:**
- On open: `S2CShopBrowserSnapshot` (asks KubeJS for current listings)
- Selecting an item → `S2CShopItemDetail(item_id)` for full info
### 4.6 Quest Progress HUD
**Render layer:** `IGuiOverlay`, drawn over the vanilla HUD.
**Layout:** small panel in top-right (configurable position) showing
each active quest as a row — quest name + progress bar + N/M numeric.
Fades on inactive (no progress in last 30s) so it doesn't visually
clutter when you're not actively bounty-hunting. Re-appears the
moment any quest progresses.
**State source:** KubeJS pushes updates via `S2CHudQuestUpdate` packet
when any quest's progress changes (debounced to once per second
maximum per quest). The HUD reads from a client-side cache.
---
## 5. Mixin targets
Minimal — we don't fork FTB Chunks, we inject only what we need.
| Target | Method | Purpose | Mixin file |
|---|---|---|---|
| `ChunkScreenPanel` | `drawBackground` (TAIL) | Overlay drawing — cost preview for the currently-hovered or selected chunk, "Your plot slots: N of M" text in a corner | `ChunkScreenPanelMixin.java` |
| `ChunkScreenPanel$ChunkButton` | `addMouseOverText` (TAIL) | Append "Cost: N spurs" or "Plot: <name>" to the per-chunk hover tooltip | `ChunkButtonMixin.java` |
| `ChunkScreenPanel$ChunkButton` | `onClicked` (HEAD, with cancellation if applicable) | If the clicked chunk is a plot region, open `PlotPurchaseScreen` instead of triggering a regular claim | `ChunkButtonMixin.java` |
That's it. Three injection points across two mixin files. No
modification of FTB Library, FTB Quests, FTB Ranks, or FTB Teams.
---
## 6. Network packet contract
NeoForge `PayloadRegistrar`. All packets versioned via a single
`PROTOCOL_VERSION` constant; bump on schema change.
### Server → Client
| Packet | Payload | When sent |
|---|---|---|
| `S2CQuestSnapshot` | `List<QuestStateView>` | On Quest Log open, on any quest-state change |
| `S2CBountyBoardSnapshot` | `List<BountyDef>` (5-10 entries) | On Bounty Board open |
| `S2CTierSnapshot` | `(currentTier, balance, perksByTier)` | On Tier Upgrade Screen open |
| `S2CShopBrowserSnapshot` | `List<ShopListing>` | On Shop Browser open |
| `S2CHudQuestUpdate` | `(questId, progress, target)` | On quest progress (debounced 1s) |
| `S2CPlotPurchaseResult` | `(success, message)` | After `C2SBuyPlot` resolves |
| `S2CTierUpgradeResult` | `(success, message)` | After `C2SRequestTierUpgrade` resolves |
### Client → Server
| Packet | Payload | What server does |
|---|---|---|
| `C2SAcceptBounty` | `questId` | Call `/bnstoolkit_internal accept_bounty <uuid> <questId>` (KubeJS) |
| `C2SCancelQuest` | `questId` | Call `/bnstoolkit_internal cancel_quest <uuid> <questId>` (KubeJS) |
| `C2SBuyPlot` | `plotId` | Verify slot capacity, ask KubeJS to debit spurs, record ownership |
| `C2SRequestTierUpgrade` | (none — server knows player) | Verify balance, ask KubeJS to debit + promote |
| `C2SOpenBoard` | `guildId` (optional context) | Reply with appropriate snapshot |
All C2S packets include the player implicitly (NeoForge passes
the `ServerPlayer` in the handler).
---
## 7. Data persistence
| Data | Storage | Lifetime |
|---|---|---|
| Plot ownership | `bnstoolkit:plots` — vanilla NeoForge `SavedData` attached to overworld | Persists with world. Authoritative source. |
| Quest state | KubeJS-managed JSON at `world/data/bns_economy/quests/<uuid>.json` | Not the mod's concern — the mod only reads via packets |
| Tier configuration | `config/bnstoolkit/tiers.json` (hot-reloadable) | Until edited |
| Plot definitions | `config/bnstoolkit/plots.json` (hot-reloadable via `/bnstoolkit reload`) | Until edited |
Plot ownership is server-authoritative. Clients only get views via
packets — they never write to these stores directly.
---
## 8. Configuration files
### `config/bnstoolkit/tiers.json`
Defines the 13-tier ladder. Hot-reloadable.
```json5
{
"tiers": [
{
"id": "peasant",
"displayName": "Peasant",
"cost": 0,
"perks": {
"wildernessChunks": 9,
"plotSlots": 0,
"dailyBountySlots": 3,
"shopFeeDiscount": 0,
"chatColour": "grey",
"joinBroadcast": "none"
}
},
/* ... 12 more tiers ... */
]
}
```
### `config/bnstoolkit/plots.json`
Defines spawn plot regions. Each plot has a unique ID, region bounds
(chunk coordinates), type (standard/premium), and price.
```json5
{
"plots": [
{
"id": "bazaar_stall_01",
"displayName": "Bazaar Stall 1",
"type": "standard",
"price": 2000,
"chunks": [[10, 20], [10, 21]], // chunk coords, inclusive
"world": "minecraft:overworld"
},
{
"id": "bazaar_corner_premium",
"displayName": "Premium Corner Plot",
"type": "premium",
"price": 5000,
"chunks": [[15, 25], [15, 26], [16, 25], [16, 26]],
"world": "minecraft:overworld"
}
]
}
```
---
## 9. KubeJS integration surface
The mod calls into KubeJS via custom commands registered by KubeJS
(`ServerEvents.commandRegistry`). All have the `bnstoolkit_internal`
prefix to mark them as not-for-player-use:
| Command | Purpose |
|---|---|
| `/bnstoolkit_internal accept_bounty <uuid> <questId>` | Mark quest as ACTIVE in KubeJS state |
| `/bnstoolkit_internal cancel_quest <uuid> <questId>` | Cancel quest, apply cooldown |
| `/bnstoolkit_internal complete_quest <uuid> <questId>` | Mark quest READY (for testing/manual) |
| `/bnstoolkit_internal turn_in_quest <uuid> <questId>` | Pay reward via Numismatics |
| `/bnstoolkit_internal debit <uuid> <amount> <reason>` | Charge spurs (used by plot purchase, tier upgrade) |
| `/bnstoolkit_internal credit <uuid> <amount> <reason>` | Pay spurs |
| `/bnstoolkit_internal upgrade_rank <uuid> <tierId>` | Promote via FTB Ranks |
| `/bnstoolkit_internal get_balance <uuid>` | Returns current spur balance (used for screen header) |
All commands return a JSON result via the command output that the
mod parses. KubeJS handles the actual Numismatics + FTB Ranks calls
(it already has the `Java.loadClass` helpers from the economy KubeJS
scripts).
KubeJS in turn pushes events to the mod via custom payload packets
when quest state changes (so the HUD can update without polling).
---
## 10. Build & release
- **Repo:** new private Gitea repo `minecraft/bnstoolkit` (separate
from the modpack repo for clean release-versioning, but mirrored
to the modpack's `pack/overrides/mods/` on each release)
- **Build:** standard NeoForge 1.21.1 ModDevGradle template
- **Dependencies:** Architectury API (provided), FTB Library
(provided), Numismatics (compileOnly), FTB Chunks (compileOnly)
- **CI:** Gitea Actions runs `./gradlew build` on push to main,
uploads the jar as a release artifact
- **Modpack integration:** the modpack's bump-version flow includes
pulling the latest bnstoolkit release jar and updating
`pack.lock.json` / manifest like any other mod
- **Versioning:** semantic — `1.0.0` for V1 ship, `1.x.y` for
patches, `2.0.0` if we ever change the network protocol or data
format
---
## 11. Perk specifications (detailed)
Every perk listed in the 13-tier ladder, expanded with mechanics,
implementation, and edge cases.
### 11.1 Wilderness chunks
**What it does:** caps how many wilderness chunks (outside spawn)
a player can claim via the FTB Chunks map. Claiming within the cap
is free; over the cap is refused with an in-game message.
**Implementation:** FTB Ranks permission node
`ftbchunks.max_claimed_chunks` set per rank in the FTB Ranks config
(written by the mod on first launch from `tiers.json`). FTB Chunks
reads this permission natively — no mod-side enforcement needed.
**Edge case:** if a player downgrades (which they can't normally,
but might happen via op command), they keep already-claimed chunks
over the new cap, but can't claim more until they free some.
### 11.2 Plot slots
**What it does:** caps how many spawn plots (predefined regions at
spawn, see `plots.json`) a player can own concurrently. Independent
of wilderness chunks.
**Implementation:** stored in `PlotOwnership` SavedData. On purchase,
mod checks `playerOwnedPlots.size() < tier.plotSlots`. If at cap,
the Plot Purchase Popup shows the slot count and disables the
`Purchase` button.
**Releasing a plot:** players can sell their plot back via a "Release"
button in the plot-detail screen (opens when they click their own
plot in the map). Releases the slot. Refund: 50% of purchase price.
### 11.3 Daily bounty slots
**What it does:** how many bounties a player can have ACTIVE
simultaneously. Once the slot is freed (by completion or cancel),
the player can accept another from any guild board.
**Implementation:** enforced in KubeJS bounty engine. The mod's
Bounty Board and Quest Log screens read the slot count via packet
and grey out the Accept button when at cap.
**Edge case:** a quest in READY state still counts against your
active slots until you turn it in. This is intentional — pushes
players to actually visit the NPC to claim.
### 11.4 Shop fee discount
**What it does:** reduces the "merchant cut" deducted when you sell
items at the Bazaar's NPC sell shop. The Bazaar charges a base 25%
fee on all sales; your tier provides a discount that reduces this
toward zero.
**Worked example:** you sell a diamond worth 10 spurs at the Bazaar.
- Peasant (0% discount): 25% fee → you receive 7.5 spurs (rounded
down to 7).
- Knight (-5% discount): 20% fee → 8 spurs.
- Sovereign (-25% discount): 0% fee → full 10 spurs.
**Implementation:** KubeJS sell-shop handler reads the seller's tier
and computes effective payout. Rounded down to nearest integer.
**Edge case:** player-to-player Numismatics shop blocks do NOT
apply this fee — those are direct peer transactions, no Bazaar
middleman. The fee only applies to the official Bazaar sell shop.
### 11.5 Chat colour
**What it does:** the player's name in chat appears in this colour,
visible to everyone. Visual rank marker.
**Implementation:** KubeJS `PlayerEvents.chat` event modifies the
chat component for the player's display name based on their tier
permission (read from FTB Ranks). The mod also colours the player's
nameplate above their head (via a small client packet on tier change
that other clients use to set the display name colour locally).
**Colour ladder:**
- 1-2 Peasant/Farmer: grey
- 3 Citizen: white
- 4 Merchant: yellow
- 5 Knight: green
- 6 Baron: cyan
- 7 Viscount: blue
- 8 Earl: purple
- 9 Marquess: gold
- 10 Duke: orange
- 11 Archduke: red
- 12 Grand Duke: bright red
- 13 Sovereign: rainbow (animated via colour-cycling text component)
**Edge case:** the Founder cosmetic `[Founder]` prefix sits before
the coloured name, in a distinct colour (probably bright yellow or
white-bold). Sovereign + Founder is visually busy but acceptable —
only one player on the server will have both.
### 11.6 Join broadcast
**What it does:** when the player logs in, this controls what message
goes to everyone else on the server.
**Levels:**
- **none:** standard vanilla `<player> joined the game`.
- **simple:** rank-prefixed message in their chat colour. Example:
```
[Knight] Matt has logged in.
```
- **fancy:** multi-line, with a separator. Example:
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[Viscount] Matt has arrived
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
- **epic:** multi-line, with a sound effect (e.g. trumpet fanfare,
configurable per-tier). Larger ASCII separator. Possibly an ASCII
art banner of the rank.
- **epic + particles:** as epic, plus a 5-second particle effect at
the player's spawn location (firework spark, end-rod sparkle, etc.)
for everyone nearby to see.
- **epic + spectacular:** as epic + particles, plus a server-wide
effect — actionbar message visible to everyone, brief light-up of
the sky if Sovereign joins.
**Implementation:** KubeJS `PlayerEvents.loggedIn` reads tier and
dispatches the appropriate message. Particle/sound effects are
server-side commands (`/particle`, `/playsound`).
### 11.7 Future perk dimensions (not in V1, but easy to add later)
Architecture supports adding new perk columns in `tiers.json`
without code changes (the schema is open-ended). Candidates for
later expansion:
- **Waystone slot cap** — limit how many waystones a player can place
and/or own. Starter waystone is granted; higher tiers unlock 2nd,
3rd, etc. Cleanest in-theme perk for a Create Aeronautics pack
where restrictive travel is intentional.
- **Waystone XP cost reduction** — vanilla Waystones charges XP per
teleport. Tier-based discount on this cost (or eventual exemption
for Sovereign).
- **Particle trail** — high tiers leave a visible particle wake when
walking
- **Custom join sound** — different sound effect per tier on login
- **Custom title prefix** — text added before chat name beyond the
colour (e.g. `⚔ Knight Matt`)
- **Priority bounty access** — higher tiers see a larger pool of
daily bounties to pick from
- **Tier-gated vendor stock** — specific Numismatics shops only
visible to Baron+ etc.
- **Daily login bonus** — small spur reward for logging in, scaling
by tier
- **Death keep-inventory chance** — RNG % to keep inventory on death
(controversial, gate carefully)
---
## 12. Open implementation questions
These can be deferred until they become blocking, but worth flagging:
1. **Sovereign tier rainbow chat name** — Minecraft text components
don't natively support gradient text. Implementation options:
(a) cycle through colours character by character, or (b) animate
via packet on a tick interval. Choose during Phase 0c scaffolding.
2. **Plot release refund percentage** — proposed 50%. Tune via
playtest.
3. **Sethome cooldown values per tier** — proposed scaling from 60s
(low tiers) to 5s (Sovereign). Confirm in `tiers.json` schema.
4. **`/back` interaction with dimension changes** — does dying in
the End and `/back`ing return you to the End or to overworld?
Default: returns to wherever you died including the dimension.
5. **Founder cosmetic prefix colour** — bright yellow? Bold white?
Animated? Settle during implementation.
6. **Sovereign spectacular join effect** — exact effects need design
pass (sky flash? Server-wide actionbar text?). Tune in
`tiers.json`.
---
## 13. Implementation phases (mod-specific)
Aligned with the economy doc's Phase 0c (mod scaffolding):
| Mod phase | Deliverable |
|---|---|
| **M0** | Gradle project, mod metadata, FTB Library dep wired, network channel registered, empty placeholder for each screen class. Builds + loads cleanly into the running pack. |
| **M1** | Tier system: TierConfig loads from `tiers.json`, `TierUpgradeScreen` works, perks applied via FTB Ranks promotion command. Founder cosmetic prefix. Chat colour. Join broadcast at all tiers. |
| **M2** | Plot system: `PlotRegistry` + `PlotOwnership`, FTB Chunks mixins, `PlotPurchaseScreen`. KubeJS integration for spur debit. |
| **M3** | Quest UI: `QuestLogScreen`, `BountyBoardScreen`, `QuestProgressHud`. Network packets between KubeJS bounty engine and the mod. |
| **M4** | Shop browser: `ShopBrowserScreen`. |
| **M5** | Polish — animations, sound effects, particle effects for high tiers, theming pass on all screens. |
Each phase is independently shippable as a `1.x.0` release of the
mod jar.
---
## See also
- [`economy-system.md`](./economy-system.md) — gameplay-side design (currency, quest mechanics, villager rebalance, etc.)
- `MEMORY.md` entries — KubeJS Rhino gotchas, defaultconfigs pattern
@@ -0,0 +1,114 @@
# bnstoolkit feature-complete — testing notes for 2026-06-07 evening
> Shipped: pack v0.35.0 / bnstoolkit 0.3.1.
> Repo: http://10.16.5.102:3000/minecraft/bnstoolkit
> Server status: ✅ live + verified at 02:41 UTC.
## What's in the build
### 5 screens (FTB Library widgets — matches FTB Quests look)
All opened via key bindings. Rebindable in vanilla controls menu under
"Brass and Sigil" category.
| Key | Screen | What it does |
|---|---|---|
| **K** | Civic Tier ladder | 13 rows, current tier highlighted. Right pane shows next tier's cost + perks + Upgrade button. Disabled if you can't afford it or are at Sovereign. |
| **J** | Bounty Board | Today's 5-bounty pool + any active set. Detail pane: target, count, reward, progress. Accept / Cancel / Turn In buttons by state. |
| **N** | Plot Office | Plot list with ownership state-coding (yours / taken / free). Detail pane: size, chunks, price. Buy / Release buttons. |
| **H** | Bazaar shop | 9-cell sell item grid. Click an item, see DR-aware effective price for current tier. Sell 1 / 16 / 64 quick buttons. |
| **M** | Quest Log | At-a-glance status: tier + balance + active bounties (with progress bars) + owned plots. |
### HUD overlay
Top-left of the screen, always visible: `<tier-colour><tier-name>` on
one line, `<balance> spurs` on the second. Refreshes 1Hz from server
push — picks up coin pickups immediately, no inventory polling.
Auto-hides when F1 is pressed or chat is open (standard NeoForge HUD
layer behavior).
### Villager trade rebalance
Every emerald cost or result in vanilla + modded villager trades is
now Numismatics:
- 1..7 emeralds → spurs
- 8..63 → bevels (1 bevel = 8 spurs)
- 64+ → cogs (1 cog = 64 spurs)
Works automatically on:
- All 14 vanilla villager professions
- Wandering trader (generic + rare)
- Any modded villager professions (we wrap the event's list, no enum
of professions needed)
This was the "blocked on API" item. Built it into bnstoolkit instead
of pulling a third-party mod, because the cleanest Modrinth option
needed NeoForge 21.1.230+ and we're on 228.
## Testing path
```
/bns me # confirm Peasant tier 1
/give @s numismatics:spur 500 # give spurs for upgrades
K # open tier ladder
# click Upgrade — should pay 200 and bump you to Farmer
/bns admin tier set <you> 5 # jump to Knight for plot testing
K # confirm new tier visible
H # open bazaar
# click diamond, click Sell 1 (you'll need a diamond in inventory)
J # open bounty board, accept one
# kill the target mobs — progress shows in chat AND the screen
# turn in via the J screen
N # open plot office, buy p001
M # quest log shows everything
# Test villager trades:
/summon villager ~ ~ ~
# Right-click the villager once it picks a profession (place a workstation)
# Trades should show spurs/bevels/cogs instead of emeralds
```
## Architecture
```
KubeJS server_scripts/economy/ ← single source of truth for state + logic
│ (chat commands via server.getCommands)
ServerEconomyBridge.java ← reads NBT, sums Numismatics coins,
routes write verbs through /bns
↑↓
4 snapshot + 2 command packets
↑↓
ClientBnsState.java ← single static state holder
Screens read this each frame
```
Source of truth stays in KubeJS. Java is just the UI layer and a thin
write-path proxy. If you want to rebalance prices or add bounties, you
do it in KubeJS — Java auto-reflects.
## Known gaps for future iteration
| Gap | Where to fix |
|---|---|
| No client toast notifications — feedback comes via KubeJS chat messages | Add a dist-isolated `ClientToastDispatcher` class (0.4.0) |
| Bazaar grid doesn't tooltip the per-unit price | One `addMouseOverText` per `ItemCell` |
| Plot office doesn't show a minimap | Render bluemap PNG snippet as background or use FTB Chunks' map widget |
| Quest log doesn't show FTB Quests progression | Hook into FTB Quests' API once first chapter ships |
| Numismatics coins as bunch — no fractional change | Bazaar pays in highest denom that fits; player converts at the Bank |
| Wanderer rare-trade rebalance is greedy (every rare item costs are converted) | Profession-aware exclusion list if anything weird sneaks through |
## File map
| File | Purpose |
|---|---|
| `pack/overrides/mods/bnstoolkit-0.3.1.jar` | The mod itself (95 KB) |
| `pack/overrides/kubejs/server_scripts/economy/*.js` | State + business logic (unchanged from v0.33.0) |
| `pack/overrides/world/serverconfig/ftbranks/ranks.snbt` | 13-tier rank config (unchanged) |
| `docs/bnstoolkit-architecture.md` | Original architecture spec |
Source repo: http://10.16.5.102:3000/minecraft/bnstoolkit (main = 14dc69f at time of pack ship).
@@ -0,0 +1,215 @@
# Economy V1 — overnight build handoff
> Built 2026-06-06 / 23:00-23:55 UTC. All shipped via PR #67 (v0.33.0).
> Server running clean at port 25565. All scripts verified loaded with 0 errors.
## TL;DR — what to test tomorrow
A full command-driven economy is live. Pure-server (no client mod yet), works in chat and over the Telegram bridge equally. Open the in-game chat or telnet and try:
```
/bns help ← top of the iceberg
/bns me ← your status
```
That alone surfaces every command available. Below is the deeper walkthrough.
---
## What ships
### 1. 13-tier civic ladder (`/bns tier upgrade`)
Every player has a tier integer (1-13) in their NBT. New joiners are auto-set to tier 1 (Peasant). Upgrading costs spurs and runs `ftbranks add <player> <rank>` to apply the rank's permission nodes (chunk allowance, name format).
| # | Title | Cost to reach | Chunk allowance | Plot slots | Bounty slots | Shop fee |
|---|---|---|---|---|---|---|
| 1 | Peasant | 0 | 9 | 0 | 3 | 25% |
| 2 | Farmer | 200 | 18 | 0 | 3 | 25% |
| 3 | Citizen | 750 | 35 | 1 | 3 | 25% |
| 4 | Merchant | 2,500 | 60 | 1 | 4 | 23% |
| 5 | Knight | 6,500 | 100 | 2 | 4 | 20% |
| 6 | Baron | 15,000 | 150 | 2 | 5 | 18% |
| 7 | Viscount | 35,000 | 220 | 3 | 5 | 16% |
| 8 | Earl | 75,000 | 300 | 3 | 6 | 14% |
| 9 | Marquess | 150,000 | 400 | 4 | 6 | 12% |
| 10 | Duke | 300,000 | 525 | 5 | 7 | 10% |
| 11 | Archduke | 700,000 | 700 | 6 | 7 | 8% |
| 12 | Grand Duke | 1,500,000 | 900 | 7 | 8 | 6% |
| 13 | Sovereign | 10,000,000 | 1,500 | 10 | 10 | 0% |
**Test it:**
- `/bns me` shows your tier + balance + everything else
- `/bns admin tier set <player> <1-13>` to jump anyone instantly
- `/bns admin tier list` shows the full table in-chat
- `/give @s numismatics:spur 64` for testing money
- `/bns tier upgrade` pays + promotes
### 2. Bazaar sell shop (`/bns sell`)
Sell raw commodities for spurs. Tier discount reduces a flat 25% Bazaar fee. Diminishing returns lower the per-unit payout as you sell more of the same item over a lifetime (10% per 64 sold, floor 50%).
| Item | Base price |
|---|---|
| Diamond | 10 spurs |
| Netherite ingot | 100 |
| Emerald | 1 |
| Gold ingot | 2 |
| Iron ingot | 1 |
| Lapis | 1 |
| Redstone | 1 |
| Coal | 1 |
| Ancient debris | 500 |
**Test it:**
- `/bns sell list` — your effective prices right now (after fee + DR)
- `/bns sell minecraft:diamond 8` sells 8 diamonds
- Sell ~70 diamonds and watch the per-unit price drop as the DR kicks in
### 3. Bounty engine (`/bns bounty`)
20 bounties in the catalogue. Each player gets a random 5 from the pool, refreshed every 24h per-player. Slot cap = your tier (3 at Peasant, up to 10 at Sovereign).
States: AVAILABLE → ACTIVE → READY → COMPLETED → (24h cooldown) → AVAILABLE.
- Kill bounties auto-track via `EntityEvents.death` — you'll get progress messages in chat
- Cancelling applies the same 24h cooldown as completing (no RNG-shopping)
Highlights from the pool:
- Slay 10 Zombies — 15 spurs
- Hunt 5 Plunderers — 75
- Slay 3 Wither Skeletons — 150
- Slay a Skreecher — 400
- Slay the Warden — 800
- Slay a Warped Mosco — 700
- Slay the Farseer — 900
- Destroy a Void Worm — 1,200
**Test it:**
- `/bns bounty list` — what's on offer for you today
- `/bns bounty accept b_zombie_10` — accept
- Kill 10 zombies — progress chat messages every 5, ready notification at 10
- `/bns bounty active` — see your accepted with progress
- `/bns bounty turnin b_zombie_10` — claim 15 spurs
### 4. Plot system (multi-slot) (`/bns plot`)
Existing plot system (3 plots at chunks 10,10 / 12,10-11 / 14-15,10-11) now supports tier-based multi-ownership. Slot cap from your tier (0 at Peasant, up to 10 at Sovereign).
- `/bns plot list` — all plots, their state, prices
- `/bns plot buy <id>` — claim it (must be Citizen+ for at least 1 slot)
- `/bns plot release <id>` — 50% refund, opens slot
- `/bns plot mine` — list your owned plots
You'll need at least Citizen (tier 3) for any plot. **You can't buy a plot at Peasant.**
### 5. Waystones + welcome (pre-existing, untouched)
- One starter Waystone given on first login (per existing `welcome.js`)
- Waystone placement cap of 1 per player (per existing `waystones_policy.js`)
- Sharestones / Portstones banned via existing config
### 6. FTB Ranks 13-rank config
Live at `world/serverconfig/ftbranks/ranks.snbt`. Each rank has:
- `ftbchunks.max_claimed_chunks` (sets the FTB Chunks claim cap)
- `ftbchunks.max_force_loaded_chunks` (set to ~15% of claim cap)
- `ftbranks.name_format` (chat colour)
FTB Ranks auto-applies `peasant` to all players via `condition: always_active`. Tier-up commands set the rank explicitly. The `admin` rank with `condition: op` overrides everyone — you'll appear as Admin in chat regardless of your civic tier, useful for op-mode actions.
---
## What's deferred
### Villager trade rebalance
**Blocked.** KubeJS 2101.7.2 doesn't ship a villager-trade event. I verified by extracting the KubeJS jar — `ServerEvents` only exposes `command`, `loaded`, `recipes`, `registry`, `tags`, `tick`, `unloaded`. No `villagerTrades`. No KubeJS-villager-trade addon on Modrinth for NeoForge 1.21.1.
Options to unblock later:
1. **Add a third-party trade-overrides mod** — needs research (Villager Trade Tables-style mod for 1.21.1 NeoForge)
2. **Build into the custom mod** — KubeJS calls into Java mixin
3. **Mixin via a tiny standalone mod** — minimal Java project that just rebalances trades from a JSON
For now: vanilla villager trades remain. The 60+ trades I drafted live in the commit history (deleted file `pack/overrides/kubejs/server_scripts/economy/01_villager_rebalance.js`, restore from git when we unblock the API).
### Custom UI mod (bnstoolkit)
**Deferred to a session where you can iterate visually.** No Java/Gradle toolchain on glados, and the screens (QuestLog, BountyBoard, TierUpgrade, PlotPurchase, ShopBrowser, HUD, FTB Chunks mixin) all need visual feedback I can't get blind overnight.
The complete architecture spec is in `docs/bnstoolkit-architecture.md` — when you're ready to build, that's the source of truth. The KubeJS substrate I shipped tonight is exactly the backend the mod will sit on top of.
### Spawn build
Your manual task. The economy works from anywhere right now — no spawn buildings needed.
### FTB Quests progression chapters
Needs your arc design (themes, milestones). Mod is installed; the campaign content isn't.
---
## Files added/changed (PR #67)
```
pack/overrides/kubejs/server_scripts/economy/
00_tier_system.js NEW (170 lines)
02_sell_shop.js NEW (197 lines)
03_bounty_engine.js NEW (347 lines)
04_player_commands.js NEW (146 lines)
pack/overrides/kubejs/server_scripts/
plots.js MODIFIED (+86 lines — multi-slot)
pack/overrides/world/serverconfig/ftbranks/
ranks.snbt NEW (122 lines — 13 ranks)
pack/pack.lock.json version bump → 0.33.0
```
## Quick recovery if something goes wrong
If a script errors at startup, KubeJS logs to:
```
/srv/fast/brass-sigil-server/server/logs/kubejs/server.log
```
To hot-reload KubeJS server scripts without a server restart:
```
/kubejs reload server_scripts
```
To revert this PR entirely if it bricks something:
```
cd /home/matt/dev/brass-and-sigil
git revert <PR-67-merge-commit-hash>
git push origin main
```
The next pack-version-sync at MC server restart will undo all the changes.
## Suggested testing path
1. `/bns me` — sanity check, you should be Peasant tier 1
2. `/give @s numismatics:spur 500` — give yourself spurs
3. `/bns tier upgrade` — should pay 200 and bump you to Farmer
4. `/bns tier upgrade` again — should pay 550 (the difference up to Citizen at 750) … actually wait — the cost is per-tier-to-reach. Each upgrade costs the NEXT tier's listed cost. So Peasant → Farmer = 200, Farmer → Citizen = 750, Citizen → Merchant = 2,500. **Worth balance-checking in playtest.**
5. `/bns admin tier set <you> 13` — jump to Sovereign for testing
6. `/bns me` — should show Sovereign + 10 plot slots + unlimited everything
7. `/bns plot list` — should show 3 plots available
8. Stand in the relevant chunks (e.g. chunk 10,10), `/bns plot buy p001`
9. `/bns bounty list` — random 5 from pool
10. Accept one, kill enough mobs, turn in
11. `/bns sell list` — see effective prices
12. `/bns sell minecraft:diamond 4` (if you have diamonds)
If any step fails, the KubeJS log tells you why. Ping me with the error and I'll patch tomorrow.
---
## What I'd build next session
In priority order:
1. **bnstoolkit mod scaffolding (M0)** — need to install Java/Gradle on glados first. Empty Gradle project that builds + loads cleanly. Probably 4-6 hours of focused work.
2. **TierUpgrade GUI** (M1) — first screen in the mod, replaces the `/bns tier upgrade` text flow with a visual ladder + Upgrade button.
3. **BountyBoard GUI** (M3, after M0 is done) — replaces the chat-based `/bns bounty list/accept` with a poster-board screen.
Or — if you'd rather playtest the command-driven version first and only build the UI once the mechanics feel right — that's a perfectly defensible choice. The CLI version is fully functional.
+38 -12
View File
@@ -1,6 +1,6 @@
# Brass and Sigil — Economy & Quest System Design # Brass and Sigil — Economy & Quest System Design
> Status: architecture + UI approach locked (v0.3). Custom companion mod confirmed. > Status: 13-tier ladder locked (v0.4). Architecture + UI confirmed in v0.3.
> Owner: matt > Owner: matt
> Last updated: 2026-06-06 > Last updated: 2026-06-06
@@ -81,19 +81,44 @@ Two distinct land/status systems that often get conflated. Keeping them clean:
Every player has a Civic rank. Higher rank unlocks perks. Players spend spurs at the Civic Office NPC to upgrade. Powered by FTB Ranks under the hood (permission nodes drive what each rank unlocks). Every player has a Civic rank. Higher rank unlocks perks. Players spend spurs at the Civic Office NPC to upgrade. Powered by FTB Ranks under the hood (permission nodes drive what each rank unlocks).
| # | Title | Cost to reach | Wilderness chunks claimable | Spawn plot slots | Sethomes | Daily bounty slots | Shop fee | Chat colour | Join broadcast | `/back` after death | The ladder is intentionally long (13 tiers) so endgame ranks feel rare and aspirational. Top tiers represent months of dedicated play; Sovereign is "you've finished the game."
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | **Peasant** | free (starter) | 9 | 0 | 1 | 3 | -0% | grey | no | no | | # | Title | Cost to reach | Wilderness chunks | Plot slots | Daily bounties | Shop fee | Chat colour | Join broadcast |
| 2 | **Citizen** | 500 | 25 | 1 | 2 | 3 | -0% | white | no | no | |---|---|---|---|---|---|---|---|---|
| 3 | **Patron** | 2,000 | 60 | 1 | 3 | 4 | -2% | yellow | no | yes | | 1 | **Peasant** | free (starter) | 9 | 0 | 3 | 0% | grey | — |
| 4 | **Notable** | 6,000 | 100 | 2 | 4 | 4 | -5% | green | simple | yes | | 2 | **Farmer** | 200 | 18 | 0 | 3 | 0% | grey | — |
| 5 | **Baron** | 15,000 | 160 | 2 | 5 | 5 | -8% | cyan | simple | yes | | 3 | **Citizen** | 750 | 35 | 1 | 3 | 0% | white | — |
| 6 | **Noble** | 35,000 | 240 | 3 | 7 | 5 | -10% | purple | fancy | yes | | 4 | **Merchant** | 2,500 | 60 | 1 | 4 | -2% | yellow | — |
| 7 | **Lord** | 80,000 | 350 | 4 | 10 | 6 | -12% | gold | fancy | yes | | 5 | **Knight** | 6,500 | 100 | 2 | 4 | -5% | green | simple |
| 8 | **Sovereign** | 200,000 | 500 | 5 | unlimited | 6 | -15% | red | epic | yes | | 6 | **Baron** | 15,000 | 150 | 2 | 5 | -7% | cyan | simple |
| 7 | **Viscount** | 35,000 | 220 | 3 | 5 | -9% | blue | fancy |
| 8 | **Earl** | 75,000 | 300 | 3 | 6 | -11% | purple | fancy |
| 9 | **Marquess** | 150,000 | 400 | 4 | 6 | -13% | gold | fancy |
| 10 | **Duke** | 300,000 | 525 | 5 | 7 | -15% | orange | epic |
| 11 | **Archduke** | 700,000 | 700 | 6 | 7 | -17% | red | epic |
| 12 | **Grand Duke** | 1,500,000 | 900 | 7 | 8 | -19% | bright red | epic + particles |
| 13 | **Sovereign** | 10,000,000 | 1,500 | 10 | 10 | -25% | rainbow | epic + spectacular |
**Travel philosophy:** this is a Create Aeronautics pack — the journey is the point. We deliberately do **not** give players sethomes, `/back`, or other instant-teleport conveniences. The intended travel system is **Waystones** (mod already in pack):
- Players get **one waystone** at the start of the game. It anchors their base.
- They can teleport between spawn ↔ their own waystone(s).
- Beyond that, real travel means building airships, trains, vehicles — the actual gameplay.
This is restrictive by design. We can layer **waystone-related perks** into the tier ladder in a later revision (e.g. "tier N unlocks 2nd waystone slot", or "tier M reduces waystone XP cost"). For V1 the ladder stays as above and travel pressure remains intentionally high.
**Notable shape choices:**
- **Knight (tier 5)** is the gentry threshold — first colored chat (green), first join broadcast, meaningful jump in plot slots
- **Duke → Archduke jump** is the late-game wall (300k → 700k, ~2.3×) — Archduke is the start of "I've really committed to this server" territory
- **Sovereign is dramatic** — 6.7× the Grand Duke cost. Real "finished the game" status. -25% shop fee is a step-change leap; spectacular join effects.
Numbers are first-pass calibration. The full perk table lives in a single JSON (`config/bnstoolkit/tiers.json`) that's hot-reloadable via `/bnstoolkit reload`. Easy to tune any column without code changes. Numbers are first-pass calibration. The full perk table lives in a single JSON (`config/bnstoolkit/tiers.json`) that's hot-reloadable via `/bnstoolkit reload`. Easy to tune any column without code changes.
### 4a.1 Founder flag (admin cosmetic)
The server owner / admin has a permanent **`[Founder]`** cosmetic prefix in chat that sits alongside their normal rank. It carries no gameplay perks — it's just visible identification so friends know who runs the server. The Founder still grinds the regular tier ladder like everyone else (good for playtesting balance + sharing the experience). Op commands are the actual admin tool; the Founder tag is purely visual.
**Wilderness claims:** at every tier above Peasant, you get a flat allowance of N chunks you can claim anywhere outside spawn via the FTB Chunks map. Claiming is **free** within your allowance, refused above. No per-chunk pricing. Driven by FTB Ranks → FTB Chunks permission nodes (`ftbchunks.max_claimed_chunks`). **Wilderness claims:** at every tier above Peasant, you get a flat allowance of N chunks you can claim anywhere outside spawn via the FTB Chunks map. Claiming is **free** within your allowance, refused above. No per-chunk pricing. Driven by FTB Ranks → FTB Chunks permission nodes (`ftbchunks.max_claimed_chunks`).
### 4b. Spawn plots ### 4b. Spawn plots
@@ -411,7 +436,8 @@ Resolved before implementation:
| Numismatics access | Direct Java via `Java.loadClass` from KubeJS; via API directly from the mod | | Numismatics access | Direct Java via `Java.loadClass` from KubeJS; via API directly from the mod |
| Wilderness chunk allowance | Tier-driven via FTB Ranks permission nodes — no per-chunk pricing | | Wilderness chunk allowance | Tier-driven via FTB Ranks permission nodes — no per-chunk pricing |
| Spawn plots | Predefined regions, slot-limited by tier, individually purchased with spurs | | Spawn plots | Predefined regions, slot-limited by tier, individually purchased with spurs |
| Tier ladder | Civic: Peasant → Citizen → Patron → NotableBaron → Noble → Lord → Sovereign (8 tiers) | | Tier ladder | Civic (13 tiers): Peasant → Farmer → Citizen → Merchant → Knight → Baron → Viscount → EarlMarquess → Duke → Archduke → Grand Duke → Sovereign |
| Founder flag | Admin/owner has `[Founder]` cosmetic prefix in chat, grinds the regular ladder otherwise |
| Tier perks | JSON-configured (`config/bnstoolkit/tiers.json`), hot-reloadable | | Tier perks | JSON-configured (`config/bnstoolkit/tiers.json`), hot-reloadable |
| Custom mod | Confirmed. Built on FTB Library UI framework. Working name `bnstoolkit`. | | Custom mod | Confirmed. Built on FTB Library UI framework. Working name `bnstoolkit`. |
| Sethomes / warps | Tier-allocated count (not paid per use) | | Sethomes / warps | Tier-allocated count (not paid per use) |
@@ -0,0 +1,170 @@
// Brass and Sigil -- tier system (foundation)
//
// 13-tier civic ladder per docs/economy-system.md §4a. This file owns:
// - The TIER_CONFIG table (single source of truth for tier perks)
// - Helper functions: getTier(player), setTier, getTierConfig
// - Admin commands: /bns admin set-tier, get-tier
//
// Storage: player.persistentData.bnsTier (int, 1-13). Default 1 (Peasant).
//
// Eventually FTB Ranks will mirror this — when the bnstoolkit mod ships,
// rank promotion will set both the NBT tier here AND the FTB Ranks rank
// (which carries the FTB-Chunks permission nodes for wilderness caps).
// For now this NBT is the authoritative source.
//
// All other economy scripts read tier perks through this module's
// exposed globals.
// ─── The ladder ─────────────────────────────────────────────────────
// Names + costs + perks per tier. Edit here to rebalance.
// Sub-fields:
// wildernessChunks — FTB Chunks claim cap (also enforced via FTB Ranks
// permission node once configured; this is the
// fallback / source-of-truth value)
// plotSlots — how many spawn plots the player can own at once
// dailyBountySlots — how many bounties they can have active at once
// shopFeeDiscount — % discount on the 25% base Bazaar fee (negative %)
// chatColour — name colour in chat
// joinBroadcast — broadcast level on login
const TIER_CONFIG = Object.freeze([
null, // tiers are 1-indexed, slot 0 reserved
{ id: 1, name: 'Peasant', cost: 0, wildernessChunks: 9, plotSlots: 0, dailyBountySlots: 3, shopFeeDiscount: 0, chatColour: 'gray', joinBroadcast: 'none' },
{ id: 2, name: 'Farmer', cost: 200, wildernessChunks: 18, plotSlots: 0, dailyBountySlots: 3, shopFeeDiscount: 0, chatColour: 'gray', joinBroadcast: 'none' },
{ id: 3, name: 'Citizen', cost: 750, wildernessChunks: 35, plotSlots: 1, dailyBountySlots: 3, shopFeeDiscount: 0, chatColour: 'white', joinBroadcast: 'none' },
{ id: 4, name: 'Merchant', cost: 2500, wildernessChunks: 60, plotSlots: 1, dailyBountySlots: 4, shopFeeDiscount: -2, chatColour: 'yellow', joinBroadcast: 'none' },
{ id: 5, name: 'Knight', cost: 6500, wildernessChunks: 100, plotSlots: 2, dailyBountySlots: 4, shopFeeDiscount: -5, chatColour: 'green', joinBroadcast: 'simple' },
{ id: 6, name: 'Baron', cost: 15000, wildernessChunks: 150, plotSlots: 2, dailyBountySlots: 5, shopFeeDiscount: -7, chatColour: 'aqua', joinBroadcast: 'simple' },
{ id: 7, name: 'Viscount', cost: 35000, wildernessChunks: 220, plotSlots: 3, dailyBountySlots: 5, shopFeeDiscount: -9, chatColour: 'blue', joinBroadcast: 'fancy' },
{ id: 8, name: 'Earl', cost: 75000, wildernessChunks: 300, plotSlots: 3, dailyBountySlots: 6, shopFeeDiscount: -11, chatColour: 'dark_purple', joinBroadcast: 'fancy' },
{ id: 9, name: 'Marquess', cost: 150000, wildernessChunks: 400, plotSlots: 4, dailyBountySlots: 6, shopFeeDiscount: -13, chatColour: 'gold', joinBroadcast: 'fancy' },
{ id: 10, name: 'Duke', cost: 300000, wildernessChunks: 525, plotSlots: 5, dailyBountySlots: 7, shopFeeDiscount: -15, chatColour: 'gold', joinBroadcast: 'epic' },
{ id: 11, name: 'Archduke', cost: 700000, wildernessChunks: 700, plotSlots: 6, dailyBountySlots: 7, shopFeeDiscount: -17, chatColour: 'red', joinBroadcast: 'epic' },
{ id: 12, name: 'Grand Duke', cost: 1500000, wildernessChunks: 900, plotSlots: 7, dailyBountySlots: 8, shopFeeDiscount: -19, chatColour: 'red', joinBroadcast: 'epic_particles' },
{ id: 13, name: 'Sovereign', cost: 10000000, wildernessChunks: 1500, plotSlots: 10, dailyBountySlots: 10, shopFeeDiscount: -25, chatColour: 'light_purple', joinBroadcast: 'epic_spectacular' },
]);
const MAX_TIER = 13;
// Bazaar's base sell fee, before tier discount. A Peasant pays 25%; a
// Sovereign with -25% discount pays 0%. Read by the sell-shop module.
const BAZAAR_BASE_FEE_PCT = 25;
// ─── Tier accessors ─────────────────────────────────────────────────
// Module-local helpers. Each consumer file declares its own TIER_CONFIG
// copy because KubeJS Rhino strict-mode doesn't allow cross-script globals.
// See feedback-kubejs-rhino-gotchas memory note.
const bnsTier = {
// get the player's current tier (1-13). Defaults to 1 if unset.
get: function(player) {
const data = player.persistentData;
if (!data.contains('bnsTier')) return 1;
const t = data.getInt('bnsTier');
return (t >= 1 && t <= MAX_TIER) ? t : 1;
},
// set the player's tier (1-13). Clamps to range.
set: function(player, tier) {
const clamped = Math.max(1, Math.min(MAX_TIER, tier|0));
player.persistentData.putInt('bnsTier', clamped);
return clamped;
},
// get the full config row for a tier (1-13).
config: function(tier) {
if (tier < 1 || tier > MAX_TIER) return null;
return TIER_CONFIG[tier];
},
// get config for the player's current tier
playerConfig: function(player) {
return TIER_CONFIG[this.get(player)];
},
// expose the table read-only
table: function() { return TIER_CONFIG; },
// bazaar base fee accessor for the sell-shop module
bazaarBaseFee: function() { return BAZAAR_BASE_FEE_PCT; },
// effective shop-fee percentage for the player (base + discount).
// Returns 0..25, never negative.
effectiveShopFeePct: function(player) {
const cfg = this.playerConfig(player);
return Math.max(0, BAZAAR_BASE_FEE_PCT + cfg.shopFeeDiscount);
},
MAX: MAX_TIER,
};
// ─── Admin commands ──────────────────────────────────────────────────
// /bns admin tier get <player>
// /bns admin tier set <player> <tier>
// /bns admin tier list (show the full table)
ServerEvents.commandRegistry(event => {
const { commands: Commands, arguments: Arguments } = event;
const tierCmd = Commands.literal('bns')
.then(Commands.literal('admin')
.requires(src => src.hasPermission(2))
.then(Commands.literal('tier')
.then(Commands.literal('get')
.then(Commands.argument('target', Arguments.PLAYER.create(event))
.executes(ctx => {
const t = Arguments.PLAYER.getResult(ctx, 'target');
const tier = bnsTier.get(t);
const cfg = bnsTier.config(tier);
ctx.source.sendSystemMessage(Text.aqua(
`${t.username}: tier ${tier} (${cfg.name})`
));
return 1;
})))
.then(Commands.literal('set')
.then(Commands.argument('target', Arguments.PLAYER.create(event))
.then(Commands.argument('tier', Arguments.INTEGER.create(event))
.executes(ctx => {
const t = Arguments.PLAYER.getResult(ctx, 'target');
const requested = Arguments.INTEGER.getResult(ctx, 'tier');
if (requested < 1 || requested > MAX_TIER) {
ctx.source.sendSystemMessage(Text.red(
`Tier must be 1..${MAX_TIER} (got ${requested})`
));
return 0;
}
const actual = bnsTier.set(t, requested);
const cfg = bnsTier.config(actual);
ctx.source.sendSystemMessage(Text.gold(
`Set ${t.username} -> tier ${actual} (${cfg.name})`
));
t.tell(Text.gold(`You have been promoted to ${cfg.name}.`));
console.info(`[bns/tier] admin set ${t.username} tier=${actual} (${cfg.name})`);
return 1;
}))))
.then(Commands.literal('list').executes(ctx => {
ctx.source.sendSystemMessage(Text.aqua('--- Tier ladder ---'));
for (let i = 1; i <= MAX_TIER; i++) {
const c = TIER_CONFIG[i];
ctx.source.sendSystemMessage(Text.gray(
` ${i}. ${c.name} cost ${c.cost} chunks ${c.wildernessChunks} plots ${c.plotSlots} bounties ${c.dailyBountySlots} fee ${c.shopFeeDiscount}% ${c.chatColour}`
));
}
return 1;
}))
)
);
event.register(tierCmd);
});
// ─── First-join: ensure tier defaults to 1 ──────────────────────────
// Doesn't conflict with welcome.js (different flag).
PlayerEvents.loggedIn(event => {
const p = event.player;
if (!p.persistentData.contains('bnsTier')) {
bnsTier.set(p, 1);
console.info(`[bns/tier] initialised ${p.username} to tier 1 (Peasant)`);
}
});
console.info('[bns/tier_system] loaded — 13 tiers configured');
@@ -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 <item> [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 <item-id> [count] (omit count to sell 1)'));
return 1;
}))
// /bns sell <item-id> [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');
@@ -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');
@@ -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 <player> <rankId>` 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 <id> details for one plot',
'§7 /bns plot buy <id> purchase a plot (uses a tier slot)',
'§7 /bns plot release <id> 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 <item-id> [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 <id> accept a bounty',
'§7 /bns bounty cancel <id> drop a bounty (cooldown applies)',
'§7 /bns bounty turnin <id> 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');
+71 -15
View File
@@ -135,12 +135,25 @@ function ownerOf(plotId) {
return map.contains(plotId) ? map.getString(plotId) : null; return map.contains(plotId) ? map.getString(plotId) : null;
} }
function plotOwnedBy(playerUuid) { function plotsOwnedBy(playerUuid) {
const map = getOwnerMap(); const map = getOwnerMap();
const owned = [];
for (const key of map.getAllKeys()) { 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) { function recordPurchase(plotId, playerUuid) {
@@ -247,10 +260,18 @@ ServerEvents.commandRegistry(event => {
player.tell(Text.red(`Plot ${id} is already owned.`)); player.tell(Text.red(`Plot ${id} is already owned.`));
return 0; return 0;
} }
const existing = plotOwnedBy(player.uuid.toString()); // Tier-based slot cap.
if (existing) { const owned = plotsOwnedBy(player.uuid.toString());
const cap = playerPlotSlotCap(player);
if (cap <= 0) {
player.tell(Text.red( 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 <id>, or upgrade your tier for more slots.`
)); ));
return 0; return 0;
} }
@@ -271,33 +292,68 @@ ServerEvents.commandRegistry(event => {
recordPurchase(id, player.uuid.toString()); recordPurchase(id, player.uuid.toString());
claimChunksForPlayer(plot, player); claimChunksForPlayer(plot, player);
player.tell(Text.gold( 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; return 1;
}))) })))
// /bns plot release // /bns plot release <id>
.then(Commands.literal('release').executes(ctx => { // 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; const player = ctx.source.player;
if (!player) { if (!player) {
ctx.source.sendSystemMessage(Text.red('Run from a player context.')); ctx.source.sendSystemMessage(Text.red('Run from a player context.'));
return 0; return 0;
} }
const id = plotOwnedBy(player.uuid.toString()); const id = Arguments.STRING.getResult(ctx, 'id');
if (!id) { const ownerUuid = ownerOf(id);
player.tell(Text.red(`You don't own a plot.`)); if (ownerUuid !== player.uuid.toString()) {
player.tell(Text.red(`You don't own plot ${id}.`));
return 0; return 0;
} }
const plot = PLOT_DEFS.get(id); 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); const refund = Math.floor(plot.price * REFUND_FRACTION);
unclaimChunksForPlayer(plot, player); unclaimChunksForPlayer(plot, player);
clearOwner(id); clearOwner(id);
giveCoins(player, refund); giveCoins(player, refund);
const owned = plotsOwnedBy(player.uuid.toString());
const cap = playerPlotSlotCap(player);
player.tell(Text.gold( 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; return 1;
})) }))
Binary file not shown.
@@ -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 <player> <rankId> 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}"
}
}
+35 -2
View File
@@ -1,13 +1,13 @@
{ {
"$schema": "Brass-and-Sigil pack.lock.json - generated, do not edit by hand unless you know what you are doing", "$schema": "Brass-and-Sigil pack.lock.json - generated, do not edit by hand unless you know what you are doing",
"name": "Brass and Sigil", "name": "Brass and Sigil",
"version": "0.31.2", "version": "0.35.0",
"minecraft": "1.21.1", "minecraft": "1.21.1",
"loader": { "loader": {
"type": "neoforge", "type": "neoforge",
"version": "21.1.228" "version": "21.1.228"
}, },
"lockedAt": "2026-06-01T19:47:31Z", "lockedAt": "2026-06-07T02:33:07Z",
"mods": [ "mods": [
{ {
"source": "modrinth", "source": "modrinth",
@@ -1097,6 +1097,39 @@
"sha1": "4d81055810e07a94b68d16ab20ec298ad0d597a0", "sha1": "4d81055810e07a94b68d16ab20ec298ad0d597a0",
"size": 29865, "size": 29865,
"side": "both" "side": "both"
},
{
"source": "curseforge",
"slug": "ftb-quests",
"fileId": "8195575",
"version": "2101.1.25",
"path": "mods/ftb-quests-neoforge-2101.1.25.jar",
"url": "https://mediafilez.forgecdn.net/files/8195/575/ftb-quests-neoforge-2101.1.25.jar",
"sha1": "c51f43029930cce87e86366b6629a27e4876a03d",
"size": 1469336,
"side": "both"
},
{
"source": "curseforge",
"slug": "ftb-xmod-compat",
"fileId": "7715134",
"version": "21.1.8",
"path": "mods/ftb-xmod-compat-neoforge-21.1.8.jar",
"url": "https://mediafilez.forgecdn.net/files/7715/134/ftb-xmod-compat-neoforge-21.1.8.jar",
"sha1": "0c25fc1a43c99eaa68c883dc77e3ebf4539629e6",
"size": 141328,
"side": "both"
},
{
"source": "curseforge",
"slug": "ftb-ranks",
"fileId": "6431744",
"version": "2101.1.3",
"path": "mods/ftb-ranks-neoforge-2101.1.3.jar",
"url": "https://mediafilez.forgecdn.net/files/6431/744/ftb-ranks-neoforge-2101.1.3.jar",
"sha1": "07fdfa3a75c3481770ce0118090a347d89e3a560",
"size": 88382,
"side": "both"
} }
], ],
"defaultServer": { "defaultServer": {