kostamax27 / loottable
Requires
- php: ^8.1
- pocketmine/pocketmine-mp: ^5.5.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-22 02:13:47 UTC
README
Load Bedrock Edition loot tables and roll them in PocketMine-MP.
Motive
Drop logic in plugins tends to be a match over entity classes, or a config list of item: chance pairs. Both hold up until the first request that does not fit the shape: "1 to 3 of this, only on hard, doubled by looting, with a 2.5% chance of an iron ingot instead". Each plugin then grows its own pools, weights, conditions and post-processing, none of them compatible with the others, and none of them editable by a server owner without a PHP change.
Bedrock already ships a format for exactly this problem. Every mob, block, chest, fishing and trading drop in the game is a JSON file under loot_tables/ in the vanilla behaviour pack (Mojang/bedrock-samples), the schema is documented and well understood, and add-on authors have been writing these files for years.
Approach
LootTableFactory parses a file once into a LootTable. LootTable::generate() rolls it against a LootContext describing the situation and returns list<Item>.
// onEnable $factory = LootTableFactory::createDefault($this->getDataFolder() . "behavior_pack", EnchantmentLootingEvaluator::fromName("looting")); $zombie_drops = $factory->fromFile($this->getDataFolder() . "behavior_pack/loot_tables/entities/zombie.json"); // EntityDeathEvent $zombie = $event->getEntity(); $cause = $zombie->getLastDamageCause(); $killer = $cause instanceof EntityDamageByEntityEvent ? $cause->getDamager() : null; $context = new LootContext( random: $random, entity: $zombie, killer: $killer, tool: $killer instanceof Player ? $killer->getInventory()->getItemInHand() : null, difficulty: LootDifficulty::fromWorld($zombie->getWorld()) ); $event->setDrops($zombie_drops->generate($context));
Everything the format describes is parsed into an object with a single job: WeightedLootPool and TieredLootPool for the two pool kinds, WeightedLootEntry around an item, loot_table or empty entry, one class per function and condition. Values are typed on the way in: LootData reads the JSON (int(), intOr(), intNullable() and so on per type) and throws with the full path on a mismatch, ranges are IntRange or FloatRange depending on the field, difficulties and entity targets are enums. A tiered pool cannot hold weights or entry conditions, because Bedrock ignores them there.
Failed to parse loot table 'loot_tables/entities/zombie.json': 'pools[1].entries[0].functions[0] (set_count)': 'pools[1].entries[0].functions[0].count.max' directive not found
Where the server cannot answer a question on its own, the factory takes a strategy, following the same shape each time: an interface, a null or vanilla implementation, and a constructor argument.
| Question | Strategy | Default |
|---|---|---|
| what is this item name? | ItemResolver |
ChainedItemResolver::createDefault() |
| what is the looting level? | LootingEvaluator |
NullLootingEvaluator, always 0 |
| how does this item take enchantments? | ItemEnchanter |
VanillaItemEnchanter, books become enchanted books |
| what type, variant and age is this entity? | EntityInspector |
DefaultEntityInspector |
where is the table named by a loot_table entry? |
LootTableResolver |
DirectoryLootTableResolver when a directory is given |
Item names
StringToItemParser has never covered every block, so names go through a chain of resolvers, each answering resolve(string $name, int $meta) : ?Item or handing over to the next:
SavedItemDataItemResolver: the upgrader and deserializer pocketmine uses for world saves (GlobalItemDataHandlers). Covers every item the deserializer maps, every rename in BedrockItemUpgradeSchema (minecraft:muttonRaw→minecraft:mutton,minecraft:dye+ 4 →minecraft:lapis_lazuli) and 1.12 block ids with a data value.BlockRegistryItemResolver: current block ids. Every state inRuntimeBlockStateRegistryis run through the block serializer once and indexed by the id it produces;BlockItemIdMapreconciles ids that differ between block and item.StringToItemParserItemResolver: aliases, including any a plugin registered under its own namespace.LegacyStringToItemParserItemResolver:wool:14,35:14.
A name without a namespace is treated as vanilla, so wool, minecraft:wool and muttonRaw all resolve. Because the resolver understands data values, set_data and random_aux_value are real functions: the item is re-resolved under its own Bedrock id with the rolled value.
Looting
Pocketmine ships no looting enchantment, so looting_enchant and random_chance_with_looting ask a LootingEvaluator instead of reading a level off the tool. EnchantmentLootingEvaluator::fromName("looting") picks up whatever enchantment a plugin registered under that name; ClosureLootingEvaluator takes anything else (a permission, a stat, a lore tag); StaticLootingEvaluator fixes a level for tests. The evaluator is handed to functions at parse time, so it goes into the factory before any table is parsed.
Enchanting
enchant_with_levels uses the server's own enchanting-table algorithm with the rolled level as the option's required XP level. EnchantingHelper::createOption() is private, so EnchantingHelperBridge reaches it by reflection, verifying the signature once and throwing a clear message if a pocketmine update changes it. enchant_randomly picks from AvailableEnchantmentRegistry; specific_enchants takes both the string and the {id, level} forms. All three go through the ItemEnchanter, which is what turns minecraft:book into an enchanted book while keeping its count, name and lore.
Entities
killed_by_entity, entity_killed, damaged_by_entity, has_variant, has_mark_variant and is_baby read through an EntityInspector. The default reports the save id from EntityFactory when the class is registered and getNetworkTypeId() otherwise (custom mobs commonly borrow a vanilla network id, but save under their own), reads variants from the synced network metadata, and answers is_baby with Ageable. damaged_by_entity looks at the projectile when the last damage came through one.
When a vanilla file asks for something pocketmine does not have
Functions with no server-side equivalent (exploration_map, set_armor_trim, ... see LootFunctionRegistry::UNSUPPORTED_VANILLA) are registered as no-ops, and conditions the server cannot answer (passenger_of_entity, bool_property, biome_has_tag, random_regional_difficulty_chance, see LootConditionRegistry::UNSUPPORTED_VANILLA) as always-false, so every file in the vanilla pack loads and the affected pools do the least surprising thing. Anything not in either list throws at parse time. Both are ordinary registrations and can be replaced.
Examples
1. Block drops with a tool
$context = new LootContext( random: $random, killer: $player, tool: $player->getInventory()->getItemInHand() ); foreach($lapis_ore->generate($context) as $item){ $world->dropItem($position, $item); }
2. A condition of your own
Parsers receive the condition's JSON object as LootData and the factory. Validate through LootData so error messages keep the same shape as the built-in ones.
$factory->condition_registry->register("myplugin:weather", static function(LootData $data, LootTableFactory $factory) : LootCondition{ $raining = $data->bool("raining"); // "'pools[0].conditions[1].raining' must be a boolean, got string" return new ClosureLootCondition(static fn(LootContext $context) : bool => ($context->entity?->getWorld()->isRaining() ?? false) === $raining); });
{"condition": "myplugin:weather", "raining": true}
3. Custom items and mobs
Implement the strategy that knows about them and hand it to the constructor; keep the defaults for everything else.
$factory = new LootTableFactory( LootFunctionRegistry::createDefault(), LootConditionRegistry::createDefault(), new ChainedItemResolver(new MyItemResolver(), ...ChainedItemResolver::createDefault()->resolvers), EnchantmentLootingEvaluator::fromName("looting"), new VanillaItemEnchanter(), new MyEntityInspector() ); $factory->setTableResolver(new DirectoryLootTableResolver($directory, $factory));
4. Answering an unsupported condition
biome_has_tag is always-false by default. A server that knows its biomes replaces it:
$factory->condition_registry->unregister("biome_has_tag"); $factory->condition_registry->register("biome_has_tag", static function(LootData $data, LootTableFactory $factory) : LootCondition{ $tag = $data->string("tag"); $negate = $data->stringOr("operator", "==") === "!="; return new ClosureLootCondition(static fn(LootContext $context) : bool => $negate !== BiomeTags::has($context->entity?->getPosition(), $tag)); });