Minecraft 1.21.1 Fabric mod. Instanced loot overhaul.
Asset philosophy: Container blocks stay vanilla — the proxy layer never registers custom blocks, replaces block entities, or retextures/swaps world-gen blocks, which keeps full compatibility with Sodium, Enhanced Block Entities (EBE), and shader packs (see Architectural philosophy below). That is an architectural constraint on the block layer, not a vanilla-purity stance: mod-specific UI, HUD, and world-overlay sprites are custom pixel art authored through Concord's glyph pipeline (/glyph, mc-textures skill, concord design/DESIGN-SYSTEM.md §8, with .glyph sources kept beside the masters) — see the asset inventory in design/DESIGN.md. Unlooted-container indicators render as client-side sprite overlays via WorldRenderEvents.LAST. Sounds stay vanilla where the cue is organic (chest lids, XP pickup — physical sounds vanilla already nails); custom synthesized cues are added through the /sfx pipeline where a sound benefits from its own identity (concord design/DESIGN-SYSTEM.md §9).
Architectural philosophy: Zero-trust proxy. The mod intercepts interactions with vanilla loot containers dynamically via events — it does not register custom blocks or entities, replace block entities, or modify world generation. Per-player loot state is attached to the vanilla loot sources — RandomizableContainerBlockEntity block entities and AbstractMinecartContainer minecarts — via persistent Fabric data attachments (the same AttachmentType mechanism the rest of the Concord suite uses, here on block-entity and entity targets). The vanilla block, entity, model, block entity type, and renderer are never touched.
Per-player loot instances for all naturally generated containers. Replaces the functionality of Lootr.
Vanilla Minecraft generates loot once per container. The first player to open a dungeon chest claims everything — subsequent players find it empty. This creates a race condition in multiplayer and removes the reward loop for exploration in shared worlds.
When a player right-clicks a container that has (or had) a valid LootTable:
- Intercept —
UseBlockCallback.EVENT(block containers) andUseEntityCallback.EVENT(container minecarts) fire. The handler ignores fake-player openers (see Container Adapters) and checks whether the target is a proxy-managed loot container — aRandomizableContainerBlockEntityorAbstractMinecartContainerwith an active or previously-consumed loot table. - Cancel vanilla open — Return
InteractionResult.SUCCESSto prevent the normal container screen from opening. - Lookup — Read the instanced-loot attachment on the block entity. It stores a
Map<UUID, DefaultedList<ItemStack>>mapping player UUIDs to their individual loot inventories. - Generate or retrieve:
- First visit (UUID absent): Generate loot using the container's
LootTableand the player's context (luck, position). Store the result in the map under this player's UUID. Nullify the vanillalootTablefield on the block entity after the first generation for any player (prevents hopper/comparator exploits that trigger vanilla'sunpackLootTable). - Return visit (UUID present): Retrieve the player's saved inventory from the map.
- First visit (UUID absent): Generate loot using the container's
- Serve virtual UI — Open a
SimpleInventory-backed container screen sized to match the original container (27 slots for chests, 27 for barrels, 5 for hoppers — both block hoppers and hopper minecarts). The inventory is populated from the player's instanced loot. - Sync animations — On open:
world.blockEvent(pos, block, 1, 1)+world.playSound()(chest open sound). On close:world.blockEvent(pos, block, 1, 0)+world.playSound()(chest close sound). This triggers the vanilla lid animation and audio without needing access to the block entity's internal animation state. - Persist — On close, write the current inventory state back to the attachment map (and mark the block entity changed). Changes (items taken, items left, items rearranged) are saved per-player.
Prosperity instances every naturally-generated, loot-table-bearing container, across the three vanilla loot-source shapes (see Container Adapters for how each is reached):
Block-entity containers — RandomizableContainerBlockEntity:
- Chests (single and double), trapped chests (single and double)
- Barrels
- Shulker boxes (end city loot)
- Dispensers (jungle temple), droppers, and hoppers (the latter two if modded loot tables target them — vanilla world generation does not place loot-bearing droppers or block hoppers, but any 5-slot block hopper bearing a loot table is instanced and served through a hopper menu)
Container entities — AbstractMinecartContainer:
- Chest minecarts and hopper minecarts. Mineshaft loot is overwhelmingly chest minecarts, so this is first-class coverage, not an afterthought.
Not instanced: Ender chests (already per-player), decorated pots (no loot table in vanilla world generation — they hold a single hand-placed item, not a rolled table), and brushable blocks — suspicious sand and gravel (BrushableBlockEntity) stay vanilla (global, first-come), as archaeology is a niche feature whose self-destroying, single-item extraction model is disproportionately costly to instance per player.
The proxy reaches loot through a thin adapter over the vanilla loot-source shapes rather than assuming a single class. An adapter exposes the operations the instancing loop needs — read/clear the loot table and seed, container size, display name, world position — so the §1 core loop (generate-or-retrieve, nullify, serve, persist, scale) is written once against the adapter, not against RandomizableContainerBlockEntity directly. Two adapters ship:
- Block-entity adapter — wraps
RandomizableContainerBlockEntity. State lives in anInstancedLootDatablock-entity attachment (§1 Implementation Notes). The common case. - Minecart adapter — wraps
AbstractMinecartContainer(chest and hopper minecarts). State is the sameInstancedLootData, registered as a distinct entity-targeted attachment (INSTANCED_MINECART_LOOT) so one state class and codec cover every loot-source shape. Interception isUseEntityCallback.EVENT. Loot-table nullification, per-player generation, the virtual screen, distance/structure scaling, and persistence are identical to the block path — only the attachment point and the open/close feedback (no lid block event; play the chest open/close sound at the entity's position) differ. A 5-slot hopper minecart is served through a hopper menu, a 27-slot chest minecart through a chest menu. Because a minecart moves, its unlooted indicator (§2) is anchored to the live entity position, not the per-chunkBlockPoscache used for static containers.
Fake-player guard. Automation mods (quarries, auto-clickers, item routers) open containers through fake ServerPlayer proxies, which pass a plain instanceof ServerPlayer check. An interaction is treated as a fake-player open — and passed through to vanilla untouched — when the opener has no live client connection (connection == null), is absent from the server player list, or is an instance of a recognised fake-player class. Fake openers never generate, retrieve, or mutate an instance, and never trigger loot-table nullification: a machine pointed at a loot container does nothing, leaving every real player's instance intact and the container untouched until a genuine player visits.
Double chests are two block entities sharing a visual container. When a player interacts with either half:
- Detect the double chest via
ChestBlock.getConnectedDirection(). - Both halves are checked for the attachment. Loot is generated and stored on the primary half (the half with the smaller BlockPos, using lexicographic comparison of x, z, y). The secondary half's attachment stores a redirect marker pointing to the primary.
- The virtual UI is 54 slots (full double chest) served from the primary half's instanced inventory.
- Both halves fire
blockEventfor the lid animation.
After the first player triggers loot generation for a container, the vanilla lootTable and lootTableSeed fields on the RandomizableContainerBlockEntity are set to null. This is critical:
- Hopper exploit prevention: Vanilla's
unpackLootTable()is called whenever a hopper or comparator interacts with the container. Without nullification, a hopper adjacent to the container would generate and extract the global loot, bypassing the instancing system. - The original loot table ResourceLocation is preserved in the attachment (
originalLootTablefield) so it remains available for future player generations. - Loot table seed is also preserved in the attachment. Each player's generation uses this seed combined with their UUID and a refresh salt to produce deterministic-but-unique results per player. The salt is the player's refresh count for that container; it is
0(and so a no-op) unlessrandomizeLootOnRefreshis enabled, in which case each refresh re-rolls fresh-but-reproducible loot rather than repeating the prior contents.
Once a container's vanilla loot table has been nullified:
- Hoppers see an empty container (the vanilla inventory is empty; instanced inventories live in the attachment).
- This is the correct behavior — hoppers should not extract per-player instanced loot.
- If a player places items into a non-instanced container that happens to be a loot chest they've already opened, those items exist only in their instance and are not hopper-extractable.
Comparator output for instanced containers reads zero (empty vanilla inventory). This is an acceptable trade-off — the alternative (faking a signal) would require per-player redstone state, which is not possible in vanilla's redstone model.
If a player breaks an instanced loot container:
- The block drops as normal (vanilla behavior).
- All instanced loot data (the attachment) is lost. This is intentional — the physical container is destroyed.
- Players who have taken items from the container keep those items (they're in their inventory). Players who hadn't visited yet lose access to that container's potential loot.
- Instancing still applies in creative mode. Creative players can use the
/prosperity resetcommand to clear instanced data for a specific container.
- Instanced-loot attachment:
InstancedLootData, a persistent Fabric data attachment (AttachmentRegistry.builder().persistent(CODEC)…) attached toRandomizableContainerBlockEntity. Persistence rides the block entity's owncreateNbt/readseam, so it serializes with the chunk and never leaks to the client update tag (Fabric strips attachments from vanilla block entities' sync NBT). Contains:Map<UUID, NonNullList<ItemStack>> playerInventories— per-player loot, holding an entry only while a player has uncollected items. When a player closes a container they have looted clean, their emptied inventory is evicted from this map so a high-traffic container does not accumulate one stored inventory per visitor indefinitely; theirlastGeneratedTickstays put as the "has visited" marker, so the container still reads as looted for them (no fresh loot, indicator unchanged) until a refresh clears it. ThelastGeneratedTick/refreshCountmaps still grow one small long-valued entry per distinct player.ResourceKey<LootTable> originalLootTable— preserved after nullification (the block entity's own loot-table key type, so it copies in/out without conversion).long originalSeed— preserved after nullification.boolean generated— whether any player has triggered generation (and the vanilla loot table has been nullified).Map<UUID, Long> lastGeneratedTick— per-player absolute game time of generation, for loot refresh (§8).- cached
String tierName/ResourceLocation structure— resolved scaling state for notifications and tooltips (§3, §6). BlockPos redirect— on a double chest's secondary half, points at the primary half that holds the shared inventory.- The attachment is latent: a naturally-placed storage container has none until loot is generated, so it stays byte-identical to vanilla.
- Dirtying invariant: mutating the attached value in place (e.g. updating
playerInventories) must be followed byblockEntity.setChanged()— onlysetAttached(...)auto-marks the block entity dirty. Every write path goes through one helper that re-attaches or callssetChanged(), so no mutation can silently fail to persist.
- A parallel attachment covers the minecart shape: an entity attachment (
INSTANCED_MINECART_LOOT) onAbstractMinecartContainer, reusing the sameInstancedLootDatastate and codec as the block-entity attachment, so it flows through the same generation, nullification, scaling, and refresh code paths via the container adapter (see Container Adapters). - Virtual container screen:
SimpleInventorywrapped in aSimpleMenuProvider. TheAbstractContainerMenusubclass syncs slot changes back to the attachment on close (followed bysetChanged()). UseBlockCallback.EVENThandler checks: (1) block entity exists, (2) isRandomizableContainerBlockEntity, (3) has a loot table OR has the attachment withgenerated=true. If none of these, pass through to vanilla.- Mixin into
RandomizableContainer#unpackLootTable()(the default method the block entity inherits) as a safety net — if the attachment exists andgenerated=true, skip vanilla generation entirely. This catches edge cases where vanilla code calls unpack directly. A parallel safety-net mixin covers the minecart shape: the chest-vehicle unpack method onContainerEntity.
Client-side visual markers on unlooted containers so players can identify which containers they haven't opened yet.
In vanilla (and even with instanced loot), players cannot tell whether they've already looted a container without opening it. In large structures (strongholds, mansions, mineshafts), this leads to repeated backtracking and re-checking.
- Unlooted containers (player has never opened this instanced container) display a small sprite overlay hovering above the block.
- Looted containers (player has opened and received their instanced loot) display no indicator.
- Non-loot containers (placed by players, no loot table) display no indicator.
- A small 2D sprite rendered in world space, centered 0.25 blocks above the container's top face, always facing the camera (billboard).
- Sprite: a four-point sparkle that pulses over a 4-frame animated strip (16×16 per frame, stored as a 16×64 sheet,
assets/prosperity/textures/overlay/unlooted.png, sourceart/glyphs/unlooted-sparkle.glyph). Gold body with diamond-cyan core to evoke treasure. - Subtle bobbing animation (sinusoidal Y offset, ±0.05 blocks, 2-second period).
- Renders through walls up to 8 blocks (configurable) — useful in mineshafts where containers are behind walls. Beyond 8 blocks, occluded containers are hidden.
- Maximum render distance: 48 blocks (configurable). Beyond this, indicators are not rendered for performance.
- Fade-out: indicators fade to transparent over the last 8 blocks of render distance (smooth disappearance, not a hard cutoff).
- Rendered via
WorldRenderEvents.LAST(Fabric Rendering API). - Uses
VertexConsumeron aRenderTypewith translucency and depth testing disabled for the through-wall range, depth testing enabled beyond it. - Sodium/EBE compatibility: This approach does not touch block rendering, chunk meshing, or block entity rendering. It is a post-pass overlay — fully compatible with any block renderer.
- Iris/shader compatibility: Rendering in
LASThappens after the main scene pass. Shaders may apply post-processing (bloom, etc.) to the overlay. This is acceptable and requires no workaround.
- The server does not push unlooted container positions to the client. Instead:
- When a chunk is loaded on the client, the client sends a lightweight request for instanced container positions in that chunk.
- The server responds with a list of
BlockPosentries in that chunk where the requesting player has not yet generated loot, along with the container type (for sizing the indicator). - The client caches this data per-chunk and invalidates it when: (a) the player opens a container, (b) a chunk is unloaded, (c) the player receives a sync packet indicating a container has been broken.
- Packet:
UnlootedContainersS2C— sent per-chunk, contains a list of entries relative to the chunk origin: the in-chunk XZ packed into one byte ((relX << 4) | relZ), the world Y as a short, and the container's slot count as a VarInt (the client derives single-vs-double fromslots == 54). - Packet:
ContainerLootedS2C— sent when the player opens an instanced container, so the client removes the indicator. - Packet:
ContainerRemovedS2C— sent when a loot container is broken, so all clients remove the indicator.
- Client-side rendering class:
UnlootedOverlayRenderer, registered viaWorldRenderEvents.LAST. - Chunk data cache:
Map<ChunkPos, Set<BlockPos>>on the client, populated fromUnlootedContainersS2Cpackets. - The server-side handler for the chunk request iterates the chunk's block entities, filters for those carrying
InstancedLootData, and checks the player's UUID against the attachment's map. - Container minecarts move, so they are tracked per-entity rather than through the per-chunk
BlockPoscache: the server includes proxy-managed minecarts the requesting player has not generated in the chunk response, and the client anchors their indicator to the live entity position, refreshing it as the entity moves or is removed. - Performance target: smooth rendering with 200+ indicators in view. The billboard sprite is a single quad per indicator — GPU cost is trivial.
Loot quality and quantity scale with distance from world spawn. Replaces the core functionality of BetterLoot.
Vanilla loot tables produce the same quality loot at any distance from spawn. A chest 100 blocks from spawn contains the same tier of items as one 10,000 blocks away. This removes the incentive to explore further — and in difficulty-scaled worlds (e.g. Tribulation), the risk/reward ratio breaks because risk scales up but rewards stay flat.
When instanced loot is generated for a player (section 1, step 4), a distance scaling modifier is applied to the loot generation context:
- Calculate distance — Euclidean distance from the container's
BlockPosto world origin (0, 0in the XZ plane, Y ignored). World origin is used rather than world spawn because spawn can be moved by commands or datapacks, and distance scaling should represent absolute geography, not a movable reference point. - Determine tier — The distance falls into a configurable tier bracket.
- Apply modifier — The tier's multiplier affects loot generation.
| Tier | Distance (blocks) | Stack Multiplier | Quality Modifier | Description |
|---|---|---|---|---|
| Local | 0 – 999 | 1.0x | 0 | Baseline. Vanilla loot unchanged. |
| Frontier | 1,000 – 2,999 | 1.5x | +1 | Noticeably more of each item. |
| Wilderness | 3,000 – 5,999 | 2.0x | +2 | Double stack sizes. Worth the trip. |
| Outlands | 6,000 – 9,999 | 2.75x | +3 | Nearly triple. Significantly better. |
| Depths | 10,000+ | 3.5x | +4 | Best possible loot. Endgame territory. |
All tier boundaries and multipliers are configurable.
The stack multiplier scales the count of each generated item stack, not the number of rolls or pools:
- After the loot table resolves normally, each item stack in the result has its count multiplied:
newCount = floor(originalCount * stackMultiplier). - Stackable items only. Items with a max stack size of 1 (tools, weapons, armor, enchanted books) are not affected. The multiplier targets consumables and materials — iron ingots, arrows, gold, food, etc.
- Capped at max stack size (64). A stack of 24 arrows at Depths tier →
floor(24 * 3.5) = 84→ capped to 64. - Minimum count preserved. The multiplier never reduces a stack below its original count (relevant at 1.0x baseline, but future-proofs against sub-1.0 multipliers if configured).
- Example: 8 iron ingots at Wilderness →
floor(8 * 2.0) = 16. 3 diamonds at Outlands →floor(3 * 2.75) = 8. 1 enchanted book at Depths → unchanged (non-stackable).
The quality modifier adjusts the luck parameter passed to loot table generation:
- Vanilla loot tables use
luckto bias quality conditions (random_chance_with_looted_enchantment, weighted random selections, etc.). - The quality modifier is added to the player's effective luck value for this generation.
- This stacks with the player's
generic.luckattribute and any other luck sources (see section 4). - Higher luck biases loot toward rarer entries in loot pools that use
qualityweights.
- Nether: Distance is calculated from the container's Nether coordinates (not multiplied by 8). Nether loot tables are typically higher quality by default, so the same tier thresholds apply but the effective scaling is less dramatic due to the dimension's compressed geography.
- End: All End containers are treated as Depths tier (maximum scaling), regardless of distance. The End is endgame content — scaling it by distance from the origin portal would arbitrarily penalize end cities near the main island.
- Quality scaling (luck) is applied by modifying the
LootParamsbefore the loot table is resolved. - Quantity scaling (stack size) is applied as a post-processing step after loot table resolution: iterate the generated
List<ItemStack>, checkgetMaxStackSize() > 1, multiply count, clamp to max stack size. - The distance calculation and tier lookup are performed once per loot generation (when the player first opens the container) and cached in the attachment alongside the generated inventory.
- Tier data stored in config as an ordered list of
{minDistance, stackMultiplier, qualityModifier}objects. The list is walked from highest to lowest distance; first match wins.
An extensible hook that allows other mods to inject custom attributes into the loot generation context. Replaces the functionality of LootIntegrations.
Loot generation in vanilla considers only the loot table definition and a fixed context (luck, position, killing entity). Mods that add RPG-style attributes (skill levels, perks, class bonuses) have no way to influence loot quality without wholesale loot table replacement. This leads to incompatible, overlapping loot modifications across mods.
A Fabric-style event callback (LootModifierCallback.EVENT) that fires after distance scaling (section 3) but before the loot table is resolved. Registered listeners receive a mutable context object and can adjust the loot generation parameters.
public interface LootModifierCallback {
Event<LootModifierCallback> EVENT = EventFactory.createArrayBacked(
LootModifierCallback.class,
(listeners) -> (context) -> {
for (LootModifierCallback listener : listeners) {
listener.onModifyLoot(context);
}
}
);
void onModifyLoot(LootModifierContext context);
}public interface LootModifierContext {
// The player receiving the loot
ServerPlayer player();
// The container's position
BlockPos containerPos();
// The loot table being resolved
ResourceLocation lootTable();
// Current effective luck (base + distance scaling + attribute)
float luck();
void setLuck(float luck);
// Additive luck bonus (applied on top of current luck)
void addLuck(float bonus);
// Current stack size multiplier (from distance scaling)
float stackMultiplier();
void setStackMultiplier(float multiplier);
// Multiply the existing stack multiplier
void multiplyStacks(float factor);
// Custom data bag for inter-mod communication
CompoundTag customData();
}Prosperity registers its own listener at default priority that reads the player's generic.luck attribute and adds it to the context's luck value. This ensures vanilla luck (from potions, equipment, etc.) always participates in loot generation, even when other mods are also modifying the context.
LootModifierCallback.EVENT.register(context -> {
double vanillaLuck = context.player()
.getAttributeValue(Attributes.LUCK);
context.addLuck((float) vanillaLuck);
});An external RPG mod (e.g. a hypothetical "Meridian Skills" addon) can register a listener:
LootModifierCallback.EVENT.register(context -> {
int lootSkillLevel = SkillManager.getLevel(context.player(), Skills.PROSPECTING);
// Each level of Prospecting adds 0.5 effective luck
context.addLuck(lootSkillLevel * 0.5f);
// High Prospecting also boosts stack sizes slightly
if (lootSkillLevel >= 10) {
context.multiplyStacks(1.1f);
}
});- Listeners fire in registration order (Fabric default).
- Prosperity's own distance scaling and vanilla luck listeners register at initialization. External mods register during their own
onInitialize(), which runs after Prosperity's due to mod load order (or they can declare a dependency to ensure ordering). - All listeners see and can modify the cumulative state — a later listener sees the luck value after earlier listeners have adjusted it.
The customData() CompoundTag is an unstructured key-value store for inter-mod communication during a single loot generation event. Use cases:
- An RPG mod writes a
"prospecting_level"key; a loot table condition (also from the RPG mod) reads it. - A quest mod writes a
"quest_bonus"flag; a loot modifier from the same mod reads it to inject quest-specific items. - Prosperity itself does not read or write to this bag — it exists purely for third-party use.
LootModifierCallbackis a FabricEvent(not a custom event bus). Registration is type-safe and follows the standard Fabric event pattern.LootModifierContextis created fresh for each loot generation, populated with the player, position, loot table, and the post-distance-scaling values.- The context is passed to all listeners, then its final
luckvalue is used to build theLootParamsfor loot table resolution, and the finalstackMultiplieris applied as a post-processing step on the generated items. - The API classes (
LootModifierCallback,LootModifierContext) are in a separateapisubpackage (com.rfizzle.prosperity.api) and are annotated with@ApiStatus.Stableto signal they are safe for external mods to depend on.
Datapack-driven system to add custom items to existing vanilla loot tables based on distance tier.
Vanilla loot tables are static definitions. Distance scaling (section 3) adjusts quantity and quality, but the pool of possible items stays the same — a desert temple chest at 10,000 blocks rolls the same item list as one at 500 blocks, just with better odds. True loot progression needs tier-exclusive items that only appear at higher distances, giving players concrete rewards for pushing further.
Custom loot entries are defined in datapack files and injected into vanilla loot table resolution at runtime. Each entry specifies:
- The target loot table to inject into (e.g.
minecraft:chests/simple_dungeon). - The minimum distance tier required for the entry to be eligible.
- An optional dimension filter restricting the entry to specific dimensions.
- An optional chance (per injection group) that the group injects at all in a given generation.
- The item(s) to add, with full data component support.
- An optional weight controlling how often the entry appears relative to the pool it joins.
Files at data/prosperity/loot_injections/<name>.json:
{
"replace": false,
"injections": [
{
"target": "minecraft:chests/simple_dungeon",
"min_tier": "frontier",
"entries": [
{
"item": "minecraft:enchanted_book",
"count": 1,
"components": {
"minecraft:stored_enchantments": {
"levels": { "minecraft:sharpness": 3 }
}
},
"weight": 5
}
]
},
{
"target": "minecraft:chests/stronghold_corridor",
"min_tier": "outlands",
"dimensions": [ "minecraft:the_nether" ],
"chance": 0.05,
"entries": [
{
"item": "minecraft:netherite_upgrade_smithing_template",
"count": 1,
"weight": 1
}
]
}
]
}replace: Iftrue, replaces all Prosperity injections for the affected target loot tables. Does not affect vanilla entries.target:ResourceLocationof the vanilla loot table to inject into.min_tier: Minimum distance tier name (matches config tier names:local,frontier,wilderness,outlands,depths). Entry is only eligible if the container is at or above this tier.dimensions: Optional list of dimension IDs the entry is restricted to (e.g.["minecraft:the_nether"]). Omitted or empty matches any dimension. Composes withmin_tier— both gates must pass for the entry to be eligible.chance: Optional per-group injection probability in[0.0, 1.0](default1.0: always inject — files without the field behave exactly as before the gate existed). At generation time each tier-and-dimension eligible group rolls its chance independently from the injection draw's deterministic per-playerRandomSourcebefore the weighted draw, so the whether and the which of the bonus re-roll together in refresh lockstep; the weighted draw then runs over the merged entries of the surviving groups, and when every group fails nothing is placed. A group at the default1.0consumes no randomness, keeping existing worlds' draws unchanged. Note the composition trap when tuning: because groups roll independently, an always-inject group fills the slot whenever its gated siblings fail, making its items dramatically more common — tune all files that share a target together (every shipped file carries achancefor this reason).chance: 0.0is not a soft delete: the group still consumes its gate roll (shifting sibling groups' rolls), still pays out through the structure-completion bonus (which bypasses the gate), and still appears in the loot index — to remove a group, remove it (orreplacethe target) instead.requires_mods: Optional list of mod IDs that must all be loaded for the injection to apply (e.g.["meridian"]). Omitted or empty is unconditional. Evaluated at load time only — an injection naming an absent mod is silently dropped (no log spam), so a file can mix unconditional injections with ones scoped to a sibling mod.entries[].item: Item ID.entries[].count: Stack count (default 1).entries[].components: Optional data components (same format as vanilla/giveand recipe definitions).entries[].enchant_randomly: Optional enchantment tag ID (with or without a#prefix, e.g."#meridian:rarity/rare") making the entry generative: at draw time one enchantment is picked uniformly from the tag's members and stored on the item at thelevelpolicy, so a whole catalog is covered without enumerating it per enchant. Mutually exclusive withcomponents(a file mixing both in one entry fails to parse). The pick uses the injection draw's deterministic per-playerRandomSource, so instanced loot stays reproducible. A tag that resolves empty or absent (mod not installed, empty tag) drops the entry from the pool before weighting — the draw slot falls to the remaining eligible entries rather than being wasted.entries[].level: Level policy for a generative entry, relative to the drawn enchantment's own[min, max]range:mid(the rounded-up midpoint, ⌈max/2⌉, floored at min),max(the top level), oruniform(the default — a uniform draw over the whole range, vanillaenchant_randomlysemantics; the only policy that consumes randomness).entries[].weight: Relative weight within the loot pool (default 1). Higher weight = more likely to appear. Injected entries compete with existing pool entries.
Two complementary gates let an in-jar datapack carry injections that activate only when a sibling mod is present, without shipping split datapacks:
requires_mods(above) — per-injection, conjunctive, the minimal form for "this entry needs mod X."fabric:load_conditions— a file-level Fabric resource conditions header, evaluated before the file is parsed. It providesfabric:and/fabric:or/fabric:not/fabric:all_mods_loadedfor richer logic; an unmet header skips the whole file silently.
{
"fabric:load_conditions": [
{ "condition": "fabric:all_mods_loaded", "values": [ "meridian" ] }
],
"injections": [
{
"target": "minecraft:chests/stronghold_library",
"min_tier": "outlands",
"requires_mods": [ "meridian" ],
"entries": [ { "item": "meridian:guide_book", "weight": 1 } ]
}
]
}Both gates are re-evaluated on every load (SERVER_STARTING and END_DATA_PACK_RELOAD). Existing injections with neither field are unaffected.
Prosperity ships a default set of injections to make distance scaling feel meaningful out of the box. Every book entry is generative, drawing uniformly from a rarity tag; every group is chance-gated so the overwhelming majority of chests contain vanilla-only loot (issue #68):
| Tier | Chance | Injections (prosperity:all_chests) |
|---|---|---|
| Frontier | 0.04 | Common-rarity book (uniform level), iron horse armor, golden apple |
| Wilderness | 0.03 | Common book (max), uncommon book (mid), enchanted golden apple, diamond horse armor, Otherside music disc |
| Outlands | 0.035 | Uncommon book (max), rare book (mid), netherite upgrade template, Efficiency IV diamond pickaxe |
| Depths | 0.045 | Rare book (max), very-rare book (uniform), treasure book (uniform), netherite upgrade template (×2), trident |
Because tier pools are cumulative and each group rolls independently, the effective bonus rate is roughly 1 in 20 chests at Frontier, rising to about 1 in 7 in the Depths (Prospector's Compass group included). The chances escalate with depth — past the Frontier anchor, the deepest eligible group is the likeliest payer, so deeper bonuses skew toward that tier's rewards rather than being drowned out by the accumulated lower-tier groups. The default set is conservative — it adds items that already exist in vanilla progression but are normally structure-locked or extremely rare. Pack makers can extend or replace via datapacks.
The generative book entries draw from shipped enchantment tags at
data/prosperity/tags/enchantment/rarity/{common,uncommon,rare,very_rare,treasure}.json, grouping
vanilla enchantments by the weight field of their data-driven definitions: weight 10 → common
(Sharpness, Protection, Efficiency, Power, Piercing), weight 5 → uncommon, weight 2 → rare,
weight 1 → very_rare. Treasure enchantments (Mending, Frost Walker, Soul Speed, Swift Sneak, Wind
Burst) are excluded from the four rarity bands and live only in rarity/treasure, reachable solely
through the Depths group — mirroring the Meridian ladder's treasure rule. Curses are excluded from
every tag, the treasure one included: an injected book is a reward, not a booby trap.
When Meridian is installed, a Meridian-gated
in-jar file (meridian_books.json) adds its full non-curse enchantment catalog
(73 enchants) to the same prosperity:all_chests pool as vanilla
minecraft:enchanted_book items carrying meridian:* enchants in
stored_enchantments. Each enchant's home tier follows Meridian's own rarity:
| Meridian rarity | Home tier |
|---|---|
| Common | Frontier |
| Uncommon | Wilderness |
| Rare | Outlands |
| Very Rare (treasure) | Depths |
A multi-level enchant appears twice: at its home tier at mid level (⌈max/2⌉) and
one tier deeper at max level (Very Rare enchants get both at Depths) — so deeper
travel upgrades the same enchants, and the treasure-tagged set (unavailable from
Meridian's enchanting table) makes distant chests a genuine acquisition path.
Each Meridian group is chance-gated like the built-in files (issue #68): 0.03 at
Frontier and 0.015 per deeper group, holding a Meridian book to roughly 1 in 30
chests at Frontier — its pre-gate effective rate — rising to about 1 in 15 in
the Depths where all four groups are eligible (a surviving Meridian group must
also win the merged weighted draw against any co-surviving vanilla group, which
dilutes the raw ~1-in-14 gate rate), instead of inflating when the vanilla
groups fail their own rolls. The file carries both load gates: a
file-level fabric:load_conditions header, so its meridian:* enchantment
components never reach the registry-aware codec when Meridian is absent, and
requires_mods on each injection.
At generation time the authored enchantments on those books are the fallback,
not the served result: MeridianCompat (in compat/meridian, class-loaded only
behind isModLoaded("meridian")) installs the injection manager's
stack-finalizer hook, and any injected enchanted_book carrying a meridian:*
stored enchantment has its enchantments rolled live via
MeridianAPI.rollLootEnchantments — the same rules as Meridian's enchanting
table — at a power derived from the container's distance tier. The tier→power
curve (MeridianEnchantPower) is conservative and pure math: index 0 (local)
rolls nothing, the first travelled tier rolls at power 8, and the curve ramps
linearly to 30 at the ladder's deepest tier; treasure-tagged enchantments only
roll at the deepest tier, and Meridian's per-enchantment maxLootLevel caps the
rolled levels. The roll consumes the injection draw's own deterministic
RandomSource (container seed × refresh salt × player UUID), so instanced loot
stays reproducible per player and re-rolls in lockstep with the main loot under
randomizeLootOnRefresh. A Meridian call that fails — including the
LinkageError from an older Meridian jar without the roll API — is contained
and logged once, leaving the authored static enchantments standing. Books from
the built-in vanilla injection files pass through the hook untouched.
The special target "prosperity:all_chests" injects into every loot table matching **/chests/**. This allows pack makers to add items globally without listing every loot table individually.
- Injection data is loaded on
SERVER_STARTING(and re-loaded onEND_DATA_PACK_RELOADfor runtime/reload) byLootInjectionManager, which reads each file with a registry-aware codec so item components deserialize against the loaded enchantment/effect registries. - At loot generation time (section 1, step 4), after the distance tier is determined,
LootInjectionManager.augmentqueries the injection registry for entries matching the container's loot table whosemin_tieris at or below the resolved tier and whosedimensionsfilter (if any) contains the container's dimension. The dimension is threaded from the generation call site viaServerLevel#dimension(). - Injection is purely additive: each eligible group rolls its
chance(in registry order, groups at1.0consuming no randomness), the surviving groups' entries form a single weighted pool, and at most one item is drawn (deterministically, from the container's per-player seed and refresh salt — so it re-rolls in lockstep with the main loot whenrandomizeLootOnRefreshis on) and placed in a spare slot. When every group fails its roll, nothing is placed. Vanilla loot is never displaced — injected rewards sit alongside the rolled items rather than competing with them in a vanilla pool. - The injection registry is a
Map<ResourceLocation, List<TieredInjection>>keyed by target loot table, rebuilt wholesale and published atomically on each load. Lookup is O(1) per loot table. - Wildcard targets are expanded to concrete loot table IDs at load time by scanning the resource manager for loot tables whose path contains a
chests/segment.
Override distance tiers on a per-structure basis for fine-grained loot control.
Distance-based tiers work well as a general rule, but some structures have inherently fixed difficulty regardless of where they generate. An ocean monument at 500 blocks from spawn is just as dangerous as one at 8,000 blocks — and its loot should reflect that. Similarly, a village blacksmith chest near spawn shouldn't get inflated loot just because the village happens to be at 1,500 blocks.
Each structure type can be assigned a tier override that replaces (or sets a minimum/maximum for) the distance-calculated tier:
- Fixed tier: The structure always uses this exact tier, ignoring distance. Example: ocean monuments → always Wilderness.
- Minimum tier: The structure uses at least this tier, even if distance would place it lower. Example: end cities → minimum Outlands (though the End dimension rule already handles this).
- Maximum tier: The structure uses at most this tier, even if distance would place it higher. Example: villages → maximum Frontier (prevents inflated loot from village chests at high distances).
Structure overrides are defined in the server config:
{
"structureOverrides": [
{ "structure": "minecraft:monument", "mode": "fixed", "tier": "wilderness" },
{ "structure": "minecraft:stronghold", "mode": "minimum", "tier": "outlands" },
{ "structure": "minecraft:village_plains", "mode": "maximum", "tier": "frontier" },
{ "structure": "minecraft:village_desert", "mode": "maximum", "tier": "frontier" },
{ "structure": "minecraft:village_savanna", "mode": "maximum", "tier": "frontier" },
{ "structure": "minecraft:village_snowy", "mode": "maximum", "tier": "frontier" },
{ "structure": "minecraft:village_taiga", "mode": "maximum", "tier": "frontier" },
{ "structure": "minecraft:ancient_city", "mode": "minimum", "tier": "outlands" },
{ "structure": "minecraft:trail_ruins", "mode": "minimum", "tier": "frontier" },
{ "structure": "minecraft:trial_chambers", "mode": "minimum", "tier": "wilderness" }
]
}structure:ResourceLocationof the structure type (fromBuiltInRegistries.STRUCTURE). Supports modded structures.mode:"fixed","minimum", or"maximum".tier: Tier name matching the distance tier config.
Prosperity ships sensible defaults (shown above). The design principle: structures with fixed difficulty get fixed or minimum tiers; structures with trivial loot get maximum caps.
When loot is generated for a container, the structure it belongs to must be determined:
- Query
StructureManager.getStructureWithPieceAt(blockPos)to find the structure containing the container. - If the container is not inside any structure (e.g. a standalone dungeon spawner chest), no override applies — pure distance scaling.
- If the container is inside multiple structures (rare but possible with overlapping generation), the most specific structure wins (the one with the smallest bounding box containing the block).
Structure overrides are part of distance scaling, applied after the base distance tier is calculated:
- Calculate distance tier from world origin (section 3).
- Look up the container's structure in the configured override list.
- Apply the override mode (tiers compared by
minDistance):fixed: Replace the tier entirely.minimum: Usemax(distanceTier, overrideTier).maximum: Usemin(distanceTier, overrideTier).
- The resolved tier is used for quantity multiplier, quality modifier, and loot injection eligibility.
Because overrides are part of scaling, enableDistanceScaling = false suppresses them along with distance bands (the generation falls back to vanilla quantities/quality everywhere), and structure detection is skipped entirely when no overrides are configured.
- Structure overrides are stored in config as a list of
{structure, mode, tier}objects and matched by structure id at generation time. The list is short and matched only once per container's first generation, so a linear scan is used rather than a derived map; structure detection (getAllStructuresAt) dominates the cost. An override naming an unknown mode or a tier the config does not define degrades gracefully to pure distance scaling. StructureManageraccess requires theServerLevel— available during loot generation since it happens server-side.- Structure lookup is cached in the attachment alongside the generated inventory (the structure won't change after generation).
- Modded structures are supported automatically — any
ResourceLocationin the structure registry works.
Config-driven list of loot tables excluded from instancing.
Not every loot container benefits from instancing. Some modded containers have custom interaction logic that conflicts with event interception. Some server admins want specific structures to remain first-come-first-served for gameplay reasons. Without a blacklist, the only option is disabling the entire instancing system.
- Loot tables on the blacklist are completely ignored by Prosperity's
UseBlockCallbackhandler. The container opens with vanilla behavior — global loot, no per-player instances, no visual indicator. - Distance scaling, loot injection, and all other Prosperity features do not apply to blacklisted containers.
- The blacklist is a list of
ResourceLocationpatterns in the server config.
Entries support two formats:
- Exact match:
"minecraft:chests/village/village_weaponsmith"— matches only this specific loot table. - Wildcard: any entry ending in
*matches by prefix."somebigmod:*"excludes a whole namespace (useful for blanket-excluding a mod that manages its own container logic);"minecraft:chests/*"excludes a whole subtree; a bare"*"excludes everything.
Empty by default — all loot containers are instanced. The blacklist is opt-in for server admins who encounter specific conflicts.
{
"lootTableBlacklist": [
"somebigmod:*",
"minecraft:chests/village/village_weaponsmith"
]
}- Blacklist is parsed at config load into a
LootBlacklistmatcher cached on the live config: aHashSetof exactnamespace:pathids plus a list of wildcard prefixes. Rebuilt byProsperityConfig.clamp()on every load/reload. - The matcher is checked against the source's live loot table at the interaction gate — both the block
UseBlockCallback(single and double-chest paths) and the minecartUseEntityCallback. A blacklisted container returnsInteractionResult.PASS(full vanilla behavior) before any instance is generated. Gating on the live table means a fresh blacklisted container is never instanced or nulled, while an already-generated container (live table null) is served normally — instancing cannot be undone retroactively. - Blacklist check is O(1) for exact matches (HashSet lookup) and O(n) for wildcards (n = number of wildcard entries, typically very small).
- The blacklist is also respected by the visual indicator system — both the block and minecart unlooted scans skip blacklisted containers, so they never show the unlooted sparkle.
Action bar messages showing the loot tier and active modifiers when a player opens an instanced container.
Distance scaling and loot modifiers are invisible by default. Players have no feedback that the system is working — they can't tell whether the good loot they found is because they're far from spawn or just lucky. Without feedback, the "risk vs. reward" loop that motivates exploration doesn't land.
When a player opens an instanced container for the first time (loot generation, not return visit):
- An action bar message appears showing the resolved loot tier and any active modifiers.
- Format:
"✦ Wilderness — 2.0x stacks, +2 quality" - The message is brief (action bar, not chat) and non-intrusive.
| Component | Source | Example |
|---|---|---|
| Tier name | Distance scaling (section 3) / structure override (section 6) | "Wilderness" |
| Stack multiplier | Final value after all modifiers | "2.0x stacks" |
| Quality modifier | Final value after all modifiers | "+2 quality" |
| Structure override indicator | If a structure override changed the tier | "(Ocean Monument)" |
"✦ Frontier — 1.5x stacks, +1 quality"— basic tier notification."✦ Outlands — 2.75x stacks, +3 quality (Ancient City)"— structure override active."✦ Depths — 3.5x stacks, +4 quality"— maximum tier, no override."✦ Local"— baseline tier, multipliers omitted when default.
| Key | Type | Default | Description |
|---|---|---|---|
enableLootNotifications |
bool | true | Toggle action bar notifications |
- Only on first open (loot generation). Returning to an already-opened container does not re-show the message.
- Only when instanced loot is active for this container (not blacklisted, not vanilla passthrough).
- If loot refresh (section 9) regenerates loot, the notification fires again on the fresh generation.
- Server-side: after loot generation completes, send the resolved tier data to the player with
ServerPlayer#displayClientMessage(component, true)— a system-chat packet withoverlay=true(action bar placement). - The message is built from the
LootModifierContextfinal values, so it reflects all modifiers (distance + structure override + API listeners): the multiplier and quality shown are the post-listenerstackMultiplierandluck(the latter rounded to a whole number), not the raw tier values. - Assembled from three translation keys:
notification.prosperity.loot_generated(✦ %s, the tier name),notification.prosperity.modifiers(— %sx stacks, +%s quality, appended only when a value is off its baseline — so the bare tier shows at Local), andnotification.prosperity.structure((%s), appended only when a structure override changed the tier from the pure distance band). The multiplier renders in natural-decimal form (2.0,2.75); structure names resolve throughnotification.prosperity.structure.*with a humanized-path fallback for unmapped (e.g. modded) structures.
Containers can regenerate loot after a configurable cooldown, simulating restocking.
On long-running servers, all containers eventually get looted by all players, and there's no reason to revisit explored structures. A refresh mechanic keeps exploration relevant over weeks and months of play.
- Cooldown: After a player generates loot for a container, a timer starts. Once the cooldown expires, the player's instanced inventory for that container is cleared (not regenerated immediately).
- Next visit: When the player opens the container after their cooldown has expired, fresh loot is generated as if they'd never visited.
- Determinism: By default the re-roll is deterministic — the same player draws the same items the container held before (the roll seed depends only on the preserved seed and their UUID). Enabling
randomizeLootOnRefreshfolds the player's refresh count into the seed as a salt, so each refresh draws different items while staying reproducible across a reload for a given count. The salt advances on every clear (cooldown refresh and/prosperity reset|refreshalike). - Default cooldown: 7 in-game days (168,000 ticks), configurable.
- Per-player: Each player's cooldown is independent. Player A's loot may refresh while Player B's is still on cooldown.
- Visual indicator: When a player's loot has refreshed (cooldown expired, inventory cleared), the gold sparkle reappears on the client (the container is "unlooted" again for this player). A chunk the client requests after expiry lights up from the scan on its own; a low-frequency server sweep covers the gap where a chunk was already loaded when the cooldown elapsed, resending that chunk's indicator set to the player who just crossed the threshold.
- Stored in the attachment:
Map<UUID, Long> lastGeneratedTick— the game tick when each player's loot was last generated. - On any interaction, the handler checks
currentTick - lastGeneratedTick >= cooldownTicks. If true, the player's entry inplayerInventoriesis removed, and the container is treated as unvisited. - Cooldown expiry is computed on demand (at the open path and the indicator scan), not per-tick. The only periodic work is the indicator sweep, which runs on a coarse interval (every 600 ticks) and only when both
enableLootRefreshandenableVisualIndicatorsare on; it sends a packet to a player solely when one of their instances in a loaded, tracked chunk transitions to expired, so a player standing still triggers one resend per container rather than one per tick.
| Key | Type | Default | Description |
|---|---|---|---|
enableLootRefresh |
bool | false | Toggle loot refresh (disabled by default) |
lootRefreshDays |
int | 7 | In-game days before loot refreshes |
randomizeLootOnRefresh |
bool | false | Re-roll fresh loot on each refresh instead of repeating the same items |
- Cooldown is stored as a game tick value (absolute, not relative). This survives server restarts because Fabric persists the world's game time.
- When
enableLootRefreshis false,lastGeneratedTickis still recorded (no cost) so enabling the feature later retroactively applies to already-looted containers. - Clearing a player's inventory entry resets the container to "unvisited" for that player. A per-player refresh count is tracked in the attachment and advanced on each clear (it outlives the cleared inventory). It is the salt for
randomizeLootOnRefresh; it is always tracked, so toggling the option on applies to subsequent refreshes without migration.
Tooltip overlay showing loot status and scaling information when looking at instanced containers.
Players looking at a container have no way to know its loot status without opening it. The visual indicator (section 2) communicates "unlooted" vs. "looted" at a glance, but doesn't convey distance tier, refresh timing, or why this container's loot is special. Jade and WTHIT are the standard way to surface block-level information.
When looking at a container that has (or had) a loot table, the Jade/WTHIT tooltip shows:
| Line | Condition | Example |
|---|---|---|
| Loot status | Always | "Unlooted" / "Looted" / "Refreshed" |
| Distance tier | When distance scaling is enabled | "Wilderness tier (2.0x stacks)" |
| Structure override | When a structure override is active | "Ancient City — min. Outlands" |
| Refresh timer | When loot refresh is enabled and container is looted | "Refreshes in: 2d 14h" |
| Blacklisted | When container's loot table is blacklisted | "Vanilla loot (not instanced)" |
- Unlooted — Player has never opened this container. Gold text.
- Looted — Player has opened and generated loot. Gray text.
- Refreshed — Player's loot has expired and new loot is available. Green text.
- Vanilla — Container is blacklisted; vanilla behavior applies. White text.
- Displayed as
Xd Yh(days and hours) when more than 1 hour remaining. - Displayed as
Xm(minutes) when less than 1 hour remaining. - Not shown when loot refresh is disabled or the container is unlooted.
- Jade plugin:
IWailaPluginregistering aIBlockComponentProviderfor blocks withRandomizableContainerBlockEntity. - WTHIT plugin: parallel implementation via
waila_plugins.json. - Server-side data provider sends: loot status (enum), distance tier name + multipliers, structure override (if any), last generated tick (for refresh timer calculation), blacklist status.
- Both plugins are optional dependencies — feature is simply absent if neither is installed.
- Data is sent per-look (standard Jade/WTHIT server data request pattern), not pushed proactively.
A searchable catalog of loot table contents integrated into recipe viewers. Shows what items can drop from which structures, filterable by distance tier.
Vanilla provides no way to see what a structure's loot table contains without opening the data files or consulting a wiki. With Prosperity adding distance-based scaling and loot injection, the effective loot pool changes based on where you are — making discovery even harder. Players need an in-game reference that answers "what can I find in a stronghold at Outlands tier?"
A single, browseable list of all loot table entries across all structures and tiers.
Entry format (one row per item source):
[Structure Icon] [Output Item] Loot Table: chests/simple_dungeon Tier: Frontier+
- Structure icon: representative item for the structure (e.g. mossy cobblestone for dungeons, prismarine for monuments, deepslate for ancient cities). Mapped in a registry class.
- Items rendered as standard recipe viewer item slots (hoverable for full tooltip).
- Tier badge shows the minimum tier where this entry is available. Entries from vanilla loot tables (no tier restriction) show "Any tier."
Search integration:
- Fully indexed by the recipe viewer's search. Typing "mending" shows all loot sources that can drop Mending books. Typing "netherite" shows structures and tiers where netherite items are available.
- Bidirectional: search by output item to find where it drops.
Filtering:
Each filter axis is a marker item registered as a recipe-viewer workstation/catalyst and attached to the matching rows as an invisible ingredient; viewing that item's "uses" narrows the index to those rows. The same mechanism backs all three viewers (EMI, REI, JEI) and all three axes, so the filter logic — which markers a row carries — lives once in the shared layer. Markers are vanilla items disjoint from the structure icons so the three axes never alias.
- By structure — The structure's representative icon item. Viewing a structure icon (e.g. the stronghold's) shows only that structure's loot.
- By distance tier — A per-tier marker item. Viewing a tier's marker shows every entry obtainable at that distance: rows gated at that tier or a shallower one, plus all "Any tier" vanilla rows.
- By source — A Vanilla and an Injected marker item. Viewing one scopes the index to that origin (base loot-table entries, or Prosperity additions from section 5); the unfiltered category shows both.
| Structure | Representative Item |
|---|---|
| Dungeon | minecraft:mossy_cobblestone |
| Mineshaft | minecraft:rail |
| Stronghold | minecraft:end_portal_frame |
| Village | minecraft:emerald |
| Desert Pyramid | minecraft:sandstone |
| Jungle Pyramid | minecraft:mossy_cobblestone |
| Ocean Monument | minecraft:prismarine |
| Woodland Mansion | minecraft:dark_oak_log |
| End City | minecraft:purpur_block |
| Buried Treasure | minecraft:heart_of_the_sea |
| Shipwreck | minecraft:oak_boat |
| Ruined Portal | minecraft:crying_obsidian |
| Bastion Remnant | minecraft:blackstone |
| Nether Fortress | minecraft:nether_bricks |
| Ancient City | minecraft:sculk |
| Trail Ruins | minecraft:decorated_pot |
Modded structures use their namespace's icon item if registered, otherwise a generic chest icon.
Entries added by Prosperity's loot injection system (section 5) are visually distinct:
- A small Prosperity icon (gold sparkle, same as the unlooted indicator) in the corner of the entry.
- Hovering shows: "Added by Prosperity at [tier]+ tier."
- This lets players distinguish vanilla drops from mod-added drops.
- A generative entry (
enchant_randomly) would otherwise display as a blank prototype book, so its display stack carries a lore line naming the draw — "Random [rarity] enchantment", the rarity title-cased from the tag path's last segment (prosperity:rarity/commonandmeridian:rarity/commonboth read "Common"). The line rides the stack itself, so all three viewers and the S2C index sync inherit it without payload changes.
- Loot table contents are extracted from the running server's reloadable loot registry at runtime — not hardcoded. The index walks each table's pools and entries (item entries, tag entries expanded, nested-table references and composite groups recursed) to enumerate its item sources, so it automatically picks up datapack modifications.
- Built server-side on
SERVER_STARTINGand after/reload, then published as an immutable snapshot the viewers read. Singleplayer's integrated server populates it in-JVM for the client viewers. On a remote dedicated server the client has no loot data, so the server syncs the assembled index to each client (after the config on join, and re-broadcast after/reload) via theLootIndexS2CPayload; the client publishes it into the same snapshot the viewers read. The payload is bounded to 8192 rows (oversize indexes truncate with a warning). The integrated host ignores the sync to keep its full in-JVM snapshot. When the synced index lands, EMI and JEI are force-refreshed so they reflect the server's rows regardless of whether the sync beat their own list build — EMI through its internal reload reached by reflection, JEI through its publicIRecipeManagerruntime API (the new rows are hidden-then-re-added so a/reloadreplaces rather than stacks them). REI exposes no safe programmatic reload (only fragile staged-pipeline internals), so its tab refreshes on rejoin or a manual resource reload; it is correct on first join, since the sync lands before it builds its list. - Prosperity loot injections are loaded from the injection registry (section 5); injected entries carry their
min_tier, vanilla entries show "Any tier." - Loot table → structure mapping uses a hardcoded vanilla map: most vanilla structure→loot links live in Java (legacy structures, and the dungeon worldgen feature), not in data, so they cannot be scanned. Tables the map does not cover — modded or otherwise unknown — still appear, bucketed under a generic "Other" structure (chest icon), and are logged once at build so a pack author can assign them a structure via the
lootTableStructuresconfig map.
The loot index is implemented as three parallel plugins sharing a common data layer:
| Viewer | Plugin Interface | Priority |
|---|---|---|
| EMI | EmiPlugin + EmiRecipeCategory |
Primary — most popular on Fabric |
| REI | REIClientPlugin + DisplayCategory |
Secondary |
| JEI | IModPlugin + IRecipeCategory |
Tertiary — for players using JEI on Fabric |
A shared LootIndexDataSource class builds the index once; each plugin adapter wraps it for its viewer's API.
- All three viewers are compile-only optional dependencies.
- Plugin classes registered via respective entrypoint mechanisms (EMI:
emientrypoint infabric.mod.json, REI:rei_cliententrypoint, JEI:@JeiPluginannotation). - Loot data is rebuilt on resource reload (captures datapack changes).
- Custom
EmiRecipeCategory/DisplayCategory/IRecipeCategorynamed "Loot Tables". The category tab icon reuses the mod brand icon (assets/prosperity/icon.png) scaled to the 16×16 category slot — the suite convention rather than a bespoke chest glyph. - Each loot table entry is one recipe entry. The recipe viewer handles search indexing automatically once items are registered as outputs.
- Structure icon mapping stored in a registry class (
StructureIcons) with aMap<ResourceLocation, Item>. Modded structures fall back toItems.CHEST.
Optional protection for world-generated loot containers to prevent griefing.
With instanced loot, a single player breaking a world-gen chest destroys every player's instanced inventory for that container. On shared servers, this enables griefing — one player can systematically break dungeon chests and erase loot for everyone else, with no way to undo it.
When enabled, world-gen loot containers that still hold unclaimed loot receive increased break resistance:
- Mining speed reduction: Breaking takes several times longer than normal — the exact multiplier is set by
protectionBreakMultiplier. This signals "this is deliberate" and prevents accidental breaks. - Hard lock (optional): With
protectionUnbreakable, a protected container is fully unbreakable in survival (like bedrock) instead of merely slow, and it is also blast-proof — TNT, creepers, and other explosions cannot destroy it. It cannot be removed until its loot is claimed. The slow-break mode (flag off) leaves explosions alone, staying a speed bump rather than a wall. - Feedback: An action-bar warning (the "open it instead of breaking it" form, or a "can't be broken" form under
protectionUnbreakable), a small particle burst, and a subtle anvil-land sound cue play when a player starts breaking a protected container, reinforcing that something is different. - Still creative-bypassable: Players in creative mode break instantly as normal, in both modes.
- Only applies to Prosperity-managed loot containers — a container with a non-blacklisted loot table, or one that has generated an instance from one. Player-placed storage chests and blacklisted loot tables are never affected.
- A managed container is protected while it still has unclaimed loot, whether or not anyone has opened it yet: a freshly generated, never-opened loot chest is protected (including in singleplayer).
- If
enableContainerProtectionis false (default), containers break at normal speed. - Once the container has been opened by all online players (everyone has generated their instance) it holds no pending loot, so protection is lifted — breaking is normal speed.
- Refreshable containers stay protected. When loot refresh (§ Loot refresh) is enabled, a managed container's loot always returns on its cooldown, so it is never emptied for good. Protection therefore holds it indefinitely — even after every online player has looted it — so no one can break it to deny everyone the refreshed loot. Turning refresh off restores the "lifts once everyone has looted" behavior.
| Key | Type | Default | Description |
|---|---|---|---|
enableContainerProtection |
bool | false | Toggle container break protection |
protectionBreakMultiplier |
float | 4.0 | Mining speed multiplier for protected containers |
protectionUnbreakable |
bool | false | Make protected containers fully unbreakable in survival instead of merely slower |
- A common mixin on
BlockBehaviour#getDestroyProgress(BlockState, Player, BlockGetter, BlockPos)divides the returned per-tick mining progress by the protection multiplier.ContainerProtection.breakMultipliersupplies the divisor. WhenprotectionUnbreakableis on, a protected container reportsFloat.POSITIVE_INFINITYand the mixin zeroes the progress instead, so the break gate never trips — the container is unbreakable in survival, exactly as a block with a-1destroy speed (bedrock) is. - Protected check (
ContainerProtection.isProtectedServer):enableContainerProtectionon, breaker not creative, the block is aRandomizableContainerBlockEntitythat is a managed loot container (its live loot table — or, once generated, the original key preserved on theInstancedLootData— is non-null and not blacklisted), and loot is still pending. Loot is pending when no instance has generated yet (no one has looted), or, once generated, while at least one online player has not generated their instance, or — whenenableLootRefreshis on — for any generated managed container regardless of who has looted, since its loot returns each cooldown. An emptied container (every online player has generated) is not protected with refresh off — a player who has looted their instance clean has claimed it and no longer counts as pending, even though their emptied inventory is no longer stored. - The
InstancedLootDataattachment is server-only, so the mixin evaluates protection authoritatively only wherelevelis aServerLevel; the server independently gates the actual break (getDestroyProgress x (ticks+1) >= 0.7), so the slowdown is enforced even against an unmodified client. To slow the client's cracking animation to match, the client queries the server at break-start (QueryProtectionC2S→ProtectionResultS2Ccarrying the multiplier) and the mixin's client branch divides by that answer. - The break-start cue (an action-bar warning plus a quiet
ANVIL_LANDsound and a small particle burst) fires from a server-sideAttackBlockCallback, throttled per player so mashing attack does not spam it. - Explosion immunity (
protectionUnbreakableonly) is a multi-target mixin ongetBlockExplosionResistancein bothExplosionDamageCalculatorandEntityBasedExplosionDamageCalculator(the latter backs every entity-sourced blast — TNT, creepers, end crystals). For a protected container it reportsFloat.MAX_VALUE, driving the explosion's ray power negative so the block is never added to the destroy set, the same way obsidian and bedrock survive.ContainerProtection.isExplosionProofgates it and is a no-op unless both protection flags are on. - Chest/hopper minecarts are entities with no
getDestroyProgress, so this block-only protection does not cover them.
Distance-based scaling applied to mob drops, extending the tier system beyond containers.
With Prosperity's container loot scaling, chests get better at higher distances — but mob drops stay flat. A zombie at 10,000 blocks drops the same 0–2 rotten flesh as one at 100 blocks. This creates an inconsistent reward signal: the world tells you "further = better loot from chests" but mobs contradict it. For players also running Tribulation (harder mobs at distance), the imbalance is worse — more risk, same mob drops.
When a player kills a mob, the mob's drop loot table is processed with the same distance tier system used for containers:
- Calculate distance — Euclidean distance from the mob's death position to world origin (XZ plane).
- Determine tier — Same tier brackets as container scaling (section 3).
- Apply stack multiplier — Same stack size scaling as containers: each stackable item drop has its count multiplied by the tier's
stackMultiplier, floored, capped at max stack size. - Apply quality modifier — The tier's
qualityModifieris added to theluckvalue in theLootParamsused for the mob's loot table, biasing toward rarer drops.
- Hostile mobs only. Passive mobs (cows, pigs, chickens) are not affected — their drops are farming resources, not exploration rewards. Scaling them would just inflate passive farms.
- Mob type filter: Applied to mobs in
MobCategory.MONSTER, so modded hostiles and the Wither are covered automatically (see the implementation note). The Ender Dragon is excluded — its bespoke death-drop path never reachesdropFromLootTable. - Player kills only. Mobs that die from environmental damage, other mobs, or despawning do not receive scaling. The
LootContextmust have aLAST_DAMAGE_PLAYERparameter.
Mob loot scaling fires LootModifierCallback.EVENT the same way container loot does. External mods that registered listeners for container loot automatically affect mob loot too — the API is context-agnostic. The LootModifierContext includes:
player()— the killing player.containerPos()— the mob's death position (reused field, semantically "loot source position").lootTable()— the mob's loot tableResourceLocation.
When Tribulation is co-installed, it can register a LootModifierCallback listener to further boost mob drops based on the player's difficulty level. This is Tribulation's responsibility — Prosperity exposes the hook, Tribulation decides what to do with it. Example:
// In Tribulation's initializer, only if Prosperity is loaded
LootModifierCallback.EVENT.register(context -> {
int playerLevel = TribulationState.getLevel(context.player());
// Higher difficulty level = slightly more drops
if (playerLevel >= 100) {
context.multiplyStacks(1.0f + (playerLevel / 1000.0f));
}
});| Key | Type | Default | Description |
|---|---|---|---|
enableMobLootScaling |
bool | true | Toggle distance scaling for mob drops |
Same dimension rules as container scaling:
- Nether: Distance calculated from Nether coordinates (not multiplied by 8).
- End: All mob kills in the End use the maximum configured tier.
- A mixin on
LivingEntity#dropFromLootTable(DamageSource, boolean)— the method all standard mob death loot funnels through — carries the scaling. Three coordinated injectors share one decision resolved once atHEAD:@Injectruns the gate and firesLootModifierCallback(via theMobLootScalinghelper),@ModifyArgonLootParams.Builder#withLuckreplaces vanilla'splayer.getLuck()with the event's final luck, and@ModifyArgon theLootTable#getRandomItems(…, Consumer)drop consumer wraps it to scale each rolled stack.withLuckand the consumer both sit inside the player-kill branch, so a non-scalable kill leaves the drop byte-identical to vanilla and fires no event. - The gate and the loot-modifier fire live in
MobLootScaling.resolve, the entity parallel ofLootScaling.resolveForGeneration; the death position is theLootModifierContextcontainerPos()and the tier is the ungated geographicLootScaling.resolveTier, so the Nether's raw coordinates and the End's max tier carry over for free. - Stack scaling reuses
LootScaling.scaledCount— multiply each stackable stack's count, floor, cap at the item's max stack — identical to container scaling. - The hostile-mob check is
MobCategory.MONSTER, not a hardcoded entity list, so modded hostiles and the Wither are included automatically. The Ender Dragon uses a bespoke death-drop path that never reachesdropFromLootTable, so it is excluded. enableMobLootScalinggates this feature independently ofenableDistanceScaling(which gates only container generation) — they are separate toggles for separate loot sources.
A persistent on-screen badge showing the player's current distance tier, and an on-demand full-screen panel — held open on a keybind — that expands it into the complete loot picture. The two form one HUD surface: the badge is the always-on glance (§2–§7 of the HUD standard), the panel its optional hold-to-peek companion (§8 of the HUD standard).
Distance tiers are invisible during normal gameplay. The action bar notification (section 8) only fires when opening a container. Players exploring have no ambient awareness of which tier they're in — they can't tell whether they've crossed into Wilderness territory without opening a chest or running /prosperity info.
A small badge rendered in a corner of the screen showing the current distance tier:
- Icon: A treasure-chest pixel-art icon unique to Prosperity, rendered 16×16 (authored at 32×32 for HUD-STANDARD glyph density and blitted down; source
art/glyphs/hud_icon.glyph). - Text: The tier name (e.g. "Wilderness") rendered next to the icon.
- Background: Semi-transparent dark rectangle behind the icon + text, with padding.
- Tier color: The text color changes based on the current tier.
- Updates in real-time as the player moves. Tier is recalculated from the player's current XZ position each frame (cheap — just a distance calculation and tier lookup).
| Tier | Color | Hex |
|---|---|---|
| Local | White | 0xFFFFFFFF |
| Frontier | Green | 0xFF55FF55 |
| Wilderness | Yellow | 0xFFFFFF55 |
| Outlands | Orange | 0xFFFF8C00 |
| Depths | Purple | 0xFFAA55FF |
When the player crosses a tier boundary, the badge briefly flashes:
- Text color lerps from gold (
0xFFFFD700) to the new tier color over 1.5 seconds. - This draws attention to the transition without being intrusive.
- Same animation approach as Tribulation's level-up color lerp.
The badge follows a shared visual convention so that multiple overhaul mods' HUD elements look cohesive when installed together:
- Anchor: Configurable corner (default: top-left). All overhaul mods should default to the same corner.
- Stacking order: Each mod occupies a fixed slot in the shared HUD strip (concord
HUD-STANDARD.md). Stacking order from top: Tribulation (slot 1), Mercantile (slot 2), Prosperity (slot 3); Meridian takes no slot by design. Prosperity therefore renders below both Tribulation and Mercantile whenever they are present. - Offset calculation: Prosperity offsets past the sum of the HUD heights that each higher-priority sibling (Tribulation, then Mercantile) reports through its HUD coordination accessors, plus the 2px inter-element spacing — a running sum of the siblings actually loaded and showing a badge, not a fixed multiple, so it stays correct as a sibling's badge height changes. Its own slot is the HUD-STANDARD 20px box (16px icon + 2px vertical padding top and bottom).
- Badge dimensions: Icon (16×16 rendered, from a 32×32 texture) + 3px gap + text + 4px horizontal padding on each side, 2px vertical padding.
- Background:
0x80000000(50% opacity black) — same as Tribulation's current background. - Font: Minecraft's default font with shadow.
- No frame texture. Each badge is self-contained — no shared frame that looks empty when only one mod is installed.
Each mod detects the higher-priority overhaul mods present at client init (via FabricLoader.getInstance().isModLoaded()) and offsets past those that are loaded and showing a badge. Prosperity's higher-priority siblings are Tribulation and Mercantile; with neither installed it renders at the top of the anchor, and it shifts up to fill the gap whenever a higher-priority sibling is absent or its HUD is disabled. The slot order is fixed by the standard — no runtime negotiation needed.
- Overworld: Shows the calculated tier normally.
- Nether: Shows the tier based on Nether coordinates (same as loot scaling).
- End: Shows "Depths" (always max tier, same as loot scaling).
| Key | Type | Default | Description |
|---|---|---|---|
enableTierHud |
bool | true | Toggle tier HUD badge |
hudAnchor |
enum | TOP_LEFT | HUD corner: TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT |
hudOffsetX |
int | 4 | Horizontal offset from anchor in pixels |
hudOffsetY |
int | 4 | Vertical offset from anchor in pixels |
- Client-side only. Rendered via
HudRenderCallback(Fabric API). - Tier is calculated from
player.getX()/player.getZ()— no server communication needed for position. - Tier config (tier boundaries) is synced from server to client on join (same config sync mechanism used for other features).
- Icon texture:
assets/prosperity/textures/gui/hud_icon.png(32×32, blitted down to 16×16), authored through the/glyphpipeline with its.glyphsource atart/glyphs/hud_icon.glyph. - Transition animation: store
lastTierChangeTimeand current/previous tier. On each render, ifcurrentTime - lastTierChangeTime < 1500ms, lerp text color from gold to tier color. - Priority offset: Prosperity reads each higher-priority sibling's reported HUD height (Tribulation, then Mercantile) through that sibling's HUD coordination accessors and offsets past the sum, so it always renders below them and tracks their live badge heights rather than a hardcoded reserve.
The badge says roughly where the player stands; the peek panel says exactly. Holding the Peek Loot Detail key overlays a framed panel that expands the badge into the full loot picture, then dismisses the instant the key is released — the mod's richest feedback surface, on demand and out of the way.
While the key is held (and the badge's normal visibility rules pass), a centered panel shows three stacked pillars:
- Current tier & progress — the tier the player is standing in, their exact distance from world origin, and a progress bar toward the next tier's boundary (the top tier reads as maxed).
- The tier ladder — every configured distance tier in order with its boundary, stack multiplier, and quality bonus, the current tier highlighted, so the whole reward curve is legible at a glance.
- Nearby unlooted containers — the instanced containers the player has not yet looted within range, grouped by container type with a count per group, drawn from the same unlooted set that feeds the sparkle indicators (§2) and the Prospector's Compass (§17), and extended over loot minecarts. When the player is carrying a Prospector's Compass, a
Nearest: <blocks> <bearing>line names the rounded distance and 8-way cardinal bearing to the single nearest target, suffixed with that target's tier in its tier color; without a compass, or with no candidates in range, the line is absent and only the grouped rows show.
The panel is a transient overlay, not a screen: like vanilla's hold-Tab player list it never captures the mouse, pauses the game, or blocks movement. Because a non-focused HUD layer cannot scroll, the nearby list is a fixed comfortable size — everything that fits shows at once, and any overflow pages on a timed cross-fade with page dots while the header, progress, and ladder stay static.
The panel obeys the badge's four standard visibility rules (hidden by F1/hideGui, an open screen, spectator mode, and death) plus the enableTierHud toggle, and additionally requires the peek key to be held. It has no dedicated on/off config: disabling the tier HUD hides it with the badge, and clearing the keybind disables the hold entirely.
Because the panel lives behind a keybind a player might never think to press, a one-line chat hint names the bound key on world join — on the player's first eligible join and every fifth eligible join thereafter — until they open the panel once, after which it never shows again. The hint is skipped entirely while the keybind is unbound (a hint naming no key is meaningless, and that join does not count toward the cadence). The "seen once" flag and the eligible-join tally persist in the client config so the cadence survives restarts.
- Translation key
key.prosperity.peek_detail, categorykey.categories.prosperity, default Left Alt. The default is safe because the panel never opens a screen or captures the mouse, so it does not conflict with normal play, and it makes the feature discoverable without a Controls-menu visit. - Fully rebindable and clearable under Controls → Prosperity. A player who has already assigned their own key keeps it; clearing the binding disables the panel.
The panel adds no feature toggle of its own. Two client fields carry only the discovery-hint state:
| Key | Type | Default | Description |
|---|---|---|---|
peekHintDismissed |
bool | false | Set true the first time the panel is opened; suppresses the discovery hint thereafter |
peekHintJoins |
int | 0 | Running tally of eligible world joins, driving the once-every-fifth hint cadence |
- Client-side only, rendered via
HudRenderCallback(LootDetailPanelRenderer). Every figure derives from data already on the client — the tier pillars resolve against the synced config through the same tier lookup the badge and/prosperity infouse, so the panel can never disagree with server generation; the nearby pillar reads the existing unlooted-indicator caches on a tick interval rather than per frame, so it adds no scan. - The panel deliberately does not enumerate a container's possible or injected loot — that browsing surface stays in the EMI/REI/JEI loot index (§11). The panel is about where you are and what is unlooted around you, not what a table can roll.
- Distance/bearing math (
LootDetailPanelMath, including the 8-waybearing8) and the hint's eligibility/cadence rules (PeekHint) are kept free of Minecraft imports and covered by pure JUnit.
All commands use the prosperity root. Admin commands require operator level 2.
| Command | Permission | Description |
|---|---|---|
/prosperity info |
Any player | Shows your loot scaling tier for your current position (distance, tier name, multipliers) |
/prosperity info <player> |
Op level 2 | Shows another player's loot scaling tier at their position |
/prosperity stats |
Any player | Shows your loot statistics: containers looted, per-tier breakdown, distinct structure types, injected rewards received |
/prosperity stats <player> |
Op level 2 | Shows another player's loot statistics |
/prosperity reset <pos> |
Op level 2 | Clears all instanced loot data for the container at the given position. All players' instances are removed. |
/prosperity reset <pos> <player> |
Op level 2 | Clears a specific player's instanced loot at the given position |
/prosperity reset around [radius] [player] |
Op level 2 | Clears instanced loot for every container in loaded chunks within radius blocks of the command source (default 128, max 256), optionally scoped to one player |
/prosperity refresh <pos> |
Op level 2 | Forces a loot refresh for the container at the given position (all players) |
/prosperity refresh <pos> <player> |
Op level 2 | Forces a loot refresh for a specific player at the given position |
/prosperity refresh around [radius] [player] |
Op level 2 | Forces a loot refresh for every container in loaded chunks within radius blocks of the command source (default 128, max 256), optionally scoped to one player |
/prosperity reload |
Op level 2 | Reloads config from disk and syncs to all connected clients |
/prosperity infooutput example:"Distance: 4,521 blocks — Wilderness tier (2.0x stacks, +2 quality)"/prosperity statsoutput: a header line, the container total, one indented row per tier with a recorded count (configured ladder order), the distinct-structure count, and the injected-reward count/prosperity resetconfirms:"Cleared instanced loot at [x, y, z] for all players (4 instances removed)"- All feedback uses translation keys (
command.prosperity.*).
- Register via
CommandRegistrationCallback. /prosperity infocalculates the player's current chunk position, determines the distance tier, and formats the tier data./prosperity reset/refreshread the attachment(s) at the target position — or every loaded container within thearoundradius — clear the specified entries, and resend the affected chunks'UnlootedContainersS2Cset to tracking clients (full per-player replace), so a container that is unlooted again re-lights rather than being dropped. Thearoundform scans only loaded chunks (never force-loads) and bounds the radius at 256 blocks./prosperity reloadre-readsconfig/prosperity.jsonand pushes updated values to all connected clients via a config sync packet./prosperity statsreads a persistent per-player attachment (loot_statson the player,copyOnDeath) recorded once per loot generation — first opens and refresh re-rolls, never return visits; a double chest counts once. The injected-rewards bucket counts only actually-placed injections. Structure attribution is independent of the scaling gates: when tier resolution skipped structure detection (distance scaling off, or no overrides configured) the stats path resolves the structure itself through the same resolver. Counting starts when the feature ships; there is no backfill from existing container attachments.
Distance-based scaling applied to trial chamber reward sources — vault loot (normal and ominous) and trial spawner ejected rewards. Scaling only, no instancing: vanilla vaults already gate rewards per player, so instancing them would add nothing.
- Vault loot — when a player inserts a key, the reward roll's luck is replaced with the loot-modifier event's final value (tier quality + the player's
generic.luck+ any API listener) and each rolled stackable stack is multiplied by the tier'sstackMultiplier(floored, capped at max stack size). Normal and ominous vaults are both covered — each is aVaultConfigwith its own loot table on the same roll path. - Trial spawner rewards — the ejected reward roll is scaled the same way for the player being rewarded. Vanilla ejects one roll per detected player and then removes the head of the detected set; that head is the player the roll is attributed to.
- Structure override — the roll position runs through the standard tier pipeline (
LootScaling.resolveForGeneration), so theminecraft:trial_chambersentry instructureOverridesparticipates. The shipped default raises trial chambers to at least thewildernesstier. - Loot Modifier API —
LootModifierCallback.EVENTfires once per roll with the vault/spawner position ascontainerPos()and the rolled table aslootTable(), so API listeners compose exactly as they do for containers and mob drops. - Notification — the tier action-bar notification (section 8) shows on a successful vault open, consistent with container generation. Spawner ejections stay silent — they fire on a timer with no per-player open moment.
Key consumption, the per-player rewarded set, and vault re-locking all live outside the hooked roll methods and are not modified. The vault display-item cycling roll is likewise untouched.
| Key | Type | Default | Description |
|---|---|---|---|
enableTrialChamberScaling |
bool | true | Toggle distance scaling for trial chamber vault and spawner rewards |
Gated on enableTrialChamberScaling and enableDistanceScaling — this is an extension of distance scaling, not an independent loot source. With either off, trial chamber loot is byte-identical to vanilla and no event fires.
- The gate and the loot-modifier fire live in
TrialChamberScaling.resolve, the trial chamber parallel ofMobLootScaling.resolve; the two mixins are pure plumbing. VaultBlockEntityServerMixinhooksVaultBlockEntity.Server#resolveItemsToEject— the one method every vault unlock funnels through, called only fromtryInsertKeywith the opening player. Three coordinated injectors:@Inject(HEAD)resolves the decision,@ModifyArgonLootParams.Builder#withLuckreplaces vanilla'splayer.getLuck()with the event's final luck, and@Inject(RETURN)scales the rolled stacks in place and sends the notification on a non-empty roll.TrialSpawnerMixinhooksTrialSpawner#ejectReward:@Inject(HEAD)attributes the roll to the head of the spawner's detected-player set (via aTrialSpawnerDataaccessor),@ModifyArgonLootTable#getRandomItemsswaps in aLootParamscarrying the final luck (vanilla rolls this table with no luck at all), and@ModifyArgonDefaultDispenseItemBehavior#spawnItemscales each dispensed stack.- Stack scaling reuses
LootScaling.scaledCount, identical to container and mob scaling. - Existing configs gain the
minecraft:trial_chambersdefault override via a v1 → v2 config migration that appends it only when no entry for the structure exists — a hand-tuned or deliberately removed entry is respected.
A held compass item whose needle points at the nearest container the holder has not yet looted — the directional complement to the sparkle indicators (section 2), answering "where should I go next?" beyond the indicators' render distance.
- Targeting — the needle points at the nearest position in the client's unlooted-container cache (
UnlootedIndicatorCache), the per-player set the server already computes inUnlootedContainers.scanChunk. Blacklist, double-chest anchoring, and refresh-expiry rules are therefore inherited: a blacklisted container is never a target, and a refreshed container becomes one again. Reach is the client's loaded-chunk radius. - Per-player — two players holding the compass at the same spot see different needles, because each client's cache reflects its own loot history.
- Retargeting — looting or breaking the target evicts it from the cache (existing
ContainerLootedS2C/ContainerRemovedS2Cflow) and the needle swings to the next nearest candidate. The current target is sticky within a 2-block hysteresis so the needle does not flicker between near-equidistant containers. - No candidates — the needle spins randomly, exactly like a vanilla compass outside its dimension (vanilla
CompassItemPropertyFunctionbehavior). - Obtainability — two paths. The lucky find: injected into chest loot via the bundled
loot_injections/prospectors_compass.jsonatmin_tier: frontier,chance: 0.01(its own gate roll, independent of the other bundled groups — about 1 in 100 chests), weight 8. The deliberate craft: a shaped recipe framing a vanilla compass in a gold-ingot casing (GNG/GCG/GEG) with an end rod for the needle and a netherite ingot at the crown — deliberately late-game, since the compass reveals the bearing and distance to the holder's nearest unlooted container. Uncommon rarity, stack size 1. - Peek-panel readout — carrying a compass anywhere in the inventory adds a
Nearest: <blocks> <bearing>line to the peek panel's "Nearby unlooted" pillar: the rounded distance and 8-way cardinal bearing to the same plain-nearest target the needle selects (extended over loot minecarts, which the pillar also lists), with the target's tier suffixed in its tier color. The line is absent with no compass or no candidates in range; the pillar's empty state is unchanged. Bearing math (LootDetailPanelMath.bearing8) is pure and under JUnit. - Out of scope — pointing at ungenerated structures, GUIs/waypoints/maps, and loot minecart targets.
ProsperityItems.PROSPECTORS_COMPASSis the mod's only registered item (a plainItem— no server-side behavior), placed in the Tools & Utilities creative tab after the vanilla compass.- The crafting recipe and its recipe-book unlock advancement are datagen-emitted by
ProsperityRecipeProvider(generating rather than hand-authoring keeps the unlock advancement in lockstep with the recipe) — the mod's only shipped recipe. - Needle rotation is the vanilla
angleitem property:ProspectorsCompassClient.register()installs aCompassItemPropertyFunctionwhoseCompassTargetreads the indicator cache, reusing vanilla's wobble and random-spin logic wholesale. Target selection (selectTarget) is a pure static function under JUnit. - The model mirrors the vanilla compass's 32-frame
angleoverride ladder; the textures are the vanilla frames with the casing remapped to the design-system gold ramp (dial face, outline, and red needle stay vanilla) so the item reads instantly as "a compass, but for loot".
Distance-based scaling applied to fishing catches, extending the tier system to the third vanilla loot source.
Distance tiers apply to container loot (section 3) and mob drops (section 13), but fishing loot stays flat. A player fishing at 10,000 blocks pulls the same treasure odds as one at spawn — the "further = better" reward signal breaks for the third vanilla loot source.
When a player reels in a catch, the fishing loot roll is processed with the same distance tier system used for containers and mobs:
- Calculate distance — Euclidean distance from the bobber's position (not the angler's) to world origin (XZ plane).
- Determine tier — Same tier brackets as container scaling (section 3).
- Apply quality modifier — The tier's
qualityModifieris added to theluckvalue in the fishing roll'sLootParams, biasing vanilla's quality-weighted fishing table toward the treasure category. Luck of the Sea's own contribution is untouched and stacks on top. - Apply stack multiplier — Same stack size scaling as containers: each stackable catch has its count multiplied by the tier's
stackMultiplier, floored, capped at max stack size. Non-stackable catches (enchanted books, bows, name tags) are never count-multiplied.
- Loot rolls only. Reeling in a hooked entity or an empty hook is unaffected.
- Player-owned bobbers only. A hook with no player owner rolls vanilla loot.
- Instancing does not apply — fishing loot is inherently per-player already.
- Wait-time/bite mechanics and Luck of the Sea itself are unchanged.
Fishing scaling fires LootModifierCallback.EVENT the same way container and mob loot do, so registered listeners automatically affect fishing rolls too. The LootModifierContext carries the angler as player(), the bobber's position as containerPos() (the loot-source position), and minecraft:gameplay/fishing as lootTable().
| Key | Type | Default | Description |
|---|---|---|---|
enableFishingLootScaling |
bool | true | Toggle distance scaling for fishing catches |
Same dimension rules as container scaling:
- Nether: Distance calculated from Nether coordinates (not multiplied by 8).
- End: All fishing in the End uses the maximum configured tier.
- A mixin on
FishingHook#retrieve— the method every fishing catch funnels through — carries the scaling.@ModifyArgonLootParams.Builder#withLuckruns the gate, firesLootModifierCallback(via theFishingLootScalinghelper), and replaces vanilla'sthis.luck + player.getLuck()withthis.luckplus the event's final luck;@ModifyVariableon the rolledList<ItemStack>scales each catch withLootScaling.scaledCount. The resolve lives in thewithLuckinjector rather thanHEADbecauseretrievealso handles hooked entities and empty reels — thewithLuckcall sits inside the caught-something branch, so the event fires exactly once per actual loot roll and a non-scalable reel stays byte-identical to vanilla. - The gate and the loot-modifier fire live in
FishingLootScaling.resolve, the fishing parallel ofMobLootScaling.resolve; the tier is the ungated geographicLootScaling.resolveTierfrom the bobber's coordinates, so the Nether's raw coordinates and the End's max tier carry over for free. - The event luck already folds in the angler's
generic.luckvia the default listener, so the mixin adds back only the rod's Luck of the Sea contribution (FishingHook.luck) from the vanilla operands — vanilla luck is never double-counted. enableFishingLootScalinggates this feature independently ofenableDistanceScalingandenableMobLootScaling— separate toggles for separate loot sources.
A one-time, per-player reward for fully clearing a structure's instanced containers, paying out thorough exploration over chest-hopping.
Instanced loot rewards opening individual chests, but nothing rewards fully clearing a structure. Players chest-hop the obvious containers and leave; thorough exploration of a stronghold or mansion pays no better than a smash-and-grab.
When a player generates loot for the last remaining instanced container inside a structure's bounds (per that player):
- Bonus roll — one extra reward is drawn from the tier-appropriate loot injection pool (section 5) for the final container's loot table, decorrelated from the container's regular injected item, and placed in the first empty slot of that final container's instance. A table with no eligible injection entry (or a full container) places nothing — the completion itself still stands.
- Fanfare — an action-bar message (e.g.
✦ Stronghold cleared!), localizable with a humanized fallback and gated byenableLootNotificationslike every other loot notification. - Ledger — the completion is recorded per player per structure instance (structure id + start chunk) in a per-dimension
SavedDataledger, so a loot refresh,/prosperity reset|refresh, or even breaking the awarding container can never re-arm the bonus.
What counts. Block-entity loot containers inside the structure's piece bounding boxes, under the same filters as the unlooted-indicator scan: it is a loot container, it is not blacklisted (section 7), and a double chest counts once via its primary half. Container minecarts neither count toward nor trigger completion — entities in unvisited chunks cannot be enumerated deterministically, so including them would make completion silently unreliable in minecart-bearing structures (mineshafts). A container broken before being looted no longer exists to be counted, so the remaining set shrinks and completion stays reachable. Membership is geometric (inside the piece boxes), not attributive: a container belonging to an overlapping structure that happens to sit inside this structure's piece boxes counts toward — and can hold up — this structure's completion, even though its own generation event attributes to the more specific structure. Overlaps are rare and the per-candidate attribution walk would be disproportionate; the divergence is deliberate.
Qualification. Only structures with more than one qualifying container award — no "completion" for a lone buried-treasure chest.
Known edge: the trigger is loot generation, so if a player breaks the would-be-final unlooted container instead of opening it after looting everything else, there is no further generation event in the structure to fire the check; the completion is picked up by the next generation there (e.g. after a refresh re-roll).
| Key | Type | Default | Description |
|---|---|---|---|
enableStructureCompletionBonus |
bool | true | Toggle the structure completion bonus |
The bonus draws from the injection pool but is deliberately not gated by enableLootInjection — it is its own feature that reuses the pool, not an injection. For the same reason it bypasses the per-group chance gate (issue #68): the completion is already earned, so the draw runs over every tier-and-dimension eligible entry and always pays out when the pool is non-empty.
The draw runs at the container's generation tier, mirroring regular injection: with enableDistanceScaling off every generation is Local-tier, and the shipped injection defaults all gate at Frontier or above, so completions then pay the fanfare only. Ship a Local-tier (min_tier: "local") injection entry via datapack to give scaling-off servers a material bonus.
- The hook (
StructureCompletion.onLootGenerated) runs at the tail of both generation paths inInstancedLootInteraction, after the player's instance is stored, so the just-generated container already reads as looted. Structure attribution reusesLootScaling.resolveStructureStart— the same most-specific-by-volume walk as structure tier overrides (section 6) — and works independently ofenableDistanceScaling. - The census walks the chunks intersecting the structure's piece boxes via
ServerLevel#getChunk(loading, and if necessary generating, each) — required for a correct total, since an ungenerated piece still contains future containers. Three bounds keep this off the common path: an already-completed player skips the census entirely; the walk visits chunks nearest the triggering container first and early-exits at the first unlooted container; and the one full sweep a completion requires caches the instance's discovered container positions in a bounded session cache, so later censuses of the same instance (another player finishing the same structure) walk just those positions instead of the span. - "Looted" is has ever generated (
hasGenerated), not "currently unlooted": a player who looted a chest, let it refresh-expire, then looted the rest still completes. - The bonus draw is deterministic for a given (seed, salt, player) triple and passes through the injected-stack finalizer, exactly like a regular injection.
- The census-and-award core takes explicit piece boxes so gametests drive it against placed containers; live
StructureStartresolution is the same documented manual in-world check as section 6.
Config-gated reclamation of residual per-player container entries left behind by players who have stopped playing.
Bounding the stored inventories (a player's entry is evicted once they loot a container clean) still leaves two lightweight per-player maps growing forever on a public server: lastGeneratedTick (the retained "has visited" marker) and refreshCount (the re-roll salt, which survives every clear — including /prosperity reset). Each entry is tiny (~50 bytes serialized), but a high-traffic container accrues one per distinct player who ever opened it, unboundedly. Loot refresh cannot reclaim them: a refresh clear fires only on the returning player's own open, so a player who never returns is never cleared, and the salt is retained besides.
- Off by default. With
evictAbsentPlayerDatadisabled, no container entry is ever touched — attachment behavior is unchanged from the stored-inventory bounding alone. The last-seen ledger itself is always maintained (the same pattern aslastGeneratedTickbeing recorded while refresh is off), so login history accrues before an admin ever flips the toggle and eviction applies meaningfully from day one of enabling it. - Last-seen ledger. A world-global
SavedData(prosperity_player_last_seen, in the overworld's storage) maps UUID → overworld game time of the player's most recent join or disconnect. A UUID with no entry — a player who has not logged in since the ledger first existed — falls back to the ledger's creation epoch, so the historical players an upgraded server most wants to evict become evictable one full threshold after the ledger's creation (the first launch with this feature) and never sooner. Note the anchor is the ledger's creation, not the config flip: enabling the toggle later than that can make long-gone historical players evictable immediately. - Opportunistic trigger. When an instanced container is next touched (the generation choke points, covering block containers, double chests, and container minecarts alike), every stored player whose last-seen exceeds
absentPlayerEvictionDaysin-game days is dropped from all three maps — inventory, generation tick, and refresh count. No global or force-loaded world scan ever runs; an untouched container is simply never pruned, which costs nothing beyond what it already held. - Online players are always present, whatever the ledger says, so a player can never be evicted mid-session.
- The trade-off: an evicted player who does return regenerates that container from scratch — any uncollected loot is forfeit and their refresh count restarts at 0. This is the same loot-loss trade a cooldown refresh already makes, applied only to players gone far longer than any cooldown.
| Key | Type | Default | Description |
|---|---|---|---|
evictAbsentPlayerData |
bool | false | Toggle absent-player eviction |
absentPlayerEvictionDays |
int | 60 | In-game days a player must be gone before their stored container entries are evicted |
- Absence is measured in overworld game time — the same clock and day length (24,000 ticks) as
lootRefreshDays— so thresholds are deterministic and testable; the clock only advances while the server runs, which is the conservative direction. - The ledger is updated on player join and disconnect. A crash can skip the disconnect stamp, under-reporting presence by at most one session; the next join re-stamps it.
- Eviction (
InstancedLootData.evictPlayer) removes all three entries without advancing any salt, unlike a refresh clear; the salt semantics for present players are untouched. - The prune computes the evictable set read-only first and writes (dirtying the block entity) only when someone actually qualifies.
A Prosperity advancement tab that gives the exploration and looting loops the goals and milestones vanilla communicates through advancements.
Distance tiers and instanced looting have no progression feedback. Crossing into Wilderness or looting a hundredth chest goes unacknowledged, so the exploration loop lacks the concrete goalposts advancements otherwise provide.
Three milestone families hang off a root advancement (icon: Prospector's Compass), each granted server-side at loot-generation time:
- Tier — the first instanced container looted in each distance tier, as a linear chain in geographic order: Frontier → Wilderness → Outlands → Depths (Depths is a challenge). The Local baseline tier has no milestone.
- Volume — lifetime count of instanced containers looted: 10 / 50 / 250 (250 is a challenge).
- Variety — distinct structure types a container has been looted in: 3 / 8 / 15 (15 is a challenge).
Icons are vanilla items (the root uses the mod's Prospector's Compass); titles and descriptions are advancements.prosperity.* translation keys.
- One criterion trigger backs the whole tab.
prosperity:instanced_loot(aSimpleCriterionTrigger, registered intoBuiltInRegistries.TRIGGER_TYPESbyProsperityCriteria) fires once per generation. Its instance predicate carries three optional fields —tier(exact match),min_containers,min_structures— and each advancement sets only the one it needs; an unset field never gates. Because an advancement grants only once, "first container in tier X" needs no extra bookkeeping. - Fired from the single generation choke point.
InstancedLootInteraction.recordStats— the same place per-player loot stats (§15) are recorded — fires the trigger off the just-updated running totals. Both container paths reach it only after their return-visit / blacklist / vanilla-passthrough early returns, so the criteria inherit that gating: return visits, blacklisted containers, and vanilla opens never count, and the counters ride the persistentLootStatsDataattachment, surviving relog and restart. - Datagen'd, not hand-written.
ProsperityAdvancementProvider(the mod's firstFabricDataGenerator.Packprovider) emits every advancement JSON, keeping the predicates in lockstep with the trigger's field names. The tier milestones only fire while distance scaling is enabled (a disabled scaler resolves every container to Local); volume and variety fire regardless.
Instanced loot gives every player their own roll, but co-op groups exploring together often want the opposite: one shared discovery per group, so opening a chest together is a shared event rather than four parallel ones. The sweet spot is public/community servers — strangers still cannot strip a dungeon before your group arrives (the mod's core value), while within your party loot stays a shared pot like vanilla. Reduced group loot volume is a side effect, not the goal; this is opt-in cooperation, not economy enforcement.
- Config-gated by
partyLootMode(default off). With it enabled, players on the same vanilla scoreboard team resolve to one shared loot key per container instead of their own UUID, so a single instance backs the whole team's inventory, generation, and refresh cooldown for that container. - The first team member to open a container generates the loot — their luck, entity context, and the container position seed the roll. Teammates opening later see the same shared inventory; what one takes is gone for the others.
- Teamless players fall back to individual instancing while the mode is on. Leaving or joining a team affects only future generations — it never migrates existing instances.
- Membership snapshot — a team instance records the member UUIDs that have opened it. Those players keep resolving to that instance for that container even after leaving the team (resolution, not migration), closing the "leave, re-loot the same chest" loop. The snapshot rides the container's persistent attachment.
- Leave grace — optional
teamLeaveGraceMinutes(default 0 = off): a player who recently resolved into a team keeps generating new instances against that former team's key for N real minutes, making leave/loot/rejoin cycling more hassle than it is worth. This memory is in-memory only (a deterrent, not enforcement) and does not survive a server restart. - v1 concurrency — a second teammate opening a shared instance already open by a teammate is refused with an action-bar message and a "no" cue (
notification.prosperity.container_in_use), rather than served a copy that a last-close-wins write would clobber. The in-use lock releases when the first member closes the screen. Live simultaneous access is a v2 candidate. - The unlooted indicator and Jade/WTHIT status reflect the shared state: a container looted by any teammate reads as looted for the whole team, and the refresh sweep re-lights it once for the team. Newly-bound teammates converge via the passive per-chunk scan.
ProsperityAPI.registerPartyGroupProvider(PartyGroupProvider) lets a social/party mod supply the group key for a player in place of scoreboard teams. Providers are consulted in registration order under host-side error isolation; the first non-null, non-blank key wins, otherwise Prosperity falls back to the scoreboard team. Consulted only while partyLootMode is on.
The obvious exploit is leave-team → open (fresh individual roll) → rejoin. In vanilla /team needs permission level 2, so players cannot self-serve membership; the exploit only becomes player-accessible when a free-join party mod supplies the group key via the API hook, at which point membership churn is that mod's policy surface. The snapshot and grace window raise the cost of cycling; airtight anti-abuse is a non-goal (players can always coordinate out-of-band). Likewise accepted: a team may send its highest-luck member to open first.
PartyLootKeys.resolve(player, data)is the single seam, side-effect-free: existing team-instance binding → existing individual instance → API/scoreboard current group → grace window → own UUID. The two "existing instance" short-circuits are both resolution, not migration: a player who opened a container with their team keeps that team instance even after leaving (closes leave-and-re-loot), and a player who looted a container solo keeps their individual instance even after joining a team (closes join-and-re-roll). Team keys are deterministic name-based (type-3) UUIDs (teamKey), which never collide with players' type-4 UUIDs, so a team key safely shares the per-playerInstancedLootDatamaps. The resolved key is threaded in place of the player UUID at every read/write site (generation, refresh cooldown, indicator scan, Jade/WTHIT status, structure completion), so per-team sharing follows from the one keying change. The double-chest serve path resolves the key once and threads it into generation, the lock, and the close-time persist so the three cannot diverge. Read paths (scan, tooltip, sweep) resolve the current group once per pass and never stamp the grace memory; only the open path stamps it.- The membership snapshot lives on
InstancedLootData(teamMembers, serialized in the CODEC). Absent-player eviction (§20) skips team keys so a shared instance is never wrongly evicted as an "absent player". - The in-use lock (
SharedInstanceLocks) and the leave-grace memory (PartyGraceTracker) are transient, server-thread state cleared on server stop byPartyLootMode.
All features are independently toggleable via ModMenu / Cloth Config screen and a JSON config file (config/prosperity.json), created with defaults on first launch. configVersion is 2; ProsperityConfigMigrator runs ordered JSON-level migrations on the raw file (before deserialize) so renamed or restructured keys carry forward, and the file is re-saved when a migration runs. Unknown/missing fields are filled with defaults and clamped to valid ranges by clamp() after load; a corrupted file falls back to defaults and is left untouched.
| Key | Type | Default | Description |
|---|---|---|---|
enableInstancedLoot |
bool | true | Master toggle for instanced loot |
enableVisualIndicators |
bool | true | Toggle client-side unlooted indicators |
indicatorRenderDistance |
int | 48 | Max render distance for indicators (blocks) |
indicatorXrayDistance |
int | 8 | Distance indicators render through walls (blocks) |
enableDistanceScaling |
bool | true | Toggle distance-based loot scaling |
distanceTiers |
list | (see below) | Ordered list of distance tier definitions |
structureOverrides |
list | (see below) | Per-structure tier overrides |
lootTableBlacklist |
list | [] | Loot tables excluded from instancing (exact or namespace wildcard) |
enableLootInjection |
bool | true | Toggle datapack-driven loot injection |
enableStructureCompletionBonus |
bool | true | Toggle the per-player bonus for fully looting a structure (§19) |
enableLootNotifications |
bool | true | Toggle action bar tier notifications |
enableLootRefresh |
bool | false | Toggle loot refresh |
lootRefreshDays |
int | 7 | In-game days before loot refreshes per player |
randomizeLootOnRefresh |
bool | false | Re-roll fresh loot on each refresh instead of repeating the same items |
evictAbsentPlayerData |
bool | false | Toggle eviction of per-player container entries for long-absent players (§20) |
absentPlayerEvictionDays |
int | 60 | In-game days a player must be gone before their entries are evicted |
enableContainerProtection |
bool | false | Toggle container break protection |
protectionBreakMultiplier |
float | 4.0 | Mining speed multiplier for protected containers |
protectionUnbreakable |
bool | false | Make protected containers fully unbreakable in survival instead of merely slower |
partyLootMode |
bool | false | Players on the same scoreboard team share one loot instance per container (§22) |
teamLeaveGraceMinutes |
int | 0 | Minutes a player who left a team keeps generating new instances against the former team's key (0 = off) |
enableMobLootScaling |
bool | true | Toggle distance scaling for mob drops |
enableFishingLootScaling |
bool | true | Toggle distance scaling for fishing catches |
enableTrialChamberScaling |
bool | true | Toggle distance scaling for trial chamber vault and spawner rewards |
endAlwaysMaxTier |
bool | true | Treat all End containers as max distance tier |
lootTableStructures |
map | {} | Loot index (§11): loot-table id → structure id overrides for tables the hardcoded vanilla map does not cover |
[
{ "minDistance": 0, "stackMultiplier": 1.0, "qualityModifier": 0 },
{ "minDistance": 1000, "stackMultiplier": 1.5, "qualityModifier": 1 },
{ "minDistance": 3000, "stackMultiplier": 2.0, "qualityModifier": 2 },
{ "minDistance": 6000, "stackMultiplier": 2.75, "qualityModifier": 3 },
{ "minDistance": 10000, "stackMultiplier": 3.5, "qualityModifier": 4 }
][
{ "structure": "minecraft:monument", "mode": "fixed", "tier": "wilderness" },
{ "structure": "minecraft:stronghold", "mode": "minimum", "tier": "outlands" },
{ "structure": "minecraft:village_plains", "mode": "maximum", "tier": "frontier" },
{ "structure": "minecraft:village_desert", "mode": "maximum", "tier": "frontier" },
{ "structure": "minecraft:village_savanna", "mode": "maximum", "tier": "frontier" },
{ "structure": "minecraft:village_snowy", "mode": "maximum", "tier": "frontier" },
{ "structure": "minecraft:village_taiga", "mode": "maximum", "tier": "frontier" },
{ "structure": "minecraft:ancient_city", "mode": "minimum", "tier": "outlands" },
{ "structure": "minecraft:trail_ruins", "mode": "minimum", "tier": "frontier" },
{ "structure": "minecraft:trial_chambers", "mode": "minimum", "tier": "wilderness" }
]| Key | Type | Default | Description |
|---|---|---|---|
showIndicators |
bool | true | Client-side toggle for overlay rendering |
enableTierHud |
bool | true | Toggle tier HUD badge |
hudAnchor |
enum | TOP_LEFT | HUD corner: TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT |
hudOffsetX |
int | 4 | Horizontal offset from anchor in pixels |
hudOffsetY |
int | 4 | Vertical offset from anchor in pixels |
peekHintDismissed |
bool | false | Set true on first peek-panel open; suppresses the discovery hint thereafter (§14) |
peekHintJoins |
int | 0 | Eligible-join tally driving the peek-hint cadence (§14) |
- Fabric Loader >=0.16.10
- Fabric API (the Data Attachment API module carries per-player loot state — no third-party persistence dependency)
- Minecraft 1.21.1
- ModMenu + Cloth Config — Config screen
- Jade — Container tooltip: loot status, distance tier, structure override, refresh timer (section 10)
- WTHIT — Same as Jade (parallel plugin)
- EMI / REI / JEI — Searchable loot index with structure/tier/source filtering and injection display (section 11)
- Sodium — Full compatibility. No block rendering is modified. Visual indicators use
WorldRenderEvents.LAST. - Enhanced Block Entities (EBE) — Full compatibility. Same reason as Sodium — container block entities are never replaced or subclassed.
- Iris/shaders — Visual indicators render in the
LASTevent, after the main scene. Shader post-processing may affect indicator appearance (bloom, color grading). No workaround needed. - Tribulation — Distance scaling complements Tribulation's mob scaling. Both use distance from origin. Players facing harder mobs at greater distances also receive better loot.
- Lootr — Incompatible. Prosperity replaces Lootr's functionality entirely. Both mods cannot be loaded simultaneously (detected at init, logged as error, Prosperity disables its instancing if Lootr is present).
Sounds stay vanilla where the cue is organic — chest lids, barrel lids, and
XP pickup are physical sounds vanilla already nails, so synthesis would only make
them feel fake. Custom synthesized cues (via the /sfx pipeline) are added where
a sound benefits from its own identity, per concord design/DESIGN-SYSTEM.md §9.
The current cues all map to vanilla events:
| Feature | Event | Vanilla Sound |
|---|---|---|
| Instanced container — open | Chest open | minecraft:block.chest.open |
| Instanced container — close | Chest close | minecraft:block.chest.close |
| Instanced container — open (barrel) | Barrel open | minecraft:block.barrel.open |
| Instanced container — close (barrel) | Barrel close | minecraft:block.barrel.close |
| Loot generated (first open) | Experience pickup | minecraft:entity.experience_orb.pickup |
All user-facing text uses translation keys in assets/prosperity/lang/en_us.json. Keys are namespaced by the surface the string renders on, per concord DESIGN-SYSTEM §10 — never by gameplay concept. Cross-surface enum names (tiers, structures) route through a single translation-key helper (DistanceTier.translationKey, LootNotification.structureNameKey) so every site reads the name from one place.
| Pattern | Example | Used For |
|---|---|---|
config.prosperity.* |
config.prosperity.enable_distance_scaling |
Cloth Config screen labels |
config.prosperity.*.tooltip |
config.prosperity.enable_distance_scaling.tooltip |
Cloth Config field descriptions |
command.prosperity.* |
command.prosperity.info |
Command feedback messages (incl. /prosperity info output) |
hud.prosperity.* |
hud.prosperity.detail.title |
Tier HUD badge and peek detail-panel text |
gui.prosperity.* |
gui.prosperity.injected |
Loot-index recipe-viewer screen labels (incl. gui.prosperity.structure.* names) |
tooltip.prosperity.* |
tooltip.prosperity.status.looted |
Hover + Jade/WTHIT probe tooltip lines (compass behavior; container status, tier, override, refresh timer) |
message.prosperity.* |
message.prosperity.peek_hint |
Chat hints (the peek-panel discovery line naming the bound key) |
notification.prosperity.* |
notification.prosperity.loot_generated |
Action-bar toasts (loot, structure-cleared, protection + shared-instance-in-use refusals) plus the tier (notification.prosperity.tier.*) and structure (notification.prosperity.structure.*) display names they embed |
advancements.prosperity.* |
advancements.prosperity.root.title |
Advancement titles and descriptions |
key.categories.prosperity / key.prosperity.* |
key.prosperity.peek_detail |
Controls-menu keybind category and binding names |
item.prosperity.<id> |
item.prosperity.prospectors_compass |
Item display name (vanilla-mandated) |
emi.category.prosperity.* |
emi.category.prosperity.loot_tables |
EMI recipe category title (EMI derives the key from the category id) |
rei.prosperity.category.* / jei.prosperity.category.* |
jei.prosperity.category.loot_tables |
REI / JEI recipe category title (per-viewer, kept unshared) |
Parameterized messages use String.format style (%s, %d) — e.g. "command.prosperity.info": "Distance: %s blocks — %s tier (%s)".
Visual indicator features (section 2) use client-side world rendering. These must work with common rendering mods.
- Use Fabric Rendering API event
WorldRenderEvents.LASTfor all custom world rendering. - The overlay is a textured quad (billboard sprite) per container — rendered via
VertexConsumeron a customRenderTypewith the indicator texture. - No block rendering modifications. No mixins into chunk building, block entity renderers, or model loaders.
- Sodium/EBE compatibility is guaranteed because the overlay is entirely decoupled from block rendering.
- Iris/shader compatibility: The overlay renders after the main scene. Shader post-processing (bloom, tone mapping) may affect indicator appearance — this is expected and not a bug.
Fast, no Minecraft runtime needed. Located in src/test/.
- Config parsing and serialization (round-trip JSON, default values, migration from older config versions)
- Distance tier calculation (boundary values, descending tier walk, single-tier config, empty config)
- Structure override resolution (fixed/minimum/maximum modes, missing structure, overlapping structures)
- Stack multiplier math (floor behavior, max stack size cap, non-stackable items excluded, edge cases)
- Loot modifier context (luck stacking, stack multiplication, custom data isolation)
- Loot injection datapack parsing (component format, tier gating, replace flag, wildcard targets)
- Blacklist pattern matching (exact match, namespace wildcard, empty blacklist)
- Attachment Codec serialization (round-trip NBT via the attachment, empty inventory, multiple players, double chest redirect)
- Cooldown expiration logic (boundary tick values, disabled refresh, retroactive enable)
- Absent-player eviction (threshold boundary math, all-three-entry removal without a salt bump, last-seen ledger NBT round-trip and epoch fallback)
- HUD priority calculation (with/without Tribulation loaded, correct stacking offset)
Require a running server instance. Located in src/gametest/.
- Instanced loot: two players open same chest, verify each gets independent loot
- Loot table nullification: open chest, verify vanilla
lootTableis null, verify hopper cannot extract - Double chest: open double chest, verify 54-slot inventory, verify both halves animate
- Loot refresh: set short cooldown, generate loot, advance game time, verify loot regenerates
- Distance scaling: place containers at known distances, generate loot, verify tier multipliers applied
- Structure override: place container in structure with fixed tier, verify override applied
- Loot injection: place container at Frontier+ distance with injection configured, verify injected item appears in loot pool
- Blacklist: add loot table to blacklist, verify container opens with vanilla behavior, verify no indicator
- Loot notification: open instanced container, verify action bar message sent with correct tier
- Container protection: enable protection, verify break speed is reduced on instanced container, verify creative bypasses
- Mob loot scaling: kill hostile mob at known distance, verify stack sizes scaled, verify passive mob unaffected
- Container destruction: break instanced container, verify the attachment data is gone
- Commands:
/prosperity resetclears instanced data,/prosperity inforeturns correct tier
Features that require visual/UI verification:
- Visual indicator rendering (sprite appearance, bobbing animation, through-wall behavior, fade-out)
- Indicator sync (indicator disappears after opening, reappears after refresh)
- Loot notification appearance and formatting on action bar
- Tier HUD badge (position, tier color, transition animation when crossing tier boundaries)
- HUD stacking with Tribulation installed (correct vertical ordering, no overlap)
- Jade/WTHIT tooltip rendering (all status states, refresh timer countdown, structure override display)
- EMI/REI/JEI loot index (search integration, structure/tier/source filtering, injected entry display)
- Sodium/EBE/Iris visual compatibility
- Loot preview — Sneak-click to peek at partial loot without committing to generation.