test: mine a block in creative too, where nothing drops

tools/test_block_removal_parity.py only ever mined as a survival player:
every mine case ran ServerPlayerGameMode.destroyBlock in SURVIVAL, so the
creative branch (Player.preventsBlockDrops, no mined statistic, no tool
wear) was never compared even though game.rs::break_block_as_player splits
on it. Half the mine cases now run in creative, with a tool that can destroy
blocks in creative (a sword and a mace cannot, which the creative-mining
predicate corpus already owns).

VanillaDispenserOracle's Miner gains a creative flag (isCreative and
hasInfiniteMaterials) and the real CREATIVE game mode; the game's dispense
fixture sets the same mode for the case. --survival-only reproduces the
corpus as it was before this change.

4000/4000 cases match on the new corpus.
This commit is contained in:
MSedgeMC committed 2026-11-06 14:29:14 +00:00
1 parent 41f822b659
commit 4062a09766
3 files changed
+45 -17

No files matched your search

@@ -878,14 +878,19 @@ impl Game<'_> {
Ok(Vec::new())
}
/// A survival player three blocks south of `pos` mines it holding the
/// case's item (`break_block`, which every player's mining goes through):
/// the statistics and the held stack, as `VanillaDispenserOracle.mine`
/// prints them.
/// A player three blocks south of `pos` mines it holding the case's item
/// (`break_block`, which every player's mining goes through), in survival
/// or in creative, where the block and its contents never drop: the
/// statistics and the held stack, as `VanillaDispenserOracle.mine` prints
/// them.
fn fixture_mine(&mut self, pos: BlockPos, op: &Value) -> Result<Vec<String>> {
let stack = parse_stack(op["item"].as_str().unwrap_or("-"))?;
self.mode = GameMode::Survival;
self.creative = false;
self.mode = if op["creative"].as_bool().unwrap_or(false) {
GameMode::Creative
} else {
GameMode::Survival
};
self.creative = self.mode == GameMode::Creative;
self.player = Some(PlayerMotion::new(
self.motion,
horizon_core::geometry::Vec3::new(
@@ -193,10 +193,13 @@ public final class VanillaDispenserOracle {
static List<String> mine(VanillaBlockUpdateOracle.World w, BlockPos pos, JsonObject op) throws Exception {
Miner player = (Miner) VanillaBlockUpdateOracle.allocate.invoke(VanillaBlockUpdateOracle.unsafe, Miner.class);
player.world = w;
player.creative = op.has("creative") && op.get("creative").getAsBoolean();
player.hand = stack(op.get("item").getAsString());
player.offhand = ItemStack.EMPTY;
player.stats = new ArrayList<>();
VanillaBlockUpdateOracle.set(net.minecraft.world.entity.player.Player.class, player, "abilities", new net.minecraft.world.entity.player.Abilities());
var abilities = new net.minecraft.world.entity.player.Abilities();
abilities.invulnerable = abilities.instabuild = abilities.mayfly = player.creative;
VanillaBlockUpdateOracle.set(net.minecraft.world.entity.player.Player.class, player, "abilities", abilities);
VanillaBlockUpdateOracle.set(net.minecraft.world.entity.player.Player.class, player, "foodData", new net.minecraft.world.food.FoodData());
var advancements = (net.minecraft.server.PlayerAdvancements) VanillaBlockUpdateOracle.allocate.invoke(VanillaBlockUpdateOracle.unsafe, net.minecraft.server.PlayerAdvancements.class);
VanillaBlockUpdateOracle.set(net.minecraft.server.PlayerAdvancements.class, advancements, "activeTriggers", new HashMap<>());
@@ -208,7 +211,7 @@ public final class VanillaDispenserOracle {
player.setBoundingBox(EntityTypes.PLAYER.getDimensions().makeBoundingBox(at));
var gameMode = new net.minecraft.server.level.ServerPlayerGameMode(player);
VanillaBlockUpdateOracle.set(net.minecraft.server.level.ServerPlayerGameMode.class, gameMode, "gameModeForPlayer",
net.minecraft.world.level.GameType.SURVIVAL);
player.creative ? net.minecraft.world.level.GameType.CREATIVE : net.minecraft.world.level.GameType.SURVIVAL);
gameMode.destroyBlock(pos);
List<String> out = new ArrayList<>(player.stats);
out.add("mine held " + ParityData.canon(player.hand));
@@ -218,14 +221,15 @@ public final class VanillaDispenserOracle {
/** The player of a "mine" case: a survival `Clicker` whose selected inventory slot is its main hand. */
static final class Miner extends net.minecraft.server.level.ServerPlayer {
VanillaBlockUpdateOracle.World world;
boolean creative;
ItemStack hand, offhand;
List<String> stats;
Miner() {
super(null, null, null, null);
}
@Override public ServerLevel level() { return world; }
@Override public boolean hasInfiniteMaterials() { return false; }
@Override public boolean isCreative() { return false; }
@Override public boolean hasInfiniteMaterials() { return creative; }
@Override public boolean isCreative() { return creative; }
@Override public boolean isSpectator() { return false; }
@Override public EntityType<?> getType() { return EntityTypes.PLAYER; }
@Override public net.minecraft.core.Holder<EntityType<?>> typeHolder() { return EntityTypes.PLAYER.builtInRegistryHolder(); }
+26 -7
View File
@@ -6,8 +6,11 @@ shelf, shulker box or another block entity holder at the origin holds random
contents; the case then replaces it with `Level.setBlock` (another block, the
same block in another state, with or without UPDATE_SKIP_BLOCK_ENTITY_SIDEEFFECTS),
destroys it with `Level.destroyBlock` (with or without drops), removes it with
`Level.removeBlock`, or a survival player mines it (`ServerPlayerGameMode.destroyBlock`,
holding a tool or nothing, #844). Vanilla runs over the headless level of the dispenser oracle
`Level.removeBlock`, or a player mines it (`ServerPlayerGameMode.destroyBlock`,
holding a tool or nothing, #844): in survival, where the block's drops, the tool's wear and the
mined statistic follow, and in creative, where the block itself never drops (a container's
contents still spill) and no statistic is awarded. Vanilla runs over the headless level of the
dispenser oracle
(`VanillaDispenserOracle`, "remove" cases), which replays `LevelChunk.setBlockState`'s
`BlockEntity.preRemoveSideEffects`; Horizon runs its game glue in a headless game
(`HORIZON_DISPENSE_FIXTURE`). Both print the block changes, the contents left,
@@ -111,7 +114,7 @@ def other_state(states, rng, name, current):
return rng.choice(choices) if choices else None
def cases(facts, count, seed, only):
def cases(facts, count, seed, only, creative=True):
rng = random.Random(seed)
states = d.States(facts)
names = list(HOLDERS)
@@ -143,9 +146,23 @@ def cases(facts, count, seed, only):
elif roll < 0.92:
op = {'op': 'remove'}
else:
# ServerPlayerGameMode.destroyBlock: a survival player mines it (#844). The tool comes
# from the roll, not another draw, so the cases after this one stay the same.
op = {'op': 'mine', 'item': TOOLS[int(roll * 1000) % len(TOOLS)]}
# ServerPlayerGameMode.destroyBlock: a player mines it (#844), in survival or, every
# other mine, in creative, where the block itself never drops and no statistic is
# awarded. The tool comes from the roll, not another draw, so the cases after this
# one stay the same.
item = TOOLS[int(roll * 1000) % len(TOOLS)]
if creative and n % 2 == 0:
# A sword or mace carries `can_destroy_blocks_in_creative: false`, so a creative
# player holding one cannot mine at all (`Item.canDestroyBlock`). That predicate
# has its own corpus (test_creative_mining_parity); these cases need a tool that
# does remove the block, and the empty hand does too.
if item in (plain('iron_sword'), plain('mace')):
item = plain('diamond_pickaxe')
op = {'op': 'mine', 'item': item, 'creative': True}
else:
op = {'op': 'mine', 'item': item}
if creative:
op['creative'] = False
case = {**h.environment(rng), 'mode': 'remove', 'pos': [0, 0, 0], 'cells': cells, 'items': items, 'remove': op}
case.pop('raining', None)
case.pop('thundering', None)
@@ -162,6 +179,8 @@ def describe(case, facts):
def main():
parser = h.argument_parser(__doc__)
parser.add_argument('--survival-only', action='store_true',
help='mine in survival only, as the corpus did before creative mining')
args = parser.parse_args()
if args.count == 40000:
args.count = 4000
@@ -170,7 +189,7 @@ def main():
resources = lab.LOCAL / 'horizon-client' / lock['minecraft']['version']
work = lab.LOCAL / 'block-removal-parity'
work.mkdir(parents=True, exist_ok=True)
all_cases = list(cases(facts, args.count, args.seed, args.only))
all_cases = list(cases(facts, args.count, args.seed, args.only, not args.survival_only))
(work / 'cases.jsonl').write_text(''.join(json.dumps(case) + '\n' for case in all_cases))
classes = work / 'classes'
classes.mkdir(exist_ok=True)