Loot & Rewards
Rewards are never handed to the player directly inside a dungeon. Mob drops and reward pots produce owner-locked item entities that carry their payoff (commands) in the item's PDC; picking one up queues that payoff onto every alive participant's DungeonPlayerData instead of running it. Experience works the same way — it just accumulates as a number. Everything queued is only actually paid out once the player leaves the instance and reconnects to the lobby, where the bridge module claims it. See Dungeon & Room Blueprints for how blueprints are authored and loaded, and Placeholders for %dungeons_experience%.
The two loot sources are configured in different places:
Source | Configured on | Page section |
|---|---|---|
Reward pots | The dungeon blueprint's | |
Mob drops | The AstralMobs blueprint's own drops, tagged with a persistent-data payload |
Loot tables
A DungeonLootTable is the shape used by components-loot-tables on a dungeon blueprint:
Field | Type | Description |
|---|---|---|
|
| Map of entry id → |
| int | Minimum number of loot rolls per |
| int | Maximum number of loot rolls (inclusive) per |
Each Entry is:
Field | Type | Description |
|---|---|---|
|
| The dropped item's appearance. See item-stack for the field reference. |
|
| Commands queued for the player once they pick up this reward and later claim it (see Picking up rewards). |
| double | Relative weight for this entry, fed into a |
generateLoot() rolls a random count in [minimum, maximum], then draws that many entries one at a time via generateSingleLoot(). The results are collected into a Set<Entry>, so if the same entry is drawn more than once in one call it only yields one drop — the actual number of items can be lower than the roll count when the table has few high-weight entries. A misconfigured table (minimum > maximum, or negatives) is clamped rather than throwing: minimum floors at 0 and maximum at the clamped minimum.
generateSingleLoot() has one short-circuit worth knowing: a table with exactly one entry whose chance is exactly 100 returns that entry directly, bypassing the weighted draw. Any other single-entry table still goes through the draw and returns that entry anyway, so the effect is the same — it only matters if you were relying on a sub-100 chance in a one-entry table to sometimes yield nothing. It won't.
Mob loot
MobListener#onMobDrop handles EntityDeathEvent at MONITOR priority, only when the direct damaging entity is a Player. For every item in event.getDrops():
Air and meta-less stacks are skipped.
The stack's persistent data container is read for the key configured as
mobs.loot-persistent-data-key(defaultastraldungeons:loot), as aSTRING. A drop without that key, or with a blank value, is logged as a warning and discarded.A drop that has it is respawned through
RewardService.spawnItemat the death location, owned by the killer, carrying that string as its single queued command.
Then event.getDrops() is cleared — so nothing lands as an ordinary drop. In practice: a mob's drop only reaches a player if its AstralMobs item carries the loot key, and the string stored under that key is the console command the player receives on claim.
The stack the player sees is the AstralMobs drop itself, unchanged; there is no separate display to author.
Mob experience
mob-experiences on the dungeon blueprint is keyed by mob blueprint id (the id registered with AstralMobs, not the entity type). MobListener#onMobDeath handles EntityDeathEvent at LOW priority: it only acts when the direct damaging entity is a Player, the mob died inside a dungeon world that player participates in, and that instance is ACTIVE.
On a qualifying kill:
The mob's blueprint id is resolved via
MobsAPI.getBlueprint(entity.getUniqueId()). If it isn't found, ormob-experienceshas no entry for that id, the kill is logged as a warning and skipped — no XP.The configured experience value is added to the
DungeonPlayerData.experienceof every alive participant, not just the killer. A participant with no player data is logged and skipped.The killer's
killedMobscounter is incremented — this is the only per-killer bookkeeping, and it is what feeds themobsleaderboard.
Experience is a running double; there is no leveling or currency conversion inside the dungeon. While a player is inside an instance their current total is exposed live as %dungeons_experience% — see Placeholders.
Reward pots
components-loot-tables is a general per-blueprint map, but only one key is currently consumed: pots. PotListener reads blueprint().componentsLootTables().get("pots") — any other key in that map is inert.
Reward pots are placed as vanilla DecoratedPot blocks inside room schematics. A right-click with the main hand on such a block, while the interacting player is alive and participating in that pot's dungeon instance, is always cancelled and instead:
Draws one entry with
generateSingleLoot()— the table'sminimum/maximumare not used here; a click always yields exactly one entry (or nothing, if the draw returnsnull).Reads the remaining-reward counter from the pot block's PDC, key
astral-dungeons:pot_rewards(PersistentDataType.INTEGER), defaulting to 3 when absent — so each pot starts with 3 rewards.Wobbles the pot (
DecoratedPot.startWobble, randomlyPOSITIVEorNEGATIVE) and spawns 5Particle.CLOUDparticles above it.Decrements and writes back the counter, plays
Sound.BLOCK_DECORATED_POT_INSERT, and updates the block state.Spawns the reward via
RewardService.spawnItem, tossed toward the player (see below), trailingParticle.SMALL_GUSTeach tick until it lands or dies.If the counter reached
0, removes the block from the instance'salivePots()tracking set, sets it toAIRand playsSound.BLOCK_DECORATED_POT_SHATTER.
Each pot therefore yields up to 3 rewards (one per right-click) before it shatters, regardless of the table's minimum/maximum. Unlike the mob path, a pot's display is resolved with no placeholder container (ItemStackWrapper#get(), no-arg), so %placeholder% values in it resolve against the root container rather than per-player.
Only the last click removes the pot from alivePots(); while rewards remain it keeps its particle hint from PotParticleTask.
Where the item lands
Pots are often built into niches, against walls, or under shelves, where a naive upward toss flings the item into geometry or spawns it inside a block. The spawn point and velocity are therefore derived from the pot's surroundings:
Top is passable → spawn just above the pot (
+1.05), with an upward speed of0.35–0.50.Top is blocked → spawn out of the face the player clicked (or, for a top/bottom click, out of the face opposite the player's facing), offset
0.85blocks — falling back to the player's own location if that face is blocked too. Upward speed drops to0.10–0.18.
Horizontal velocity always aims at the player rather than a random direction, at 0.06–0.14 blocks/tick.
Picking up rewards
Every reward item — mob drop or pot — is spawned through the same RewardService.spawnItem(owner, itemStack, location, commands, …):
The dropped
Itementity's owner is set to the player it belongs to (Item#setOwner) andsetCanMobPickup(false), so mobs at the corpse can't pick items back up.The entry's
commandsare written to the item entity's PDC underastraldungeons:commandas aPersistentDataType.LIST.strings()(RewardService.getCommandsreads it back).
RewardListener#onPickup handles EntityPickupItemEvent at MONITOR priority:
An item with no
astraldungeons:commandPDC list is not picked up — the event is cancelled. In a dungeon world, ordinary item pickup does not happen at all.If the picking-up player isn't currently tracked in a dungeon instance, or has no
DungeonPlayerData, the pickup is cancelled and a warning logged (defensive guard — should not happen in normal play).Otherwise the event is cancelled, the item entity removed, and for every alive participant in the instance: the entry's commands are appended to their queued
commandsonce per item in the stack (a stack of 3 queues each command three times), aCHALLENGE-type toast showing the item is sent, andminecraft:entity.player.levelupplays.
The reward is therefore shared, not competed for: whoever walks over a drop banks it for the whole surviving party. The vanilla pickup never happens — the reward only exists as queued commands from this point on.
Dead players cannot trigger any of this: PlayerAttemptPickupItemEvent is cancelled outright for a participant who is no longer alive.
Cross-server reward claim
Rewards accumulate per-player on DungeonPlayerData (blueprintId, experience, killedMobs, duration, completed, and a List<String> commands) and are only turned into effects once the player is back on the lobby:
On the dungeon server —
PlayerDataService(paper) keeps an in-memoryMap<UUID, DungeonPlayerData>.add(player, blueprint)seeds an entry on join (when the player's party has an active instance), stamped with the blueprint id and the current time;MobListener/RewardListenermutate it in place while the player is inside; killing the boss setscompleted(true)on every alive participant. On quit,flush(playerId)stampsduration(now minus the entry's creation time), removes it from the cache and persists it todungeons:players_data:<uuid>(DungeonPlayerDataRepository, JSON viaGson).On the lobby — the bridge's
PlayerConnectionListenerlistens for AstralSync'sPlayerDataLoadedEventand calls the bridge'sPlayerDataService#claimRewards(player).claimRewardsreads the cache entry asynchronously, then hops back onto the main thread — inventory access and event dispatch are main-thread-only. A read failure is logged and stops; no cached data silently does nothing (nothing was earned, or it was already claimed).The key is consumed. The player's inventory is scanned for a key matching the run's blueprint and exactly one is removed (the stack is decremented if it holds more). A blueprint that no longer resolves locally skips this step and step 5.
DungeonCompletedEventis fired, carrying the blueprint and the run's stats. A listener may rewriteexperienceand mutate thecommandslist; the event'stoPlayerData()is what the rest of the claim uses. See Developer API.The player's placeholder container gets the (possibly rewritten)
DungeonPlayerDataregistered onto it (namespacedata), and the configuredreward-actionsrun against it.Every queued command is dispatched from console, substituting
%player_name%.The cached entry is deleted; a delete failure is logged but does not abort.
A
dungeon_completedanalytics event is logged (blueprint_id,duration,completed,mobs_killed), and the threeleaderboards.ymlboards are updated.
Any exception across steps 4–9 is caught and logged. A failure does not re-queue the claim — if the delete never ran, the player would need another PlayerDataLoadedEvent (a relog) to retry, and would then be paid twice.
reward-actions lives in the bridge config.yml:
Authoring notes
Pot loot and experience are defined per dungeon blueprint, on the dungeon (paper) server —
components-loot-tablesandmob-experiencesare blueprint fields loaded fromplugins/AstralDungeons/blueprints/. Mob loot is not: it lives on the AstralMobs blueprint.The payoff for experience is not decided on the dungeon server at all: a kill only adds a number to
DungeonPlayerData.experience. What that number actually does (a chat message, a currency grant, an item, …) is entirely up to the bridge'sreward-actions, evaluated once, on the lobby, with%data_experience%available — or to aDungeonCompletedEventlistener.commandson a lootEntry, and the loot-key payload on a mob drop, are plain console command strings (no placeholder substitution beyond%player_name%at claim time) — build any dynamic values (amounts, item ids) directly into the string.chanceis a relative weight, not a percentage — adjust the table by changing entries' weights relative to each other, not by trying to make every table sum to a fixed total.Because loot and experience go to every alive participant, a larger party is strictly better off per drop. Balance the tables for the party sizes the blueprint's
minimum-players/maximum-playersallow.The reward item's PDC command key (
astraldungeons:command), the pot's remaining-reward key (astral-dungeons:pot_rewards) and the key stamp on a dungeon key (dungeons:blueprint) use three different namespace spellings. This is a genuine inconsistency in the source, not a typo on this page; the keys are unrelated to each other so it has no functional effect.