Compare commits

..

141 Commits

Author SHA1 Message Date
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
Matt 4ca2f17d9d Merge pull request 'docs: economy v0.3 lockdown (custom mod + UI + tiers + plots)' (#63) from docs/economy-v0.3-lockdown into main 2026-06-06 22:27:07 +00:00
Matt 121ae4c603 docs: economy v0.3 — custom mod + UI approach + tier ladder + spawn plots locked
Architectural revisions:

- UI approach: custom companion mod (working name 'bnstoolkit') built
  on FTB Library's UI framework. Polished GUI screens for Quest Log,
  Bounty Board, Tier upgrade, Shop browser, Plot purchase. Quest
  progress as a custom HUD render layer (no stacked vanilla bossbars).
  All player interaction through GUI + NPC dialogue, no chat commands
  to memorise. FTB Library UI chosen for visual consistency with the
  rest of the FTB stack already in the pack.

- Tier ladder locked: 8 tiers, Civic theme, starting at Peasant.
  Peasant -> Citizen -> Patron -> Notable -> Baron -> Noble -> Lord
  -> Sovereign. Replaced Burgess with Baron; reordered ascending.

- Tier perks: full table with chunks claimable, spawn plot slots,
  sethome count, daily bounty slots, shop fee, chat colour, join
  broadcast tier, /back access. JSON-configured at
  config/bnstoolkit/tiers.json, hot-reloadable. Easy to tune any
  column without code changes.

- Spawn plots clarified as distinct from wilderness chunk claims:
  predefined regions at spawn, purchased individually for spurs,
  primary use is shops, slot-limited by tier. Purchase happens
  inside the FTB Chunks map UI via custom mod mixin. Plot data
  lives in the custom mod, not in FTB Chunks.

- Wilderness chunk claims: tier-allocated, free within allowance,
  refused above. NO per-chunk pricing (dropped from v0.2). Powered
  by FTB Ranks -> FTB Chunks permission nodes.

- Phasing updated to add Phase 0c (mod scaffolding) and reorder
  Phases 1-7 to fit the mod-supplied UI work.

- Implementation notes: KubeJS script layout, custom mod package
  layout, state data locations, config file locations all
  documented.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 22:27:06 +00:00
Matt 107622fc6d Merge pull request 'docs: economy architecture lockdown (v0.2)' (#62) from docs/economy-architecture-lockdown into main 2026-06-06 21:09:45 +00:00
Matt 95b0d1564d docs: economy architecture lockdown (v0.2)
Final architecture for the economy + quest system. No more V1/V2/V3
iteration cycles. Resolved against actual mod source code and verified
APIs via research spike.

Key decisions locked:

- Bounty UI: custom (bossbar + /quests commands + Easy NPC dialog).
  NOT FTB Quests -- it lacks per-player visibility, has no abandon
  event, and cooldown is per-team. Confirmed via FTBQ source review.

- Progression UI: FTB Quests (right tool for long-form content where
  per-team cooldowns and one-shot rewards are fine).

- Numismatics: direct Java.loadClass access (no events shipped, no
  KubeJS bindings). Documented exact API surface and call patterns.

- FTB Chunks claim cost: FTBChunksEvents.before('claim') with
  event.setResult() to refuse. Requires adding FTB XMod Compat.

- Tier system: FTB Ranks (CurseForge only) + permission-driven chunk
  limits + KubeJS-mediated upgrades via runCommandSilent('ftbranks add').

- Per-player HUD: bossbars, not scoreboards. Vanilla scoreboards are
  global; bossbars natively per-player.

- Custom mod for V1: NOT NEEDED. Re-evaluate if blocked.

Mods to add in Phase 0a (one PR before any implementation):
  - FTB Quests 2101.1.25
  - FTB XMod Compat (latest)
  - FTB Ranks 2101.1.3

Phasing updated: Phase 0a (mod additions) blocks the rest. Phase 0b
(spawn build) is non-blocking and user-paced; all systems can be
built and verified before spawn exists.

KubeJS file layout documented for clean implementation:
  pack/overrides/kubejs/server_scripts/economy/{01..99}_*.js

11 open decisions resolved. 3 remain non-blocking.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:09:45 +00:00
Matt 0c591faf14 Merge pull request 'docs: quest tracking + cancel UX' (#61) from docs/economy-quest-tracking into main 2026-06-06 20:51:32 +00:00
Matt 63e9185bf8 docs: add quest tracking + cancel UX to economy design
Three-layer visibility plan:
  V1 (Phase 3): /quests, /quests info, /quests cancel commands.
                Mobile-friendly via the bridge, no extra mods.
  V2 (Phase 3.5): Quest Log book item with dynamic pages.
  V3 (later): optional sidebar HUD via vanilla scoreboards.

3-slot active-quest cap so cancellation is a real choice:
  - Prevents accept-all-then-cancel-easy-ones RNG shopping
  - Keeps state files small
  - READY frees the slot so turn-in backlogs are workable

State storage schema documented (active + completed/cooldown).

Phase 3.5 added to the roadmap for the book item.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 20:51:14 +00:00
Matt 5bd1ace6aa Merge pull request 'docs: economy & quest system design (v0.1)' (#60) from docs/economy-system-design into main 2026-06-06 20:48:13 +00:00
Matt 1af2b9a86f docs: economy & quest system design (v0.1 draft)
Full design for the unified spawn economy:

- Seven themed guild buildings (Adventurer's, Merchant's, Civic,
  Library, Tavern, Foundry, Mint)
- Universal accept → complete → return quest pattern
- Villagers moved fully to spurs with rebalance principles +
  initial price guidance
- Money flow architecture (bounded inflows, scaling sinks)
- Phasing plan (8 phases, each independently shippable)
- Mod stack confirmed (we have everything we need for V1)
- Risk register + open decisions to make before implementation

Status: design, not yet implemented. Phase 0 (spawn skeleton)
is the next concrete deliverable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 20:48:12 +00:00
Matt 678e63497c Merge pull request 'pack: v0.31.2 — proper JEI hide via c:hidden_from_recipe_viewers tag' (#59) from fix/v0.31.2-hidden-from-recipe-viewers-tag into main 2026-06-01 19:48:37 +00:00
Matt 2710dce479 pack: v0.31.2 — proper item hiding via c:hidden_from_recipe_viewers tag
Stop hacking around the problem -- use the standard cross-recipe-
viewer hide convention that both NeoForge and JEI define.

Source-level confirmation:

  mezz/JustEnoughItems @ 1.21.1
    CommonApi/.../api/constants/Tags.java
      HIDDEN_FROM_RECIPE_VIEWERS = c:hidden_from_recipe_viewers

    Library/.../ingredients/IngredientVisibility.java
      isIngredientVisible() calls
      ingredientHelper.isHiddenFromRecipeViewersByTags(typedIngredient)
      -> any item carrying this tag is hidden from JEI.

  neoforged/NeoForge @ 1.21.x
    common/Tags.java
      Tags.Items.HIDDEN_FROM_RECIPE_VIEWERS = c:hidden_from_recipe_viewers
      -> same tag, NeoForge agrees with JEI.

  EMI / REI also respect this tag (common convention).

So the proper modpack-shippable fix is a single datapack file:

  pack/overrides/world/datapacks/bns-fuel/data/c/tags/item/
    hidden_from_recipe_viewers.json
  {
    "replace": false,
    "values": [
      "alexsmobs:shattered_dimensional_carver",
      "alexsmobs:transmutation_table"
    ]
  }

No mod, no KubeJS, no CraftTweaker, no Item Obliterator JEI integration
(which doesn't exist for 1.21.1 NeoForge anyway). Just the tag the
recipe-viewer ecosystem agreed on years ago.

Item Obliterator + Necronomicon stay in the pack for what they actually
deliver on 1.21.1: inventory-pickup block + villager-trade removal +
right-click cancellation. The Capsid recipe override stays too as the
creation block.

The full disable stack now:
  - Tag (this commit)         -> Hidden from JEI / REI / EMI
  - Datapack capsid recipe    -> Can't be created in-world
  - Item Obliterator          -> Can't be picked up / right-clicked
                                 if obtained via /give or creative

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 19:48:36 +00:00
Matt 99aa11c94d Merge pull request 'pack: v0.31.1 — restore Capsid recipe override (Shattered Carver UX fix)' (#58) from fix/v0.31.1-restore-capsid-override into main 2026-06-01 19:34:34 +00:00
Matt 0cd43c8f2d pack: v0.31.1 — restore Capsid recipe override (Shattered Carver)
I removed this datapack override in v0.31.0 on the (incorrect)
assumption that Item Obliterator would also handle Alex's Mobs'
custom capsid_recipes recipe type. It doesn't -- Item Obliterator
intercepts vanilla and some common modded recipes, but the Capsid
uses its own SimpleJsonResourceReloadListener bound to the
"capsid_recipes" folder, and that listener still loaded the
default shipped recipe.

The visible bug: a player could put a regular Dimensional Carver
into a Capsid -- the recipe matched, the Dimensional Carver was
consumed, and the Capsid spat out a Shattered Dimensional Carver.
Item Obliterator then refused the pickup. Net result:
  - Player loses their hard-earned regular Carver
  - Player can never pick up the Shattered one
  - World has an unrecoverable floating item entity

Restore the override at:
  pack/overrides/world/datapacks/bns-fuel/data/alexsmobs/
    capsid_recipes/shattered_dimensional_carver.json

This swaps the recipe's input from `alexsmobs:dimensional_carver` to
`minecraft:barrier`, which is unobtainable in survival. The recipe
loads cleanly but can never match. The Capsid transformation for
the Shattered Carver effectively no longer exists.

Item Obliterator still handles the JEI / creative tab / inventory /
trade removal -- both layers complement each other.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 19:34:33 +00:00
Matt 7acd3f4da8 Merge pull request 'pack: v0.31.0 — Item Obliterator replaces v0.30.x hacks' (#57) from feature/v0.31.0-item-obliterator into main 2026-06-01 19:21:41 +00:00
Matt 1b9e35efea pack: v0.31.0 — replace hacks with Item Obliterator
Replace the v0.30.x ad-hoc disable mechanisms (KubeJS server script,
datapack recipe override, KubeJS client-side JEI hide -- which was
broken on this KubeJS build) with a single proper tool:

  - **Item Obliterator 2.3.0** + dep **Necronomicon API 1.6.0**
    Declarative JSON config to remove items from inventories,
    creative tabs, all recipes (including modded custom recipe
    types), villager trades, and JEI. Exactly the missing capability.

Config: pack/overrides/defaultconfigs/item_obliterator.json5
  Blacklists:
    alexsmobs:transmutation_table
    alexsmobs:shattered_dimensional_carver

Removed (no longer needed):
  - pack/overrides/kubejs/server_scripts/disable_alexsmobs_features.js
    (Item Obliterator handles transmutation_table recipe removal
    and right-click cancel automatically)
  - pack/overrides/world/datapacks/bns-fuel/data/alexsmobs/
      capsid_recipes/shattered_dimensional_carver.json
    (Item Obliterator handles modded recipe removal too)

Both items now correctly:
  - Don't appear in JEI search / item list
  - Don't appear in creative tabs
  - Have no working recipe path
  - Can't be right-clicked / used
  - Get scrubbed from inventories on join

The Void Worm boss + regular Dimensional Carver still work --
nothing else in Alex's Mobs is affected.

Verified live: both mods discovered, Necronomicon read our config
file successfully ("File exists, reading config"), Item Obliterator
loaded with the blacklist.

Note: config schema is v2; v1 configs get overwritten by the mod's
default. Always write configVersion: 2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 19:21:41 +00:00
Matt f88f6eaaca Merge pull request 'pack: v0.30.4 — drop broken JEI-hide client script' (#56) from fix/v0.30.4-drop-broken-jei-hide into main 2026-06-01 19:15:19 +00:00
Matt 3912ec541d pack: v0.30.4 — drop the broken JEI-hide client script
The hide_disabled_items.js client script I added in v0.30.3 used
`JEIEvents.hideItems(...)` -- a namespace that does NOT exist in
KubeJS 2101.7.2 on NeoForge 1.21.1. There is no kubejs-jei bridge
mod for 1.21.1 NeoForge published on Modrinth at this time.

Client launchers were showing:
  KubeJS client script errors
  hide_disabled_items.js#8
  ReferenceError: \"JEIEvents\" is not defined.

Removing the script entirely. The Shattered Dimensional Carver
still:
  - Cannot be produced (Capsid recipe override with barrier input)
  - Does nothing on use (server-side right-click cancel)

Tradeoff: the item will still appear as a recipe-less entry in JEI
search if players type its name. Cosmetic only; no gameplay path
to obtain or use it.

If a JEI-hide approach becomes available in a future KubeJS update
or via a new bridge mod, we can revisit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 19:15:19 +00:00
Matt 5068087a27 Merge pull request 'pack: v0.30.3 — fully hide Shattered Dimensional Carver from JEI + recipe' (#55) from fix/v0.30.3-hide-shattered-carver into main 2026-05-30 21:16:09 +00:00
Matt d259695932 pack: v0.30.3 — fully hide Shattered Dimensional Carver
Replaces v0.30.2's right-click intercept with two cleaner layers:

  1. Datapack override of the Capsid recipe
     pack/overrides/world/datapacks/bns-fuel/data/alexsmobs/
       capsid_recipes/shattered_dimensional_carver.json
     Changes the input from `alexsmobs:dimensional_carver` to
     `minecraft:barrier`, which players cannot obtain in survival.
     The recipe loads cleanly but never matches, so the item is
     never produced through normal play.

  2. Client-side KubeJS JEI hide
     pack/overrides/kubejs/client_scripts/hide_disabled_items.js
     Hides `alexsmobs:shattered_dimensional_carver` from JEI search
     and the item list, so it doesn't appear as a recipe-less ghost.

The right-click intercept in server_scripts/disable_alexsmobs_features.js
stays in place as a safety net for /give cases. Transmutation Table
recipe removal also stays as-is.

Net effect: the Shattered Dimensional Carver effectively does not
exist on this server. Void Worm fight + regular Dimensional Carver
return-home item both stay intact.

Verified live: pack synced to 0.30.3, Alex's Mobs registered its
capsid_recipes datapack listener (which reads our override file).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:16:08 +00:00
Matt 0a6fd851c1 Merge pull request 'pack: v0.30.2 — disable Alex's Mobs Transmutation Table + Shattered Carver' (#54) from fix/v0.30.2-disable-alexsmobs-bypass into main 2026-05-30 20:49:20 +00:00
Matt c635de501a pack: v0.30.2 — disable Alex's Mobs Transmutation Table + Shattered Carver
KubeJS server script that:

  1. Removes the crafting recipe for alexsmobs:transmutation_table.
     The Transmutation Table converts items freely with XP cost,
     which competes with Ars Nouveau's depth and undermines the
     Numismatics-based plot economy. No new tables can be made;
     no existing tables exist on the world yet.

  2. Cancels the right-click action on
     alexsmobs:shattered_dimensional_carver. The item teleports the
     user 1,000,000 blocks in a random direction -- breaks any
     server-side world-boundary assumption, drops the player into
     unloaded chunks far from the rest of the world. The item can
     still be produced (the Capsid-shatters-Carver interaction is
     a block reaction, not a recipe we can remove cleanly), but it
     does nothing on use and sends an explanation to the player.

The Void Worm boss + regular Dimensional Carver (return-home
teleport) stay enabled. Players can still fight the boss and earn
the home-recall item, just not the world-bounding-breaking variant.

Verified live: KubeJS loaded script in 3ms, removed recipe at
recipe-event time, server bound to 25565.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 20:49:19 +00:00
Matt 52eca18f0d Merge pull request 'pack: v0.30.1 — fix lithostitched client-side bug' (#53) from fix/v0.30.1-lithostitched-clientside into main 2026-05-30 20:07:41 +00:00
Matt 34d180404a pack: v0.30.1 — fix lithostitched client-side bug
Client launcher reported on startup:
  Mod improved_village_placement requires lithostitched 1.4 or above
  Currently, lithostitched is not installed

Cause: lithostitched was flagged side=server in pack.lock and the
live manifest, so the client launcher skipped it during sync. But
Improved Village Placement (added in v0.29.1) declares lithostitched
as a runtime dependency and IVP itself is side=both, so the client
loaded IVP and then failed the dep check.

Lithostitched is a worldgen library — many mods that use it need it
on both sides (client renders modded structures using its hooks).
Flipping to side=both is the safer default for any library mod.

The other server-side flagged entries (Yung's Better X, noisium,
async-locator, servercore) are genuinely server-only and stay as-is.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 20:07:40 +00:00
Matt feb95e2e7e Merge pull request 'pack: v0.30.0 — Combat batch (Better Combat + Combat Roll + Alex's Mobs)' (#52) from feature/v0.30.0-combat-batch into main 2026-05-29 19:49:39 +00:00
Matt d6c6da446c pack: v0.30.0 — Combat batch (Better Combat + Combat Roll + Alex's Mobs)
Adds the v0.30.0 combat-feel batch:

  - Better Combat 2.3.2 -- directional attacks, multi-hit swings,
    weapon-type animations. Data-driven attack profiles so Create,
    CBC, Ars weapons all get appropriate swings automatically. No
    new weapons or recipes -- pure mechanics overhaul. First-person
    view fully supported as of 2.0.4.

  - Combat Roll 2.0.6 -- dodge roll on double-tap with brief invuln
    + roll-related attributes/enchants. Same author and shares deps
    with Better Combat, explicitly designed to work alongside.

  - Alex's Mobs port 1.22.17 -- wide nature-themed mob variety
    (~70+ creatures). Original mod stops at 1.20.1; this is the
    community port for 1.21.1, 187k downloads, recently updated.

Deps added with the batch (all new to pack):
  - Cloth Config 15.0.140 (BC + Combat Roll)
  - playerAnimator 2.0.4 (BC + Combat Roll)
  - Citadel port 2.7.6 (Alex's Mobs)

Verified live: 0.30.0 synced, all 6 jars discovered, BC registered
3007-byte weapon attribute table, Combat Roll loaded, Alex's Mobs
adding datapack listeners. KubeJS 0 errors, port 25565 binds.

The v0.31.0 batch (Twilight Forest + Aether) is parked for later --
holding until plot/economy work is running so players have reason
to leave spawn for those dimensions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 19:49:38 +00:00
Matt 6bc44ffd2e Merge pull request 'pack: v0.29.1 — Improved Village Placement' (#51) from feature/v0.29.1-improved-village-placement into main 2026-05-29 18:09:16 +00:00
Matt b5ebe26649 pack: v0.29.1 — Improved Village Placement
Adds Improved Village Placement 1.1.1 (Modrinth). Forces villages
to spawn on flat terrain instead of clipping into hillsides /
floating mid-air on slopes. Tiny mod (34KB). Required dep
Lithostitched is already in the pack at 1.7.2.

Verified live: mod loaded, server boots clean, port 25565 binds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 18:09:15 +00:00
Matt 4f13fd1408 Merge pull request 'fuel: tier lava equivalent to crude oil (8000 ticks)' (#50) from fuel/lava-tier-crude into main 2026-05-28 20:46:36 +00:00
Matt 77d1cef1d0 fuel: tier lava equivalent to crude oil (8000 ticks/bucket)
Without this entry, lava buckets get the vanilla bucket-burn fallback
of 20000 ticks each — which made lava more powerful than refined
TFMG kerosene/naphtha despite being free and trivially obtainable.

Setting lava to 8000 ticks matches crude_oil exactly: both are the
raw entry-tier fuels you can scoop without any infrastructure, and
the player has to commit to a refining setup to unlock the higher
tiers.

Solid fuels (coal, charcoal, blaze rods, etc.) continue to work via
realisticTrains' chest/barrel pathway -- no datapack entry needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:46:35 +00:00
Matt 19705118d3 Merge pull request 'cbc: restore mod-default failure chances' (#49) from config/cbc-restore-defaults into main 2026-05-28 20:44:07 +00:00
Matt cfc1a21d25 cbc: restore mod-default failure chances
Revert the custom tier-strict + user-error-forgiveness tuning shipped
in v0.29.0 and use the mod's shipped defaults instead:

  squibChance              0.0  -> 0.25
  barrelChargeBurstChance  0.0  -> 0.2
  interruptedIgnitionChance 0.0 -> 0.33
  overloadBurstChance      1.0  -> 0.5
  failureExplosionPower    0.0  -> 2.0

disableAllCannonFailure stays false. Tier balance is still preserved
(material maxSafePropellantStress is what gates overload). The
difference is failures are now probabilistic per the mod author's
intended balance, and the explosion has its default power.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:43:54 +00:00
Matt 4cbc8c42af Merge pull request 'pack: v0.29.0 — train fuel + CBC tier-preserving safety' (#48) from feature/v0.29.0-train-fuel-and-cbc-tier into main 2026-05-28 20:42:38 +00:00
Matt e84de6c190 pack: v0.29.0 — train fuel requirement + CBC tier-preserving safety
Two related gameplay tweaks:

1. Train fuel requirement (Steam 'n' Rails)
   - Set realisticTrains = true in railways-server.toml.
     Trains now consume fuel from a Fuel Tank (liquid) or solid fuels
     in chests/barrels on the contraption. realisticFuelTanks stays
     true so tanks won't accept water/junk.
   - Ship a datapack bns-fuel/ that defines TFMG fluids as proper
     liquid fuels at tiered burn rates:
       crude_oil    8000 ticks/bucket   (raw, abundant -- entry)
       furnace_gas 12000 ticks/bucket   (cheap by-product)
       naphtha     24000 ticks/bucket   (mid refined)
       kerosene    24000 ticks/bucket   (mid refined)
       gasoline    36000 ticks/bucket   (top refined)
       diesel      48000 ticks/bucket   (top refined, longest)
     Vanilla lava bucket still works via bucket-burn fallback
     (20000 ticks). Tier curve incentivises building a real refinery.

2. CBC tier-preserving cannon safety
   - Revert the nuclear disableAllCannonFailure=true switch shipped
     in v0.28 hotfix -- it killed the bronze/steel/nethersteel tier
     balance because overloaded cannons fired without consequence.
   - Per-lever config that protects against random RNG losses while
     keeping the tier mechanic intact:
       disableAllCannonFailure = false
       squibChance = 0.0              (no random projectile-stuck)
       barrelChargeBurstChance = 0.0  (forgive misplaced charges)
       interruptedIgnitionChance = 0.0 (forgive gapped loads)
       overloadBurstChance = 1.0      (TIER STRICT -- exceed your
                                       cannon's rated propellant
                                       stress = lose the cannon)
       failureExplosionPower = 0.0    (no collateral blast when it
                                       does die from overload)
   - Effect: a cannon built to spec and used within its
     maxSafePropellantStress is immortal. Overloading dies cleanly
     per the material's failureMode, no fortress damage. User error
     (squib/misplace/gap) doesn't destroy expensive builds.

Stress units NOT supported for trains: verified by inspecting Rails
and Create bytecode, nothing consumes SU for train movement. Liquid
fuel is the supported mechanism.

Verified live: server boots clean, port 25565 binds, KubeJS loads
0 errors, Minecraft auto-enables the bns-fuel datapack, Rails
initializes the LiquidFuelManager reload listener.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:42:38 +00:00
Matt 92306823cb Merge pull request 'cbc: disable cannon self-failure (RNG self-destruct)' (#47) from config/cbc-disable-self-failure into main 2026-05-28 19:39:44 +00:00
Matt c723a42628 cbc: disable all cannon self-failure
Cannons are expensive to build; users were losing them to RNG
self-destruction (squib, barrel charge burst, overload, interrupted
ignition, catastrophic failure). Per-cannon failure is configurable
via a single master switch in createbigcannons-server.toml:

  [failure]
  disableAllCannonFailure = true

This zeroes out all six failure paths in one go. Applied to the live
server and tracked here under defaultconfigs/ so future server resets
preserve it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 19:39:44 +00:00
Matt 83abbc90d3 Merge pull request 'pack: v0.28.0 - Steam n Rails + Copycats+/Aero weight' (#46) from feature/v0.28.0-rails-and-aero-bridge into main 2026-05-28 19:30:04 +00:00
Matt 5a24801f48 pack: v0.28.0 — Steam 'n' Rails (port) + Copycats+/Aero weight bridge
Adds two mods on top of v0.27.0:

  - Create: Steam 'n' Rails 0.2.0+neoforge-mc1.21.1 (stable). The
    official Steam 'n' Rails team skipped 1.21.1, so this is the
    "Porters of Railways" community port — actively maintained,
    310k downloads on the beta line and 0.2.0 just hit stable.
    Brings train QoL: track tools, semaphores, conductor cap,
    smokestacks, narrow-gauge tracks, etc.

  - Copycats+ aeronautics weight 1.1.1. Small interop mod so
    copycat blocks contribute weight to Create: Aeronautics
    contraptions instead of being treated as massless. ~15KB.

All deps satisfied by mods already in the pack (Create, Copycats+,
Sable, Create: Aeronautics).

Tested live: server boots, port 25565 binds, KubeJS loads 0/0 errors,
aerocopycats logs HELLO FROM COMMON SETUP, Railways registers
55+53+238 entries across its three creative tabs. No new crashes.

Known noise: Steam 'n' Rails ships optional loot tables for tracks
of wood types from many other mods (TwilightForest, BYG, TFC, Blue
Skies, Hex Casting, BoP, etc.). Mods absent from this pack produce
~276 'Unknown registry key' ERROR lines once at boot. Harmless —
loot tables for items that don't exist just fail to parse and get
skipped. Standard behavior for Rails on lean packs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 19:29:43 +00:00
Matt 5e816e1a7a Merge pull request 'kubejs: use kubejs-create builder API for flax press recipe' (#45) from refactor/supplementaries-bridges-kubejs-create-api into main 2026-05-28 19:08:42 +00:00
Matt d2cc179ebd kubejs: use kubejs-create builder API for flax press recipe
Now that v0.27.0 ships kubejs_create, the high-level
event.recipes.create.pressing(out, in) builder works, replacing the
raw event.custom({type: 'create:pressing', ...}) JSON shim we needed
before.

Verified live on the server -- recipe loads cleanly:
[bns/supplementaries_bridges] added flax -> string press recipe

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:55:43 +00:00
Matt 847ca460ff Merge pull request 'pack: v0.27.0 - Create QoL + aesthetic batch (KubeJS Create + Encased + Bells & Whistles)' (#44) from feature/v0.27.0-create-batch into main 2026-05-28 18:51:06 +00:00
Matt 96d4608e4c pack: v0.27.0 — Create QoL + aesthetic batch
Three small Create addons, no overlap with current 18-mod Create stack:

  + kubejs-create v2101.3.1-build.18
      Recipe builders for KubeJS scripts. Will replace the raw
      Create:pressing JSON in supplementaries_bridges.js with a
      cleaner event.recipes.create.* call.

  + create-encased v1.8.1-ht1
      Encased/cased variants of Create kinetic blocks (shafts,
      gearboxes, cogwheels in compact casings) for cleaner builds.

  + bellsandwhistles v0.4.7-1.21.1
      Train whistles, bells, signal lights, station decoration.

All three already deployed live; pack-version.json bumped, MC
server restarted to sync. No mod-load errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:55:55 +00:00
Matt 61321e5fcd Merge pull request 'ops: cap ZFS ARC at 2GB on glados' (#43) from ops/zfs-arc-cap into main 2026-05-28 13:52:59 +00:00
Matt a8ff660114 Merge pull request 'backup-scheduler: dont replay missed daily slots on startup' (#42) from fix/backup-scheduler-no-replay into main 2026-05-28 13:52:58 +00:00
Matt ab63908d04 Merge pull request 'v0.24.0 -> v0.26.0: content + storage batch' (#41) from feature/v0.26.0-content-and-storage-batch into main 2026-05-28 13:52:56 +00:00
Matt fd211aae51 ops: cap ZFS ARC at 2GB on glados
The cold ZFS pool is a backup tier only — hot data (MC world, Gitea,
Directus DB, containers) lives on NVMe LVM, not ZFS. So ARC's default of
50% RAM was wasted: nothing it could cache was hot enough to matter, and
the ~16GB cache crowded MC out of RAM, pushing 2.4GB into swap.

This file lives in the repo for reproducibility; it gets dropped into
/etc/modprobe.d/ on the host and applied immediately via
/sys/module/zfs/parameters/zfs_arc_{max,min}.

Effect on glados:
  before:  used=27/31GB, free=748MB, ARC=15.6GB
  after:   used=~10GB, free=~14GB, ARC=1.0GB (1-2GB range)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 23:05:52 +00:00
Matt f81bb29fda backup-scheduler: don't replay missed daily slots on startup
The wake loop's daily-times branch was firing one missed slot per minute
on service restart. With a schedule like "00:00,04:00,08:00,12:00,16:00,
20:00" and a restart at 19:00, the wrapper would catch up by firing 5
backups at 1-minute intervals -- each a full-world zip of the same
in-memory world. Pure waste: the live world hasn't diverged across the
missed clock slots, the snapshots would be near-identical, and the rapid
save-off/save-on churn impacts player experience.

Source comment already documented the intended behaviour ("Doesn't catch
up if the server was off when a slot passed -- daily/interval backups
don't need replay logic.") but the code didn't implement it.

Fix: in Start(), pre-populate _firedToday with all times <= now so the
wake loop only fires future slots for the rest of today.

Tested live on glados: switched schedule to clock-aligned daily-times
after deploying the fix, zero catch-up backups fired in the 90s window
after restart (vs 5+ before the fix).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:31:32 +00:00
Matt 9f0c9e262a pack: content + storage batch v0.24.0 -> v0.26.0
Mods added (in lockfile):
  v0.24.0  YUNG's structure mods (9):
           - Better Dungeons, Better Mineshafts, Better Ocean
             Monuments, Better Nether Fortresses, Better End Island,
             Better Witch Huts, Better Desert Temples, Better Jungle
             Temples, Bridges, Extras
  v0.24.1  Create: Radars (Aeronautics-compatible from v0.4.5.1+)
  v0.24.2  Ars Creo + Create Ars Compact (the latter removed in 0.25.0)
  v0.24.3  FallingTree (whole-tree chop on axe hit)
  v0.24.4  Copycats+, Create: Connected, Create: Interiors
  v0.24.5  Create: Power Loader (Aeronautics-airship chunkloading)
  v0.24.6  -- KubeJS bridges only (see below)
  v0.24.7  -- Patchouli welcome book attempt 1 (kubejs/data) -- failed
  v0.24.8  -- Patchouli attempt 2 (assets/data split) -- failed
  v0.24.9  -- Patchouli attempt 3 (patchouli_books/ root path) -- worked
  v0.25.0  REMOVED Create Ars Compact (broken sauce:source_fluid recipes,
           no public tracker, half-finished compat layer);
           added Create: Dragons Plus, Create: Enchantment Industry
  v0.26.0  Sophisticated Core/Storage/Backpacks/Create-Integration,
           Create: Additional Logistics

KubeJS additions:
  - supplementaries_bridges.js: flax -> 2x string via Create Press,
    Plunderer drops 1-3 spurs via EntityEvents.drops
  - recipes_numismatics.js: renamed COIN_IDS -> COIN_RECIPE_REMOVE_IDS
    to avoid Rhino global-scope collision with numismatics_tooltips.js,
    added sprocket to the removal list (was missing)
  - welcome.js: migrated from vanilla written_book (broken Filterable
    codec in 1.21) to Patchouli book at patchouli_books/manual/

Patchouli manual:
  - 5 entries (Welcome, Waystones, Money, Plots, Rules) under one
    "Server Basics" category
  - Lives at pack/overrides/patchouli_books/ (root path) -- this is
    the modpack-author path per Patchouli docs, NOT data/+assets/
    which is for mods bundled in jars
  - Book id: patchouli:manual (default namespace in this mode)

All v0.24-v0.26 work has been live on the server and tested.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 22:42:32 +00:00
Matt 0cc8effce0 Merge pull request 'v0.23.0: Building mods batch (Tier 1 + 2 minus Chipped, + Effortless Building)' (#40) from feature/v0.23.0-building-batch into main 2026-05-23 18:26:18 +00:00
Matt 42bec33aa1 pack: building-mods batch v0.23.0 (10 mods)
Tier 1 essentials for the spawn-town aesthetic:
- Macaws Roofs 2.3.2          proper sloped roofs (no more staircase hacks)
- Macaws Paths and Pavings 1.1.1  streets + sidewalks + plot boundaries
- Macaws Bridges 3.1.2         covered walkways + market awnings
- Macaws Lights and Lamps 1.1.5  streetlights + decorative lighting
- Supplementaries 3.6.5        vanilla++ decoration (signposts!), needs Moonlight

Tier 2 (skipping Chipped per user -- discuss JEI clutter separately):
- Macaws Windows 2.4.2         window variety
- Macaws Doors 1.1.5           door variety
- Create Deco 2.1.3            Create-aesthetic decoration, fits pack vibe

Bonus:
- Effortless Building 4.1      WorldEdit-lite survival building tool
- Moonlight 3.0.14             required dep for Supplementaries

All side=both. Total added ~25 MB. 62 lockfile entries now.
Pack 0.22.6 -> 0.23.0.
Pre-flight Check-Deps clean: all 87 mod IDs (incl jar-in-jar bundles)
satisfied.
2026-05-23 18:26:18 +00:00
Matt 0d74c9ef97 Merge pull request 'plots: actual coin values (non-uniform chain)' (#39) from fix/coin-values-actual into main 2026-05-23 18:19:37 +00:00
Matt a41ae52d9b plots: AUTHORITATIVE coin values (non-uniform), 0.22.5 -> 0.22.6
User confirmed in-game tooltip values:
  spur=1, bevel=8, sprocket=16, cog=64, crown=512, sun=4096.

Chain is non-uniform (x8, x2, x4, x8, x8). My earlier guesses
(uniform x8, assuming Sun=64 Cogs) gave consistent but wrong
intermediate values. Now sourced from the mod's own tooltip, not
triangulated.

Plot prices at 100/250/600 spurs now mean roughly:
  100 spurs = 6 bevels + 4 spurs (1 sprocket + 5 bevels + 4 spurs)
  250 spurs = 3 cogs + 3 bevels + 2 spurs
  600 spurs = 9 cogs + 3 bevels (... still small change tier)

User-side: edit PLOT_DEFS in plots.js to set real prices once the
spawn marketplace and economy scale are decided.
2026-05-23 18:19:37 +00:00
Matt 59d50c9ee6 Merge pull request 'plots: correct coin chain (6 denoms incl sprocket)' (#38) from fix/coin-values-with-sprocket into main 2026-05-23 17:59:58 +00:00
Matt b85eef04bd plots: correct Numismatics coin chain (6 denoms incl sprocket), 0.22.4 -> 0.22.5
Was: 5-denom x8 chain (spur=1, bevel=8, cog=64, crown=512, sun=4096).
Now: 6-denom x8 chain with sprocket inserted between bevel and cog.

Verified in-game (user reported Sun = 64 Cogs, which only fits these
values: spur=1, bevel=8, sprocket=64, cog=512, crown=4096, sun=32768).

Plot purchase math (countCoins, takeCoins, giveCoins) was using the
old 5-denom map: cog would have been counted as 64 spurs when it's
actually 512, and sprockets weren't counted at all. Plot prices remain
at 100/250/600 spurs which are still appropriate small-change amounts;
user can edit PLOT_DEFS to raise once playtesting confirms desired
economy scale.
2026-05-23 17:59:57 +00:00
Matt 179486bd2c Merge pull request 'Simplify coin tooltips + include sprocket' (#37) from fix/coin-tooltips-simplify into main 2026-05-23 17:55:44 +00:00
Matt fad867976f tooltips: simplify coin tooltips (Numismatics already shows value), include sprocket, 0.22.3 -> 0.22.4
Three corrections per user feedback:
1. Drop the conversion-crib lines -- Numismatics' built-in tooltip
   already shows 'Value: N spurs' so our added lines were duplicate.
2. Include sprocket (numismatics:sprocket) -- the 6th denomination
   I missed when first writing the script. Full set: spur, bevel,
   sprocket, cog, crown, sun.
3. Keep only the actually-useful hint: 'Right-click a Bank Teller
   block to convert denominations' -- the one piece of info that
   isn't on the mod's own tooltip.

Decision punted (deliberately): no 8-spurs-shapeless-to-1-bevel
crafting recipes. The Bank Teller is the spawn-economy anchor;
crafting recipes would let players bypass the bank entirely. Players
who want local conversion can place their own Teller -- which is the
intended Numismatics design.
2026-05-23 17:55:44 +00:00
Matt 858fa690ba Merge pull request 'Build-Pack: tweak jars default to side=server (urgent client fix)' (#36) from fix/tweak-jars-server-only into main 2026-05-23 17:40:35 +00:00
Matt 135ecf4a5a Build-Pack: default tweak jars to side=server, 0.22.2 -> 0.22.3
URGENT client-side fix. brassandsigil_tweaks-1.0.0.jar (built from
pack/tweaks/ into pack/overrides/mods/) declares lithostitched as a
hard dependency in its mods.toml. lithostitched is tagged side=server
in the lockfile because it's worldgen-only. brassandsigil_tweaks had
no explicit side tag so Build-Pack defaulted it to side=both. Clients
downloaded the tweak jar but not the dep -> NeoForge refused to start
with 'Mod brassandsigil_tweaks requires lithostitched 1.7 or above'.

Fix: Build-Pack now injects side='server' on any local-override file
under mods/. All tweak jars are data-only (worldgen modifiers, recipe
overrides) and pure server-side. If a future tweak is actually client-
relevant, this rule will need a per-file exception.

Tested in build output -- the brassandsigil_tweaks entry now shows
side='server' in the generated manifest.
2026-05-23 17:40:34 +00:00
Matt 5813c7555e Merge pull request 'tooltips: correct event name to modifyTooltips' (#35) from fix/tooltip-event-name into main 2026-05-23 17:37:31 +00:00
Matt e1a77d2e19 tooltips: ItemEvents.tooltip -> ItemEvents.modifyTooltips, 0.22.1 -> 0.22.2
KubeJS 2101.x has ModifyItemTooltipsKubeEvent (the modify-tooltips API);
the older 'tooltip' event name doesn't exist in this version.
2026-05-23 17:37:30 +00:00
Matt 31f7d5428c Merge pull request 'Numismatics: conversion-crib coin tooltips' (#34) from feature/numismatics-tooltips into main 2026-05-23 17:36:03 +00:00
Matt f19e7b187e numismatics: add conversion-crib tooltip to each coin, 0.22.0 -> 0.22.1
Numismatics' x8 uniform chain isn't intuitive. Mod values are
hardcoded so we cant fix the ratios, but we can put the conversion
table directly on each coins tooltip via KubeJS ItemEvents.tooltip.
Players hover any coin -> see how it relates to the others + a hint
to use Bank Teller for on-the-fly conversion.

~25 lines, ItemEvents.tooltip hot path is cached per item-id so no
performance concern.
2026-05-23 17:36:02 +00:00
Matt f3134d23c4 Merge pull request 'Add Configured 2.6.3 (CurseForge)' (#33) from feature/add-configured into main 2026-05-23 17:30:53 +00:00
Matt f72be58c6a pack: add MrCrayfish's Configured 2.6.3 (CurseForge), bump to 0.22.0
Configured is the in-game config GUI library JEI suggests when you
press its wrench icon (and the same library a bunch of other mods use
for their config screens). CurseForge-only (not on Modrinth) so we
add as source: curseforge -- following the same pattern as the FTB
mods already in the lockfile.

Tagged side=client because Configured is purely a client-side config
UI provider; the server doesn't need it. Server-side filter will skip
it on sync.

Filename gotcha noted for future CF adds: the CF web page shows the
mod with a friendly name like 'Configured 2.6.3.jar', but the actual
file on mediafilez.forgecdn.net is named 'configured-neoforge-1.21.1
-2.6.3.jar'. Use the CDN filename in the URL, not the web display
name.
2026-05-23 17:30:52 +00:00
Matt 8e816bc067 Merge pull request 'launcher: record 0.4.10 version bump' (#32) from chore/launcher-version-0.4.10 into main 2026-05-23 17:06:30 +00:00
Matt f757c2d31c launcher: record version bump to 0.4.10
The 0.4.10 build was already deployed (replacing the broken 0.4.9 that
shipped without launcher-config.json embedded). This commit just makes
the repo match what's live so a future 'git status' isn't dirty.
2026-05-23 17:06:30 +00:00
Matt 804d085e36 Merge pull request 'rpo: add required:true to force-enable CC pack' (#31) from feature/rpo-required-true into main 2026-05-23 17:05:53 +00:00
Matt 05e700cfc7 rpo: try 'required: true' to force-enable CC ReCreated, 0.21.2 -> 0.21.3
Players with an existing options.txt (i.e. anyone who has launched MC
once) don't get the 'default_packs' applied -- RPO only treats those as
defaults for fresh installs.

Adding 'required: true' to the override entry on the assumption that
the field forces the pack to be enabled on every launch regardless of
player choice. Decompiled the jar for string constants and 'required'
appears alongside 'force_compatible' as a known key.

If the field name is wrong RPO will silently ignore it -- no harm, easy
iteration.
2026-05-23 17:05:52 +00:00
Matt a139196f77 Merge pull request 'launcher: EnableWindowsTargeting for Linux builds' (#30) from feature/cross-compile-launcher-from-linux into main 2026-05-23 16:40:24 +00:00
Matt 4315696136 launcher: add EnableWindowsTargeting for cross-compile from Linux
Microsoft.NET.Sdk.WindowsDesktop emits NETSDK1100 when a Windows-
targeting project is built from a non-Windows host. Setting
EnableWindowsTargeting=true opts in to the cross-compile path, which
works on Linux as long as the WindowsDesktop SDK files are present.

On glados they were copied from a Windows .NET 8 SDK archive into
/usr/lib/dotnet/sdk/8.0.126/Sdks/Microsoft.NET.Sdk.WindowsDesktop/
and /usr/lib/dotnet/packs/Microsoft.WindowsDesktop.App.Ref/. This is
a one-time setup per Linux host -- see the bridge-claude memory note.

No-op on Windows hosts (the flag has no effect when the SDK is native).
Both PC builds and glados builds now produce the same output.
2026-05-23 16:40:23 +00:00
Matt af45997a8b Merge pull request 'launcher: version 0.4.9' (#29) from chore/launcher-version-bump into main 2026-05-23 16:35:13 +00:00
Matt 444cadd921 launcher: bump version 0.4.8 -> 0.4.9 for session-refresh fix
So existing 0.4.8 installs see the upgrade banner in their current
launcher after the next deploy. The actual session-refresh code change
landed in the previous commit (DoLaunchAsync pre-launch refresh).
2026-05-23 16:35:13 +00:00
Matt 36acbafea5 Merge pull request 'launcher: refresh Microsoft session before launch' (#28) from feature/launcher-pre-launch-refresh into main 2026-05-23 16:31:17 +00:00
Matt 8e00526d27 launcher: refresh Microsoft session right before launch
Microsoft access tokens have a ~1 hour TTL. The launcher cached the
MSession once at sign-in and re-used it on every Play click, so leaving
the launcher open for >1 hour and then hitting Play sent stale
credentials to MC -- Mojang's session check on the server then rejected
the join with "Invalid session".

Fix: in DoLaunchAsync, before assembling the MC launch command, call
_auth.TryAuthenticateSilentlyAsync(). XboxAuthNet's silent flow uses
the cached refresh token (~14 day TTL) to mint a fresh access token
transparently. On success ApplySession swaps in the new MSession and
we launch with valid credentials. On failure (refresh token also
stale) the launcher prompts the player to sign in again -- they keep
the launcher open, hit Sign In, no MC restart needed.

No new dependencies, no UI change beyond the brief "Refreshing
session..." status line, no perf cost beyond the ~500ms HTTP roundtrip
to Microsoft each launch.

Bump the launcher version in launcher/ModpackLauncher.csproj before
publishing so old launcher installs see the upgrade banner.
2026-05-23 16:31:17 +00:00
Matt 67870f0648 Merge pull request 'Drop jei_hides.js (wrong JEI API binding)' (#27) from fix/drop-jei-hides into main 2026-05-23 16:22:01 +00:00
Matt d24f7f03a3 kubejs: drop jei_hides.js (wrong API binding), 0.21.1 -> 0.21.2
Two consecutive client_scripts errors traced to jei_hides.js:
1. Array-spread syntax not supported by Rhino (fixed in 0.21.1)
2. JEIEvents global is not defined in KubeJS 2101.7 -- the actual JEI
   event API uses classes like JEIRemoveEntriesKubeEvent but the binding
   name is something different we haven't identified yet.

Rather than ship more guesses, drop the script entirely. Functional
impact is minimal: recipes for waystones + coins are still removed
server-side (recipes_waystones.js, recipes_numismatics.js), so players
cannot craft these items even if they appear in JEI search. The only
loss is some JEI clutter.

When we identify the correct JEI hide API in KubeJS 2101.x, can add
back as kubejs/client_scripts/jei_hides.js with the right binding.
2026-05-23 16:22:01 +00:00
Matt 28a02d0ced Merge pull request 'jei_hides: Rhino-compatible (no array spread)' (#26) from fix/jei-hides-rhino-spread into main 2026-05-23 16:18:51 +00:00
Matt d5fd2c97e9 jei_hides: use .concat instead of array-spread (Rhino compat). 0.21.0 -> 0.21.1.
KubeJS uses Rhino as its JS engine, which doesn't support array-spread
syntax (`[...a, ...b]`). The line `[...WAYSTONE_ITEMS, ...COIN_ITEMS]
.forEach(...)` threw EvaluatorException: syntax error at jei_hides.js:46
on client launch, surfacing the KubeJS client script errors dialog.

Fix: use Array.prototype.concat() which is universal JS. Also extracted
the hide-quietly helper into a named function for readability.
2026-05-23 16:18:50 +00:00
Matt ae061a5035 Merge pull request 'v0.21.0: Plot system V1' (#25) from feature/plots-v1 into main 2026-05-23 16:02:05 +00:00
Matt 6264785f2c plots: v1 spawn-commercial-plots system, bump to 0.21.0
/bns plot list|info|buy|release|reload. Single ~316-line file with
clear sections. Hardcoded PLOT_DEFS Map (3 example plots), ownership
state in server.persistentData.bnsPlots, best-effort Numismatics +
FTB Chunks integration (will be verified in playtest). See file
header for full design notes.
2026-05-23 16:01:47 +00:00
Matt 998ec21bb5 Merge pull request 'Fix bns_admin command registration' (#24) from fix/bns-admin-int-range into main 2026-05-23 15:41:34 +00:00
Matt aa881bfc65 bns_admin: drop INTEGER range args (KubeJS wrapper doesn't expose them), validate in handler instead. 0.20.0 -> 0.20.1. 2026-05-23 15:41:34 +00:00
Matt 3cec2cb69b Merge pull request 'v0.20.0: KubeJS tidying + admin commands' (#23) from feature/v0.20.0-tidying into main 2026-05-23 15:40:00 +00:00
Matt c699dcc77b pack: KubeJS tidying + admin commands, bump to 0.20.0
Tidying pass on the KubeJS suite before plot system V1 lands. Sets up
the file structure + conventions + diagnostics so the larger plot
system has a clean foundation.

Changes:

1. NEW kubejs/README.md
   File layout doc + conventions: logging prefix [bns/<module>],
   persistent-data key naming (bns* prefix), one concern per file,
   performance discipline (O(1) lookups, no polling).

2. Split economy_policy.js -> recipes_waystones.js + recipes_numismatics.js
   Single-concern files. recipes_waystones removes ALL waystones
   crafting (welcome.js distributes the only allowed waystone instead).
   recipes_numismatics removes the 5 coin-denomination crafts.

3. NEW kubejs/server_scripts/bns_admin.js
   /bns admin subcommands for OP diagnostics:
     - info                       calling player's flags+counts
     - waystone-count <player>    read stored count
     - waystone-set <player> <n>  override stored count
     - reset-welcome <player>     clear bnsWelcomeGranted -- replays
                                  the welcome grant on next login
   Registered via ServerEvents.commandRegistry with Brigadier tree,
   permission level 2 (OP). No performance cost (operator-triggered only).

4. Logging added to waystones_policy.js + welcome.js
   Every action prints `[bns/<module>] ...` so server logs can be
   filtered with `journalctl ... | grep '\[bns/'`.

Performance discipline applied:
- All event-handler lookups use Set.has (O(1)), not Array.includes
- No periodic tick polling anywhere
- No network calls in event handlers
- console.info is cheap (no string format unless logged)

Bumps to 0.20.0. Plot system V1 follows in v0.21.x as a separate
multi-file submodule under server_scripts/plots/.
2026-05-23 15:39:59 +00:00
Matt f39205404a Merge pull request 'RPO: default-enable CC Recreated pack' (#22) from feature/rpo-default-config into main 2026-05-23 15:32:49 +00:00
Matt b3d46931b2 rpo: ship default-enable config for ComputerCraft Recreated, bump 0.19.5
Resource Pack Overrides config field names extracted directly from the
mod's class files (default_packs + default_overrides confirmed via grep
on the .class strings). Config ships through defaultconfigs/ so it
seeds the player's local config on first run and respects later edits.

The 'default_position: TOP' override puts the ReCreated pack at the top
of the enabled-packs list so it takes precedence over the vanilla
ComputerCraft textures.

If the filename or schema turns out to be off when tested, it's a
one-line follow-up to fix.
2026-05-23 15:32:49 +00:00
Matt 5d0011d4e2 Merge pull request 'Add Resource Pack Overrides + revert server-push' (#21) from feature/resource-pack-overrides into main 2026-05-23 15:27:11 +00:00
Matt c6fb859db8 pack: add Resource Pack Overrides, revert server-push enforcement
Two changes per user direction:
- server.properties is reverted (resource-pack lines cleared, require=
  false). No more server-pushed pack -- no prompt on join, no kick if
  declined, no redundant re-download of a file the launcher already
  shipped.
- Adds resource-pack-overrides (Modrinth's gold-standard "auto-enable
  bundled resource packs" mod, 8.2M dl, client-only). The Computer
  Craft Recreated v1.2.zip already ships in the modpack manifest
  (side=client) so it's already in players' resourcepacks/ folder via
  the launcher -- RPO will be configured to default-enable it.

The RPO config (`pack/overrides/config/resourcepackoverrides-client.toml`
or similar) lands in a follow-up once we've seen the generated format.
For now this commit just gets the mod into the pack. Bumps to 0.19.4.
2026-05-23 15:27:10 +00:00
Matt 86d8f3e80d Merge pull request 'CC recipes: plastic-everywhere + advanced-contains-basic' (#20) from feature/cc-recipes-progression into main 2026-05-23 15:20:21 +00:00
Matt 3e1f5c01ed cc: plastic-everywhere + advanced-contains-basic upgrade chains
Two design changes:

1) Every electronic CC item now requires tfmg:plastic_sheet. Plastic is
   the electrical insulator and gates ALL CC tech behind TFMG's full
   oil chain (crude oil -> naphtha -> distillation -> molten plastic
   -> sheet). Even cable -- you can't lay wires without insulation.

2) Advanced items now embed their Basic counterpart in the recipe:
   - computer_advanced  contains  computer_normal
   - monitor_advanced   contains  monitor_normal
   - wireless_modem_advanced  contains  wireless_modem_normal
   - pocket_computer_advanced contains pocket_computer_normal
   - turtle_advanced    contains  computer_advanced  (which contains
                                  computer_normal -- chained upgrade)

   This makes the progression literal -- you can't skip Basic to make
   Advanced. You upgrade through, paying both costs.

Other tweaks:
- Replaced create:railway_casing with create:industrial_iron_block in
  the Advanced Computer recipe. Railway casing is thematically tied to
  trains, not computing -- creates a weird "build trains to make a
  computer" dependency. industrial_iron_block fits the server-rack
  aesthetic and is a more general Create tier-3 material.
- Added plastic to disk_drive, wired_modem, printer, redstone_relay,
  speaker (recipes that previously skipped it).

Bumps to 0.19.3.
2026-05-23 15:20:20 +00:00
Matt 34e24df0b3 Merge pull request 'Drop orphan pocket_computer_colour recipe' (#19) from fix/cc-pocket-colour into main 2026-05-23 15:12:13 +00:00
Matt ce8304d6cb cc: drop orphan pocket_computer_colour recipe (not a real item, just a state variant of _advanced). Removes startup warning. 0.19.1 -> 0.19.2. 2026-05-23 15:12:12 +00:00
Matt 7eb70bfa78 Merge pull request 'Full CC recipe overhaul (19 items, 5 tiers)' (#18) from feature/cc-recipes-full into main 2026-05-23 15:11:11 +00:00
Matt add5803191 pack: full CC recipe overhaul — 19 items across 5 progression tiers
Expands the v0.19.0 recipe set from 10 to 19 items and builds a deliberate
5-tier progression using richer Create + TFMG materials.

Newly overhauled items:
  T1 - cable (8-output, copper_wire-heavy so bulk-laying is viable)
  T3 - redstone_relay
  T3 - turtle_normal
  T4 - turtle_advanced
  T5 - pocket_computer_normal, _advanced, _colour
  Misc - wired_modem_full (block form, derived from wired_modem)

Existing items reworked with better thematic materials:
  computer_normal/advanced  -> now uses create:brass_casing / railway_casing
                                as the chassis instead of bare plastic
  monitor_normal/advanced   -> nixie_tube for the retro CRT, display_link
                                for the advanced data-binding monitor
  wireless_modem_normal     -> tfmg:electromagnetic_coil is the antenna,
                                copper_wire + aluminum_wire signal lines
  wireless_modem_advanced   -> constantan_spool + large_coil for high-Q
                                tuning -- properly end-game wireless
  disk_drive                -> create:cogwheel for the spinning read head
  speaker / printer         -> copper_sheet / cogwheel for thematic flair

Wire usage is the through-line: copper_wire in T1-T2 (cheap, used in
quantity), aluminum_wire in T3 mid-tier signal lines, constantan_wire
+ constantan_spool in T4 for high-frequency wireless. TFMG's electrical
production now genuinely gates CC progression.

Bumps pack to 0.19.1 (point release — same mod set, expanded scripts).
2026-05-23 15:11:11 +00:00
Matt e74f123896 Merge pull request 'v0.19.0: Easy NPC + CC: Tweaked + recipe overhaul' (#17) from feature/v0.19.0-npc-cc-plots into main 2026-05-23 14:59:55 +00:00
Matt fe5b7ce1ea pack: add Easy NPC + CC: Tweaked + recipe overhaul, bump to 0.19.0
Mods (5 additions):
- Easy NPC 6.16.1 (3 jars: bundle + core + config_ui). Lightweight,
  config-UI-driven NPC mod -- 817k downloads, NOT the heavyweight
  Custom NPCs by Noppes which has no 1.21.1 NeoForge port. Players
  will interact with NPCs at spawn (Plot Office land clerk + future
  shop NPCs) via right-click -> dialogue.
- CC: Tweaked 1.119.0. ComputerCraft tier added for technical displays,
  server stats, and player-built automation. Future plot-system V2
  could use CC monitors as the central kiosk with touch buttons; V1
  uses NPC + signs.
- Computer Craft: ReCreated v1.2 (resource pack, client-only). Styles
  CC's computers + monitors with a brass/wooden Create aesthetic so
  they don't look out of place in the pack.

KubeJS scripts:
- cc_recipes.js: Overhauls 10 CC crafting recipes to require Create
  (electron_tube, precision_mechanism, rose_quartz) + TFMG (plastic
  sheet, steel ingot, silicon ingot). Gates the tech tier behind
  Create+industrial progression. Recipes for cables, peripheral
  blocks, turtles, etc. left at default -- can iterate later.

Plot system V1 deferred to v0.20.0 -- needs its own focused effort
because of the FTB Chunks claim-transfer integration + Numismatics
inventory coin handling. Memory plan already covers the design.
2026-05-23 14:59:55 +00:00
Matt d3a9d4e12a Merge pull request 'Fix Numismatics coin IDs (5 denominations)' (#16) from fix/numismatics-coin-ids into main 2026-05-23 14:18:12 +00:00
Matt 01e1e4a502 pack: correct Numismatics coin IDs (5 denominations, not 4)
The 0.18.0 economy_policy.js named 'numismatics:sundial' which doesn't
exist -- the real registry IDs are spur, bevel, cog, crown, sun. The
KubeJS warning surfaced this at startup:

  Failed to read ingredient from numismatics:sundial:
  Item with ID numismatics:sundial does not exist!

Updates both economy_policy.js (recipe removal) and jei_hides.js (JEI
hide) to use the correct 5-denomination list. Bumps to 0.18.1.
2026-05-23 14:18:11 +00:00
Matt e53b4cc868 Merge pull request 'Economy MVP: Numismatics+Patchouli+welcome flow' (#15) from feature/economy-mvp into main 2026-05-23 14:16:45 +00:00
Matt 2ac4b2f2cd pack: economy MVP -- Numismatics + Patchouli + welcome flow, bump to 0.18.0
Adds the currency layer and the first-join experience.

Mods:
- Create: Numismatics 1.0.20  (currency, vendor blocks, piggy banks)
- Patchouli 1.21.1-93         (kept in pack for future rich manual book)

KubeJS scripts (server_scripts/):
- economy_policy.js  remove ALL Waystones recipes; remove coin recipes
                     only (piggy banks etc. stay craftable for now)
- welcome.js         first-login grant: 1 waystone + vanilla written-book
                     "Brass & Sigil Manual v1" (5 pages: welcome,
                     waystones, money, spawn plots, rules). Per-player
                     flag bnsWelcomeGranted in persistentData gates it.

KubeJS scripts (client_scripts/):
- jei_hides.js       hide all Waystones items + 4 coin denominations from
                     the JEI ingredient list. Combined with the recipe
                     removal players never encounter these items via
                     normal play -- only via the welcome grant or the
                     bank exchange.

The written-book manual is the MVP placeholder. When more content lands
(plot system, dynamic exchange, etc.) it'll be replaced by a proper
Patchouli book authored as a tweak jar -- Patchouli's already in the
lockfile so no mod-add will be needed at that point.
2026-05-23 14:16:45 +00:00
Matt d60c0176dd Merge pull request 'Waystones policy KubeJS (1/player + ban share/portstones)' (#14) from feature/waystones-policy-kubejs into main 2026-05-23 13:40:30 +00:00
Matt b064a9f515 pack: KubeJS Waystones policy (1-per-player + ban share/portstones)
Adds pack/overrides/kubejs/server_scripts/waystones_policy.js which:

- Caps regular waystones at 1 per player. Per-player count is stored in
  player.persistentData and decremented on break (works correctly because
  restrictedWaystones = ["PLAYER"] means only owners can break).
- Bans all 16 sharestone variants. They defeat the "build transport"
  intent (solo pair-teleport) and can be placed just outside a claim
  for stealth proximity access.
- Bans all 16 portstone variants pending separate design review.
- Leaves warp plates and warp scrolls untouched.

Bumps to 0.17.0 so launcher re-syncs.
2026-05-23 13:40:29 +00:00
Matt c29dd2d690 Merge pull request 'Fix gitignore + track Waystones override' (#13) from fix/gitignore-overrides into main 2026-05-23 12:57:42 +00:00
Matt 0bdc48c86f gitignore: scope overrides ignore to mods/, track defaultconfigs/
The previous rule 'pack/overrides/' ignored the whole directory, which
made it impossible to re-include subpaths via ! exception (git's rule).

Narrow the ignore to 'pack/overrides/mods/' -- the only path that
Build-Tweaks.ps1 actually writes -- and drop the broken whitelist
block. Other subdirs under pack/overrides/ (defaultconfigs/, etc.) now
live in git like normal source.

Adds the Waystones override that 0.16.1 attempted (spawnInVillages =
DISABLED). Bumps to 0.16.3 so launcher caches re-sync.
2026-05-23 12:57:41 +00:00
Matt 02e50f2fe5 Merge pull request 'Track pack/overrides/defaultconfigs/ + Waystones override' (#12) from feature/track-defaultconfigs into main 2026-05-23 12:56:13 +00:00
Matt 50007e1109 pack: track pack/overrides/defaultconfigs/ + ship Waystones override
.gitignore previously ignored all of pack/overrides/ because that dir is
the build output of Build-Tweaks.ps1. But defaultconfigs/ inside it holds
hand-written source-of-truth configs that should be in git (otherwise
they don't survive cross-machine deploys). Whitelisting that subdir.

Adds pack/overrides/defaultconfigs/waystones-common.toml -- the same
file the empty 0.16.1 commit was supposed to ship but couldn't because
of the .gitignore rule. Bumps to 0.16.2.

The override changes just one key:
- spawnInVillages: REGULAR -> DISABLED

All other Waystones settings stay at mod default (ACTIVATION visibility,
PLAYER restriction, empty allowedVisibilities) which already provides
the base-intrusion protections we discussed.
2026-05-23 12:56:13 +00:00
Matt d4dfcb1e11 Merge pull request 'Tune Waystones config + ship via defaultconfigs' (#11) from feature/waystones-config into main 2026-05-23 12:55:19 +00:00
Matt 3dc0767a24 pack: tune Waystones config + bump to 0.16.1
Ships an override of waystones-common.toml under pack/overrides/
defaultconfigs/ so first-run installs (clients + fresh servers) get the
intended defaults. NeoForge auto-copies defaultconfigs/<file> into
config/<file> on first run only -- player-tweaked configs after that
are preserved across pack updates.

The only override vs mod-default in this commit:
- spawnInVillages: REGULAR -> DISABLED

Everything else stays at mod default, which already gives the security
posture we want:
- defaultVisibility = ACTIVATION  -> each player must physically visit
  a waystone before they can teleport TO it. Solves the "player B
  teleports into player A's base" concern without a hard per-player
  placement cap (which v21.x dropped).
- restrictedWaystones = [PLAYER]  -> only owners can edit their own
  waystones.
- allowedVisibilities = []        -> no player UI can create global
  waystones; OPs use the /waystone command for server-public ones.

Wild waystones (in the open world, not villages) stay on -- they're
public-good fast travel, not tied to bases.
2026-05-23 12:55:18 +00:00
Matt c996d4edff Merge pull request 'Add Waystones + Balm' (#10) from feature/add-waystones into main 2026-05-23 12:53:27 +00:00
Matt b080dfe325 pack: add Waystones + Balm, bump to 0.16.0
Waystones 21.1.33: fast-travel teleport network.
Balm 21.0.58: required common lib for Waystones (and other BlayTheNinth mods).

Config will be tweaked in a follow-up after the server generates its
default config files. Intended config:
- worldgen village waystones DISABLED
- maxWaystonesPerPlayer = 1
- defaultPrivacy = Private (player-placed)
- OP-placed global waystones via /waystone command
2026-05-23 12:53:26 +00:00
Matt ce28a44e89 Merge pull request 'Add Check-Deps pre-flight (mods.toml ground truth)' (#9) from feature/check-deps-preflight into main 2026-05-23 11:55:38 +00:00
Matt 40dd06b8cf scripts: add Check-Deps pre-flight (mods.toml ground truth)
Adds scripts/Check-Deps.ps1 and wires it into Deploy-Brass.ps1 as a
pre-flight gate when Stage includes Pack. The script parses each lockfile
jar's META-INF/neoforge.mods.toml directly -- the same source the loader
reads at startup -- and refuses to deploy if any [[dependencies]] block
with type=required (default) names a modId that nobody in the pack
provides.

Handles three real edge cases discovered during initial run:

1. Jar-in-jar bundled deps: Create ships Ponder, Registrate, Flywheel
   embedded under META-INF/jarjar/. The script recurses into those and
   counts their modIds as present, since the loader auto-loads them.

2. Language-provider jars (Kotlin for Forge) have no standard mods.toml
   -- they declare via a different mechanism. Tiny slugToImplicitModId
   override map (currently 1 entry) covers them.

3. Non-mod lockfile entries (shaderpacks under shaderpacks/, configs
   under config/, etc.) are skipped explicitly by path prefix check.

Vanilla deps (minecraft, neoforge, forge, fabric, java, javafml, etc.)
are pre-seeded as present.

Jars cached at /tmp/bns-check-deps-cache/<sha1>.jar; second-run cost is
~1.5s. First run downloads ~150MB.

The script would have caught the v0.15.0 -> v0.15.1 incident: Slice &
Dice's jar declares kotlinforforge required in mods.toml even though
Modrinth's dep field only lists Create. The Modrinth-only check (earlier
draft) wouldn't have helped; the mods.toml-based check does.
2026-05-23 11:55:38 +00:00
Matt 06d6a5ef45 Merge pull request 'Hotfix: add Kotlin for Forge (Slice & Dice dep)' (#8) from fix/kotlin-for-forge-dep into main 2026-05-22 21:00:38 +00:00
Matt 4ea919432e pack: add Kotlin for Forge 5.11.0, bump to 0.15.1
Hotfix for 0.15.0: Slice & Dice declares 'kotlinforforge >= 5.8' as a
hard dependency and the server crashed on load without it. Adding KFF
satisfies the dep. 6.9 MB, both sides.
2026-05-22 21:00:37 +00:00
Matt 6ccd83da5f Merge pull request 'Add Farmer's Delight + Slice & Dice' (#7) from feature/add-farmers-delight into main 2026-05-22 20:58:38 +00:00
Matt 89c41dfe4d pack: add Farmer's Delight + Slice & Dice, bump to 0.15.0
- Farmer's Delight 1.3.2 (3.2 MB): cooking expansion -- new crops
  (tomato, onion, rice, cabbage), cooking pot, stove, cutting board,
  knife, ~50+ food recipes. Both sides required.
- Create Slice & Dice 4.2.4 (407 KB): FD<->Create automation bridge.
  Adds a Slicer machine (Mechanical Press/Mixer-style) that automates
  the knife/cutting-board step so Create kinetic networks can feed FD
  prep. Loader-shared jar (forge+neoforge from one file).

Both tagged "both". Brings live mod count to 41.
2026-05-22 20:58:37 +00:00
Matt 8b599a7da7 Merge pull request 'Add AppleSkin + Mouse Tweaks' (#6) from feature/add-appleskin-mousetweaks into main 2026-05-22 20:49:14 +00:00
Matt 6143ddc111 pack: add AppleSkin + Mouse Tweaks, bump to 0.14.0
Two client-leaning QoL mods.

- AppleSkin 3.0.9: food + saturation tooltips. Modrinth side=optional both;
  tagged "both" so server still installs (harmless, ~75KB).
- Mouse Tweaks 2.26.1: shift-drag, scroll-transfer, auto-refill, click-and-
  drag stacking in inventory UI. Modrinth lists server=unsupported, so
  explicitly tagged side=client -- this is the first new mod that exercises
  the server-side filter from the 0.10.0 schema migration: server's sync
  should skip downloading it.
2026-05-22 20:49:14 +00:00
Matt a29c0e3cab Merge pull request 'Add Spark profiler 1.10.124' (#5) from feature/add-spark into main 2026-05-22 20:46:13 +00:00
Matt 3bcf8bc4f8 pack: add Spark profiler 1.10.124 + bump to 0.13.0
Best-in-class MC profiler. Server side: /spark sampler start, /spark
heap, /spark tps. Optional client side too. ~3.5MB, both sides optional
per Modrinth -- tagged "both" so launcher syncs it too.
2026-05-22 20:46:12 +00:00
Matt 2e40732e69 Merge pull request 'Add Almost Unified 1.4.2' (#4) from feature/add-almost-unified into main 2026-05-22 20:34:32 +00:00
Matt 987fedd7e4 pack: add Almost Unified 1.4.2 + bump to 0.12.0
Preemptive insurance against duplicate-item proliferation when more
metals-producing mods are added (e.g. when Power Grid lands, or any
future Mekanism/Thermal/etc. addition). Today's pack only has TFMG as
the metals provider so AU is largely a no-op now, but ships with sane
default tag preferences that activate automatically as duplicates appear.

Tiny mod (~290 KB jar), client-optional/server-required per Modrinth.
2026-05-22 20:34:32 +00:00
Matt 8d0c05bd8a Merge pull request 'Add Simple Voice Chat 2.6.17' (#3) from feature/add-simple-voice-chat into main 2026-05-22 20:23:14 +00:00
Matt 95966d2d14 pack: add Simple Voice Chat 2.6.17 + bump to 0.11.0
Adds proximity voice chat to the modpack. Server uses UDP/24454 by default
(firewall rule added separately).

Modrinth lists both sides as "optional" -- players without it can still
join, just no voice. Tagged "both" in the lockfile so server + launcher
both install it.
2026-05-22 20:23:07 +00:00
Matt 839d93f242 Add explicit client/server side field to manifest (#2) 2026-05-22 15:15:03 +00:00
Matt 4ee4a2bb43 scripts: make Build-Pack and Deploy-Brass cross-platform
Two small fixes that surface when running the deploy pipeline on a
non-Windows host (we now do this on glados via the Telegram bridge):

- Build-Pack.ps1: fall back to launcher/ModpackLauncher.csproj <Version>
  when Get-Item.VersionInfo.FileVersion returns empty. Linux PowerShell
  can't parse PE FileVersion from a Windows .exe, so the previous code
  threw "set <Version> in ModpackLauncher.csproj and republish" -- but
  the csproj <Version> *was* set, just not readable from a foreign PE.
  Appends ".0" so the embedded value still matches the 4-part form the
  Windows compile bakes in.

- Deploy-Brass.ps1: prefer rsync where available, fall back to robocopy
  otherwise. robocopy is Windows-only and bails on Linux/macOS hosts.
2026-05-22 14:23:09 +00:00
Matt 4594cb9c00 Add explicit client/server side field to manifest
Bake each file's "side" (client / server / both) into manifest.json at
build time so both the launcher and the server filter deterministically
and offline. This replaces the launcher's previous "download everything"
default and lets the server short-circuit its runtime Modrinth lookup
when a file already has an authoritative tag.

Schema:
- ManifestFile.Side: "client" | "server" | "both" (null = "both" for
  backward compat with manifests pre-dating this field)
- Files marked "both" omit the field entirely to keep the JSON tight

Code changes:
- launcher Manifest.cs + ManifestSyncService: filter out "server" files
  in both the prune+download path and FindMissingFiles
- server Manifest.cs + ManifestSync: drop "client" files outright; only
  fall back to the existing runtime Modrinth lookup for files with no
  explicit side (legacy/un-tagged mods stay protected)
- server LockedMod: side field propagates into manifest at build time
- scripts/Build-Pack.ps1: propagate side from lockfile to manifest
- scripts/Bootstrap-Sides.ps1: one-off populator; queries Modrinth's
  client_side/server_side per project, conservatively marks restricted
  only when one side is "unsupported", leaves ambiguous cases as "both"
- pack/pack.lock.json: bootstrap-populated sides (3 client, 5 server,
  rest both); CurseForge mods default to "both" pending manual review;
  version bumped 0.9.3 -> 0.10.0 since clients must re-sync

Compatibility:
- Old launcher + new manifest: ignores unknown "side", downloads all
- New launcher + old manifest: side null -> "both", installs all
- Old server + new manifest: same -- runtime Modrinth lookup still works
- New server + old manifest: side null -> runtime lookup, same behaviour
2026-05-22 14:17:43 +00:00
56 changed files with 5989 additions and 365 deletions
+2 -1
View File
@@ -55,8 +55,9 @@ CLAUDE.md
# ─── Build artifacts that get regenerated ─────────────────────────────────
# Tweak jars rebuilt by scripts/Build-Tweaks.ps1
pack/overrides/
pack/overrides/mods/
# manifest.json regenerated by scripts/Build-Pack.ps1 -- produced at scripts/
# (default OutputPath) and copied to the deploy share by Deploy-Brass.ps1
scripts/manifest.json
pack/manifest.json
+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,180 @@
# bnstoolkit M0 — discussion points for testing session
> Built 2026-06-07 (early hours UTC). Shipped via pack v0.34.0 then hotfixed via v0.34.1.
> Repo: http://10.16.5.102:3000/minecraft/bnstoolkit
> Jar: `bnstoolkit-0.1.1.jar` (15.6 KB)
## ⚠ Server status: needs a manual Start
The first deploy (`0.1.0`) crashed the dedicated server during mod
construction — see "Bug caught + fixed" below. The fixed jar (`0.1.1`)
is already staged in `/srv/fast/brass-sigil-server/server/mods/`, but
the JVM exited cleanly so systemd didn't auto-restart and I deliberately
didn't try to brute-force the admin panel credentials.
**To bring the server back: click Start in the web admin panel
([http://glados:8080](http://glados:8080)) when you wake up.** The new
boot should load `bnstoolkit-0.1.1` cleanly. Verification:
```
sudo journalctl -u brass-sigil-server.service --since "2 minutes ago" \
| grep -E "bnstoolkit|Done \([0-9]+|ModLoadingException"
```
Look for `[bnstoolkit] common setup complete` AND no `ModLoadingException`.
## Bug caught + fixed (0.1.0 → 0.1.1)
```
IllegalArgumentException: class uk.sijbers.bnstoolkit.BnsToolkit
has no @SubscribeEvent methods, but register was called anyway.
```
I called `NeoForge.EVENT_BUS.register(this)` in the @Mod constructor
without yet having any `@SubscribeEvent` methods on the class. NeoForge
21.1 treats that as fatal at construct-mods time. Removed the call;
will come back together with the first real event handler in M1.
**Why local `./gradlew build` didn't catch it:** the build step only
compiles + jars. The construct-mods phase only runs when a real
NeoForge instance loads the mod. Action item for M1: add a
`gameTestServer` Gradle run config that boots the dedicated server
profile in CI-style so this class of bug fails locally next time.
## What this is
The skeleton of the custom companion mod. It loads cleanly on the client
and the dedicated server, registers nothing visible yet, and exposes the
package layout we'll fill in over M1M4. The point of this milestone is:
1. Tooling proven end-to-end (Java 21, ModDevGradle, NeoForge 21.1.228, jar in pack)
2. Mod-id, package, and resource paths locked so future commits only ADD code
3. One C2S/S2C packet pair wired to prove the network plumbing
4. Placeholder screens in place so wiring keybinds to real UI is M1's first task
## Open decisions — please walk through these tomorrow
Each is marked with `TODO(matt …)` in the source.
### 1. Key bindings (BnsKeyBindings.java)
Defaults I picked, all unbinding is in the vanilla controls menu:
| Binding | Key | Opens |
|---|---|---|
| Tier ladder | T | `TierUpgradeScreen` |
| Bounty board | B | `BountyBoardScreen` |
| Plot map | P | `PlotPurchaseScreen` |
**Question**: any conflicts with mods you already use? `T` is vanilla's
chat-open IIRC — I should probably move it. Pick a free combo when you
playtest, I'll bump the defaults.
### 2. Screen open triggers — keybind only, or block + keybind?
Each major screen has two viable open paths:
- **Keybind** (already drafted): bnstoolkit binds T/B/P
- **In-world block**: e.g. a "Bounty Board" block placed at the spawn
guild building. Right-click opens the screen. More immersive, ties the
feature to a physical location.
Both can coexist easily — they're not exclusive. I'd default to "both for
TierUpgrade + Plots, keybind-only for QuestLog, block-only for ShopBrowser
and BountyBoard". But that's a 30-second call you make tomorrow.
### 3. FTB Library UI swap timing
Right now the placeholder screens extend vanilla `Screen`. The
architecture doc commits to `dev.ftb.mods.ftblibrary.ui.BaseScreen` for
visual consistency with FTB Quests / Chunks. The swap is one line per
screen — happens in M1 alongside the first real screen layout.
Reason it's deferred: the FTB maven coords need to be wired into
`build.gradle`, and I don't want to pin the wrong artifact name. Will
verify against the pack's installed FTB Library version (2101.1.31) before
the M1 build.
### 4. HUD overlay layout
The architecture doc shows a small panel "tier-icon | tier-name | spurs"
at top-left. Drop me a sketch when you have time and I'll lay it out for
M4. Current placeholder draws nothing — won't appear in game at all yet.
### 5. Network smoke-test packet
`RequestTierStatePacket` / `TierStateUpdatePacket` are the only wire
pair I shipped. They prove the codec + handler pattern. M1 fills in the
server-side handler that reads `player.persistentData.bnsTier` (the
KubeJS-owned NBT) and replies. **No client code yet sends the request**
the round trip is dormant in M0.
### 6. Mod settings screen
Not addressed. Will be a small screen in M4 with toggles for the HUD,
keybind reminders, and (eventually) a colour-blind mode for tier ranks.
Low priority unless you've got a specific UX concern.
---
## Files added/changed (PR for v0.34.0)
```
pack/overrides/mods/bnstoolkit-0.1.0.jar NEW (15.6 KB)
pack/pack.lock.json version 0.33.0 -> 0.34.0
docs/bnstoolkit-discussion-points-2026-06-07.md NEW (this file)
```
The bnstoolkit source itself lives at `http://10.16.5.102:3000/minecraft/bnstoolkit`
— one initial commit on `main` with the full M0 scaffold. Build:
```
cd /home/matt/dev/bnstoolkit
./gradlew build
```
## What you'll see in-game when you log in
**Nothing yet.** That's the point of M0 — it loads cleanly with zero
visible surface. Verification path:
1. Server log: grep for `[bnstoolkit] mod entry constructed` and
`common setup complete (M0 scaffold — no surface yet)`
2. Client log on connect: `registered 3 key bindings under key.category.bnstoolkit`
3. Controls menu → "Brass and Sigil" category → 3 unbound-looking lines
(Open Tier Ladder, Open Bounty Board, Open Plot Map) on T/B/P
If you bind one of those keys and press it in-game, **nothing happens**.
That's correct — the handlers are M1 work.
## Milestones — concrete next deliverables
| Milestone | Scope | Estimated work |
|---|---|---|
| M1 | TierUpgradeScreen + KubeJS bridge (tier/balance) + FTB Library swap | 1 evening |
| M2 | QuestLogScreen + ShopBrowserScreen | 1 evening |
| M3 | BountyBoardScreen + PlotPurchaseScreen | 1 evening |
| M4 | SpurHud, mod settings screen, polish | 1 evening |
Each milestone ends with a deployable jar + a screen you can poke in
game. None of them require server downtime — drop the new jar in
`pack/overrides/mods/`, the wrapper sync handles the rest.
## Quick recovery
If the mod misbehaves and bricks the world:
```
# 1. Stop the server
sudo systemctl stop brass-sigil-server.service
# 2. Pull the jar out of the live mods dir
sudo rm /srv/fast/brass-sigil-server/server/mods/bnstoolkit-0.1.0.jar
# 3. Restart
sudo systemctl start brass-sigil-server.service
```
That leaves `pack/overrides/mods/bnstoolkit-0.1.0.jar` in the pack repo
so the next sync would put it back — to fully revert, also `git revert`
the v0.34.0 commit on the pack.
@@ -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.
+517
View File
@@ -0,0 +1,517 @@
# Brass and Sigil — Economy & Quest System Design
> Status: 13-tier ladder locked (v0.4). Architecture + UI confirmed in v0.3.
> Owner: matt
> Last updated: 2026-06-06
---
## 1. Goals
- **One unified currency** — spurs (Numismatics).
- **Multiple ways to earn, each one bounded.** No infinite money printer.
- **Multiple sinks that scale with progression.** Money disappears as you advance, so wealth never dwarfs the cost of new ambitions.
- **Spatial design that gives spawn personality.** Themed guild buildings, each with a clear purpose, each tied to the same wallet.
- **Forward-compat with a proper quest line** via FTB Quests, while supporting daily/repeatable activities at spawn.
- **Anti-automation by design**, not by patches.
---
## 2. Core architecture
```
┌────── MONEY IN (bounded) ──────┐
│ │
Adventurer's Guild — Daily bounties (cooldowns, scaling)
Merchant's Bazaar — Sell shop (fixed + DR), dynamic exchange
Mint — Capped emerald → spur conversion *
First-time milestones — One-shot per player
FTB Quests — Progression milestone payouts
┌───────────────────┐
│ Player wallet │ ← Numismatics
│ (spurs) │
└───────────────────┘
┌────── MONEY OUT (scaling) ──────┐
│ │
Civic Office — Plot claim costs (FTB Chunks override)
Civic Office — Tier upgrades (FTB Ranks integration)
Merchant's Bazaar — Player-to-player job board (no minting)
Villagers — Re-priced trades, net sink
Custom services — Warps, sethomes, premium cosmetics
```
\* Only if we keep emeralds; see §5 — we're moving villagers to spurs, which probably retires the Mint as a currency-conversion station and repurposes it as a wallet/info hub.
---
## 3. Spawn — themed guild buildings
Each guild is a destination. The guild's NPC is both the **entry point** (accept work) and **exit point** (return to claim reward). This pattern applies universally — see §4.
| Building | Purpose | Primary mechanics inside |
|---|---|---|
| **Adventurer's Guild** | Combat & exploration | Bounty board (daily mob hunts), boss-kill quests, dungeon-delve quests (future) |
| **Merchant's Bazaar** | Trade & raw-mat economy | Numismatics sell shop, dynamic exchange counter, player-driven job board |
| **Civic Office** | Land & status | Tier upgrade screen, spawn plot registry (purchase / view your plots), sethome/warp services. NOTE: regular wilderness chunk claiming uses the FTB Chunks map and is tier-allocated, not paid per chunk — see §4. |
| **Library / Academy** | Knowledge & magic | FTB Quests anchor NPC, Ars Nouveau themed quests, lore terminals |
| **Tavern** | Social & onboarding | First-time newbie quests, casual NPCs, food/buff vendor |
| **Foundry / Workshop** | Crafting & commissions | Create-themed contracts, crafting orders, raw-material market |
| **Mint** | Currency information | Wallet info, currency tutorial, possibly emerald exchange |
### Forward-compat with a full quest line
The architecture is naturally dual-track:
- **FTB Quests** = the *progression* backbone — chapter-by-chapter pack content, one-time rewards, story arcs. The campaign.
- **Spawn-building NPCs** (Easy NPC + KubeJS) = the *daily/repeatable* layer — bounties, sell shops, services. The live activities.
Both pay into the same wallet. Players run the campaign at their pace and the daily content for income/replay value. They reinforce each other rather than compete.
---
## 4. Tier system & spawn plots
Two distinct land/status systems that often get conflated. Keeping them clean:
### 4a. Civic tier ladder
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).
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."
| # | Title | Cost to reach | Wilderness chunks | Plot slots | Daily bounties | Shop fee | Chat colour | Join broadcast |
|---|---|---|---|---|---|---|---|---|
| 1 | **Peasant** | free (starter) | 9 | 0 | 3 | 0% | grey | — |
| 2 | **Farmer** | 200 | 18 | 0 | 3 | 0% | grey | — |
| 3 | **Citizen** | 750 | 35 | 1 | 3 | 0% | white | — |
| 4 | **Merchant** | 2,500 | 60 | 1 | 4 | -2% | yellow | — |
| 5 | **Knight** | 6,500 | 100 | 2 | 4 | -5% | green | simple |
| 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.
### 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`).
### 4b. Spawn plots
Spawn plots are a **separate system** from wilderness claims.
- **Pre-defined regions** at spawn — single chunks or small fixed clusters — designated by admins as "plots." Visible as fenced/bordered areas on the spawn map.
- **Purchased with spurs** at additional cost per plot (separate from tier upgrade cost). Indicative pricing: standard plot ~2,000 spurs, premium (corner / prime location) ~5,000 spurs.
- **Primary use:** shops. Players set up Numismatics shop blocks on their plots and sell to other players. Residence is allowed but secondary.
- **Slot-limited by tier:** your civic rank determines how many spawn plots you can own simultaneously (column above). Hit the limit, you can't buy another until you upgrade or release a plot.
- **Purchase happens inside the FTB Chunks map UI** — clicking a plot region in the map opens a confirmation screen (provided by our custom mod's mixin) showing cost + your remaining slots. Same UI you use for wilderness claims, integrated.
- **Plot data lives in the custom mod** (`bnstoolkit:plots`), not in FTB Chunks. Plots are a separate ownership system that uses the FTB Chunks map as its visual surface.
---
## 5. Quest & bounty flow (universal pattern)
**Core principle:** for any guild, the player **accepts** work from an NPC, **completes** it in the world, then **returns** to that NPC to **turn it in** and claim the reward. No instant payouts. This:
- Anchors guilds as destinations
- Creates risk/reward tension (die on the return, lose the reward)
- Encourages waypoint use and travel
- Lets the server centrally control payouts and detect cheating
### State machine
Each quest, per player, lives in one of these states:
```
AVAILABLE ─[player accepts]─► ACTIVE ─[objective met]─► READY ─[turned in]─► COMPLETED
▲ │
└──────────────────────[cooldown elapses]───────────────────────────────────────────┘
```
- **AVAILABLE** — NPC offers the quest in dialog. Player has no current copy of it.
- **ACTIVE** — Player accepted. KubeJS tracks objective progress via relevant events (`EntityEvents.death` for kills, `ItemEvents.firstRightClicked` for explorations, etc.).
- **READY** — Objective met. Reward is *promised but not yet paid.* NPC dialog now offers a turn-in option.
- **COMPLETED** — Reward paid out. Quest goes on cooldown for this player.
- **AVAILABLE** (again) — after the cooldown, the NPC will offer it again.
### Worked example — daily bounty
1. **Adventurer's Guild NPC** has 5 randomized daily quests (e.g. "Slay 10 Plunderers — 50 spurs").
2. Player right-clicks NPC → Easy NPC dialog → "Accept: Slay 10 Plunderers."
3. KubeJS writes `quest:plunderer_10` to the player's quest state with `count: 0`.
4. Player kills Plunderers in the world. `EntityEvents.death` for `supplementaries:plunderer` increments the count.
5. At count=10, KubeJS flips state to **READY** and notifies the player ("Return to the Adventurer's Guild to claim your reward.").
6. Player returns. Right-click NPC. Easy NPC dialog detects READY state and offers "Turn in: Slay 10 Plunderers."
7. KubeJS pays 50 spurs into the player's Numismatics wallet, flips state to **COMPLETED**, sets cooldown to 24h.
### Universal applicability
The same accept → complete → return pattern works for every guild:
| Guild | Accept | Complete | Return |
|---|---|---|---|
| **Adventurer's** | "Hunt 10 plunderers" | kill them | NPC pays bounty |
| **Merchant's** | "Procure 32 diamonds for our buyer" | gather them | NPC pays inflated price + completes |
| **Civic** | "Survey 5 unclaimed chunks" | walk through them | NPC pays survey fee |
| **Library** | "Recover this lost tome" | find the book in a structure | NPC pays + grants reputation |
| **Foundry** | "Craft 10 Andesite Casings" | craft them | NPC pays commission |
### Player-facing UI (locked)
Polished, integrated, no chat commands to memorise. All player interaction happens through GUIs and NPC dialogue. Custom companion mod (working name `bnstoolkit`) provides the screens; FTB Library's UI framework is the visual basis so our screens match FTB Quests, FTB Chunks, and FTB Teams natively.
| Surface | Implementation |
|---|---|
| **Quest Log screen** | Custom mod screen. Tabs: Active / Available / Completed. Click a quest to inspect, cancel from the detail panel. Opened via keybind or by clicking a Quest Log block at any guild. |
| **Bounty Board screen** | Custom mod screen, opened by right-clicking the Adventurer's Guild Quartermaster NPC OR clicking the in-world Bounty Board block. Lists today's available daily bounties with descriptions, rewards, and "Accept" buttons. |
| **Tier upgrade screen** | Custom mod screen, opened at the Civic Office NPC. Visual ladder, your current tier highlighted, next-tier cost, perks unlocked at each rank, big Upgrade button. |
| **Plot purchase popup** | Custom mod mixin into the FTB Chunks map UI. Hover a plot region → cost + slots tooltip. Click → confirmation popup. Owned plots also clickable to see plot info. |
| **Shop browser** | Numismatics shop blocks render their existing inventory UI by default; optional richer browser screen for the central Merchant's Bazaar inventory. |
| **Quest progress HUD** | Custom mod render layer — styled HUD element showing active quest names + progress bars. NOT stacked vanilla bossbars. |
| **NPC dialogue + buttons** | Easy NPC handles all NPC interactions: accept/turn-in, info, tutorials, "click for details" buttons. Players learn the system through NPC dialogue, not by reading docs. |
### Why custom mod, on FTB Library's UI framework
- **Visual consistency:** FTB Quests, FTB Chunks, FTB Teams all use FTB Library's widget framework. Our screens built on the same framework will look and feel like part of the same product.
- **Already a pack dependency:** no new mod added just for the UI layer.
- **Mixin compatibility:** our spawn plot mixin into the FTB Chunks map operates on FTB Library widgets natively — no translation layer between vanilla MC widgets and FTB Library widgets.
- **Less boilerplate than vanilla MC Screen API:** Panel, BlockButton, IconButton, Tooltip, TextField widgets already exist and behave consistently with how players already know FTB UIs to work.
- **Forking FTB is off the table:** FTB mods are "All Rights Reserved" — depending on FTB Library as a library is fine (that's what libraries are for), forking FTB Quests or FTB Chunks for redistribution is not legally clean. Custom mod alongside the FTB stack is the correct architecture.
### Why NOT FTB Quests for daily bounties
FTB Quests stays for **long-form progression** (Phase 5+), not for daily bounties. Source review confirms:
- No abandon/cancel event (Ctrl+Shift+R reset fires no event we can hook)
- Cooldown is per-team, not per-player
- Visibility is per-team, not per-player — rotating "5 of 20 daily" doesn't fit cleanly
For long-form progression where one-shot rewards + team scope are fine, FTB Quests is great. Our custom bounty engine handles the daily/repeatable pattern with its own UI.
### Quest slot limit
Players have a **maximum of 3 active quests at a time.** This is the friction that makes cancel meaningful:
- Forces players to pick which quests they actually want — no "accept all 5, see what's easiest, cancel the rest" RNG-shopping.
- Keeps the state files small.
- Makes ready-to-turn-in queues meaningful (you can hold 3 unrewarded quests in your back pocket).
A quest moving to READY frees the slot for accepting a new one even before turn-in.
### KubeJS implementation — locked specifics
- **State storage:** per-player JSON in `world/data/bns_quests/<uuid>.json`. Schema:
```json
{
"active": [
{
"id": "advg_plunderer_10",
"acceptedAt": 1717684800,
"progress": 7,
"target": 10,
"rewardSpurs": 50,
"source": "adventurers_guild",
"bossbarId": "bns:quest_advg_plunderer_10_<uuid>"
}
],
"completed": {
"advg_plunderer_10": { "cooldownUntil": 1717771200, "completions": 3 }
}
}
```
- **Event tracking:** KubeJS event handlers (`EntityEvents.death`, `ItemEvents.pickedUp`, `BlockEvents.broken`) read the player's active quests and increment progress + update bossbar value.
- **Bossbar lifecycle:** on quest accept, KubeJS creates a unique per-player bossbar via `server.runCommandSilent('bossbar add <id> <name>')`, assigns it to that player only, updates `value` and `max` on progress. On turn-in or cancel: `bossbar remove`.
- **Numismatics integration (direct Java access — no events shipped, no KubeJS bindings):**
```js
const Numismatics = Java.loadClass('dev.ithundxr.createnumismatics.Numismatics');
const ReasonHolder = Java.loadClass('dev.ithundxr.createnumismatics.content.backend.ReasonHolder');
const acct = Numismatics.BANK.getAccount(player); // ServerPlayer
const balance = acct.getBalance(); // int (spurs)
acct.deposit(50); // pay quest reward
const ok = acct.deduct(100, ReasonHolder.empty()); // returns false if broke
Numismatics.BANK.markBankDirty(); // force persistence
```
- **Dialog branching:** Easy NPC dialog branches on scoreboard objective `bns.quest.<npc_tag>` that KubeJS mirrors quest state into. Dialog `Command` action triggers `/bns_quest accept <id> <npc_tag>` which routes to KubeJS.
- **Commands:** registered via `ServerEvents.commandRegistry` in KubeJS. Scoped to the player who runs them. The custom `/bns_quest` namespace handles NPC-triggered actions; `/quests` is the player-facing tracker.
- **FTB Chunks claim cost (Phase 2):** requires `FTB XMod Compat` mod. Then:
```js
FTBChunksEvents.before('claim', event => {
const player = event.source.player;
const acct = Numismatics.BANK.getAccount(player);
const cost = computeClaimCost(player);
if (acct.getBalance() < cost) {
event.setResult(ClaimResult.YOU_DO_NOT_HAVE_ENOUGH_SPURS); // custom enum value
return;
}
acct.deduct(cost, ReasonHolder.empty());
});
```
**Note:** the `.before` may fire twice (sim + real). Guard with idempotent state, or do balance check in `.before` and deduct in `.after('claim')`.
---
## 5. Villager rebalance
**Decision:** move all villagers to **spurs** (not emeralds). Thematic consistency over preserving vanilla mental model.
**Decision principles:**
- Villagers are **net money sinks** — players spend more on villagers than they earn from them.
- **Useful trades stay** — enchanted books, tools, food, decorative blocks. Reasonable spur prices.
- **Trash trades get deleted** — no one ever wanted to sell rotten flesh or buy paper.
- **Sell-to-villager trades exist but are bad** — `64 wheat → 1 spur`, `16 leather → 1 spur`. Farms still possible, but ~10× slower than any other income source.
### Price guidance (initial calibration — tune via playtesting)
#### Buy-from-villager (player pays spurs)
| Item | Tier-1 | Tier-3 | Tier-5 |
|---|---|---|---|
| Standard enchanted book (random) | 50 spurs | 120 spurs | 250 spurs |
| Mending book | — | — | 800 spurs |
| Diamond tool (unenchanted) | — | 80 spurs | — |
| Diamond tool (enchanted) | — | — | 350 spurs |
| Bookshelf | 15 spurs | — | — |
#### Sell-to-villager (player gets spurs)
| Item | Spurs per unit |
|---|---|
| Wheat | 1 per 64 |
| Paper | 1 per 32 |
| Leather | 1 per 16 |
| Iron ingot | 2 per 1 |
| Diamond | 8 per 1 *(matches sell-shop fixed price — players use whichever is closer)* |
### Scope
~60 trades × 5 profession tiers = ~300 trade entries to redesign. One-time data-design pass, implemented via `ServerEvents.villagerTrades` in KubeJS.
---
## 6. Money inflow sources (detailed)
| Source | Cap / DR mechanism | Rough rate |
|---|---|---|
| **Daily bounties** | Per-player per-quest cooldown (24h) | ~200-500 spurs/day for an active player |
| **Sell shop (fixed price + DR)** | Lifetime sales tracker per player, 10% price drop per 64 sold | ~50-200 spurs/hr while diamond stockpile lasts |
| **Dynamic exchange** | Global supply pool — price drops with sales | Self-balancing |
| **First-time milestones** | One-shot per player (`hasFlag`) | 5-50 spurs per milestone, ~10 milestones total |
| **FTB Quest progression** | One-time per quest | Varies; cluster rewards at chapter completion |
| **Villager sell trades** | Inherently slow + bad prices | ~50 spurs/hr from a serious villager farm |
---
## 7. Money outflow sinks (detailed)
| Sink | Cost curve | Why this scales right |
|---|---|---|
| **Plot claims** | Free up to 9 chunks. 50 spurs/chunk to 25. 200 spurs/chunk beyond. | New players claim free starter areas; ambitious players pay real money |
| **Tier upgrades** | Cumulative cost: 500 → 2k → 8k → 30k spurs | Endgame sink, status display |
| **Buy-from-villager** | See §5 | Constant outflow as you build up |
| **Premium cosmetics** | 100-2k spurs each | Optional sink for cash-flush players |
| **Services** | Sethome: 50 spurs, warp: 25 spurs, /back: 10 spurs | Convenience taxes |
---
## 8. Mod stack (locked)
### Already in pack
| Need | Mod | Version |
|---|---|---|
| Currency + wallets + shop blocks | Numismatics | 1.0.20 |
| NPC dialog presentation | Easy NPC | 6.16.x (6.17.0 available, minor bump) |
| Plot claims | FTB Chunks | 2101.1.14 |
| Team management | FTB Teams | 2101.1.9 |
| Core library | FTB Library | 2101.1.31 |
| Cross-mod compat | Architectury API | 13.0.8 |
| Glue, commands, state | KubeJS | 2101.7.2 |
### Adding in Phase 0a (mod-additions PR — blocks everything else)
| Mod | Version | Reason |
|---|---|---|
| **FTB Quests** | 2101.1.25 | Progression quest UI (Phase 5+) |
| **FTB XMod Compat** | latest 1.21.1 NeoForge | KubeJS hooks for FTB Chunks claim events + FTB Quests events |
| **FTB Ranks** | 2101.1.3 | Tier system. CurseForge only, no Modrinth listing. |
### Building our own (custom companion mod)
**`bnstoolkit`** (working name) — a small companion mod in our own repo. Confirmed locked-in.
- Provides all custom GUI screens (Quest Log, Bounty Board, Tier upgrade, Shop browser)
- Provides the FTB Chunks map mixins (spawn plot cost overlay, hover tooltip, purchase popup)
- Provides custom quest-progress HUD render layer
- Provides the `bnstoolkit:plots` data storage + ownership permissions
- Provides network packets for client↔server state sync
- Provides the `/bnstoolkit reload` admin command for hot-reloading tier config
- Built on FTB Library UI framework for visual consistency
- KubeJS handles all gameplay logic (bounty rotation, state machine, villager trades, Numismatics integration) — the mod is purely the UI + integration layer
Estimated scope: ~2-3k LOC, single-developer week of focused work. Bounded.
### Explicitly NOT adding
- CustomNPCs (Easy NPC covers it)
- Citizens (Bukkit-only)
- PMMO / skill mods (out of scope)
- Generic "quest giver" mods (FTB Quests + our KubeJS bounty engine cover it)
---
## 9. Phasing
Each phase is independently shippable. Earlier phases unlock value without depending on later ones.
| Phase | Deliverable | Spawn build required? |
|---|---|---|
| **0a** | **Mod additions PR:** FTB Quests + FTB XMod Compat + FTB Ranks (+ minor Easy NPC bump). New pack version. | No |
| **0b** | Spawn skeleton — 7 guild building blockouts (rough). **User does in-game when time permits.** Non-blocking. | Yes (user) |
| **0c** | **`bnstoolkit` companion mod — scaffolding pass.** Mod project set up, FTB Library UI dependency wired, network packet plumbing, build/release scripts. No screens yet. | No |
| **1** | **Sell shop:** Numismatics shop blocks for diamonds (10 spurs) + netherite ingots (100 spurs). Lifetime-sale tracker for diminishing returns. KubeJS only, no mod work needed. | No |
| **2** | **Tier upgrade flow:** FTB Ranks integrated + KubeJS `/tier upgrade` command. **`bnstoolkit` Tier upgrade screen** wired to Civic Office NPC. Tier perks driven by `tiers.json`. | No |
| **3** | **Spawn plot system:** plot region registry in `bnstoolkit`, ownership tracking, FTB Chunks map mixin (cost overlay + purchase popup). | No |
| **4** | **Bounty engine:** state machine, KubeJS-rotated dailies, `bnstoolkit` Bounty Board screen + Quest Log screen + Quest progress HUD. NPC dialog hooks via Easy NPC. 3-slot cap. | No (NPC layer activates once spawn is built) |
| **5** | **Villager rebalance:** strip vanilla, ship spur-priced trade table via `ServerEvents.villagerTrades`. | No |
| **6** | **Progression quest line:** FTB Quests anchor + first chapter. KubeJS rewards dispense spurs via Numismatics API. | No (anchor NPC at Library once spawn exists) |
| **7** | **Dynamic exchange rates:** global supply-pool pricing for raw mats. | No |
| **8+** | Lootr dungeons, Foundry crafting commissions, deeper FTB Quests chapters, player-vs-player bounties (if wanted). | TBD |
---
## 10. Risk register
| Risk | Likelihood | Mitigation |
|---|---|---|
| Bounty engine bugs out, players lose progress | Medium | Atomic state writes; logs of every state transition |
| Villager farms still profitable after rebalance | Medium | Track player sell volumes; tune prices in patches |
| KubeJS-EasyNPC dialog branching has limits | Low-Med | Verify hooks in a test world before Phase 3; fall back to command-driven NPCs if needed |
| FTB Chunks `/claim` command override gotchas | Low | Test in dev world; FTB Chunks API is stable |
| Plot cost feels punishing | Med | First 9 chunks free is the safety valve; tune from playtest |
| Players bypass the system via creative / `/give` | Op-only | Document op responsibility; consider audit logs for op spur grants |
---
## 11. Decisions made
Resolved before implementation:
| Question | Decision |
|---|---|
| Quest UI for bounties | **Custom mod with FTB Library UI widgets** — proper GUI screens, no chat commands |
| Quest UI for progression | FTB Quests |
| Plot purchase UI | Custom mod mixin into FTB Chunks map — hover preview, click-to-buy popup |
| Tier upgrade UI | Custom mod screen at Civic Office NPC |
| State storage | KubeJS-managed per-player JSON for quest state; mod-managed for plot ownership |
| 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 |
| Spawn plots | Predefined regions, slot-limited by tier, individually purchased with spurs |
| Tier ladder | Civic (13 tiers): Peasant → Farmer → Citizen → Merchant → Knight → Baron → Viscount → Earl → Marquess → 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 |
| Custom mod | Confirmed. Built on FTB Library UI framework. Working name `bnstoolkit`. |
| Sethomes / warps | Tier-allocated count (not paid per use) |
| Daily quest count | Tier-driven (3 at Peasant, up to 6 at Sovereign), refresh per-player 24h after last accept |
| Emeralds | Retired as currency, retain as a trade material for villager-redirect trades |
| Fork FTB | Not needed — depend on FTB Library as a UI lib, FTB Chunks/Quests stay unmodified, integration via mixins + KubeJS hooks |
### Still to decide (non-blocking — pick during implementation)
- **PvP bounties** — out of scope for V1, revisit after V1 ships
- **Lootr** — add only when structured dungeons exist (Phase 8+)
- **Default-rank starting permissions** — finalised when FTB Ranks config is written
- **Spawn plot pricing** — first-pass numbers (2,000 standard / 5,000 premium), tune in playtest
---
## 12. Implementation notes
### KubeJS scripts (gameplay logic)
Organised under `pack/overrides/kubejs/server_scripts/economy/`:
- `01_numismatics_api.js` — Java.loadClass wrapper helpers (balance / deposit / deduct)
- `02_quest_state.js` — load/save `world/data/bns_economy/quests/<uuid>.json`
- `03_commands.js` — admin commands (`/tier upgrade`, `/bnstoolkit reload`, debug)
- `04_bounty_engine.js` — daily rotation, state machine, KubeJS↔mod packet handlers
- `05_sell_shop.js` — Numismatics shop tracking + diminishing returns
- `06_villager_trades.js` — Phase 5 `ServerEvents.villagerTrades` rebalance
- `07_tier_perks.js` — FTB Ranks promote/demote, perk application
- `99_dynamic_exchange.js` — Phase 7 supply-priced market
### Custom mod codebase
Repo: separate (or sub-tree of main repo, TBD).
Modid: `bnstoolkit` (working name).
Package layout:
```
bnstoolkit/
├── client/
│ ├── screen/ QuestLog, BountyBoard, Shop, Tier, PlotConfirm (FTB Library subclasses)
│ ├── mixin/ ChunkScreenPanel (cost overlay + plot button), ChunkButton (hover tooltip)
│ ├── hud/ QuestProgressHud (custom render)
│ └── ClientEvents keybind reg, screen reg, mixin loader
├── server/
│ ├── packet/ OpenScreen, UpgradeRank, BuyPlot, ClaimBounty, etc.
│ ├── command/ /bnstoolkit reload (config refresh)
│ └── data/ PlotRegistry, PlotOwnership (saved with world data)
├── common/
│ ├── data/ TierConfig (JSON-loaded), BountyDefinition, QuestState mirror
│ └── BnsToolkit.java mod entry
└── config-defaults/
├── tiers.json copied to config/bnstoolkit/tiers.json on first run
└── plots.json spawn plot region definitions
```
Architecture doc (`docs/bnstoolkit-architecture.md`) covers package layout, screen UX sketches, network packet contract, mixin targets, KubeJS↔mod integration surface — to be written before mod work begins.
### State data
- Quest state: `world/data/bns_economy/quests/<uuid>.json`. Readable JSON, easy to debug.
- Plot ownership: persisted with world via the mod's `SavedData` (vanilla NeoForge persistence). Authoritative source of truth.
- Tier config: `config/bnstoolkit/tiers.json` shipped from `pack/overrides/defaultconfigs/bnstoolkit/tiers.json`. Hot-reloadable.
- Plot definitions: `config/bnstoolkit/plots.json` shipped similarly. Defines plot region bounds, type (standard/premium), prices.
### NPC dialog
Authored in Easy NPC's in-game editor, exported as preset JSONs into `pack/overrides/world/datapacks/bns-fuel/data/easy_npc/`. Dialog `Command` actions call `/bnstoolkit <verb> ...` which routes to either the mod (for screen-opening) or KubeJS (for state changes).
### Villager rebalance
Single source file `06_villager_trades.js`. Each profession × tier has its own block. One file = one diff per balance pass.
---
## See also
- `MEMORY.md` entries:
- `project-mc-player-economy.md` — earlier higher-level notes on Numismatics + plot system
- `feedback-kubejs-rhino-gotchas.md` — KubeJS scripting constraints
- `feedback-bns-pack-version-sync.md` — pack version + sync workflow
- Existing tasks #20-#26 — to be reshuffled to mirror the phase plan above
+26
View File
@@ -797,6 +797,32 @@ public partial class MainWindow : Window
try
{
SetBusy(true);
// ─── Refresh the Microsoft session before launch. ─────────────────
// _session was set at sign-in; the access token inside is short-lived
// (~1 hour). If the launcher's been open longer than that, MC will
// send stale credentials to the server and Mojang's session check
// rejects the join ("Invalid session"). XboxAuthNet's silent flow
// uses the cached refresh token (~14 day TTL) to mint a fresh
// access token transparently.
UpdateStatus("Refreshing session...", "");
var freshSession = await _auth.TryAuthenticateSilentlyAsync();
if (freshSession != null)
{
ApplySession(freshSession);
AppendLog("[auth] Session refreshed before launch.");
}
else
{
// Refresh token also stale -- need full interactive re-login.
// Don't try inline (player would lose context); prompt them to
// hit Sign In again.
AppendLog("[auth] Silent refresh returned no session; sign-in required.");
UpdateStatus("Session expired", "Please sign in again to continue.");
ClearSession();
return;
}
var progress = new Progress<ProgressReport>(OnProgress);
var installDir = GetInstallDir();
_launch ??= new LaunchService(installDir);
+9
View File
@@ -95,6 +95,15 @@ public sealed class ManifestFile
[JsonPropertyName("size")]
public long? Size { get; set; }
/// <summary>
/// "both" (default), "client", or "server". The launcher skips files
/// marked "server"; the server-tool skips files marked "client".
/// Null/missing/unknown = treated as "both" so manifests pre-dating
/// this field stay fully compatible.
/// </summary>
[JsonPropertyName("side")]
public string? Side { get; set; }
}
public sealed class PackVersionRecord
+6 -1
View File
@@ -4,13 +4,18 @@
<!-- net8.0-windows is required for the XboxAuthNet WebView2 OAuth flow:
the netstandard2.0 build of XboxAuthNet has no WebUI implementation. -->
<TargetFramework>net8.0-windows</TargetFramework>
<!-- Allow building this Windows-targeting project from Linux/macOS hosts.
No-op on Windows. Requires the WindowsDesktop SDK files to be
present in the local .NET install (on glados copied from the
Windows .NET 8 SDK archive into /usr/lib/dotnet/). -->
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<UseWindowsForms>true</UseWindowsForms>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<RootNamespace>ModpackLauncher</RootNamespace>
<AssemblyName>ModpackLauncher</AssemblyName>
<Version>0.4.8</Version>
<Version>0.4.10</Version>
<ApplicationIcon Condition="Exists('Assets\icon.ico')">Assets\icon.ico</ApplicationIcon>
<!-- Single-file self-contained publish defaults (Windows-only now due to WebView2) -->
+22
View File
@@ -66,6 +66,7 @@ public sealed class ManifestSyncService
var missing = new System.Collections.Generic.List<ManifestFile>();
foreach (var file in manifest.Files)
{
if (IsServerOnly(file)) continue; // never expected on disk client-side
var dest = Path.Combine(installDir, file.Path);
if (!File.Exists(dest)) missing.Add(file);
}
@@ -113,6 +114,19 @@ public sealed class ManifestSyncService
$"Pack: {manifest.Name ?? "modpack"} v{manifest.Version ?? "?"} (local: {local?.Version ?? "none"})"
));
// Drop server-only files before computing wanted/download sets. Doing
// it once here means the rest of the sync (prune + download) doesn't
// need to know about sides. Unknown/missing/'both' = include on client.
var serverOnlyCount = manifest.Files.Count(f => IsServerOnly(f));
if (serverOnlyCount > 0)
{
manifest.Files = manifest.Files.Where(f => !IsServerOnly(f)).ToList();
progress.Report(new ProgressReport(
ProgressKind.Log,
$"Skipping {serverOnlyCount} server-only file(s) declared in manifest."
));
}
var wantedPaths = new HashSet<string>(
manifest.Files.Select(f => NormalizePath(f.Path)),
StringComparer.OrdinalIgnoreCase
@@ -249,4 +263,12 @@ public sealed class ManifestSyncService
}
private static string NormalizePath(string p) => p.Replace('\\', '/').TrimStart('/');
/// <summary>
/// True if this manifest file is tagged as server-only and therefore
/// should not be installed on the client. Unknown/null/'both' = false
/// (always include) so manifests pre-dating the field still install.
/// </summary>
private static bool IsServerOnly(ManifestFile f)
=> string.Equals(f.Side, "server", StringComparison.OrdinalIgnoreCase);
}
@@ -0,0 +1,342 @@
#.
#These values affect the extent of cannon failure.
[failure]
#.
#.
#If true, cannons cannot fail whatsoever. Equivalent to setting all the chances below to zero.
disableAllCannonFailure = false
#.
#Chance that a fired projectile will get stuck in a barrel after exceeding the squib ratio. 0 is 0%, 1 is 100%.
# Default: 0.25
# Range: 0.0 ~ 1.0
squibChance = 0.25
#.
#Chance that a cannon will fail if a Powder Charge is ignited in a "barrel"-type cannon block. 0 is 0%, 1 is 100%.
#This chance can be affected by stronger and weaker charge blocks.
# Default: 0.20000000298023224
# Range: 0.0 ~ 1.0
barrelChargeBurstChance = 0.20000000298023224
#.
#How strong the explosion of a catastrophic failure is. Scaled by the amount of charges used in the shot.
# Default: 2.0
# Range: 0.0 ~ 3.4028234663852886E38
failureExplosionPower = 2.0
#.
#Chance that a cannon loaded with more Powder Charges that it can handle will fail. 0 is 0%, 1 is 100%.
# Default: 0.5
# Range: 0.0 ~ 1.0
overloadBurstChance = 0.5
#.
#Chance that a load with gaps between the loaded Powder Charges will not completely combust. 0 is 0%, 1 is 100%.
# Default: 0.33000001311302185
# Range: 0.0 ~ 1.0
interruptedIgnitionChance = 0.33000001311302185
#.
#These values affect the characteristics of cannon munitions.
[munitions]
#.
#.
#If projectiles can bounce, ricochet, and be deflected.
projectilesCanBounce = true
#.
# Default: 0.33000001311302185
# Range: 0.0 ~ 1.0
baseProjectileBounceChance = 0.33000001311302185
#.
# Default: 0.8999999761581421
# Range: 0.0 ~ 1.0
baseProjectileFluidBounceChance = 0.8999999761581421
#.
#[in Meters per Tick]
#The minimum velocity necessary to activate the penetration bonus.
# Default: 1.0
# Range: 0.0 ~ 3.4028234663852886E38
minimumVelocityForPenetrationBonus = 1.0
#.
# Default: 0.10000000149011612
# Range: 0.0 ~ 3.4028234663852886E38
penetrationBonusScale = 0.10000000149011612
#.
#The extent to which cannon projectiles can damage surrounding blocks.
#All Damage - projectiles will destroy anything they hit, if applicable. Explosive projectiles will destroy blocks on detonation.
#No Explosive Damage - projectiles will destroy anything they hit, if applicable. Explosive projectiles will only harm entities on detonation.
#No Damage - projectiles will not destroy anything they hit, and will only deal entity damage. Explosive projectiles will only harm entities on detonation.
#Allowed Values: ALL_DAMAGE, NO_EXPLOSIVE_DAMAGE, NO_DAMAGE
damageRestriction = "ALL_DAMAGE"
#.
projectilesChangeSurroundings = true
#.
munitionBlocksCanExplode = true
#.
#Damp Big Cannon Propellant
[munitions.dampBigCannonPropellant]
#.
dampPropellantBlocksStartingIgnition = true
#.
dampPropellantWeakensPropellant = true
#.
#Chunkloading
[munitions.chunkloading]
#.
projectilesCanChunkload = true
#.
smokeCloudsCanChunkload = true
#.
#Big Cannon Munitions
[munitions.bigCannonMunitions]
#.
#Allowed Values: NONE, LONG, SHORT
trailType = "SHORT"
#.
#Makes all shot big cannon projectiles tracers regardless if the item had a tracer tip applied.
allBigCannonProjectilesAreTracers = false
#.
#[in Ticks]
# Default: 20
# Range: 0 ~ 100
quickFiringBreechItemPickupDelay = 20
#.
quickFiringBreechItemGoesToInventory = false
#.
#Projectile Fuzes
[munitions.fuzes]
#.
#Chance that the Impact Fuze/Delayed Impact Fuze will detonate on hitting something. 0 is 0% (never), 1 is 100% (always).
# Default: 0.6700000166893005
# Range: 0.0 ~ 1.0
impactFuzeDetonationChance = 0.6700000166893005
#.
#How many blocks the Impact Fuze/Delayed Impact Fuze can hit before breaking. Set to -1 to never break.
# Default: 3
# Range: > -1
impactFuzeDurability = 3
#.
#Chance that the Inertia Fuze/Delayed Inertia Fuze will detonate on hitting something. 0 is 0% (never), 1 is 100% (always).
# Default: 0.8999999761581421
# Range: 0.0 ~ 1.0
inertiaFuzeDetonationChance = 0.8999999761581421
#.
#How many blocks the Inertia Fuze/Delayed Inertia Fuze can hit before breaking. Set to -1 to never break.
# Default: 3
# Range: > -1
inertiaFuzeDurability = 3
#.
#[in Ticks]
#Time it takes for a proximity fuze to arm itself.
#After the fuze has been in the air for the specified arming time, it will detonate when it gets close enough to a block or entity.
# Default: 5
# Range: > 0
proximityFuzeArmingTime = 5
#.
#Scale of the area covered by the Proximity Fuze. Larger number means wider area covered
# Default: 5
# Range: 1 ~ 10
proximityFuzeScale = 5
#.
#[in Blocks]
#Spacing of the detection points of the Proximity Fuze.
# Default: 1.5
# Range: 0.5 ~ 2.0
proximityFuzeSpacing = 1.5
#.
#Grouped Munitions
[munitions.groupedMunitions]
#.
#The chance of a fluid blob affecting a block in its area of effect (AOE). 0 is 0% (never), 1 is 100% (always).
# Default: 0.5
# Range: 0.0 ~ 1.0
fluidBlobBlockEffectChance = 0.5
#.
#Autocannon Munitions
[munitions.autocannonMunitions]
#.
#Allowed Values: NONE, LONG, SHORT
trailType = "SHORT"
#.
#Makes all shot autocannon projectiles tracers regardless if the item had a tracer tip applied. Emulates legacy behavior.
allAutocannonProjectilesAreTracers = false
#.
#How many autocannon rounds the Autocannon Ammo Container can store.
# Default: 16
# Range: 1 ~ 128
autocannonAmmoContainerAutocannonRoundCapacity = 16
#.
#How many machine gun rounds the Autocannon Ammo Container can store.
# Default: 64
# Range: 1 ~ 128
autocannonAmmoContainerMachineGunRoundCapacity = 64
#.
#These values affect the characteristics of cannon materials and cannon structures
[cannons]
#.
#.
#Maximum length of cannons that can be built.
# Default: 64
# Range: > 3
maxCannonLength = 64
#.
#Time when the Quickfiring Breech cannot be loaded by Mechanical Arms.
# Default: 40
# Range: > 0
quickfiringBreechLoadingCooldown = 40
#.
#Time it takes for the Quickfiring Breech to fully open/close, during which it cannot be loaded.
# Default: 5
# Range: > 0
quickfiringBreechOpeningCooldown = 5
#.
# Default: 4.0
# Range: 0.0 ~ 3.4028234663852886E38
bigCannonRecoilScale = 4.0
#.
# Default: 0.5
# Range: 0.0 ~ 3.4028234663852886E38
autocannonRecoilScale = 0.5
#.
#Drop Mortar
[cannons.dropMortar]
#.
#Time in ticks between inserting a munition into a drop mortar and the drop mortar firing it. There are 20 ticks in 1 second.
# Default: 5
# Range: > 0
dropMortarDelay = 5
#.
#Length in ticks that the player has to wait for before inserting the same munition type into a drop mortar again. There are 20 ticks in 1 second.
# Default: 40
# Range: > 0
dropMortarItemCooldown = 40
#.
#Loading Tools
[cannons.loadingTools]
#.
#If deployers can use loading tools.
deployersCanUseLoadingTools = false
#.
#How many blocks inside a cannon a Ram Rod can reach.
# Default: 5
# Range: > 1
ramRodReach = 5
#.
#Maximum amount of munition blocks a Ram Rod can push.
# Default: 3
# Range: > 1
ramRodStrength = 3
#.
#How many blocks inside a cannon a Worm can reach.
# Default: 5
# Range: > 1
wormReach = 5
#.
#How many hunger/saturation points it takes to move one munition block a distance of one block using loading tools.
# Default: 2.5
# Range: 0.0 ~ 3.4028234663852886E38
loadingToolHungerConsumption = 2.5
#.
#How many ticks before a player can use a manual loading tool again. There are 20 ticks in 1 second.
# Default: 20
# Range: > 0
loadingToolCooldown = 20
#.
#Cannon Carriages
[cannons.carriage]
#.
#How fast the carriage is, in blocks per tick.
# Default: 0.03999999910593033
# Range: 0.03999999910593033 ~ 1.0
carriageSpeed = 0.03999999910593033
#.
#How fast the carriage turns, in degrees per tick.
# Default: 1.0
# Range: 0.10000000149011612 ~ 10.0
carriageTurnRate = 1.0
#.
cannonWeightAffectsCarriageSpeed = true
#.
#Display Link Info
[cannons.displayLink]
#.
shouldDisplayCannonRotation = true
#.
shouldDisplayContainedMunitions = true
#.
#Big Cannon Screen Shake
[cannons.bigCannonScreenShake]
#.
# Default: 8.0
# Range: 0.0 ~ 3.4028234663852886E38
blastDistanceMultiplier = 8.0
#.
#These values affect various miscellaneous contraptions.
[kinetics]
#.
#.
#Maximum length of cannon loaders that can be built.
# Default: 64
# Range: > 3
maxLoaderLength = 64
#.
#When enabled, Cannon Loaders will not break if another contraption is placed on them. Enables legacy behavior.
enableIntersectionLoading = false
#.
#These values affect the stress of Create Big Cannons' mechanical blocks.
[kinetics.stressValues.v2]
#.
#.
#[in Stress Units]
#Configure the individual stress impact of mechanical blocks. Note that this cost is doubled for every speed increase it receives.
[kinetics.stressValues.v2.impact]
steel_sliding_breech = 32.0
cannon_drill = 8.0
nethersteel_screw_breech = 40.0
steel_screw_breech = 16.0
bronze_sliding_breech = 12.0
cannon_loader = 4.0
cannon_builder = 8.0
cast_iron_sliding_breech = 16.0
#.
#These values affect cannon crafting properties.
[crafting]
#.
#.
#Maximum height of a single cannon cast that can be built.
# Default: 32
# Range: > 1
maxCannonCastHeight = 32
#.
#Maximum length of a Cannon Drill that can be built.
# Default: 32
# Range: > 1
maxCannonDrillLength = 32
#.
#Maximum length of a Cannon Builder that can be built.
# Default: 32
# Range: > 1
maxCannonBuilderLength = 32
#.
#Maximum reach of a Cannon Builder.
# Default: 32
# Range: > 2
maxCannonBuilderRange = 32
#.
#[in Ticks]
#Time a built-up cannon block needs to be heated for until it transforms into its finished form.
# Default: 6000
# Range: > 0
builtUpCannonHeatingTime = 6000
@@ -0,0 +1,31 @@
{
// -----------------------------------------------------------
// Item Obliterator -- Brass and Sigil config
// -----------------------------------------------------------
// Hides + disables specific Alex's Mobs items that we don't
// want in this modpack:
//
// alexsmobs:transmutation_table -- competes with Ars
// and undermines the
// Numismatics economy
//
// alexsmobs:shattered_dimensional_carver -- teleports the
// player 1,000,000
// blocks in a random
// direction, breaks
// world-bounds
//
// Both are removed from inventories, creative tabs, recipes
// (including the Capsid transformation), trades, and JEI.
// -----------------------------------------------------------
"configVersion": 2,
"blacklisted_items": [
"alexsmobs:transmutation_table",
"alexsmobs:shattered_dimensional_carver"
],
"blacklisted_nbt": [],
"only_disable_interactions": [],
"only_disable_attacks": [],
"only_disable_recipes": [],
"use_hashmap_optimizations": false
}
@@ -0,0 +1,67 @@
#.
#Miscellaneous settings
[misc]
#.
#Coupler will require points to be on the same or adjacent track edge, this will prevent the coupler from working if there is any form of junction in between the two points.
strictCoupler = false
#.
#Allow controlling Brass Switches remotely when approaching them on a train
flipDistantSwitches = true
#.
#Max distance between targeted track and placed switch stand
# Default: 64
# Range: 16 ~ 128
switchPlacementRange = 64
#.
#Allow creepers and ghast fireballs to damage tracks
creeperTrackDamage = false
#.
#Multiplier used for calculating exhaustion from speed when a handcar is used.
# Default: 0.009999999776482582
# Range: 0.0 ~ 1.0
handcarHungerMultiplier = 0.009999999776482582
#.
#Semaphore settings
[semaphores]
#.
#.
#Simplified semaphore placement (no upside-down placement)
simplifiedPlacement = true
#.
#Whether semaphore color order is reversed when the semaphores are oriented upside-down
flipYellowOrder = false
#.
#Conductor settings
[conductors]
#.
#.
#Conductor whistle is limited to the owner of a train
mustOwnBoundTrain = false
#.
#Maximum length of conductor vents
# Default: 64
# Range: > 1
maxConductorVentLength = 64
#.
#How often a conductor whistle updates the train of the bound conductor
# Default: 10
# Range: 1 ~ 600
whistleRebindRate = 10
#.
#Maximum distance (in blocks) at which a conductor can be activated by looking at them
# Default: 16
# Range: 1 ~ 64
activationDistance = 16
#.
#Realism Settings
[realism]
#.
#.
#Make trains require fuel to run (either from fuel tanks or solid fuels in chests/barrels)
realisticTrains = true
#.
#Make fuel tanks only accept proper liquid fuels (so water etc can't go into them)
realisticFuelTanks = true
@@ -0,0 +1,12 @@
{
"default_packs": [
"file/Computer Craft Recreated v1.2.zip"
],
"default_overrides": [
{
"id": "file/Computer Craft Recreated v1.2.zip",
"default_position": "TOP",
"required": true
}
]
}
@@ -0,0 +1,87 @@
[general]
#List of waystone origins that should prevent others from editing. PLAYER is special in that it allows only edits by the owner of the waystone.
restrictedWaystones = ["PLAYER"]
#Set to "GLOBAL" to have newly placed or found waystones be global by default.
#Allowed Values: ACTIVATION, GLOBAL, SHARD_ONLY, ORANGE_SHARESTONE, MAGENTA_SHARESTONE, LIGHT_BLUE_SHARESTONE, YELLOW_SHARESTONE, LIME_SHARESTONE, PINK_SHARESTONE, GRAY_SHARESTONE, LIGHT_GRAY_SHARESTONE, CYAN_SHARESTONE, PURPLE_SHARESTONE, BLUE_SHARESTONE, BROWN_SHARESTONE, GREEN_SHARESTONE, RED_SHARESTONE, BLACK_SHARESTONE
defaultVisibility = "ACTIVATION"
#Add "GLOBAL" to allow every player to create global waystones.
allowedVisibilities = []
#The time in ticks that it takes to use a warp stone. This is the charge-up time when holding right-click.
warpStoneUseTime = 32
#The time in ticks that it takes to use a warp plate. This is the time the player has to stand on top for.
warpPlateUseTime = 15
#The time in ticks it takes to use a scroll. This is the charge-up time when holding right-click.
scrollUseTime = 32
[inventoryButton]
#Set to 'NONE' for no inventory button. Set to 'NEAREST' for an inventory button that teleports to the nearest waystone. Set to 'ANY' for an inventory button that opens the waystone selection menu. Set to a waystone name for an inventory button that teleports to a specifically named waystone.
inventoryButton = ""
#The x position of the inventory button in the inventory.
inventoryButtonX = 58
#The y position of the inventory button in the inventory.
inventoryButtonY = 60
#The y position of the inventory button in the creative menu.
creativeInventoryButtonX = 88
#The y position of the inventory button in the creative menu.
creativeInventoryButtonY = 33
[worldGen]
#Set to 'DEFAULT' to only generate the normally textured waystones. Set to 'MOSSY' or 'SANDY' to generate all as that variant. Set to 'BIOME' to make the style depend on the biome it is generated in.
#Allowed Values: DEFAULT, MOSSY, SANDY, BLACKSTONE, DEEPSLATE, END_STONE, BIOME
wildWaystoneStyle = "BIOME"
#Approximate chunk distance between wild waystones being generated. Set to 0 to disable generation.
chunksBetweenWildWaystones = 25
#List of dimensions that wild waystones are allowed to spawn in. If left empty, all dimensions except those in wildWaystonesDimensionDenyList will be able to spawn waystones, provided the biomes are in the `has_structure/waystone` tag.
wildWaystonesDimensionAllowList = ["minecraft:the_nether", "minecraft:overworld", "minecraft:the_end"]
#List of dimensions that wild waystones are not allowed to spawn in, even if the biome is in the `has_structure/waystone` tag. Only used if wildWaystonesDimensionAllowList is empty.
wildWaystonesDimensionDenyList = []
#Set to 'PRESET_FIRST' to first use names from the nameGenerationPresets. Set to 'PRESET_ONLY' to use only those custom names. Set to 'MIXED' to have some waystones use custom names, and others random names.
#Allowed Values: PRESET_FIRST, RANDOM_ONLY, PRESET_ONLY, MIXED
nameGenerationMode = "PRESET_FIRST"
#The template to use when generating new names. Supported placeholders are {Biome} (english biome name) and {MrPork} (the default name generator).
nameGenerationTemplate = "{MrPork}"
#These names will be used for the PRESET name generation mode. See the nameGenerationMode option for more info.
nameGenerationPresets = []
#Set to REGULAR to have waystones spawn in some villages. Set to FREQUENT to have waystones spawn in most villages. Set to DISABLED to disable waystone generation in villages. Waystones will only spawn in vanilla or supported villages.
#Allowed Values: DISABLED, REGULAR, FREQUENT
spawnInVillages = "DISABLED"
[teleports]
#Set to false to simply disable all xp costs. See warpRequirements for more fine-grained control.
enableCosts = true
#Set to false to simply disable all cooldowns. See warpRequirements for more fine-grained control.
enableCooldowns = true
#List of warp requirements with comma-separated parameters in parentheses. Conditions can be defined as comma-separated list in square brackets. Will be applied in order.
warpRequirements = ["[is_not_interdimensional] scaled_add_xp_cost(distance, 0.01)", "[is_interdimensional] add_xp_cost(27)", "[source_is_warp_plate] multiply_xp_cost(0)", "[target_is_global] multiply_xp_cost(0)", "min_xp_cost(0)", "max_xp_cost(27)", "[source_is_inventory_button] add_cooldown(inventory_button, 300)"]
#Set to ENABLED to have nearby pets teleport with you. Set to SAME_DIMENSION to have nearby pets teleport with you only if you're not changing dimensions. Set to DISABLED to disable.
#Allowed Values: ENABLED, SAME_DIMENSION, DISABLED
transportPets = "DISABLED"
#Set to ENABLED to have leashed mobs teleport with you. Set to SAME_DIMENSION to have leashed mobs teleport with you only if you're not changing dimensions. Set to DISABLED to disable.
#Allowed Values: ENABLED, SAME_DIMENSION, DISABLED
transportLeashed = "ENABLED"
#List of entities that cannot be teleported, either as pet, leashed, or on warp plates.
entityDenyList = ["minecraft:wither"]
#Set to true to enable warp modifier items for applying status effects on teleports.
enableModifiers = true
[client]
#If enabled, the text overlay on waystones will no longer always render at full brightness.
disableTextGlow = false
[compatibility]
#If enabled, JourneyMap waypoints will be created for each activated waystone.
journeyMap = true
#If enabled, JourneyMap waypoints will only be created if the mod 'JourneyMap Integration' is not installed
preferJourneyMapIntegrationMod = true
#If enabled, Waystones will add markers for waystones and sharestones to Dynmap.
dynmap = true
[blueMap]
#Controls whether Waystones' BlueMap integration should be loaded
enabled = true
#If enabled, waystones will be tracked as markers on BlueMap
includeWaystones = true
#If enabled, sharestones will be tracked as markers on BlueMap.
includeSharestones = true
#If enabled, undiscovered waystones will be tracked as markers on BlueMap.
includeUndiscoveredWaystones = false
+61
View File
@@ -0,0 +1,61 @@
# Brass & Sigil KubeJS scripts
All custom server behaviour lives here. Files in this tree ship via the
modpack manifest (see `scripts/Build-Pack.ps1``kubejs/` is in
`managedRoots`), get synced to both server and client installs, and are
loaded by KubeJS on startup.
## File layout
```
kubejs/
├── README.md this file
├── server_scripts/ Loaded by the SERVER's KubeJS on world load.
│ ├── recipes_<x>.js Crafting-recipe overhauls per mod.
│ ├── waystones_policy.js Waystones placement gating (1/player + bans).
│ ├── welcome.js First-join grant: waystone + manual book.
│ ├── cc_recipes.js Full ComputerCraft recipe overhaul.
│ └── bns_admin.js /bns admin <subcommand> for OP diagnostics.
└── client_scripts/ Loaded by the CLIENT's KubeJS on game load.
└── jei_hides.js Hide specific items from the JEI ingredient list.
```
## Conventions
- **Logging prefix**: every console line uses `[bns/<module>]` so server
logs can be filtered with `journalctl -u brass-sigil-server.service |
grep '\[bns/'`. Keeps our output separate from mod output.
- **One concern per file**. Recipe overhauls live in `recipes_<modname>`.
Block/item policy goes in a `<thing>_policy.js`. Don't mix recipe
removal with event listeners in the same file.
- **Persistent player data** keys are prefixed `bns` to avoid clashing
with other mods' use of `player.persistentData`. Currently used keys:
- `bnsWaystoneCount` (int) — see `waystones_policy.js`
- `bnsWelcomeGranted` (bool) — see `welcome.js`
- **Performance**: hot-path event handlers (BlockEvents.placed/broken,
PlayerEvents.loggedIn) must stay O(1). Use `Set` for ID lookups, never
Array.includes inside an event handler. Avoid Modrinth/network calls
in event handlers — never. No periodic-tick polling unless absolutely
necessary.
- **Server commands** are registered under the `/bns` namespace
(`/bns admin ...`, future: `/bns plot ...`). See `bns_admin.js` for
the registration pattern.
## Diagnostics
When something looks wrong in-game, first port of call:
1. `/bns admin info` — prints active flags + counts for the running player
2. `/bns admin waystone-count <player>` — read stored count
3. `/bns admin reset-welcome <player>` — re-trigger the first-join grant
4. Server log: `journalctl -u brass-sigil-server.service -f | grep '\[bns/'`
## See also
- `pack/overrides/defaultconfigs/` — first-run mod configs (auto-copied
to `config/` by NeoForge on first launch only)
- `scripts/Check-Deps.ps1` — pre-flight dep check, runs before any
Deploy-Brass `-Stage Pack` deploy
- The memory file `project-mc-player-economy.md` for the broader plan
@@ -0,0 +1,97 @@
// Brass & Sigil — admin commands (/bns admin ...)
//
// Diagnostic + maintenance commands for OPs. Players without OP level
// (>= 2) get a permission-denied response from the command framework.
//
// Subcommand summary:
// /bns admin info state of the calling player
// /bns admin waystone-count <player> read stored count
// /bns admin waystone-set <player> <n> override stored count
// /bns admin reset-welcome <player> clear bnsWelcomeGranted flag
// so the welcome grant fires again
//
// Adding new subcommands: follow the same `Commands.literal(...)
// .then(...)` pattern below. Keep handlers cheap -- these are only run
// on operator action so performance isn't critical, but make them
// readable.
ServerEvents.commandRegistry(event => {
const { commands: Commands, arguments: Arguments } = event;
const bnsAdmin = Commands.literal('bns')
.then(Commands.literal('admin')
.requires(src => src.hasPermission(2))
// /bns admin info
.then(Commands.literal('info').executes(ctx => {
const player = ctx.source.player;
if (!player) {
ctx.source.sendSystemMessage(Text.red('Run from a player context.'));
return 0;
}
const data = player.persistentData;
const lines = [
`Player: ${player.username} (${player.uuid})`,
`bnsWaystoneCount = ${data.getInt('bnsWaystoneCount')}`,
`bnsWelcomeGranted = ${data.getBoolean('bnsWelcomeGranted')}`,
];
lines.forEach(line => ctx.source.sendSystemMessage(Text.aqua(line)));
console.info(`[bns/admin] info: ${player.username} -> ${JSON.stringify({
waystones: data.getInt('bnsWaystoneCount'),
welcomed: data.getBoolean('bnsWelcomeGranted'),
})}`);
return 1;
}))
// /bns admin waystone-count <player>
.then(Commands.literal('waystone-count')
.then(Commands.argument('target', Arguments.PLAYER.create(event))
.executes(ctx => {
const target = Arguments.PLAYER.getResult(ctx, 'target');
const count = target.persistentData.getInt('bnsWaystoneCount');
ctx.source.sendSystemMessage(Text.aqua(
`${target.username} has bnsWaystoneCount = ${count}`
));
return 1;
})))
// /bns admin waystone-set <player> <n>
// (Argument-bounds aren't exposed via KubeJS's wrapper, so we
// validate the range inside the handler instead.)
.then(Commands.literal('waystone-set')
.then(Commands.argument('target', Arguments.PLAYER.create(event))
.then(Commands.argument('count', Arguments.INTEGER.create(event))
.executes(ctx => {
const target = Arguments.PLAYER.getResult(ctx, 'target');
const count = Arguments.INTEGER.getResult(ctx, 'count');
if (count < 0 || count > 1000) {
ctx.source.sendSystemMessage(Text.red(
`count must be 0..1000 (got ${count})`
));
return 0;
}
target.persistentData.putInt('bnsWaystoneCount', count);
ctx.source.sendSystemMessage(Text.gold(
`Set ${target.username}'s bnsWaystoneCount = ${count}`
));
console.info(`[bns/admin] waystone-set: ${target.username} -> ${count}`);
return 1;
}))))
// /bns admin reset-welcome <player>
.then(Commands.literal('reset-welcome')
.then(Commands.argument('target', Arguments.PLAYER.create(event))
.executes(ctx => {
const target = Arguments.PLAYER.getResult(ctx, 'target');
target.persistentData.putBoolean('bnsWelcomeGranted', false);
ctx.source.sendSystemMessage(Text.gold(
`Cleared bnsWelcomeGranted for ${target.username}. ` +
`They'll get the welcome packet again on next login.`
));
console.info(`[bns/admin] reset-welcome: ${target.username}`);
return 1;
})))
);
event.register(bnsAdmin);
});
@@ -0,0 +1,286 @@
// Brass & Sigil — ComputerCraft recipe overhaul (full coverage, v3)
//
// Two design principles:
// 1. Every electronic CC item contains TFMG plastic_sheet -- plastic is
// the electrical insulator and the gate that forces players to build
// out TFMG's full oil-refining chain (crude oil -> naphtha -> molten
// plastic -> sheet) before any CC tech.
// 2. Advanced versions contain their basic counterpart in the recipe.
// Computer Advanced contains Computer Normal; Monitor Advanced
// contains Monitor Normal; etc. This makes the upgrade chain
// literal -- you can't skip basic tier, you upgrade through it.
//
// Tier outline (with both principles applied):
// T1 Cable plastic + copper_wire + redstone (cheap commodity)
// T2 Basic CC plastic + brass_casing + electron_tube + p_mech +
// steel + silicon-or-cogwheel (Create T2 + TFMG oil
// + steel chain)
// T3 Networking+bots plastic + electromagnetic_coil + mech_arm/bearing
// + constantan_wire (TFMG advanced electrical)
// T4 Advanced Basic item + silicon + rose_quartz + gold +
// industrial_iron_block (or display_link / large_coil)
// T5 Mobile Pocket Normal -> Pocket Advanced via same pattern
ServerEvents.recipes(event => {
// ─── T1 - CABLING ──────────────────────────────────────────────────
// Cable (x8): plastic insulator + copper_wire core + redstone signal.
// Outputs 8 so network laying is still viable.
event.remove({ output: 'computercraft:cable' });
event.shaped(Item.of('computercraft:cable', 8), [
'PWP',
'WRW',
'PWP',
], {
P: 'tfmg:plastic_sheet',
W: 'tfmg:copper_wire',
R: 'minecraft:redstone',
});
// ─── T2 - BASIC COMPUTING ──────────────────────────────────────────
// Computer (Basic): plastic shell + electron_tube CPU + brass_casing
// chassis + steel reinforcement + precision_mechanism core.
event.remove({ output: 'computercraft:computer_normal' });
event.shaped('computercraft:computer_normal', [
'PEP',
'SBS',
'PMP',
], {
P: 'tfmg:plastic_sheet',
E: 'create:electron_tube',
S: 'tfmg:steel_ingot',
B: 'create:brass_casing',
M: 'create:precision_mechanism',
});
// Monitor (Basic): plastic frame + nixie_tube (retro CRT) + glass.
event.remove({ output: 'computercraft:monitor_normal' });
event.shaped('computercraft:monitor_normal', [
'PNP',
'GGG',
'PNP',
], {
P: 'tfmg:plastic_sheet',
N: 'create:nixie_tube',
G: 'minecraft:glass_pane',
});
// Disk Drive: plastic frame + cogwheel (spinning read head) +
// rose_quartz (data medium) + steel + p_mech.
event.remove({ output: 'computercraft:disk_drive' });
event.shaped('computercraft:disk_drive', [
'PCP',
'RQR',
'SMS',
], {
P: 'tfmg:plastic_sheet',
C: 'create:cogwheel',
R: 'minecraft:redstone',
Q: 'create:rose_quartz',
S: 'tfmg:steel_ingot',
M: 'create:precision_mechanism',
});
// Floppy Disk: 2 plastic + redstone, shapeless.
event.remove({ output: 'computercraft:disk' });
event.shapeless('computercraft:disk', [
'tfmg:plastic_sheet',
'tfmg:plastic_sheet',
'minecraft:redstone',
]);
// Wired Modem: plastic shell + copper_wire signal lines + brass_casing
// chassis + electron_tube amplifier.
event.remove({ output: 'computercraft:wired_modem' });
event.shaped('computercraft:wired_modem', [
'PWP',
'BEB',
'PWP',
], {
P: 'tfmg:plastic_sheet',
W: 'tfmg:copper_wire',
B: 'create:brass_casing',
E: 'create:electron_tube',
});
// Wired Modem Full: 1 modem -> block form (toggle).
event.remove({ output: 'computercraft:wired_modem_full' });
event.shapeless('computercraft:wired_modem_full',
['computercraft:wired_modem']);
// Speaker: plastic + electron_tube amplifier + brass_casing + note_block.
event.remove({ output: 'computercraft:speaker' });
event.shaped('computercraft:speaker', [
'PEP',
'BMB',
'PNP',
], {
P: 'tfmg:plastic_sheet',
E: 'create:electron_tube',
B: 'create:brass_casing',
M: 'create:precision_mechanism',
N: 'minecraft:note_block',
});
// Printer: plastic + steel + ink_sac + precision_mech + cogwheel.
event.remove({ output: 'computercraft:printer' });
event.shaped('computercraft:printer', [
'PSP',
'IMI',
'PCP',
], {
P: 'tfmg:plastic_sheet',
S: 'tfmg:steel_ingot',
I: 'minecraft:ink_sac',
M: 'create:precision_mechanism',
C: 'create:cogwheel',
});
// ─── T3 - NETWORKING + ROBOTICS ────────────────────────────────────
// Wireless Modem (Basic): plastic + copper/aluminum wire + TFMG
// electromagnetic_coil antenna + brass_casing chassis.
event.remove({ output: 'computercraft:wireless_modem_normal' });
event.shaped('computercraft:wireless_modem_normal', [
'WAW',
'PLP',
'WBW',
], {
W: 'tfmg:copper_wire',
A: 'tfmg:aluminum_wire',
P: 'tfmg:plastic_sheet',
L: 'tfmg:electromagnetic_coil',
B: 'create:brass_casing',
});
// Redstone Relay: plastic + copper_wire + electron_tube switching +
// brass_casing.
event.remove({ output: 'computercraft:redstone_relay' });
event.shaped('computercraft:redstone_relay', [
'PEP',
'RBR',
'PEP',
], {
P: 'tfmg:plastic_sheet',
E: 'create:electron_tube',
R: 'minecraft:redstone',
B: 'create:brass_casing',
});
// Turtle (Basic): steel + mechanical_bearing (movement axis) +
// gantry_shaft (arm reach) + computer_normal (the brain) + chest.
// Contains the basic computer, so turtle inherits its plastic cost.
event.remove({ output: 'computercraft:turtle_normal' });
event.shaped('computercraft:turtle_normal', [
'SBS',
'GCG',
'SHS',
], {
S: 'tfmg:steel_ingot',
B: 'create:mechanical_bearing',
G: 'create:gantry_shaft',
C: 'computercraft:computer_normal',
H: 'minecraft:chest',
});
// ─── T4 - ADVANCED ─────────────────────────────────────────────────
// All advanced items: contain their basic counterpart + premium
// upgrade materials (silicon, rose_quartz, gold, industrial_iron_blk,
// constantan).
// Computer (Advanced): Computer Normal core, silicon + rose_quartz
// upgrades, industrial_iron_block premium chassis, gold trim.
event.remove({ output: 'computercraft:computer_advanced' });
event.shaped('computercraft:computer_advanced', [
'GQG',
'SCS',
'GIG',
], {
G: 'minecraft:gold_ingot',
Q: 'create:rose_quartz',
S: 'tfmg:silicon_ingot',
C: 'computercraft:computer_normal',
I: 'create:industrial_iron_block',
});
// Monitor (Advanced): Monitor Normal as the screen, display_link as
// the data binding layer, gold trim + nixie_tube accents, plastic
// insulation on the sides.
event.remove({ output: 'computercraft:monitor_advanced' });
event.shaped('computercraft:monitor_advanced', [
'GNG',
'PMP',
'GDG',
], {
G: 'minecraft:gold_ingot',
N: 'create:nixie_tube',
P: 'tfmg:plastic_sheet',
M: 'computercraft:monitor_normal',
D: 'create:display_link',
});
// Wireless Modem (Advanced): contains Wireless Modem Normal,
// wrapped in constantan_wire + spool for high-Q tuning, large_coil
// for the powerful antenna, gold trim.
event.remove({ output: 'computercraft:wireless_modem_advanced' });
event.shaped('computercraft:wireless_modem_advanced', [
'WCW',
'PMP',
'GLG',
], {
W: 'tfmg:constantan_wire',
C: 'tfmg:constantan_spool',
P: 'tfmg:plastic_sheet',
M: 'computercraft:wireless_modem_normal',
G: 'minecraft:gold_ingot',
L: 'tfmg:large_coil',
});
// Turtle (Advanced): contains Computer Advanced (so the upgrade
// chain forces Basic -> Computer Normal -> Computer Advanced ->
// Turtle Advanced). Mechanical_arm for proper robotic manipulation,
// gold trim.
event.remove({ output: 'computercraft:turtle_advanced' });
event.shaped('computercraft:turtle_advanced', [
'GAG',
'GCG',
'GHG',
], {
G: 'minecraft:gold_ingot',
A: 'create:mechanical_arm',
C: 'computercraft:computer_advanced',
H: 'minecraft:chest',
});
// ─── T5 - MOBILE (POCKET COMPUTERS) ────────────────────────────────
// Pocket Computer (Basic): miniaturised. Plastic shell, electron_tube,
// silicon CPU, precision_mech, glass pane screen.
event.remove({ output: 'computercraft:pocket_computer_normal' });
event.shaped('computercraft:pocket_computer_normal', [
'PEP',
'SMS',
'PgP',
], {
P: 'tfmg:plastic_sheet',
E: 'create:electron_tube',
S: 'tfmg:silicon_ingot',
M: 'create:precision_mechanism',
g: 'minecraft:glass_pane',
});
// Pocket Computer (Advanced): contains Pocket Computer Normal,
// upgraded with rose_quartz, gold-trim, silicon doubled.
event.remove({ output: 'computercraft:pocket_computer_advanced' });
event.shaped('computercraft:pocket_computer_advanced', [
'PQP',
'SCS',
'PGP',
], {
P: 'tfmg:plastic_sheet',
Q: 'create:rose_quartz',
S: 'tfmg:silicon_ingot',
C: 'computercraft:pocket_computer_normal',
G: 'minecraft:gold_ingot',
});
});
@@ -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');
@@ -0,0 +1,22 @@
// Brass & Sigil — Numismatics coin tooltip helper
//
// Numismatics ships its own "Value: N spurs" tooltip on each coin --
// don't duplicate that. The one piece players need that ISN'T there is:
// where do I convert these? Answer: Bank Teller block. Add that hint
// to each of the 6 coin denominations (spur, bevel, sprocket, cog,
// crown, sun -- including sprocket which we missed in v0.22.1).
const COIN_IDS = [
'numismatics:spur',
'numismatics:bevel',
'numismatics:sprocket',
'numismatics:cog',
'numismatics:crown',
'numismatics:sun',
];
ItemEvents.modifyTooltips(event => {
COIN_IDS.forEach(function(id) {
event.add(id, Text.aqua('Right-click a Bank Teller block to convert denominations.'));
});
});
@@ -0,0 +1,375 @@
// Brass & Sigil — Plot system V1
//
// SPAWN COMMERCIAL PLOTS (not bases). Players' homes go out in the world
// with normal FTB Chunks claims. A "plot" is a small commercial chunk-
// allocation at spawn for shops. 1 plot per player; release-and-rebuy to
// change size.
//
// Player commands (/bns plot ...):
// list show all plots + availability + price
// info <id> detailed info for one plot
// buy <id> purchase if eligible (no current plot, has coin)
// release release current plot (50% refund)
// Admin commands (OP, level 2):
// reload re-load plot definitions (currently in-script)
//
// Storage:
// Plot definitions: hardcoded `PLOT_DEFS` below; admin edits and reloads.
// Path-to-disk loading deferred to V1.1 (KubeJS file
// I/O is sandboxed).
// Ownership state: server.persistentData.bnsPlots -- compound NBT
// keyed by plot id, value = player UUID string. Single
// source of truth.
//
// Integrations (best-effort; verify in playtest):
// - Numismatics coin handling: inventory-based. Counts coins by denomination
// value, takes greedily by largest first. Assumed values:
// spur=1, bevel=8, cog=64, crown=512, sun=4096
// If Numismatics uses different ratios, adjust COIN_VALUES below.
// - FTB Chunks claim transfer: runs `/ftbchunks claim <x> <z>` as the player
// for each chunk in the plot, prefixed by `execute in <dim> as <player>`.
// Release runs `/ftbchunks unclaim`. May need adjusting if FTBC command
// syntax differs in this version.
//
// Performance:
// - All event-handler-style work is bounded by player action (command).
// No periodic ticks. No event hot-path scans.
// - Plot lookup is O(1) via Map.
// - Coin inventory scan is O(36) per purchase -- once per player action.
// ─── Plot definitions ─────────────────────────────────────────────────
// V1 hardcoded. Replace with admin-defined plots once the spawn marketplace
// is built. Chunks are [chunkX, chunkZ] pairs in the overworld.
const PLOT_DEFS = new Map([
['p001', {
id: 'p001', name: 'Market Stall 1', size: 'small',
chunks: [[10, 10]], // 1x1
price: 100, // in spurs
dimension: 'minecraft:overworld',
}],
['p002', {
id: 'p002', name: 'Corner Lot', size: 'medium',
chunks: [[12, 10], [12, 11]], // 1x2
price: 250,
dimension: 'minecraft:overworld',
}],
['p003', {
id: 'p003', name: 'Anchor Plot', size: 'large',
chunks: [[14, 10], [14, 11], [15, 10], [15, 11]], // 2x2
price: 600,
dimension: 'minecraft:overworld',
}],
]);
const REFUND_FRACTION = 0.5; // released plots refund 50% of purchase price
// ─── Numismatics coin handling ────────────────────────────────────────
// Authoritative values from in-game tooltips (verified by user 2026-05-23).
// Chain is NON-uniform (x8, x2, x4, x8, x8) -- don't assume otherwise.
const COIN_VALUES = {
'numismatics:spur': 1,
'numismatics:bevel': 8,
'numismatics:sprocket': 16,
'numismatics:cog': 64,
'numismatics:crown': 512,
'numismatics:sun': 4096,
};
const DENOMS_DESC = ['numismatics:sun', 'numismatics:crown', 'numismatics:cog',
'numismatics:sprocket', 'numismatics:bevel', 'numismatics:spur'];
function countCoins(player) {
let total = 0;
const inv = player.inventory;
for (let i = 0; i < inv.containerSize; i++) {
const stack = inv.getItem(i);
const v = COIN_VALUES[stack.id];
if (v) total += v * stack.count;
}
return total;
}
function takeCoins(player, amount) {
let remaining = amount;
const inv = player.inventory;
// Pass 1: take from largest denomination downward (minimises change).
for (const id of DENOMS_DESC) {
const value = COIN_VALUES[id];
if (remaining < value) continue;
for (let i = 0; i < inv.containerSize && remaining >= value; i++) {
const stack = inv.getItem(i);
if (stack.id !== id) continue;
const take = Math.min(stack.count, Math.floor(remaining / value));
stack.shrink(take);
remaining -= take * value;
}
}
return amount - remaining; // returns how many spurs actually taken
}
function giveCoins(player, amount) {
// Give back as largest denomination possible to minimise inventory clutter.
let remaining = amount;
for (const id of DENOMS_DESC) {
const value = COIN_VALUES[id];
if (remaining < value) continue;
const count = Math.floor(remaining / value);
if (count > 0) {
player.give(Item.of(id, count));
remaining -= count * value;
}
}
}
// ─── Ownership storage (server.persistentData.bnsPlots compound) ──────
function getOwnerMap() {
// Lazily ensure the compound exists. The compound is a plot_id -> uuid map.
const root = Utils.server.persistentData;
if (!root.contains('bnsPlots')) {
root.put('bnsPlots', {});
}
return root.getCompound('bnsPlots');
}
function ownerOf(plotId) {
const map = getOwnerMap();
return map.contains(plotId) ? map.getString(plotId) : null;
}
function plotsOwnedBy(playerUuid) {
const map = getOwnerMap();
const owned = [];
for (const key of map.getAllKeys()) {
if (map.getString(key) === playerUuid) owned.push(key);
}
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) {
getOwnerMap().putString(plotId, playerUuid);
console.info(`[bns/plots] purchase recorded: ${plotId} -> ${playerUuid}`);
}
function clearOwner(plotId) {
getOwnerMap().remove(plotId);
console.info(`[bns/plots] ownership cleared: ${plotId}`);
}
// ─── FTB Chunks integration (best-effort) ────────────────────────────
// FTB Chunks doesn't expose a KubeJS API for "claim chunk X for player Y",
// but its commands (/ftbchunks claim, /ftbchunks unclaim) work as
// console-as-player. Issue them with `execute as <player>` so they claim
// to that player's team.
function claimChunksForPlayer(plot, player) {
for (const [cx, cz] of plot.chunks) {
// /ftbchunks claim claims the chunk the executor stands in by default,
// but accepts X Z args in most versions. Try with explicit coords first;
// if that fails the syntax may need a `position` subcommand variant.
const cmd = `execute as ${player.username} in ${plot.dimension} run ftbchunks claim ${cx} ${cz}`;
Utils.server.runCommandSilent(cmd);
console.info(`[bns/plots] claim: ${cmd}`);
}
}
function unclaimChunksForPlayer(plot, player) {
for (const [cx, cz] of plot.chunks) {
const cmd = `execute as ${player.username} in ${plot.dimension} run ftbchunks unclaim ${cx} ${cz}`;
Utils.server.runCommandSilent(cmd);
console.info(`[bns/plots] unclaim: ${cmd}`);
}
}
// ─── Helpers ──────────────────────────────────────────────────────────
function formatPlotLine(plot) {
const owner = ownerOf(plot.id);
const status = owner ? `OWNED (${owner.substring(0,8)}...)` : 'AVAILABLE';
const cn = plot.chunks.length;
return `${plot.id} "${plot.name}" ${plot.size} ${cn} chunk${cn>1?'s':''} ${plot.price} spurs [${status}]`;
}
function sendMulti(source, lines) {
for (const line of lines) source.sendSystemMessage(Text.of(line));
}
// ─── Command tree ─────────────────────────────────────────────────────
ServerEvents.commandRegistry(event => {
const { commands: Commands, arguments: Arguments } = event;
const plotIdArg = () => Commands.argument('id', Arguments.STRING.create(event));
const tree = Commands.literal('bns')
.then(Commands.literal('plot')
// /bns plot list
.then(Commands.literal('list').executes(ctx => {
const lines = ['§7--- Spawn plots ---'];
for (const plot of PLOT_DEFS.values()) {
lines.push(formatPlotLine(plot));
}
lines.push('§7Run §f/bns plot info <id>§7 for details or §f/bns plot buy <id>§7 to purchase.');
sendMulti(ctx.source, lines);
return 1;
}))
// /bns plot info <id>
.then(Commands.literal('info').then(plotIdArg().executes(ctx => {
const id = Arguments.STRING.getResult(ctx, 'id');
const plot = PLOT_DEFS.get(id);
if (!plot) {
ctx.source.sendSystemMessage(Text.red(`No such plot: ${id}`));
return 0;
}
const owner = ownerOf(id);
const chunkStr = plot.chunks.map(([x,z]) => `(${x},${z})`).join(', ');
sendMulti(ctx.source, [
`§6Plot ${id}: §f${plot.name}`,
`§7 Size: §f${plot.size} (${plot.chunks.length} chunk${plot.chunks.length>1?'s':''})`,
`§7 Chunks: §f${chunkStr}`,
`§7 Dimension: §f${plot.dimension}`,
`§7 Price: §f${plot.price} spurs`,
`§7 Owner: §f${owner || 'AVAILABLE'}`,
]);
return 1;
})))
// /bns plot buy <id>
.then(Commands.literal('buy').then(plotIdArg().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 plot = PLOT_DEFS.get(id);
if (!plot) {
player.tell(Text.red(`No such plot: ${id}`));
return 0;
}
if (ownerOf(id)) {
player.tell(Text.red(`Plot ${id} is already owned.`));
return 0;
}
// Tier-based slot cap.
const owned = plotsOwnedBy(player.uuid.toString());
const cap = playerPlotSlotCap(player);
if (cap <= 0) {
player.tell(Text.red(
`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;
}
const have = countCoins(player);
if (have < plot.price) {
player.tell(Text.red(
`You need ${plot.price} spurs (have ${have}). Earn coins at the bank exchange.`
));
return 0;
}
const took = takeCoins(player, plot.price);
if (took < plot.price) {
// Shouldn't happen if countCoins agreed, but be safe.
giveCoins(player, took);
player.tell(Text.red(`Coin removal failed -- refunded. Try again.`));
return 0;
}
recordPurchase(id, player.uuid.toString());
claimChunksForPlayer(plot, player);
player.tell(Text.gold(
`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 (slot ${owned.length + 1}/${cap})`);
return 1;
})))
// /bns plot release <id>
// 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;
if (!player) {
ctx.source.sendSystemMessage(Text.red('Run from a player context.'));
return 0;
}
const id = Arguments.STRING.getResult(ctx, 'id');
const ownerUuid = ownerOf(id);
if (ownerUuid !== player.uuid.toString()) {
player.tell(Text.red(`You don't own plot ${id}.`));
return 0;
}
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);
unclaimChunksForPlayer(plot, player);
clearOwner(id);
giveCoins(player, refund);
const owned = plotsOwnedBy(player.uuid.toString());
const cap = playerPlotSlotCap(player);
player.tell(Text.gold(
`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 (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;
}))
// /bns plot reload (OP)
.then(Commands.literal('reload').requires(src => src.hasPermission(2))
.executes(ctx => {
// V1: PLOT_DEFS is hardcoded; reload is a no-op until V1.1
// (JSON-from-disk loading). Logged so admins know it ran.
ctx.source.sendSystemMessage(Text.gold(
`Plot defs are hardcoded in plots.js (V1). Edit the file + restart to change.`
));
console.info(`[bns/plots] reload called (no-op in V1)`);
return 1;
}))
);
event.register(tree);
console.info('[bns/plots] commands registered: list, info, buy, release, reload');
});
@@ -0,0 +1,27 @@
// Brass & Sigil — Numismatics coin recipe removal
//
// The 5 coin denominations cannot be crafted by players. All currency
// enters circulation exclusively through the bank exchange (item-in /
// coin-out). Initially with fixed rates admin-configured on Numismatics
// vendor blocks; future dynamic-rates layer adjusts based on trade
// volume (see project-mc-player-economy.md).
//
// Other Numismatics items (piggy banks, bank tellers, vendor blocks,
// depositors, bank cards) stay craftable -- those are infrastructure,
// not currency.
// Renamed to avoid collision with COIN_IDS in numismatics_tooltips.js
// (KubeJS server_scripts share a single Rhino global scope).
const COIN_RECIPE_REMOVE_IDS = [
'numismatics:spur',
'numismatics:bevel',
'numismatics:sprocket',
'numismatics:cog',
'numismatics:crown',
'numismatics:sun',
];
ServerEvents.recipes(event => {
COIN_RECIPE_REMOVE_IDS.forEach(coin => event.remove({ output: coin }));
console.info(`[bns/recipes_numismatics] removed crafting recipes for ${COIN_RECIPE_REMOVE_IDS.length} coin denominations`);
});
@@ -0,0 +1,16 @@
// Brass & Sigil — Waystones recipe removal
//
// All Waystones items are uncraftable. Players never see waystones,
// sharestones, portstones, warp plates, warp scrolls or warp stones in
// the recipe book or JEI search results (see also jei_hides.js on the
// client side).
//
// Regular waystones are distributed exclusively via the first-join grant
// (welcome.js). Replacement waystones for players who break theirs will
// come from the bank vendor once the economy ships -- until then OPs
// hand them out via /give.
ServerEvents.recipes(event => {
event.remove({ mod: 'waystones' });
console.info('[bns/recipes_waystones] removed all waystones recipes');
});
@@ -0,0 +1,44 @@
// Brass & Sigil -- Supplementaries cross-mod bridges
//
// Supplementaries already does tag work (c:crops/flax, c:fiber/flax,
// c:drinks/lumisene, etc.) and Create auto-generates Millstone +
// 9x packing recipes from those tags. So most integration is done.
//
// What's left -- two small bridges:
// 1. Flax -> String via Mechanical Press (Create doesn't map
// c:fiber -> string natively; this fills the gap so flax becomes
// a viable string-source for textile builds).
// 2. Plunderer drops 1-3 Numismatics spurs so the hostile NPC pulls
// into the server economy. Plunderers are rare so this is a
// mild bounty, not a farmable currency source.
//
// Lumisene -> Ars Source was on the list but Ars Nouveau 5.11.x's
// sourcelink recipe schema needs verification before we add it.
// Parked for a follow-up.
ServerEvents.recipes(event => {
// 2 flax pressed -> 1 string. Cheaper than wool->4 string but
// requires a Press (kinetic infrastructure) -- balanced.
//
// Uses the kubejs_create mod's builder API (added in pack v0.27.0).
// Before that, the bare event.recipes.create.pressing() signature
// didn't match Create 6.x's recipe shape and we had to use a raw
// event.custom({type: 'create:pressing', ...}) call.
event.recipes.create.pressing('2x minecraft:string', 'supplementaries:flax')
.id('bns:press_flax_to_string');
console.info('[bns/supplementaries_bridges] added flax -> string press recipe');
});
// Plunderer is a hostile mob that raids unprotected chests. Adding
// a small spur bounty makes them a mini-event rather than a pure
// nuisance. Range tuned low: 1-3 spurs = trivial vs. the player
// economy but creates a "go hunt the plunderer" hook.
//
// KubeJS 2101.7.2 doesn't expose ServerEvents.entityLootTables on
// NeoForge -- we use EntityEvents.drops (filtered by entity id) and
// add items via event.addDrop instead.
EntityEvents.drops('supplementaries:plunderer', event => {
const count = 1 + Math.floor(Math.random() * 3);
event.addDrop(Item.of('numismatics:spur', count));
});
console.info('[bns/supplementaries_bridges] registered plunderer -> spur drops handler');
@@ -0,0 +1,97 @@
// Brass & Sigil — Waystones placement policy
//
// Rules:
// - Regular waystones (6 stone variants): 1 per player. Forces players to
// build actual transport infrastructure (trains/roads/canals) for
// long-distance routes instead of waystone-spamming.
// - Sharestones (16 colors): banned. Pair-teleport between solo-placed
// sharestones defeats the "build transport" intent, and a sharestone
// placed just outside someone's claim gives an unrestricted teleport-
// adjacent vector.
// - Portstones (16 colors): banned pending separate design discussion.
// - Warp plates: not restricted (single-block teleport pad; revisit if
// it becomes a workaround).
// - Warp scrolls / warp stones: untouched (consumable / cooldown-bound).
//
// Per-player count lives in player.persistentData.bnsWaystoneCount.
// Decrement on break works because waystones-common.toml has
// restrictedWaystones = ["PLAYER"] -- only the owner can break their own
// waystone, so breaker == owner in normal play.
const WAYSTONE_CAP = 1;
const REGULAR_WAYSTONES = new Set([
'waystones:waystone',
'waystones:mossy_waystone',
'waystones:sandy_waystone',
'waystones:blackstone_waystone',
'waystones:deepslate_waystone',
'waystones:end_stone_waystone',
]);
// Build the colored-block ban list once instead of typing 32 strings.
const COLORS = [
'white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime',
'pink', 'gray', 'light_gray', 'cyan', 'purple', 'blue',
'brown', 'green', 'red', 'black',
];
const BANNED_BLOCKS = new Set();
COLORS.forEach(c => {
BANNED_BLOCKS.add(`waystones:${c}_sharestone`);
BANNED_BLOCKS.add(`waystones:${c}_portstone`);
});
// Helper: replace the just-placed block with air and refund the item.
function refund(event, blockId) {
event.block.set('minecraft:air');
if (event.player) {
event.player.give(Item.of(blockId));
}
}
BlockEvents.placed(event => {
const blockId = event.block.id;
const player = event.player;
// Skip non-player placements (dispensers, mob events) -- nothing to refund.
if (!player) return;
if (BANNED_BLOCKS.has(blockId)) {
refund(event, blockId);
player.tell(Text.red(
'That block is disabled. Use a regular Waystone (1 per player) ' +
'and build transport between distant bases.'
));
console.info(`[bns/waystones_policy] denied ${blockId} placement by ${player.username} (banned)`);
return;
}
if (REGULAR_WAYSTONES.has(blockId)) {
const data = player.persistentData;
const current = data.getInt('bnsWaystoneCount');
if (current >= WAYSTONE_CAP) {
refund(event, blockId);
player.tell(Text.red(
`You already have ${WAYSTONE_CAP} waystone. Break your existing one ` +
`to relocate, or build infrastructure to reach distant bases.`
));
console.info(`[bns/waystones_policy] denied ${blockId} placement by ${player.username} (already at cap ${current})`);
return;
}
data.putInt('bnsWaystoneCount', current + 1);
console.info(`[bns/waystones_policy] +1 waystone for ${player.username} (now ${current + 1})`);
}
});
BlockEvents.broken(event => {
const blockId = event.block.id;
if (!REGULAR_WAYSTONES.has(blockId)) return;
const player = event.player;
if (!player) return; // explosion / world clear -- accept count drift, rare
const data = player.persistentData;
const current = data.getInt('bnsWaystoneCount');
if (current > 0) {
data.putInt('bnsWaystoneCount', current - 1);
console.info(`[bns/waystones_policy] -1 waystone for ${player.username} (now ${current - 1})`);
}
});
@@ -0,0 +1,47 @@
// Brass & Sigil -- first-join welcome grant
//
// Gives each new player exactly 1 Waystone + 1 Patchouli manual on
// first login. Gated by a per-player persistent flag so it fires once
// even if the player relogs.
//
// The manual is a Patchouli book ("patchouli:manual") shipped in the
// pack's root patchouli_books/ folder (the modpack-author path that
// Patchouli's docs recommend for non-mod books).
// Folder layout:
// patchouli_books/manual/book.json
// patchouli_books/manual/en_us/categories/<name>.json
// patchouli_books/manual/en_us/entries/<name>.json
// Namespace defaults to "patchouli" in this mode -- the book id is
// "patchouli:manual".
const WELCOME_FLAG = 'bnsWelcomeGranted';
function giveManual(player) {
// Patchouli's guide_book uses the "patchouli:book" data component
// (1.21 component system) to identify which book to open. Plain
// resource location string is the expected value type.
const book = Item.of('patchouli:guide_book');
book.set('patchouli:book', 'patchouli:manual');
player.give(book);
}
function giveStarterWaystone(player) {
player.give(Item.of('waystones:waystone'));
}
PlayerEvents.loggedIn(event => {
const player = event.player;
const data = player.persistentData;
if (data.getBoolean(WELCOME_FLAG)) return;
try {
giveStarterWaystone(player);
giveManual(player);
data.putBoolean(WELCOME_FLAG, true);
player.tell(Text.gold('Welcome to Brass & Sigil. Check your inventory for a Waystone and the server manual.'));
console.info(`[bns/welcome] granted starter packet to ${player.username}`);
} catch (e) {
// If something goes wrong, don't set the flag -- player gets another chance.
console.error(`[bns/welcome] failed for ${player.username}: ${e}`);
}
});
Binary file not shown.
@@ -0,0 +1,11 @@
{
"name": "Brass & Sigil Manual",
"landing_text": "Welcome to Brass & Sigil. This manual covers the essentials -- read it once, then keep it or toss it.",
"subtitle": "The Server Handbook",
"creative_tab": "minecraft:tools_and_utilities",
"model": "patchouli:guide_book",
"show_progress": false,
"show_advancements": false,
"book_texture": "patchouli:textures/gui/book_brown.png",
"version": 1
}
@@ -0,0 +1,6 @@
{
"name": "Server Basics",
"description": "The essentials for life in Brass & Sigil.",
"icon": "minecraft:writable_book",
"sortnum": 0
}
@@ -0,0 +1,18 @@
{
"name": "Money",
"icon": "numismatics:cog",
"category": "patchouli:main",
"sortnum": 2,
"pages": [
{
"type": "patchouli:text",
"title": "Coins & Trade",
"text": "Coins $(bold)cannot be crafted$(0). They enter circulation only through the bank at spawn.$(br2)Take valuables -- diamonds, emeralds, rare loot -- to the exchange. It trades them for coins at fixed rates."
},
{
"type": "patchouli:text",
"title": "Denominations",
"text": "$(li)$(item)Spur$() = 1$(li)$(item)Bevel$() = 8 spurs$(li)$(item)Sprocket$() = 16 spurs$(li)$(item)Cog$() = 64 spurs$(li)$(item)Crown$() = 512 spurs$(li)$(item)Sun$() = 4 096 spurs$(br2)Use a $(item)Bank Teller$() block to convert between denominations."
}
]
}
@@ -0,0 +1,18 @@
{
"name": "Plots & Claims",
"icon": "ftbchunks:map",
"category": "patchouli:main",
"sortnum": 3,
"pages": [
{
"type": "patchouli:text",
"title": "Spawn Plots",
"text": "Buy a small commercial plot at spawn to set up your shop or stall. $(bold)One plot per player$(0).$(br2)Plots are limited and rented from the Plot Office at spawn -- pay in $(item)cog$()s, or trade rare items for the upgrade."
},
{
"type": "patchouli:text",
"title": "Your Home Base",
"text": "Your $(bold)home base$(0) lives out in the world -- not on a spawn plot. Pick a spot you like and claim it with $(item)FTB Chunks$(): open the map ($(item)m$()), click chunks to claim and force-load.$(br2)FTB Chunks claims are $(bold)free$(0) and protect against grief from other players."
}
]
}
@@ -0,0 +1,27 @@
{
"name": "Rules",
"icon": "minecraft:shield",
"category": "patchouli:main",
"sortnum": 4,
"pages": [
{
"type": "patchouli:text",
"title": "House Rules",
"text": "$(li)Be excellent to each other.$(li)No griefing.$(li)Don't break others' contraptions or fly through their bases.$(li)Lost your Waystone? Ask an op or buy one from the bank.$(li)Report bugs and crashes to staff."
},
{
"type": "patchouli:text",
"title": "PvP & Combat",
"text": "PvP is $(bold)allowed$(0) outside claimed chunks. Claim your base with $(item)FTB Chunks$() to protect it.$(br2)Airships flying through unclaimed chunks are $(bold)fair game$(0) -- if you take it out beyond your claim, expect cannons."
},
{
"type": "patchouli:text",
"title": "Griefing vs PvP",
"text": "PvP and griefing are $(bold)not the same$(0).$(br2)Allowed: combat, raiding contested ground, sinking each other's airships in open air.$(br2)$(bold)Not allowed -- ban-worthy:$()$(li)Repeated targeted harassment of a player$(li)Destruction of unclaimed builds purely to ruin them$(li)Camping someone's claim border to grief them$(li)Stealing keys/coins via social engineering or exploit$(br2)If unsure, ask staff first."
},
{
"type": "patchouli:text",
"text": "$(bold)Have fun.$(0)$(br2)This manual is a living document -- expect new pages as the server grows. To re-read at any time, hold the manual and right-click."
}
]
}
@@ -0,0 +1,17 @@
{
"name": "Waystones",
"icon": "waystones:waystone",
"category": "patchouli:main",
"sortnum": 1,
"pages": [
{
"type": "patchouli:text",
"title": "Waystones",
"text": "You have one $(item)Waystone$() in your starter kit. $(bold)Place it at your base$() -- it is the only one you get from the server."
},
{
"type": "patchouli:text",
"text": "Need to travel further? Build:$(br)$(li)$(item)Create$() trains and tracks$(li)Roads with the $(item)brass road sign$()$(li)$(item)Create Aeronautics$() planes and airships$(br2)Once Quests are live, additional Waystones will be purchasable at the bank."
}
]
}
@@ -0,0 +1,14 @@
{
"name": "Welcome",
"icon": "minecraft:cake",
"category": "patchouli:main",
"priority": true,
"sortnum": 0,
"pages": [
{
"type": "patchouli:text",
"title": "Welcome",
"text": "Welcome to $(bold)Brass & Sigil$(0)!$(br2)This manual covers the basics. Skim it now, then keep it on you or toss it in a chest -- you can always craft another by combining a $(item)book$() with a server-issued $(item)sigil$()."
}
]
}
@@ -0,0 +1,12 @@
{
"ingredients": [
{
"item": "minecraft:barrier"
}
],
"result": {
"item": "alexsmobs:shattered_dimensional_carver",
"count": 1
},
"time": 200
}
@@ -0,0 +1,4 @@
{
"fluids": ["minecraft:lava"],
"fuel_ticks": 8000
}
@@ -0,0 +1,4 @@
{
"fluids": ["tfmg:crude_oil"],
"fuel_ticks": 8000
}
@@ -0,0 +1,4 @@
{
"fluids": ["tfmg:diesel"],
"fuel_ticks": 48000
}
@@ -0,0 +1,4 @@
{
"fluids": ["tfmg:furnace_gas"],
"fuel_ticks": 12000
}
@@ -0,0 +1,4 @@
{
"fluids": ["tfmg:gasoline"],
"fuel_ticks": 36000
}
@@ -0,0 +1,4 @@
{
"fluids": ["tfmg:kerosene"],
"fuel_ticks": 24000
}
@@ -0,0 +1,4 @@
{
"fluids": ["tfmg:naphtha"],
"fuel_ticks": 24000
}
@@ -0,0 +1,7 @@
{
"replace": false,
"values": [
"alexsmobs:shattered_dimensional_carver",
"alexsmobs:transmutation_table"
]
}
@@ -0,0 +1,6 @@
{
"pack": {
"pack_format": 48,
"description": "Brass and Sigil — Steam'n'Rails liquid fuel definitions"
}
}
@@ -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}"
}
}
+1140 -348
View File
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
#requires -Version 7
<#
.SYNOPSIS
Populate the `side` field on each mod entry in pack.lock.json by querying
Modrinth's project metadata. Run once after schema migration; thereafter,
Update-Pack.ps1 should set side automatically when adding new mods.
.DESCRIPTION
Modrinth returns `client_side` and `server_side` per project, with values
`required` / `optional` / `unsupported` / `unknown`. We map:
server_side = unsupported -> side = "client" (skip on server)
client_side = unsupported -> side = "server" (skip on launcher)
both required/optional/unknown -> side = "both" (install everywhere)
Conservative: only marks a mod side-restricted when the OTHER side is
explicitly `unsupported`. Anything ambiguous stays "both".
CurseForge mods can't be queried (no API key here) -- left at "both" by
default, manual edit if needed.
.PARAMETER LockPath
pack.lock.json to update in place. Defaults to ../pack/pack.lock.json.
.PARAMETER DryRun
Print proposed changes without writing the file.
#>
[CmdletBinding()]
param(
[string]$LockPath = $(Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) '..\pack\pack.lock.json'),
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$LockPath = (Resolve-Path $LockPath).Path
$json = Get-Content $LockPath -Raw | ConvertFrom-Json -Depth 20
if (-not $json.mods) { throw "pack.lock.json has no 'mods' array." }
function Get-ModrinthSide {
param([string]$Slug)
$proj = Invoke-RestMethod -Uri "https://api.modrinth.com/v2/project/$Slug" `
-Headers @{ 'User-Agent' = 'BrassAndSigil-Launcher/0.1 (matt@sijbers.uk)' }
$cs = $proj.client_side; $ss = $proj.server_side
if ($ss -eq 'unsupported' -and $cs -ne 'unsupported') { return 'client' }
elseif ($cs -eq 'unsupported' -and $ss -ne 'unsupported') { return 'server' }
else { return 'both' }
}
Write-Host ""
Write-Host "Bootstrapping side field on $($json.mods.Count) mods..."
Write-Host ""
$changes = 0
foreach ($mod in $json.mods) {
$existing = if ($mod.PSObject.Properties.Name -contains 'side') { $mod.side } else { $null }
if ($mod.source -ne 'modrinth') {
# CurseForge or other: default to 'both' if missing, leave alone otherwise.
if (-not $existing) {
$mod | Add-Member -NotePropertyName 'side' -NotePropertyValue 'both' -Force
Write-Host (" [{0}] {1,-26} {2}" -f $mod.source.PadRight(7).Substring(0,7), $mod.slug, 'both (default; non-modrinth)')
$changes++
}
continue
}
try {
$side = Get-ModrinthSide -Slug $mod.slug
} catch {
Write-Warning (" $($mod.slug) lookup failed: $($_.Exception.Message) -- leaving as 'both'")
$side = 'both'
}
if ($existing -ne $side) {
$mod | Add-Member -NotePropertyName 'side' -NotePropertyValue $side -Force
Write-Host (" [{0}] {1,-26} {2}" -f $mod.source.PadRight(7).Substring(0,7), $mod.slug, $side)
$changes++
} else {
Write-Host (" [{0}] {1,-26} {2} (unchanged)" -f $mod.source.PadRight(7).Substring(0,7), $mod.slug, $side) -ForegroundColor DarkGray
}
Start-Sleep -Milliseconds 100 # be polite to Modrinth's API
}
Write-Host ""
Write-Host "$changes mod(s) updated."
if ($DryRun) {
Write-Host "DryRun: not writing $LockPath." -ForegroundColor DarkGray
exit 0
}
# Round-trip via JSON: Modrinth-returned ConvertFrom-Json objects don't always
# round-trip with consistent property order, so we serialise with sorted keys
# at the mod level to keep the lockfile diff minimal.
$json | ConvertTo-Json -Depth 20 | Set-Content -Path $LockPath -Encoding utf8
Write-Host "Wrote $LockPath" -ForegroundColor Green
+43 -6
View File
@@ -77,14 +77,21 @@ $files = @()
$totalBytes = 0L
foreach ($mod in $lock.mods) {
$files += [pscustomobject]@{
# `side` is emitted only when explicitly set in the lockfile -- a missing or
# null lockfile side means "both", and we keep the manifest clean by omitting
# the field rather than serialising "both" everywhere.
$entry = [ordered]@{
path = $mod.path
url = $mod.url
sha1 = $mod.sha1
size = $mod.size
}
$hasSide = ($mod.PSObject.Properties.Name -contains 'side') -and $mod.side -and $mod.side -ne 'both'
if ($hasSide) { $entry.side = $mod.side }
$files += [pscustomobject]$entry
$totalBytes += $mod.size
Write-Host (" [{0}] {1,-26} {2,-22} {3,8:N0} KB" -f $mod.source.PadRight(7).Substring(0,7), $mod.slug, $mod.version, ($mod.size/1KB))
$sideTag = if ($hasSide) { " [$($mod.side)]" } else { '' }
Write-Host (" [{0}] {1,-26} {2,-22} {3,8:N0} KB{4}" -f $mod.source.PadRight(7).Substring(0,7), $mod.slug, $mod.version, ($mod.size/1KB), $sideTag)
}
# Local overrides (configs, custom files not on Modrinth/CurseForge)
@@ -100,14 +107,27 @@ if ($LocalPackSource -and (Test-Path $LocalPackSource)) {
Get-ChildItem -Path $rootDir -Recurse -File | ForEach-Object {
$rel = $_.FullName.Substring($sourceFull.Length).TrimStart('\','/') -replace '\\','/'
$sha1 = (Get-FileHash -Algorithm SHA1 -Path $_.FullName).Hash.ToLowerInvariant()
$files += [pscustomobject]@{
# Tweak jars (built by Build-Tweaks.ps1 into pack/overrides/mods/)
# are pure data-only NeoForge mods -- worldgen modifiers, recipe
# overrides, etc. They typically depend on server-only libraries
# (Lithostitched, etc.) and have no client-side rendering. Default
# them to side="server" so the client doesn't try to load them
# without their server-only deps -> the loader otherwise refuses to
# start with "Mod X requires Y or above" errors. If a future tweak
# is genuinely client-relevant, override by editing this rule.
$entry = [ordered]@{
path = $rel
url = "$base/$rel"
sha1 = $sha1
size = $_.Length
}
if ($root -eq 'mods') { $entry.side = 'server' }
$files += [pscustomobject]$entry
$totalBytes += $_.Length
Write-Host (" [local] {0}" -f $rel)
$sideTag = if ($entry.side) { " [$($entry.side)]" } else { '' }
Write-Host (" [local] {0}{1}" -f $rel, $sideTag)
}
}
}
@@ -156,8 +176,25 @@ if ($LauncherExePath) {
}
$launcherFile = Get-Item $LauncherExePath
$launcherVersion = $launcherFile.VersionInfo.FileVersion
$versionSource = 'PE FileVersion'
# Linux PowerShell can't read PE FileVersion from a Windows .exe. When that
# happens, fall back to launcher/ModpackLauncher.csproj's <Version> (the
# source of truth that the Windows compile bakes into FileVersion anyway).
# Append ".0" so callers get the same 4-part form as the PE metadata.
if ([string]::IsNullOrWhiteSpace($launcherVersion)) {
throw "Launcher exe has no FileVersion -- set <Version> in ModpackLauncher.csproj and republish."
$csprojPath = Join-Path $here '..\launcher\ModpackLauncher.csproj'
if (Test-Path $csprojPath) {
[xml]$csproj = Get-Content $csprojPath
$csprojVersion = ($csproj.Project.PropertyGroup.Version | Where-Object { $_ }) | Select-Object -First 1
if ($csprojVersion) {
$launcherVersion = if ($csprojVersion -match '^\d+\.\d+\.\d+$') { "$csprojVersion.0" } else { $csprojVersion }
$versionSource = "csproj <Version> (fallback)"
}
}
}
if ([string]::IsNullOrWhiteSpace($launcherVersion)) {
throw "Launcher exe has no FileVersion and csproj <Version> couldn't be read either -- set <Version> in ModpackLauncher.csproj and republish."
}
# FileVersion is the four-component form (e.g. "0.1.0.0" for csproj <Version>0.1.0</Version>).
# Embed as-is -- the launcher's Version.Parse handles it directly and avoids ambiguous
@@ -167,7 +204,7 @@ if ($LauncherExePath) {
Write-Host ""
Write-Host ("Launcher metadata embedded:")
Write-Host (" Version: {0}" -f $launcherVersion)
Write-Host (" Version: {0} ({1})" -f $launcherVersion, $versionSource)
Write-Host (" Url: {0}" -f $LauncherPublicUrl)
Write-Host (" Source: {0}" -f $launcherFile.FullName)
}
+267
View File
@@ -0,0 +1,267 @@
#requires -Version 7
<#
.SYNOPSIS
Audit pack.lock.json for missing required mod dependencies by reading
each jar's mods.toml (and any jar-in-jar bundles) directly. Loader-truth,
not Modrinth-truth.
.DESCRIPTION
Each NeoForge/Forge mod declares its real dependencies in
META-INF/neoforge.mods.toml (or META-INF/mods.toml) inside the jar.
This is what the loader actually checks at startup; the Modrinth
dependency field is metadata authors fill in (or don't).
The script also recurses into META-INF/jarjar/ bundles. Big mods like
Create ship their deps (Registrate, Flywheel, Ponder) embedded as
jar-in-jar -- the loader auto-loads them, so their modIds count as
"present" without explicit lockfile entries.
Slug filter: only entries under "mods/" are treated as mod jars.
Shaderpacks and other managed-but-non-mod files are skipped.
Language-provider jars (e.g. Kotlin for Forge) ship without a standard
mods.toml; their modIds are added via a tiny override map.
Jars are cached in /tmp/bns-check-deps-cache/<sha1>.jar so subsequent
runs are instant.
.PARAMETER LockPath
.PARAMETER CacheDir
.PARAMETER WarnOnly
Print missing deps but exit 0. Deploy-Brass.ps1 calls without it.
#>
[CmdletBinding()]
param(
[string]$LockPath = $(Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) '..\pack\pack.lock.json'),
[string]$CacheDir = '/tmp/bns-check-deps-cache',
[switch]$WarnOnly
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$LockPath = (Resolve-Path $LockPath).Path
$lock = Get-Content $LockPath -Raw | ConvertFrom-Json -Depth 20
if (-not $lock.mods) { throw "pack.lock.json has no 'mods' array." }
if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir | Out-Null }
# Vanilla / loader IDs are always satisfied -- they're never modpack mods.
$vanillaIds = @('minecraft','neoforge','forge','fabric','fabricloader','quilt','java','javafml','lowcodefml')
# Language-provider / library jars that ship without a standard mods.toml.
# Map their lockfile slug to the modId they register at runtime. Add entries
# here as new ones are encountered.
$slugToImplicitModId = @{
'kotlin-for-forge' = 'kotlinforforge'
}
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
function Get-JarPath {
param($mod)
$cached = Join-Path $CacheDir ("$($mod.sha1).jar")
if (Test-Path $cached) { return $cached }
Write-Host " downloading $($mod.slug) ..." -ForegroundColor DarkGray
try { Invoke-WebRequest -Uri $mod.url -OutFile $cached -TimeoutSec 60 }
catch { throw "Failed to download $($mod.url): $($_.Exception.Message)" }
return $cached
}
# Read a specific entry's bytes from an already-open ZipArchive into memory.
function Read-ZipEntry {
param([System.IO.Compression.ZipArchive]$Zip, [string]$EntryName)
$entry = $Zip.GetEntry($EntryName)
if (-not $entry) { return $null }
$ms = New-Object System.IO.MemoryStream
$s = $entry.Open()
try { $s.CopyTo($ms) } finally { $s.Dispose() }
return $ms.ToArray()
}
function Read-ZipEntryText {
param([System.IO.Compression.ZipArchive]$Zip, [string]$EntryName)
$entry = $Zip.GetEntry($EntryName)
if (-not $entry) { return $null }
$reader = New-Object System.IO.StreamReader($entry.Open())
try { return $reader.ReadToEnd() }
finally { $reader.Dispose() }
}
# Tiny TOML reader -- only the bits we need (sections + key="value" lines).
function Parse-ModsToml {
param([string]$Text)
$modIds = @()
$deps = @()
$section = $null
$current = $null
function Flush-Record {
param($refModIds, $refDeps, $sec, $cur)
if (-not $cur) { return }
if ($sec -eq 'mods' -and $cur.modId) {
$refModIds.Value += $cur.modId
} elseif ($sec -like 'dep:*' -and $cur.modId) {
$refDeps.Value += [pscustomobject]@{
parent = $sec.Substring(4)
modId = $cur.modId
type = $cur.type
mandatory = $cur.mandatory
versionRange = $cur.versionRange
}
}
}
$modIdsRef = [ref]$modIds
$depsRef = [ref]$deps
foreach ($raw in ($Text -split "`n")) {
$line = $raw.Trim()
if (-not $line -or $line.StartsWith('#')) { continue }
if ($line.StartsWith('[[')) {
Flush-Record $modIdsRef $depsRef $section $current
$current = $null
if ($line -match '^\[\[mods\]\]') {
$section = 'mods'; $current = @{}
} elseif ($line -match '^\[\[dependencies\.([^\]]+)\]\]') {
$section = "dep:$($Matches[1])"; $current = @{}
} else {
$section = $null
}
continue
}
if (-not $current) { continue }
if ($line -match '^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$') {
$k = $Matches[1]; $v = $Matches[2].Trim()
if ($v.StartsWith('"') -and $v.EndsWith('"')) { $v = $v.Substring(1, $v.Length - 2) }
$current[$k] = $v
}
}
Flush-Record $modIdsRef $depsRef $section $current
return [pscustomobject]@{ ModIds = $modIdsRef.Value; Deps = $depsRef.Value }
}
function Is-Required {
param($dep)
if ($dep.type) { return ($dep.type -eq 'required' -or $dep.type -eq 'mandatory') }
if ($dep.mandatory) { return ($dep.mandatory -eq 'true') }
return $true # NeoForge default
}
# Recursively walk a jar (and its META-INF/jarjar/ embedded jars) collecting
# every modId declared by mods.toml inside. Used to satisfy deps that ship
# bundled (e.g. ponder/registrate/flywheel inside Create).
function Collect-Jar-Identity {
param([string]$JarPath)
$modIds = @()
$deps = @()
$zip = [System.IO.Compression.ZipFile]::OpenRead($JarPath)
try {
# 1) This jar's own mods.toml (if any).
$toml = Read-ZipEntryText -Zip $zip -EntryName 'META-INF/neoforge.mods.toml'
if (-not $toml) { $toml = Read-ZipEntryText -Zip $zip -EntryName 'META-INF/mods.toml' }
if ($toml) {
$parsed = Parse-ModsToml -Text $toml
$modIds += $parsed.ModIds
$deps += $parsed.Deps
}
# 2) jar-in-jar bundles -- recursively extract + collect modIds (we only
# need their modIds; their deps are the parent's problem to satisfy).
$jarjarEntries = $zip.Entries | Where-Object {
$_.FullName -like 'META-INF/jarjar/*.jar'
}
foreach ($e in $jarjarEntries) {
$tmpInner = [System.IO.Path]::GetTempFileName() + '.jar'
$s = $e.Open()
try {
$fs = [System.IO.File]::Open($tmpInner, [System.IO.FileMode]::Create)
try { $s.CopyTo($fs) } finally { $fs.Dispose() }
} finally { $s.Dispose() }
$inner = Collect-Jar-Identity -JarPath $tmpInner
$modIds += $inner.ModIds # bundled deps count as present
# Don't propagate bundled deps -- they're satisfied within this jar
Remove-Item $tmpInner -Force -ErrorAction SilentlyContinue
}
} finally {
$zip.Dispose()
}
return [pscustomobject]@{ ModIds = ($modIds | Select-Object -Unique); Deps = $deps }
}
Write-Host ""
Write-Host "Auditing $($lock.mods.Count) lockfile entries (parsing each jar's mods.toml + jar-in-jar bundles)..."
Write-Host ""
$presentModIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($v in $vanillaIds) { [void]$presentModIds.Add($v) }
# parent modId -> list of dep records (so the missing report blames a modId, not a slug)
$jarDepsByParentModId = @{}
$skipped = @() # informational
$count = 0
foreach ($m in $lock.mods) {
$count++
# Only consider entries that live under mods/ -- shaderpacks, configs etc.
# are managed by the launcher but aren't mods.
if (-not ($m.path -like 'mods/*')) {
$skipped += "$($m.slug): not under mods/ ($($m.path))"
continue
}
# Language-provider override (kotlin-for-forge etc.)
if ($slugToImplicitModId.ContainsKey($m.slug)) {
[void]$presentModIds.Add($slugToImplicitModId[$m.slug])
}
try {
$jar = Get-JarPath -mod $m
$ident = Collect-Jar-Identity -JarPath $jar
foreach ($id in $ident.ModIds) {
[void]$presentModIds.Add($id)
}
foreach ($id in $ident.ModIds) {
$jarDepsByParentModId[$id] = $ident.Deps
}
if (-not $ident.ModIds -and -not $slugToImplicitModId.ContainsKey($m.slug)) {
$skipped += "$($m.slug): no modId discovered (no mods.toml; consider adding to override map)"
}
} catch {
$skipped += "$($m.slug): $($_.Exception.Message)"
}
if ($count % 10 -eq 0) { Write-Host " ...processed $count/$($lock.mods.Count)" -ForegroundColor DarkGray }
}
# Pass 2: check each parent's required deps against the present set.
$missing = @{}
foreach ($parentId in $jarDepsByParentModId.Keys) {
foreach ($dep in $jarDepsByParentModId[$parentId]) {
if (-not (Is-Required $dep)) { continue }
if (-not $presentModIds.Contains($dep.modId)) {
if (-not $missing.ContainsKey($dep.modId)) {
$missing[$dep.modId] = @{ parents = @() }
}
$missing[$dep.modId].parents += $parentId
}
}
}
Write-Host ""
Write-Host ("=" * 60)
if ($missing.Count -eq 0) {
Write-Host "All required deps satisfied. ($($presentModIds.Count) mod IDs in pack, incl. bundled jar-in-jar)" -ForegroundColor Green
} else {
Write-Host "MISSING REQUIRED DEPS: $($missing.Count) unique" -ForegroundColor Red
Write-Host ""
foreach ($depId in ($missing.Keys | Sort-Object)) {
$parents = ($missing[$depId].parents | Sort-Object -Unique) -join ', '
Write-Host " $depId" -ForegroundColor Red
Write-Host " needed by: $parents" -ForegroundColor DarkGray
}
}
if ($skipped.Count -gt 0) {
Write-Host ""
Write-Host "Informational (skipped / unanalysable):" -ForegroundColor DarkGray
foreach ($s in $skipped) { Write-Host " - $s" -ForegroundColor DarkGray }
}
if ($missing.Count -gt 0 -and -not $WarnOnly) {
throw "Missing required dependencies. Add them to pack/pack.lock.json (or pass -WarnOnly to bypass)."
}
+31 -5
View File
@@ -118,6 +118,17 @@ if (($shouldRunPack -or $shouldRunLauncher) -and -not $DryRun -and -not $Force)
}
}
# ─── Pre-flight: required-dependency check ─────────────────────────────────
# Walks each Modrinth mod in pack.lock.json, fetches its 'required'
# dependencies, and refuses to deploy if any aren't already in the lockfile.
# Catches the failure mode where adding a mod that hard-deps on (say)
# kotlinforforge crash-loops the daemon at startup. Only runs when pack
# content is changing; -Force skips it (rarely needed).
if ($shouldRunPack -and -not $DryRun -and -not $Force) {
Write-Host "Pre-flight: validating Modrinth dependency graph..." -ForegroundColor DarkGray
& (Join-Path $here 'Check-Deps.ps1')
}
# ─── Pre-flight: was the daemon already running? ───────────────────────────
# We don't auto-stop it (kicks active players to fix a problem that isn't
# actually broken -- the atomic swap is safe with the daemon running). But
@@ -214,11 +225,26 @@ $overridesLocal = Join-Path $repoRoot 'pack\overrides'
$shareFiles = Join-Path $DeployShare 'files'
if ($shouldRunPack -and (Test-Path $overridesLocal)) {
Step "Mirror pack/overrides/ -> $shareFiles" {
# /MIR makes destination match source (deletes orphan files in $shareFiles).
# /XJ skips junctions, /R:1 /W:1 keeps retry behaviour sane on flaky shares.
robocopy $overridesLocal $shareFiles /MIR /XJ /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Host
# Robocopy returns 0-7 for success-with-info; 8+ is real failure.
if ($LASTEXITCODE -ge 8) { throw "robocopy failed with exit $LASTEXITCODE" }
# Prefer rsync where available (Linux/macOS); fall back to Windows robocopy.
# Both perform a mirror: orphan files in the destination are removed so
# the share matches the local overrides exactly.
if (Get-Command rsync -ErrorAction SilentlyContinue) {
$src = ($overridesLocal -replace '\\','/').TrimEnd('/') + '/'
$dst = ($shareFiles -replace '\\','/').TrimEnd('/') + '/'
if (-not (Test-Path $shareFiles)) { New-Item -ItemType Directory -Path $shareFiles -Force | Out-Null }
& rsync -a --delete $src $dst
if ($LASTEXITCODE -ne 0) { throw "rsync failed with exit $LASTEXITCODE" }
}
elseif (Get-Command robocopy -ErrorAction SilentlyContinue) {
# /MIR makes destination match source (deletes orphan files in $shareFiles).
# /XJ skips junctions, /R:1 /W:1 keeps retry behaviour sane on flaky shares.
robocopy $overridesLocal $shareFiles /MIR /XJ /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Host
# Robocopy returns 0-7 for success-with-info; 8+ is real failure.
if ($LASTEXITCODE -ge 8) { throw "robocopy failed with exit $LASTEXITCODE" }
}
else {
throw "Neither rsync nor robocopy is available -- can't mirror pack overrides."
}
}
}
+7
View File
@@ -37,6 +37,12 @@ public sealed class ManifestFile
[JsonPropertyName("url")] public string Url { get; set; } = "";
[JsonPropertyName("sha1")] public string? Sha1 { get; set; }
[JsonPropertyName("size")] public long? Size { get; set; }
/// <summary>
/// "both" (default), "client", or "server". Server skips "client" files;
/// launcher skips "server" files. Null/missing = "both".
/// </summary>
[JsonPropertyName("side")] public string? Side { get; set; }
}
public sealed class PackLock
@@ -59,4 +65,5 @@ public sealed class LockedMod
[JsonPropertyName("url")] public string Url { get; set; } = "";
[JsonPropertyName("sha1")] public string Sha1 { get; set; } = "";
[JsonPropertyName("size")] public long Size { get; set; }
[JsonPropertyName("side")] public string? Side { get; set; }
}
+18 -1
View File
@@ -37,11 +37,28 @@ public sealed class BackupScheduler : IDisposable
public void Start()
{
if (string.IsNullOrWhiteSpace(_config.BackupSchedule)) return;
if (Parse(_config.BackupSchedule) == default)
var parsed = Parse(_config.BackupSchedule);
if (parsed == default)
{
_log($"[backup-scheduler] Invalid backupSchedule '{_config.BackupSchedule}'. Expected 'HH:mm', 'HH:mm,HH:mm', or 'every Nh'/'every Nm'. Disabled.");
return;
}
// No-replay startup: mark all of today's already-passed slots as fired so
// the wake loop only fires future slots. Without this, a daily-times
// schedule like "00:00,04:00,...,20:00" on a service restart at 19:00
// would catch up by firing 5 backups at 1-minute intervals (one per
// missed slot), each producing a full-world zip of the SAME world state.
// For full-snapshot backups that's pure waste -- the live world hasn't
// diverged across missed slots.
if (parsed.Times is not null)
{
var nowTime = TimeOnly.FromDateTime(DateTime.Now);
_lastFireDay = DateOnly.FromDateTime(DateTime.Now);
foreach (var t in parsed.Times)
if (t <= nowTime) _firedToday.Add(t);
}
_cts?.Cancel();
_cts = new CancellationTokenSource();
_loop = Task.Run(() => RunAsync(_cts.Token));
+14 -3
View File
@@ -38,12 +38,20 @@ public sealed class ManifestSync
progress?.Report($"Pack: {manifest.Name} v{manifest.Version}");
Directory.CreateDirectory(serverDir);
// Resolve which mods are server-side.
var skipSlugs = await ResolveServerSideSkipListAsync(manifest, ct);
// Build-time tagging is authoritative: anything marked "client" is dropped
// outright, anything marked "server" or "both" is kept. We only fall back
// to the runtime Modrinth lookup for files with NO explicit side -- this
// keeps un-tagged (or CurseForge-only) mods safe and matches pre-side
// behaviour for them.
var needsLookup = manifest.Files.Where(f => string.IsNullOrEmpty(f.Side)).ToList();
var skipSlugs = needsLookup.Count > 0
? await ResolveServerSideSkipListAsync(
new Manifest { Files = needsLookup }, ct)
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Build the filtered list of files to keep on the server.
var keepFiles = manifest.Files
.Where(f => !ShouldSkipFile(f.Path, skipSlugs))
.Where(f => !IsClientOnly(f) && (!string.IsNullOrEmpty(f.Side) || !ShouldSkipFile(f.Path, skipSlugs)))
.ToList();
var skippedCount = manifest.Files.Count - keepFiles.Count;
@@ -108,6 +116,9 @@ public sealed class ManifestSync
|| name.Equals(slug, StringComparison.OrdinalIgnoreCase));
}
private static bool IsClientOnly(ManifestFile f)
=> string.Equals(f.Side, "client", StringComparison.OrdinalIgnoreCase);
/// <summary>Walk the manifest's mod URLs; for Modrinth ones, look up server_side; build a skip set.</summary>
private async Task<HashSet<string>> ResolveServerSideSkipListAsync(Manifest manifest, CancellationToken ct)
{
+17
View File
@@ -0,0 +1,17 @@
# ZFS ARC sizing for glados — a mixed-workload host (32 GB RAM).
#
# Why these numbers:
# - The cold ZFS pool is BACKUP TIER ONLY. Hot data (MC world, Gitea repos,
# Directus DB, containers) all live on NVMe LVM, not ZFS — so a large
# ARC doesn't help any of the actual workloads.
# - Tier-2 backup work is streaming writes (rsync hot→cold every 4h):
# streaming writes don't benefit much from cache.
# - Reads happen rarely (disaster recovery, Gitea LFS fetches), and a
# one-time cold read is fine.
# - MC is the memory-hungry workload (10 GB Java heap). Every GB we DON'T
# give to ARC is a GB more headroom for MC and future content.
#
# 2 GB max / 1 GB min keeps ZFS metadata + small ARC for occasional reads,
# without crowding MC out of RAM.
options zfs zfs_arc_max=2147483648
options zfs zfs_arc_min=1073741824