From 4062a0976626239cdb7e94d128ed53cf8ced27c7 Mon Sep 17 00:00:00 2001 From: MSedgeMC <151820093+MSedgeMC@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:29:12 +0000 Subject: [PATCH] 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. --- .../src/game/dispense_fixture.rs | 17 ++++++---- .../parity/VanillaDispenserOracle.java | 12 ++++--- tools/test_block_removal_parity.py | 33 +++++++++++++++---- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/crates/horizon-client/src/game/dispense_fixture.rs b/crates/horizon-client/src/game/dispense_fixture.rs index a20d571886..e97cb22dfa 100644 --- a/crates/horizon-client/src/game/dispense_fixture.rs +++ b/crates/horizon-client/src/game/dispense_fixture.rs @@ -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> { 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( diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaDispenserOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaDispenserOracle.java index e0c4e73200..9db03cb5a6 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaDispenserOracle.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaDispenserOracle.java @@ -193,10 +193,13 @@ public final class VanillaDispenserOracle { static List 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 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 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> typeHolder() { return EntityTypes.PLAYER.builtInRegistryHolder(); } diff --git a/tools/test_block_removal_parity.py b/tools/test_block_removal_parity.py index 169806f6ed..7fe0130597 100755 --- a/tools/test_block_removal_parity.py +++ b/tools/test_block_removal_parity.py @@ -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)