/loot rolls loot_table entries

The items engine, which /loot rolls tables with, had no case for a
minecraft:loot_table entry, so a table that picks another table (the trial
chambers' reward tables, many data pack and mod tables) dropped nothing:
'/loot spawn ~ ~ ~ loot minecraft:chests/trial_chambers/reward' answered
'Dropped 0 items'. Chests were right: they roll through horizon_world::loot.

A chosen loot_table entry now rolls its table (by id or inline) in full and
applies its own functions to each stack (NestedLootTable); a table already
rolling further out adds nothing (LootContext.pushVisitedElement).

/loot also counted items where vanilla's CommandResponseTracker counts
stacks: two stacks of two returned 4 instead of 2.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
k3yhandClaude Opus 5.5 committed 2026-11-02 11:42:05 -05:00
1 parent 1952b56c6c
commit 79ae7cfe19
3 files changed
+105 -3

No files matched your search

@@ -735,8 +735,9 @@ impl Game<'_> {
let mut tracker = Tracker::default();
for u in &used {
let name = self.item_display_name(&u.item);
tracker.track(&holder(name), u.count as i32);
let _ = u;
// CommandResponseTracker.track(drop): one per stack, so the
// command returns how many stacks it used, not how many items.
tracker.track(&holder(name), 1);
}
let counts: Vec<u32> = used.iter().map(|u| u.count).collect();
let (message, total) = tracker.message(
+99 -1
View File
@@ -362,6 +362,14 @@ fn registry_order(root: &Path, order: &[Arc<str>]) -> Vec<Arc<str>> {
}
/// Item ids standing for a loot table's `minecraft:dynamic` entries.
const DYNAMIC: &str = "#dynamic:";
/// The item id standing for a chosen `minecraft:loot_table` entry, whose
/// table then rolls in full (`NestedLootTable`).
const NESTED: &str = "#loot_table";
thread_local! {
/// The nested tables rolling now, outermost first: a table never rolls
/// inside itself (`LootContext.pushVisitedElement`).
static NESTED_ROLLS: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
}
pub struct Items {
pub items: HashMap<Arc<str>, ItemInfo>,
pub order: Vec<Arc<str>>,
@@ -1231,7 +1239,11 @@ impl Items {
explosion_radius: None,
this_entity: false,
};
ctx.evaluate(table, random)
// LootTable.getRandomItemsRaw visits the table itself first.
NESTED_ROLLS.with(|v| v.borrow_mut().push(id.to_owned()));
let out = ctx.evaluate(table, random);
NESTED_ROLLS.with(|v| v.borrow_mut().pop());
out
}
/// The server random sequence a loot table draws from (`random_sequence`):
/// vanilla's `LootContext.Builder.create` uses it instead of the level random.
@@ -1646,6 +1658,21 @@ impl LootContext<'_> {
));
true
}
// NestedLootTable: chosen by its weight like an item; `choose`
// then rolls its table (an id or inline) and applies its functions.
"minecraft:loot_table" => {
out.push((
e["weight"].as_i64().unwrap_or(1) as i32,
ItemStack {
item: Arc::from(NESTED),
count: 1,
damage: 0,
extra: None,
},
e.clone(),
));
true
}
"minecraft:empty" => {
out.push((
e["weight"].as_i64().unwrap_or(1) as i32,
@@ -1691,9 +1718,46 @@ impl LootContext<'_> {
chosen
};
let (_, mut stack, modifiers) = candidates.swap_remove(pick);
if &*stack.item == NESTED {
self.nested(&modifiers, random, produced);
return;
}
self.modifiers(&modifiers, &mut stack, random);
produced.push(stack);
}
/// `NestedLootTable.createItemStack`: the entry's table rolls in full
/// (its pools and its own functions), then the entry's functions apply to
/// each stack. A table already rolling further out adds nothing.
fn nested<R: RandomSource>(
&self,
entry: &Value,
random: &mut R,
produced: &mut Vec<ItemStack>,
) {
let (name, table) = match &entry["value"] {
Value::String(name) => (name.as_str(), self.items.loot.get(name.as_str())),
inline @ Value::Object(_) => ("<inline>", Some(inline)),
_ => return,
};
let Some(table) = table else { return };
let entered = NESTED_ROLLS.with(|v| {
let mut v = v.borrow_mut();
let fresh = !v.iter().any(|n| n == name);
if fresh {
v.push(name.to_owned());
}
fresh
});
if !entered {
return;
}
let stacks = self.evaluate(table, random);
NESTED_ROLLS.with(|v| v.borrow_mut().pop());
for mut stack in stacks {
self.modifiers(&entry["modifier"], &mut stack, random);
produced.push(stack);
}
}
/// An enchantment function's `options` (`HolderSet`): a tag, a list or
/// one id; every enchantment when absent (`registryAccess().listElements`).
fn enchant_pool(e: &crate::enchant::Enchantments, options: &Value) -> Vec<Arc<str>> {
@@ -2206,6 +2270,40 @@ mod tests {
assert!(!items.item_in_tag("test:missing", "test:apple"));
}
/// `NestedLootTable`: a `loot_table` entry, by id or inline, rolls that
/// table in full and applies its own functions to each stack; a table
/// never rolls inside itself.
#[test]
fn loot_table_entries_roll_their_table() {
let mut items = Items::for_tests(HashMap::new());
let diamond = json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:item","name":"minecraft:diamond"}]}]});
items.loot = Arc::new(
[
("test:inner", diamond.clone()),
("test:outer", json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:loot_table",
"value":"test:inner","modifier":[{"type":"minecraft:set_count","count":3}]}]}]})),
("test:inline", json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:loot_table",
"value":diamond}]}]})),
("test:loop", json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:loot_table",
"value":"test:loop"}]},{"rolls":1,"entries":[{"type":"minecraft:item","name":"minecraft:stick"}]}]})),
]
.map(|(id, t)| (id.to_owned(), t))
.into_iter()
.collect(),
);
let roll = |id: &str| {
items
.loot_table(id, false, None, &mut LegacyRandom::new(1))
.into_iter()
.map(|s| format!("{} {}", s.item, s.count))
.collect::<Vec<_>>()
};
assert_eq!(roll("test:outer"), ["minecraft:diamond 3"]);
assert_eq!(roll("test:inline"), ["minecraft:diamond 1"]);
// `test:loop` is already rolling: its own entry adds nothing.
assert_eq!(roll("test:loop"), ["minecraft:stick 1"]);
}
/// `Blocks.wallVariant`: a wall block without a table of its own drops
/// from its standing block's (vanilla's tables: `blocks/torch` for
/// `wall_torch`, `blocks/oak_sign` for `oak_wall_sign`...).
+3
View File
@@ -801,6 +801,9 @@ def items_and_loot():
cmd('/loot replace block 3 206 3 container.8 loot minecraft:blocks/cobblestone'),
cmd('/loot replace entity @s hotbar.5 loot minecraft:blocks/glass'),
cmd('/loot spawn 3 210 3 loot minecraft:blocks/stone'),
# NestedLootTable: the reward tables pick another table and roll it.
cmd('/loot spawn 3 210 3 loot minecraft:chests/trial_chambers/reward'),
cmd('/loot give @s loot minecraft:chests/trial_chambers/reward_ominous'),
cmd('/loot give @s loot {pools:[{rolls:1,entries:[{type:"minecraft:item",name:"minecraft:apple",functions:[{type:"minecraft:set_count",count:4}]}]}]}'),
cmd('/setblock 2 205 2 minecraft:stone'),
cmd('/loot give @s mine 2 205 2'),