Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28c816990a | |||
| 73f55768c3 | |||
| 48a70326d2 | |||
| 5f6a5aacdd | |||
| 8d9c04dc05 | |||
| ea9326819d | |||
| f2c9cabefe | |||
| ad9a261627 | |||
| bae44d0178 | |||
| 016985a5fc | |||
| a7f95691bb | |||
| 23efdee804 | |||
| 1859613f1f | |||
| a41d8cb531 | |||
| e770cb55cb | |||
| 338617e855 | |||
| ecd5933f9f | |||
| 38fbf80bc1 | |||
| 61dd3d3754 | |||
| 61a923cbef | |||
| 57b6cd8dea | |||
| 572671f1d4 | |||
| ffff3bc78d | |||
| b4cd66036b |
@@ -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.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#Easy NPC Security Configuration
|
||||||
|
#
|
||||||
|
#Permission values use enum names: ALL, MODERATORS, GAMEMASTERS, ADMINS, OWNERS.
|
||||||
|
#Legacy numeric Minecraft command permission levels are accepted and rewritten to enum names.
|
||||||
|
#
|
||||||
|
#normalPlayerCommandLevel: Max command level for normal player-owned imports (default: ALL)
|
||||||
|
#creativePlayerCommandLevel: Max command level for creative non-admin imports (default: MODERATORS)
|
||||||
|
#maxAdminImportedCommandLevel: Max command level for admin imports (default: ADMINS)
|
||||||
|
#serverTrustedCommandLevel: Max command level for server-side trusted imports without player context (default: ADMINS)
|
||||||
|
#blockUnsafeNpcCommands: Blocks critical server management commands from NPC command execution (default: true)
|
||||||
|
#npcSpawnRateLimitCreative: Max new NPCs a creative (non-admin) player may spawn via browser per minute (default: 5)
|
||||||
|
#npcSpawnRateLimitAdmin: Max new NPCs an admin player may spawn via browser per minute (default: 20)
|
||||||
|
#Feature values use enum names: NORMAL_PLAYER, CREATIVE_PLAYER, ADMIN, SERVER_TRUSTED.
|
||||||
|
#feature.SPAWN_NPC: Minimum role required to spawn new NPCs from the preset browser (default: CREATIVE_PLAYER)
|
||||||
|
#feature.WORLD_PRESET: Minimum role required to import/export world presets (default: CREATIVE_PLAYER)
|
||||||
|
#feature.CUSTOM_PRESET: Minimum role required to import/export custom server presets (default: CREATIVE_PLAYER)
|
||||||
|
#feature.LOCAL_PRESET: Minimum role required to export presets locally to the client (default: NORMAL_PLAYER)
|
||||||
|
#feature.DEFAULT_PRESET_IMPORT: Minimum role required to import built-in default presets (default: CREATIVE_PLAYER)
|
||||||
|
#feature.URL_RESOURCE: Minimum role required to use URL-based skin loading (default: CREATIVE_PLAYER)
|
||||||
|
#Sat Jun 06 23:34:43 UTC 2026
|
||||||
|
blockUnsafeNpcCommands=true
|
||||||
|
creativePlayerCommandLevel=MODERATORS
|
||||||
|
executeAsUserCommandAllowList.ADMINS=
|
||||||
|
executeAsUserCommandAllowList.ALL=bns
|
||||||
|
executeAsUserCommandAllowList.GAMEMASTERS=
|
||||||
|
executeAsUserCommandAllowList.MODERATORS=
|
||||||
|
executeAsUserCommandAllowList.OWNERS=
|
||||||
|
feature.BASE_ATTRIBUTE=CREATIVE_PLAYER
|
||||||
|
feature.COMBAT_ATTRIBUTE=CREATIVE_PLAYER
|
||||||
|
feature.COMMAND_ACTION=CREATIVE_PLAYER
|
||||||
|
feature.CUSTOM_PRESET=CREATIVE_PLAYER
|
||||||
|
feature.DEFAULT_PRESET_IMPORT=CREATIVE_PLAYER
|
||||||
|
feature.DIALOG=NORMAL_PLAYER
|
||||||
|
feature.INTERACT_BLOCK_ACTION=CREATIVE_PLAYER
|
||||||
|
feature.LOCAL_PRESET=NORMAL_PLAYER
|
||||||
|
feature.MOVEMENT=CREATIVE_PLAYER
|
||||||
|
feature.OBJECTIVE=CREATIVE_PLAYER
|
||||||
|
feature.OPEN_TRADING_ACTION=CREATIVE_PLAYER
|
||||||
|
feature.POSITION=CREATIVE_PLAYER
|
||||||
|
feature.SCOREBOARD_ACTION=CREATIVE_PLAYER
|
||||||
|
feature.SPAWN_NPC=CREATIVE_PLAYER
|
||||||
|
feature.TRADING=CREATIVE_PLAYER
|
||||||
|
feature.URL_RESOURCE=CREATIVE_PLAYER
|
||||||
|
feature.WORLD_PRESET=CREATIVE_PLAYER
|
||||||
|
maxAdminImportedCommandLevel=ADMINS
|
||||||
|
normalPlayerCommandLevel=ALL
|
||||||
|
npcSpawnRateLimitAdmin=20
|
||||||
|
npcSpawnRateLimitCreative=5
|
||||||
|
serverTrustedCommandLevel=ADMINS
|
||||||
|
unsafeNpcCommands=ban,ban-ip,banlist,debug,deop,difficulty,forceload,function,gamerule,kick,op,pardon,reload,save-all,save-off,save-on,schedule,setidletimeout,setworldspawn,spawnpoint,spreadplayers,stop,whitelist
|
||||||
@@ -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');
|
||||||
@@ -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.
Binary file not shown.
Binary file not shown.
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
@@ -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.32.0",
|
"version": "0.38.1",
|
||||||
"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-06T23:21:32Z",
|
"lockedAt": "2026-06-08T14:42:41Z",
|
||||||
"mods": [
|
"mods": [
|
||||||
{
|
{
|
||||||
"source": "modrinth",
|
"source": "modrinth",
|
||||||
@@ -1130,6 +1130,39 @@
|
|||||||
"sha1": "07fdfa3a75c3481770ce0118090a347d89e3a560",
|
"sha1": "07fdfa3a75c3481770ce0118090a347d89e3a560",
|
||||||
"size": 88382,
|
"size": 88382,
|
||||||
"side": "both"
|
"side": "both"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "modrinth",
|
||||||
|
"slug": "structory",
|
||||||
|
"versionId": "Nvh2sSPX",
|
||||||
|
"version": "1.3.16",
|
||||||
|
"path": "mods/Structory_26.1_v1.3.16.jar",
|
||||||
|
"url": "https://cdn.modrinth.com/data/aKCwCJlY/versions/Nvh2sSPX/Structory_26.1_v1.3.16.jar",
|
||||||
|
"sha1": "ee48f145a3a09fdb3f492576cbc21ed26abc74bb",
|
||||||
|
"size": 1261336,
|
||||||
|
"side": "both"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "modrinth",
|
||||||
|
"slug": "structory-towers",
|
||||||
|
"versionId": "ioKwCTuD",
|
||||||
|
"version": "1.0.16",
|
||||||
|
"path": "mods/Structory_Towers_26.1_v1.0.16.jar",
|
||||||
|
"url": "https://cdn.modrinth.com/data/j3FONRYr/versions/ioKwCTuD/Structory_Towers_26.1_v1.0.16.jar",
|
||||||
|
"sha1": "ed22fe659cb3020f2e86b7619ce721714b9546be",
|
||||||
|
"size": 491545,
|
||||||
|
"side": "both"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "modrinth",
|
||||||
|
"slug": "create-tracks+",
|
||||||
|
"versionId": "cju2ayQC",
|
||||||
|
"version": "1.0.5",
|
||||||
|
"path": "mods/tracks_plus-1.0.5.jar",
|
||||||
|
"url": "https://cdn.modrinth.com/data/E8eHF2Yl/versions/cju2ayQC/tracks_plus-1.0.5.jar",
|
||||||
|
"sha1": "87f72a49f53d7cf4202a11e4fc68f9bd18ab2c38",
|
||||||
|
"size": 610174,
|
||||||
|
"side": "both"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"defaultServer": {
|
"defaultServer": {
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Linux-side port of Build-Pack.ps1 + Deploy-Brass.ps1 (publish step).
|
||||||
|
|
||||||
|
Reads pack/pack.lock.json + walks pack/overrides/, rebuilds manifest.json,
|
||||||
|
mirrors every override file to the live web root, and republishes the
|
||||||
|
manifest. The launcher will then see fresh hashes on its next sync.
|
||||||
|
|
||||||
|
Run on glados with passwordless sudo. Idempotent.
|
||||||
|
|
||||||
|
Web root: /srv/services/sites/bns (mounted into Caddy as /srv/sites/bns).
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path("/home/matt/dev/brass-and-sigil")
|
||||||
|
LOCK_PATH = REPO / "pack" / "pack.lock.json"
|
||||||
|
OVERRIDES = REPO / "pack" / "overrides"
|
||||||
|
WEB_ROOT = Path("/srv/services/sites/bns/pack")
|
||||||
|
WEB_FILES = WEB_ROOT / "files"
|
||||||
|
WEB_MANIFEST = WEB_ROOT / "manifest.json"
|
||||||
|
SELF_HOST_BASE = "https://bns.sijbers.uk/pack/files"
|
||||||
|
|
||||||
|
MANAGED_ROOTS = ["mods", "config", "resourcepacks", "shaderpacks", "kubejs", "defaultconfigs", "patchouli_books"]
|
||||||
|
|
||||||
|
# bnstoolkit ships client+server surface (screens, HUD, network bridge),
|
||||||
|
# so it must NOT inherit the default "server-only" tag that Build-Pack.ps1
|
||||||
|
# stamps on every pack/overrides/mods/*.jar. brassandsigil_tweaks is
|
||||||
|
# data-only (worldgen modifiers / recipe overrides) so server-side is
|
||||||
|
# correct for it.
|
||||||
|
CLIENT_AND_SERVER_LOCAL_MODS = {"bnstoolkit"}
|
||||||
|
|
||||||
|
|
||||||
|
def sha1_of(p: Path) -> str:
|
||||||
|
h = hashlib.sha1()
|
||||||
|
with p.open("rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(1 << 16), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with LOCK_PATH.open() as f:
|
||||||
|
lock = json.load(f)
|
||||||
|
|
||||||
|
files = []
|
||||||
|
total_bytes = 0
|
||||||
|
|
||||||
|
# 1. Mods from the lockfile (Modrinth-hosted)
|
||||||
|
for m in lock["mods"]:
|
||||||
|
entry = {
|
||||||
|
"path": m["path"],
|
||||||
|
"url": m["url"],
|
||||||
|
"sha1": m["sha1"],
|
||||||
|
"size": m["size"],
|
||||||
|
}
|
||||||
|
side = m.get("side")
|
||||||
|
if side and side != "both":
|
||||||
|
entry["side"] = side
|
||||||
|
files.append(entry)
|
||||||
|
total_bytes += m["size"]
|
||||||
|
|
||||||
|
# 2. Local overrides
|
||||||
|
for root in MANAGED_ROOTS:
|
||||||
|
root_dir = OVERRIDES / root
|
||||||
|
if not root_dir.exists():
|
||||||
|
continue
|
||||||
|
for p in sorted(root_dir.rglob("*")):
|
||||||
|
if not p.is_file():
|
||||||
|
continue
|
||||||
|
rel = p.relative_to(OVERRIDES).as_posix()
|
||||||
|
sha1 = sha1_of(p)
|
||||||
|
size = p.stat().st_size
|
||||||
|
entry = {
|
||||||
|
"path": rel,
|
||||||
|
"url": f"{SELF_HOST_BASE}/{rel}",
|
||||||
|
"sha1": sha1,
|
||||||
|
"size": size,
|
||||||
|
}
|
||||||
|
if root == "mods":
|
||||||
|
# See CLIENT_AND_SERVER_LOCAL_MODS — bnstoolkit must be loaded
|
||||||
|
# on both sides; the other locally-built mods are server-only.
|
||||||
|
stem = p.name.split("-")[0].split("_")[0] # before first - or _
|
||||||
|
if not any(p.name.startswith(prefix) for prefix in CLIENT_AND_SERVER_LOCAL_MODS):
|
||||||
|
entry["side"] = "server"
|
||||||
|
files.append(entry)
|
||||||
|
total_bytes += size
|
||||||
|
|
||||||
|
# 3. Compose manifest matching the existing schema
|
||||||
|
manifest = {
|
||||||
|
"name": lock["name"],
|
||||||
|
"version": lock["version"],
|
||||||
|
"minecraft": {"version": lock["minecraft"]},
|
||||||
|
"loader": {"type": lock["loader"]["type"], "version": lock["loader"]["version"]},
|
||||||
|
"files": files,
|
||||||
|
}
|
||||||
|
for opt in ("defaultServer", "defaultShader", "panelUrl"):
|
||||||
|
if opt in lock:
|
||||||
|
manifest[opt] = lock[opt]
|
||||||
|
|
||||||
|
# Carry the launcher metadata from the existing published manifest
|
||||||
|
# (we can't read a Windows .exe's FileVersion from Linux easily, and
|
||||||
|
# the launcher version isn't related to pack content).
|
||||||
|
try:
|
||||||
|
with WEB_MANIFEST.open() as f:
|
||||||
|
existing = json.load(f)
|
||||||
|
for k in ("launcherVersion", "launcherUrl"):
|
||||||
|
if k in existing:
|
||||||
|
manifest[k] = existing[k]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 4. Mirror override files to web root (uses sudo + cp -a). Only update
|
||||||
|
# files whose hash differs from what's already published to keep the
|
||||||
|
# rsync footprint tiny.
|
||||||
|
print(f"\nMirroring {sum(1 for r in MANAGED_ROOTS if (OVERRIDES / r).exists())} override roots to {WEB_FILES}\n")
|
||||||
|
changed = 0
|
||||||
|
skipped = 0
|
||||||
|
for root in MANAGED_ROOTS:
|
||||||
|
root_dir = OVERRIDES / root
|
||||||
|
if not root_dir.exists():
|
||||||
|
continue
|
||||||
|
target_root = WEB_FILES / root
|
||||||
|
for p in root_dir.rglob("*"):
|
||||||
|
if not p.is_file():
|
||||||
|
continue
|
||||||
|
rel = p.relative_to(OVERRIDES)
|
||||||
|
target = WEB_FILES / rel
|
||||||
|
try:
|
||||||
|
if target.exists() and sha1_of(target) == sha1_of(p):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
except PermissionError:
|
||||||
|
# Need sudo to read
|
||||||
|
pass
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(p, target)
|
||||||
|
changed += 1
|
||||||
|
print(f" changed: {changed}, skipped (matching hash): {skipped}\n")
|
||||||
|
|
||||||
|
# 5. Write the manifest atomically (write to .new, then move)
|
||||||
|
new_manifest = WEB_MANIFEST.with_suffix(".json.new")
|
||||||
|
bak_manifest = WEB_MANIFEST.with_suffix(".json.bak")
|
||||||
|
if WEB_MANIFEST.exists():
|
||||||
|
shutil.copy2(WEB_MANIFEST, bak_manifest)
|
||||||
|
with new_manifest.open("w") as f:
|
||||||
|
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
new_manifest.replace(WEB_MANIFEST)
|
||||||
|
|
||||||
|
print(f"Manifest written: {WEB_MANIFEST}")
|
||||||
|
print(f" Version: {lock['version']}")
|
||||||
|
print(f" Files: {len(files)}")
|
||||||
|
print(f" Total: {total_bytes / 1024 / 1024:.1f} MB download")
|
||||||
|
print()
|
||||||
|
# Quick verify
|
||||||
|
rel = "kubejs/server_scripts/economy/00_tier_system.js"
|
||||||
|
entry = next((f for f in files if f["path"] == rel), None)
|
||||||
|
if entry:
|
||||||
|
print(f" {rel} -> sha1={entry['sha1'][:12]}... size={entry['size']}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user