Skip to content
kostamax27Public

About

No description, website, or topics provided.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Latest commit

 

History

13 Commits

Folders and files

Repository files navigation

LootTable

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 (with pools, NestedPoolsLootEntry rolls them after it), 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
what is the regional difficulty? RegionalDifficultyEvaluator VanillaRegionalDifficultyEvaluator
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:

  1. 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.
  2. BlockRegistryItemResolver: current block ids. Every state in RuntimeBlockStateRegistry is run through the block serializer once and indexed by the id it produces; BlockItemIdMap reconciles ids that differ between block and item.
  3. StringToItemParserItemResolver: aliases, including any a plugin registered under its own namespace.
  4. 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 name written in the loot table is re-resolved with the rolled value, so variants such as skull + 1 (wither skeleton skull) survive.

A name no resolver knows makes the table fail to parse. AirFallbackItemResolver wraps a resolver so such an entry drops nothing instead, which lets a table written for items the server lacks load; the optional closure is told which ones:

$items = new AirFallbackItemResolver(ChainedItemResolver::createDefault(), static fn(string $name) => $logger->warning("Unknown item $name"));

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, is_baby and set_data_from_color_index 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, answers is_baby with Ageable, and takes the color from Colored. damaged_by_entity looks at the projectile when the last damage came through one.

Regional difficulty

random_regional_difficulty_chance and enchant_random_gear ask a RegionalDifficultyEvaluator. The default reproduces Bedrock's value, which only depends on the difficulty of the context and the moon of the entity's world: 0 on peaceful and easy, 1 on hard, 0.375-0.625 on normal. StaticRegionalDifficultyEvaluator fixes a value for tests.

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, 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));
});

About

No description, website, or topics provided.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages