Files
horizon/tools/lan_e2e.py
T

13233 lines
698 KiB
Python
Executable File

#!/usr/bin/env python3
"""LAN multiplayer end to end: a host opens its world to LAN and a guest joins, both real games.
The host's world runs on its integrated server thread. Two Horizon clients run under horizon-ctl in scratch folders. The host
creates a survival world and opens it to LAN with commands allowed; the guest connects to it by
address. The script then checks, from both sides, what a guest's game used to get wrong (#366):
- tab: the guest's player list holds the host and the guest, under their own names;
- self: the guest's own model exists (third person, inventory preview);
- armour: each side draws the other player in that player's own armour;
- nametags: each side draws the other player's name above them;
- chest: a chest the guest opens opens on the guest's screen, its lid opens on both sides and
closes on both when the guest closes it; the host keeps playing with its mouse look (#687);
- menu-transfer: a chest the guest has open closes when the server replaces the guest's player (a
dimension change, a death and respawn); the inventory opens as its own menu, items kept;
- crafting: the guest crafts planks in the inventory's 2x2 grid and opens a crafting table, which
leaves the host's screen and mouse look alone;
- datapack-recipe: (--server-thread) a crafting recipe from the world's data pack shows its result in the
table of the host's window and of a guest, and taking it crafts (#6733);
- recipes-hmod: opt-in, requires native Recipes: search original .hmod content, fill the server's
crafting grid, consume inputs and preserve both players' output after save/rejoin (#9647);
- spawned: the host and a guest new to the world join on different spots (PlayerSpawnFinder), and the
guest opens a crafting table, a chest, a furnace and a stonecutter where it joined;
- sign: a sign the guest places doesn't open a sign editor on the host;
- damage: a guest in survival takes fall damage;
- mobs: with the host in creative, a zombie and a skeleton go for the guest and hurt them;
- hazards: a guest in survival gets hungry sprint-jumping and is hurt by lava, a block at the eyes,
drowning and the void (then respawns);
- pvp: the guest and the host hit each other with a sword; with the pvp game rule off, or on one
team without friendly fire, nothing;
- kills: a pig the guest kills counts in the guest's killed statistic, not the host's (#484);
- leave: a guest who leaves has leave_game in their saved statistics, and again after the next leave;
- arrows: the guest and the host shoot each other with a bow; with the pvp game rule off, nothing (#484);
- flame-arrows: an arrow the host or the guest shoots from a Flame bow burns in flight on both sides
(Entity.DATA_SHARED_FLAGS_ID's fire flag), one from a plain bow doesn't (#7356);
- looks: mobs look on both sides as the server has them (their synched data): a calf is a
baby, a red sheep is sheared, a size 4 slime, a tamed wolf sits with its blue collar, a
cow on fire burns and goes out; then the wither's health drops when it is hurt, the
sheep is dyed blue with its wool back and the calf grows up (#1367, #1368, #1370,
#1371, #1385, #1390);
- leads: the host and the guest each tie a cow to a lead and untie it: the rope shows on both
sides, held by the player who tied it, and goes on both (#1360);
- respawn: a player killed is drawn dead by the other side, and alive again once it respawns (the
respawned player is a new entity to the others); the host dies, and on the server thread
the guest too;
- hurt: a pig the guest hits, and one the host hits, flashes red on the other side too (the
server's damage event) (#848), and so does a hurt host or guest (#2827);
- containers: the guest's ender chest holds the guest's items, not the host's, and a double chest
opens as 9x6 with the right half first;
- names: a renamed shulker box, copper chest and chest the guest places open on the guest's screen
with their custom names as the title (BaseContainerBlockEntity.getDisplayName, #3245);
- machines: a hopper, a dispenser and a dropper open on the guest's screen with their 5 and 9
slots, and keep what the guest shift-clicks into them (#848);
- brewing: a brewing stand opens on the guest's screen; blaze powder, nether wart and a bottle the
guest shift-clicks go to the fuel, ingredient and bottle slots, and the stand's fuel shows
(#848);
- loom: a loom opens on the guest's screen; the banner and dye the guest shift-clicks go to their
slots, a pattern button the guest clicks makes the server's result, and the guest takes the
patterned banner (#868);
- stonecutter: a stonecutter opens on the guest's screen with the recipes of the stone the guest puts
in; the guest picks the slab and shift-clicks the result, and the server has cut every
stone into slabs (#868);
- grindstone: a grindstone opens on the guest's screen; the enchanted sword the guest shift-clicks in
shows the server's result, and the guest takes it with only its curse left and the
experience orbs the server awards (#868);
- anvil: each player repairs and renames a sword (ServerboundRenameItemPacket), takes it for
the server's level cost, and the server has the repaired, renamed sword (#868);
both players hear exactly one anvil sound for each result (#1907);
- enchanting: an enchanting table opens on the guest's screen with the server's offers for the
sword the guest puts in; the guest clicks the first offer and the server enchants the
sword for a level and a lapis, and counts it in the guest's enchant_item stat (#868);
- smithing: a smithing table opens on the guest's screen; the guest upgrades a diamond sword to
netherite and the server has it (#868);
- lectern: a lectern's written book opens on the guest's screen; the guest turns its pages
(ContainerButtonClick) and the server's lectern follows, then the guest takes the book
and the server has it in the guest's inventory (#868);
- cartography: a cartography table opens on the guest's screen; the guest puts in a map the host
made and paper, takes the zoomed-out map, and the server has a new map for it (#868);
- feedback: the guest's command feedback names the guest and shows the numbers;
- chat: a line the host sends reaches the guest's event log (`wait-for chat`), and on the
server thread the host window's too;
- styles: a styled tellraw reaches the host and the guest (each its own) with its colour, bold, click and hover
events; each player's /give tells them of the Diamonds! advancement before the give's
own message, and never echoes their command back to them as `[Name: ...]`;
- effects: a beacon's power reaches every player in range, the host and the guest;
- distance: the server follows the host's render distance, past vanilla's 32 too
(IntegratedServer.tickServer), and the guest's chunks follow the server's (#804)
- portals: the server holds a level per dimension: the guest goes to the Nether
through a portal while the host stays, then the host goes to the End while the guest is in
the Nether, and both come back.
- levels: the host and the guest each in a level of their own: `execute in`
teleports into another level, and `execute in` and `at` change and test blocks there;
mobs fall in two levels at once, with ids unique across them; the guest rejoins in the
Nether, dies there and respawns in the Overworld, then in the Nether at a `spawnpoint`
and at a respawn anchor set there; the host takes a Nether portal alone;
the guest takes an End portal, an End gateway and the exit portal alone; and the host
saves, quits and reopens with the guest in the Nether.
- commands: the guest's chat completes commands and their arguments over the server's command
tree (Tab), and runs what it completed;
- recipes: the guest's recipe book lists the planks recipe the logs unlock, and clicking it
places a log in the inventory's crafting grid (the server places it).
- recipe_removal: the host and guest each forget all and individual recipes while the book
is open; it stays empty when reopened, and a later grant appears (#1495).
- recipe-reselect: the host and the guest each craft wool three times from one recipe-book click: Space
(a key repeat, Shift held: as many as fit) and Enter (one) place the recipe clicked last again.
- recipe_targets: `/recipe give|take <other player>` changes that player's book, the host's
for the guest and (server thread) the guest's for the host; their open book shows it.
- feedback: the guest's command feedback names the guest and shows the numbers, and the host (an
operator) is told of it as `[Guest: ...]`.
- clicks: with real mouse presses (not click-slot, which skips the screen's press and drag
handling), the guest puts a stack into a chest and a furnace, drags planks across
its crafting grid and gathers a stack with a double click; the server has each.
- shift-double-click: a 32 diamond stack on the cursor, Shift held and two left clicks under 250 ms
apart on a chest's 8 diamonds: what 26.3 moves, same pair 400 ms apart as control.
- recipe-shift: in a crafting table, 20 diamonds and craftable-only book: Shift-click on the
diamond block recipe fills the grid with the max sets, then a Shift-click on the
result crafts them (#4166).
- shift-drag: a 20 diamond stack dragged over three slots of a crafting table spreads 6 each with
the left button and 1 each with the right, with Shift held over the drag or not; the
server hands the rest back when the table closes (#4166).
- modifier-release: a Shift tap or the control socket's release command leaves the next real
inventory click as a normal pickup, rather than a stuck Shift transfer (#4166).
- wheel: with the Mouse Tweaks extra on, real wheel events and a right-drag in a furnace, a
crafting table, a hopper and a dropper: fuel and smeltables go to their slots, nothing
into a result slot, and down over a result takes it. The server has each.
- sleep: at night the guest lies down in its own bed (not the host); alone it stays night, it
gets up with Escape, and once the host sleeps too the night ends and both wake.
- bed-occupied: the bed the host sleeps in is occupied and the guest cannot lie down in it; it is
free again after Escape, death, the bed broken and the sleeper leaving the game.
- sleep-reload: a villager asleep in its bed is still asleep there, its bed
occupied, after the host saves, quits and reopens; the guest's bed is saved free.
- predict: with the host frozen, the oak door the guest opens opens on the guest's screen at once (its
prediction) and creaks there at once, stays open once the server answers, and each side hears
it once; an iron door stays shut (#869).
- push: a cow standing in the guest pushes it just clear of the cow (the guest's own client
pushes it, as in vanilla), not ever faster across the ground.
- eat: holding right click with bread, the guest's own screen plays the eating (the item in use
counts up) and the server eats one loaf and feeds the guest.
- eat-on-block: sweet berries clicked on a block they can't be planted on: the guest's own screen
plays the eating and the server eats one, a click released early eats nothing, and on
dirt they are planted and not eaten (BlockItem.useOn).
- cauldron: a water bucket (creative), a lava bucket and an empty bucket (survival) used on a
cauldron's side fill or empty the cauldron only: nothing is poured beside it, not even
for the moment before the server answers (#5294).
- consume-release: the host and the guest each cancel eating a golden apple and drinking milk from
either hand: use ends on client and server, the item and effects stay, and holding
use to completion still consumes the item (#1293).
- eating: each side sees the other player eat: the item in use (LivingEntity's flags) on the
player it draws, and no longer once the bread is eaten (#803);
- eat_full: holding right click with melon slices until the food bar is full: the guest's own
client eats each slice when the server says it is done (entity event 9,
USE_ITEM_COMPLETE), so no other bite starts; held on chorus fruit, both fruits are
eaten across its cooldown (#2471).
- xp: experience orbs 4 blocks away fly to the guest, show on the guest's screen and give the
guest their points (not the host).
- objects: boats, minecarts, armor stands, item frames and paintings the host summons reach the
guest as the host has them (kind, place, rotation, what they hold or wear, the variant).
- projectiles: primed TNT (one falling), an end crystal and an arrow that lands reach the guest where
the host has them; a falling block lands on both sides.
- shulker_bullets: every shot a shulker fires has its own id and flies on both sides, following
the same trajectory; the bullets disappear on both sides when they hit blocks (#1494).
- crystals: the host and the guest punch placed End crystals in survival and creative; the blast
kills the survival player, and attacks respect reach and blocks in front (#1532).
- thrown: a splash potion and an eye of ender the host and the guest each throw fly on both
sides (#1363, #1391);
- sparks: level events' particles on both sides: the host's and the guest's bone meal on wheat
(growth sparks), honeycomb on copper and an axe taking the wax off, a water bucket
poured beside lava (its smoke, and its hiss heard once on each side), and a spawner's
burst when it spawns (#1395, #1401, #1402, #7537);
- fireball: a fire charge a dispenser shoots onto stone lights a fire both sides see, and the fire
goes out on its own within a minute (it ticks: FireBlock.tick on a floor that doesn't burn)
- blast: primed TNT going off beside a guest in survival hurts the guest and pushes it away
(ServerExplosion.hurtEntities takes every player); facing it behind a raised shield, the
guest takes nothing.
- blast-projectiles: primed TNT going off pushes a flying arrow and a hanging snowball away (ServerExplosion.hurtEntities
takes every entity, Entity.pushFromExplosion), while an arrow lying in the floor stays put
(AbstractArrow.push does nothing in the ground).
- worn: iron armour the guest puts on in its inventory protects it: a 10-damage blow takes 6
(armour 15, LivingEntity.getDamageAfterArmorAbsorb), and the full 10 without it.
- plates: a stone pressure plate the guest steps on is pressed on both sides, and comes up once
the guest steps off (#848).
- plate-projectiles: a guest's thrown trident, a firework rocket and a guest's fishing bobber press a wooden
pressure plate; a trident leaves a stone plate up, which senses only players and mobs (#5896).
- swings: each side sees the other's arm swing while it mines a block, and the guest sees its own
swing when its hoe tills dirt (the server's use) (#848).
- cracks: each side sees the other player's crack on the block they mine grow, and go once it breaks
(#1096).
- time: the guest's clock is the host's: it stops with `/time pause`, and catches up with the
server's game time after the guest's game stalls for a few seconds (#848).
- growth: far from the host, the chunks around the guest tick (#890): water placed there flows
and wheat on farmland grows (random ticks), however the host runs its world.
- farmland: the host and guest each trample planted farmland: the wheat breaks and drops its items,
and both clients see dirt/air (#1492).
- ladder: the guest climbs down ten blocks of ladder and lands unhurt: the server resets its fall
distance on a ladder every tick, as LivingEntity.handleOnClimbable does (#1255).
- boats: the host (single player, on its server thread or not) and the guest each right-click a
boat on water and ride it, paddle it forward (the other side sees it move), sneak out,
and break it with their fists (#1222).
- boat_shoved: the host and the guest each sit in a boat a piston shoves and /tp moves; the server
takes the new spot as the boat's last good position, so the rider's next moves pass: at most a few
"moved wrongly" lines, not an endless loop of them.
- minecarts: the host and the guest each get into a minecart on rails, sneak out beside it, and break
it (#1222).
- cart_mob_rider: a minecart with a pig in it runs about 3.3 blocks farther than an empty one in 40 ticks
(AbstractMinecart.getSlowdownFactor 0.997 when isVehicle, not only for a player).
- frames: the host and the guest each put a diamond in an item frame and turn it, and put a
helmet on an armor stand and take it back (#1222).
- hands: the host and the guest each, at a cow that cares for none of it: draw a bow and shoot, and
let go of a golden apple at once without eating it (the click goes on to the item);
milk the cow with a bucket in the off hand and feed it wheat from the off hand; and
punch the diamond out of an item frame, which then shows empty on both sides (#1350,
#1355, #1362).
- mounts: the host and the guest each open a tamed horse's, donkey's and llama's inventory
(shift + right click, then the inventory key while riding), put on a saddle, armour
or a carpet and fill the chest by shift-clicking.
- mount_other_level: with a Nether chunk force-loaded (a second level ticking), the host's and the
guest's horse, camel, llama, trader llama, nautilus, donkey and mule inventories stay
open, opened by shift + right click, by the inventory key while riding and again after
getting off; also with an End chunk force-loaded, and with the player and its horse in
the Nether while the other player keeps the Overworld ticking (#5331).
- riding: the host and the guest each saddle and ride a horse (walk, jump), a pig and a strider
(with their sticks), a camel and a donkey, sit on a llama and fly a happy ghast, and
get off by sneaking; the other side sees the animal move.
- cart_portal_rider: the host and the guest each ride a minecart along rails into a Nether portal: the
cart carries the player, who arrives in the Nether still riding the same cart (#6021).
- portal_rider: a survival guest rides a horse into a Nether portal: the horse goes
through at once and carries it, so it arrives in the Nether still riding the same horse.
- riding_teleport: the host and the guest each /tp through chat while riding a horse; after 60
ticks each stays dismounted at the destination and both sides see the horse left
behind. The host also teleports a mounted guest by name.
- lightning: a bolt summoned beside the host, then the guest: both sides get the bolt and flash the
sky (on its server thread the host's window is a client of it too).
- drops: an item lies where the host, then the guest, places a block: it comes out of the block
on the server, and both sides see it outside (#1252).
- underwater: the host and the guest each dig dirt by hand standing under water: five times slower
than in the air on their own client as on the server, and it breaks (#1254).
- worldspawn: the host moves the world spawn (`setworldspawn`): the host and the guest, with no bed,
respawn there (#1310).
- shield: a vindicator's axe disables the host's, then the guest's, raised shield: the server
counts its 5 s cooldown down once a tick (#1316).
- beds: the host and the guest each set their respawn point at a bed and die elsewhere: they
stand up beside the bed, or, when the bed is gone or boxed in by stone, come back at
the world spawn (#1308).
- deathmining: the host and the guest each die while breaking obsidian and let go on the death
screen: once respawned, the obsidian stays (#1315).
- contacts: the host and the guest each stand on magma (and sneak on it), stand on a lit campfire,
walk against a cactus and through grown sweet berry bushes: the server hurts them, but
not the sneaking player or young bushes (#1305, #1311, #1312, #1313).
- absorption: the host and the guest each take Absorption II and a hit of 3: their own HUD has the
server's golden hearts, 8 then 5 (#1306).
- breath: the host and the guest each stay underwater for 15 s in a Respiration III helmet: their
own bubbles follow the server's air, which the enchantment makes last (#1304).
- lava: the host and the guest each stand in a Nether lava current with Fire
Resistance, which pushes them at the Nether's strength (#1309).
- flight: a player in creative double-taps jump and flies, stays up, and stops flying in survival:
the guest, and on the server thread the host's window too, take what may fly from the
server's abilities (ClientPacketListener.handlePlayerAbilities).
- light: the guest's light matches the host's: around a torch placed near it, under a roof and
once a block of the roof is removed under the sky, and in the last chunk it tracks next to
glowstone in a chunk it doesn't (only the server's light updates bring that one).
- totem: the host and the guest each take a lethal hit holding a totem of undying in the off
hand, then the main hand: the server uses it up and keeps them alive at 1 health with its
effects, and each side hears the totem once (entity event 35) (#1292).
- keepinv: the host and the guest each die holding diamonds and wearing a helmet: with keep_inventory
off both drop where they died and the player respawns without them; with it on nothing
drops and the player keeps them (#1284).
- signs: the host and the guest each place an oak sign: the editor opens on their own screen
(ClientboundOpenSignEditorPacket), the two lines they write reach the server
(ServerboundSignUpdatePacket) and both sides show them (#1380).
- books: the host and the guest each write in a book and quill on their own screen, sign it
(ServerboundEditBookPacket), read the written book the server gave them
(ClientboundOpenBookPacket), then drop it: the other side sees its title, author and
page (#1366).
- elytra: the host and the guest, high up with an Elytra on, jump in the air and glide
(ServerboundPlayerCommandPacket START_FALL_FLYING): both their client and the server
have them gliding, they go forward far more than down, and the glide ends on both once
they land (#1288).
- helduse: the host and the guest raise a shield, charge and fire a crossbow, throw a trident
and scope with a spyglass: each use starts on their own client and on the server, and
letting go ends it on both (RELEASE_USE_ITEM), so the shield lowers, the trident flies
and the loaded crossbow fires on the next use (#1300, #1281, #1282, #1301).
- wind: the host and the guest each throw a wind charge at their feet: the server's burst
launches them about six blocks up (the push in ClientboundExplodePacket) and they land
unhurt (#1386).
- fishing: the host and the guest each cast a fishing rod: the hook stays out on the server and
both sides draw it and it reels back in from the ground, and in open water a fish
bites and reeling in catches it (#1348).
- fishing-item: reeling in an item hooked by a fishing rod moves it smoothly on both clients
instead of leaving it still until a later position update.
- maps: the host and the guest each make a map and hold it: their client draws the terrain
around them and their marker, from the server's map data (#1382).
- body: the host and the guest each walk while drawing a bow (slowed), stand on powder snow
with leather boots (held up) and without (sinking), and sprint-swim looking down
(diving): their own client decides it (#1278, #1296, #1303).
- strikes: a bolt strikes the host and the guest standing in water and hurts them, and a cave
spider's bite poisons them (#1480, #1484).
- cursor: the host and the guest each close their inventory with a stack on the cursor: it
goes back into their inventory, none is thrown and the cursor is empty (#1283).
- knockback: the host hits the guest three times, then the guest the host, two seconds apart: each
hit slides the victim as far as vanilla's does, 1.989 blocks (a guest's server-side
velocity settles between hits; it used to add up, so each push went further).
- villagers: the guest sees each villager's and zombie villager's type, profession and level as the
host has them, and an unemployed villager that takes a composter becomes a farmer on
the guest too (the guest used to draw every villager as an unemployed plains one).
- stopsound: the host's and the guest's own /stopsound, by source and by event, reach their own
client's sounds, and the host's /stopsound of the guest only the guest's (StopSoundCommand
sends each target its packet).
- boat_input_latch: a boat on land paddled from rest moves one tick after W goes down, not in that tick:
it steers with the input latched on the previous tick, as the paddle packet it sends says (#3789).
- popped_break_effects: a torch on the side of a block and sugar cane above one play their break sound once
for the miner and for the player around when the support is mined (#3747).
- wind_burst_player: a mace smash with Wind Burst on another player launches the attacker up, for the host and for a guest (#3830).
- hook_smooth: a cast fishing hook moves every tick of its flight on the client, between the tracker's updates (#3781).
- merchant-close: the host and the guest each open a trade screen with the payment in its slot; the screen
closes and the payment returns when they walk out of reach (7 blocks in survival, 9 in
creative) or the villager dies, and stays open in range.
- f3_density: F3 on the host (single player on its server thread) shows the Density line of the noise settings'
debug functions (N, T, V, C, E, D, W, PV, PS) at its feet; a LAN guest, which has no world generator, has none.
- explorer_map: three shipwreck maps from a loot table point at three different buried treasures (an exploration
map skips the structures a map already pointed at), and the host and the guest each see their map.
- sulfur_cube_power: an explosive sulfur cube (TNT inside) stays calm on stone, lights its fuse when a redstone
block is put beside it, and blows up when the fuse runs out; the guest sees it flash.
- merchant-reselect: the host and the guest each trade three times from one offer click: Space (a key
repeat) and Enter press the focused offer button again and refill the payment slot.
- parrot_follows_mob: a wild parrot (FollowMobGoal) flies to a cow standing 6 blocks away and hovers within 3.5 blocks of it (a parrot that only wanders stays about 4.5 blocks off).
- riptide_rain: the host and the guest each launch with a Riptide trident in rain, with an elytra worn or not (#3458).
- hero-trading: the host and the guest each trade 20 wheat for an emerald, paying 14 with Hero I
and 12 with Hero III; clearing the effect restores the ordinary price (#1365).
- cure-discount: the host and the guest each cure a zombie villager with a golden apple and pay 14
wheat instead of 20 for its emerald (the ZOMBIE_VILLAGER_CURED gossip, #3306).
- falling: an item, a falling block and primed TNT the host summons high up fall smoothly on the
guest, a little further every tick between the server's updates (every 20 ticks, 10 for
TNT), as their own client tick moves them (they used to hang, then jump down once a
second, #1568), and the TNT's fuse burns down on the guest too.
- instant_digging: an instant survival break leaves no repeated stone hit sounds or stale server dig (#1908).
- replant: flowers and crops replanted after a short harvest click stay planted for both players;
the server forgets the completed instant break (#1880).
- dig_stop: a block the client breaks at once (a creative break, a dead bush) leaves the
server no dig to keep: the block put back stays and no mining sound plays on (#1340, #1908).
- dig_clock: a guest in survival holds attack on a stone seven blocks above its feet: the standing
eye is 5.38 from its underside and only near a jump's apex (4.13) inside the 4.5 pick,
and the dig resumed after each fall is measured from the START that named the block, so
it breaks about one dig in rather than one dig per landing (#4124).
Each check is reported on its own (a failing one doesn't stop the others) and the screenshots of
both sides go to --out (host-*.png, guest-*.png).
python3 tools/lan_e2e.py # build with the fast profile, run every check
python3 tools/lan_e2e.py --bin-dir target/fast # prebuilt binaries
python3 tools/lan_e2e.py --check chest --check tab
python3 tools/lan_e2e.py --check portals
Needs the prepared resources in .local/horizon-client. On Linux the games run on vscreen (the
desktop), or headless (offscreen, no pause) where there is none; on macOS both games render offscreen. Games started here have no audio (HORIZON_NO_AUDIO=1).
"""
import argparse
import hashlib
import io
import json
import math
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import zlib
import zipfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from agent_control_e2e import Cli, Failed, game_env # noqa: E402
import lab # noqa: E402
from integration_env import game_environment, require_server_reports # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
GUEST_NAME = 'Guest'
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'menu-transfer', 'crafting', 'spawned', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'deaths',
'flow-push', 'bubble-column', 'arrows', 'projectile-vehicles', 'flame-arrows', 'kills', 'looting', 'leave', 'hurt', 'looks', 'leads', 'entity-tags', 'respawn', 'containers', 'names', 'feedback', 'titles', 'chat', 'styles',
'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing',
'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'shift-double-click', 'recipe-shift', 'shift-drag', 'modifier-release', 'wheel', 'sleep', 'sleep-villager', 'bed-occupied', 'sleep-reload', 'predict', 'push',
'piston-push', 'piston-entities', 'piston-slime', 'piston-slide', 'eat', 'eat-on-block', 'cauldron', 'eating', 'xp', 'objects', 'projectiles', 'thrown', 'sparks', 'hud', 'sidebar', 'border', 'particles',
'worn', 'blast', 'blast-projectiles', 'barter', 'piglin_offspring', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'plate-projectiles', 'effects',
'portals', 'distance', 'growth', 'doors', 'flight', 'ladder', 'boats', 'boat_ice_reboard', 'boat_shoved', 'minecarts', 'frames', 'hands',
'lightning', 'riding', 'occupied_horse', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption',
'breath', 'lava', 'levels', 'drops', 'underwater', 'fireworks', 'rocket-walls', 'rocket-ceilings', 'tag-query']
class Game:
"""One client: its scratch folder, its socket and a CLI driver."""
def __init__(self, name, ctl, resources):
self.name = name
self.work = Path(tempfile.mkdtemp(prefix=f'hzlan-{name}-'))
(self.work / '.local/horizon-client').mkdir(parents=True)
lab.link_dir(resources, self.work / '.local/horizon-client' / lab.VERSION)
self.socket = self.work / 'c.sock'
env = game_env(os.environ.get('VSCREEN', 'lan-e2e'), home=self.work / 'home')
env = game_environment(env, self.work)
self.d = Cli(ctl, self.socket, self.work, env)
self.d.name = name
def __call__(self, tool, **args):
return self.d(tool, **args)
def raw(self, *words):
"""A game command horizon-ctl has no table entry for (lan, connect): the words as typed."""
result = subprocess.run(self.d.base + [str(w) for w in words], cwd=self.work, env=self.d.env,
capture_output=True, text=True, timeout=600)
lines = result.stdout.strip().splitlines()
reply = json.loads(lines[-1]) if lines else {'error': result.stderr.strip()}
print(f'[{self.name}] {list(words)} -> {json.dumps(reply)[:400]}')
if result.returncode or 'error' in reply:
raise Failed(f'{" ".join(map(str, words))}: {reply}')
return reply
def shot(self, out, label):
path = out / f'{self.name}-{label}.png'
self.d('screenshot', path=path)
return path
def wait(self, ticks):
self.d('wait', ticks=ticks)
def script(self, lines, every=False):
"""Keep tick-sensitive input in one ctl process, through the normal control commands. Returns the last
reply, or with every=True all of them in order."""
result = subprocess.run(self.d.base + ['script'], input='\n'.join(lines) + '\n',
cwd=self.work, env=self.d.env, capture_output=True, text=True,
timeout=600)
replies = [json.loads(line) for line in result.stdout.splitlines() if line.strip()]
ticks = [r['ticks'] for r in replies if 'ticks' in r]
print(f'[{self.name}] script {lines} -> {replies[-1] if replies else result.stderr[:400]}; ticks={ticks}')
assert result.returncode == 0 and replies and not any('error' in r for r in replies), result.stdout
return replies if every else replies[-1]
def until(self, observe, predicate, ticks=100):
"""Wait for an observable answer, within the existing tick budget."""
last = observe()
for _ in range(ticks):
if predicate(last):
return last
self.wait(1)
last = observe()
assert predicate(last), f'{self.name}: state did not arrive within {ticks} ticks: {last}'
return last
def close(self):
try:
self.d('quit')
except Exception:
pass
time.sleep(2)
log = self.socket.with_suffix('.log')
return log.read_text(errors='replace') if log.exists() else ''
def remote(guest):
"""The guest's view of the server (`status`'s server part)."""
return guest('status')['server']
def dead(guest):
"""Whether the guest is dead: its death screen (the last health it was sent can stay above 0)."""
return guest('state').get('screen') == 'dead' or remote(guest)['health'] <= 0
def back_to_game(game):
"""Close whatever screen a failed check left open, without opening the pause menu."""
try:
if game('state').get('screen') not in (None, 'playing'):
game.raw('key', 'esc', 'tap')
game.wait(2)
except Failed:
pass
def pos_of(game):
return game('state')['player']['position']
def face_each_other(host, guest):
"""Put the guest 3 blocks east of the host, both looking at the other's head."""
hx, hy, hz = pos_of(host)
guest('command', command=f'tp @s {hx + 3:.2f} {hy:.2f} {hz:.2f}')
guest.wait(30)
host.raw('look-at', f'{hx + 3:.2f}', f'{hy + 1.6:.2f}', f'{hz:.2f}')
guest.raw('look-at', f'{hx:.2f}', f'{hy + 1.6:.2f}', f'{hz:.2f}')
host.wait(10)
def check_tab(host, guest, out):
players = remote(guest)['players']
names = sorted(p['name'] if isinstance(p, dict) else str(p) for p in players)
assert names == sorted([host.player, GUEST_NAME]), f'guest tab list: {players}, host {host.player}'
guest.raw('key', 'tab', 'down')
guest.wait(5)
guest.shot(out, 'tab')
guest.raw('key', 'tab', 'up')
return names
def check_self(host, guest, out):
guest.raw('key', 'f5', 'tap')
guest.wait(10)
guest.shot(out, 'self-third-person')
status = guest('state')
guest.raw('key', 'f5', 'tap')
guest.raw('key', 'f5', 'tap')
guest.raw('key', 'e', 'tap')
guest.raw('wait-for', 'screen', 'inventory')
guest.shot(out, 'self-inventory')
model = remote(guest).get('player_model')
guest.raw('key', 'esc', 'tap')
assert model, f'the guest has no player model: {status.get("camera")}'
return 'model drawn'
def check_armour(host, guest, out):
host('command', command='item replace entity @s armor.head with minecraft:golden_helmet')
host('command', command='item replace entity @s armor.chest with minecraft:golden_chestplate')
guest('command', command='item replace entity @s armor.chest with minecraft:diamond_chestplate')
guest('command', command='item replace entity @s armor.legs with minecraft:diamond_leggings')
face_each_other(host, guest)
host.shot(out, 'armour')
guest.shot(out, 'armour')
# The host's window is a client of its server thread: what it draws is what it was sent.
seen = [e for e in remote(host).get('player_equipment', [])]
assert all(any(item in json.dumps(e) for e in seen) for item in
('diamond_chestplate', 'diamond_leggings')), f'the host draws the guest in {seen}'
host_gear = [e for e in remote(guest).get('player_equipment', [])]
assert any('golden_chestplate' in json.dumps(e) for e in host_gear), f'the guest sees the host in {host_gear}'
return 'each in their own'
def check_nametags(host, guest, out):
face_each_other(host, guest)
h = host('status').get('name_tags') or []
g = guest('status').get('name_tags') or []
host.shot(out, 'nametags')
guest.shot(out, 'nametags')
assert GUEST_NAME in h, f'host draws name tags {h}'
assert host.player in g, f'guest draws name tags {g}'
return {'host': h, 'guest': g}
def grab(game):
"""Mouse look on, as a player's grabbed pointer: an agent's mouse-move stands in for it."""
game.raw('mouse-move', 1, 0)
game.raw('mouse-move', -1, 0)
def host_unaffected(host, what):
"""What a guest opens is the guest's: the host keeps its world on screen and its mouse look (#687)."""
state = host('state')
assert state.get('screen') == 'playing', f'{what} opened the {state.get("screen")} screen on the host'
assert state.get('pointer_grabbed'), f"{what} released the host's pointer"
def lid(game, pos):
return game.raw('block', *pos).get('lid', 0.0)
def check_chest(host, guest, out):
x, y, z = (int(v // 1) for v in pos_of(guest))
chest = (x, y, z + 2)
guest('command', command=f'setblock {chest[0]} {chest[1]} {chest[2]} minecraft:chest')
guest.wait(10)
grab(host)
guest.raw('interact', *chest)
screen = guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
guest.wait(20)
host_unaffected(host, "the guest's chest")
opened = {'guest': lid(guest, chest), 'host': lid(host, chest)}
host.raw('look-at', chest[0] + 0.5, chest[1] + 0.5, chest[2] + 0.5)
host.wait(2)
host.shot(out, 'chest-open')
guest.shot(out, 'chest-open')
guest.raw('key', 'esc', 'tap')
guest.wait(20)
closed = {'guest': lid(guest, chest), 'host': lid(host, chest)}
host.shot(out, 'chest-closed')
assert screen, 'no container screen on the guest'
assert opened['guest'] > 0.9 and opened['host'] > 0.9, f'lid while open: {opened}'
assert closed['guest'] < 0.1 and closed['host'] < 0.1, f'lid after closing: {closed}'
return {'open': opened, 'closed': closed}
def check_menu_transfer(host, guest, out):
"""A container the guest has open does not outlive the server replacing its player (a dimension
change, a death and respawn): the screen is closed and the inventory opens as its own menu again."""
guest('command', command='clear @s')
guest('command', command='give @s minecraft:diamond 3')
home = pos_of(guest)
x, y, z = (int(v // 1) for v in home)
chest = (x, y, z + 2)
guest('command', command=f'setblock {chest[0]} {chest[1]} {chest[2]} minecraft:chest')
guest.wait(10)
results = {}
def open_chest():
guest.raw('interact', *chest)
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
guest.wait(10)
def settled(label):
"""The menu is gone after the transfer; `e` opens the player's own 2x2 inventory with its items."""
screen = guest('state').get('screen')
guest.raw('key', 'e', 'tap')
guest.raw('wait-for', 'screen', 'inventory', '--timeout', 10)
inventory = guest('screen')
stale = [s for s in inventory.get('slots', []) if s.get('slot', 0) >= 300]
diamonds = guest('inventory')['counts'].get('minecraft:diamond', 0)
guest.shot(out, f'menu-transfer-{label}')
guest.raw('key', 'esc', 'tap')
guest.wait(5)
results[label] = {'screen': screen, 'craft_size': inventory.get('craft_size'), 'stale': len(stale),
'diamonds': diamonds}
assert screen in (None, 'playing'), f'{label}: the screen after the transfer was {screen}'
assert inventory.get('craft_size') == 2 and not stale, f'{label}: the inventory menu was {inventory}'
assert diamonds == 3, f'{label}: {diamonds} diamonds after the transfer'
try:
open_chest()
into_level(guest, NETHER, x, 100, z)
settled('dimension')
guest('command', command=f'execute in {OVERWORLD} run tp @s {home[0]:.2f} {home[1]:.2f} {home[2]:.2f}')
wait_dimension(guest, OVERWORLD, 400)
guest.wait(20)
open_chest()
guest('command', command='kill @s')
guest.wait(20)
guest.raw('respawn')
wait_dimension(guest, OVERWORLD, 400)
guest.wait(40)
settled('respawn')
finally:
back_to_game(guest)
guest('command', command=f'execute in {OVERWORLD} run setblock {chest[0]} {chest[1]} {chest[2]} minecraft:air')
return results
def check_crafting(host, guest, out):
guest('command', command='clear @s')
guest('command', command='give @s minecraft:oak_log 1')
guest.wait(10)
guest.raw('key', 'e', 'tap')
guest.raw('wait-for', 'screen', 'inventory')
inv = guest('inventory')
slot = next(s['slot'] for s in inv['hotbar'] if s and s.get('item') == 'minecraft:oak_log')
# Horizon's slot ids (click-slot): the hotbar is 0-8, the grid 100-108, the result 200.
guest.raw('click-slot', slot)
guest.raw('click-slot', 100)
guest.wait(5)
result = [s for s in guest('screen').get('slots', []) if s.get('slot') == 200]
guest.shot(out, 'crafting-grid')
guest.raw('click-slot', 200, 'shift')
guest.wait(5)
guest.raw('key', 'esc', 'tap')
guest.wait(5)
planks = guest('inventory')['counts'].get('minecraft:oak_planks', 0)
assert planks == 4, f'crafted {planks} planks (result slot: {result})'
x, y, z = (int(v // 1) for v in pos_of(guest))
table = (x, y, z - 2)
guest('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:crafting_table')
guest.wait(10)
grab(host)
guest.raw('interact', *table)
guest.raw('wait-for', 'screen', 'inventory', '--timeout', 10)
host_unaffected(host, "the guest's crafting table")
size = guest('screen').get('craft_size')
assert size == 3, f'the crafting table opened a {size}x{size} grid'
# Two planks one above the other in the 3x3 grid: four sticks.
slot = next(s['slot'] for s in guest('inventory')['hotbar'] if s and s.get('item') == 'minecraft:oak_planks')
guest.raw('click-slot', slot)
guest.raw('click-slot', 100, 'right')
guest.raw('click-slot', 103, 'right')
guest.raw('click-slot', slot)
guest.wait(5)
guest.shot(out, 'crafting-table')
guest.raw('click-slot', 200, 'shift')
guest.wait(5)
guest.raw('key', 'esc', 'tap')
guest.wait(5)
counts = guest('inventory')['counts']
assert counts.get('minecraft:stick') == 4 and counts.get('minecraft:oak_planks') == 2, f'after the table: {counts}'
return {'planks': planks, 'table': counts}
def check_spawned(host, guest, out):
"""Players new to a world don't share one spot: ServerPlayer.adjustSpawnLocation spreads them over the
respawn radius (PlayerSpawnFinder). Two players standing in each other target each other, so a guest
left in the host could open nothing it clicked. At its own spot, the guest opens a crafting table, a
chest, a furnace and a stonecutter."""
joined = {'host': [round(v, 2) for v in host.joined_at], 'guest': [round(v, 2) for v in guest.joined_at]}
apart = abs(joined['host'][0] - joined['guest'][0]) + abs(joined['host'][2] - joined['guest'][2]) >= 0.6
guest('command', command='gamemode survival')
for game in (host, guest):
x, y, z = game.joined_at
game('command', command=f'tp @s {x} {y} {z}')
guest.wait(20)
x, y, z = (int(v // 1) for v in pos_of(guest))
at = (x, y, z + 2)
grab(host)
opened = {}
for block in ('crafting_table', 'chest', 'furnace', 'stonecutter'):
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:{block}')
guest.wait(10)
guest.raw('interact', *at)
guest.wait(10)
opened[block] = guest('state').get('screen')
guest.shot(out, f'spawned-{block}')
back_to_game(guest)
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
host_unaffected(host, "the guest's stations")
face_each_other(host, guest)
assert apart, f'the host and the guest joined on one spot: {joined}'
want = {'crafting_table': 'inventory', 'chest': 'container', 'furnace': 'container', 'stonecutter': 'station'}
assert opened == want, f'where it joined, the guest opened {opened}'
return {'joined': joined, 'opened': opened}
def check_sign(host, guest, out):
x, y, z = (int(v // 1) for v in pos_of(guest))
spot = (x + 2, y, z)
guest('command', command=f'setblock {spot[0]} {spot[1] - 1} {spot[2]} minecraft:stone')
guest('command', command=f'setblock {spot[0]} {spot[1]} {spot[2]} minecraft:air')
guest('command', command='give @s minecraft:oak_sign 1')
guest.wait(10)
grab(host)
guest.raw('place', *spot, '--item', 'minecraft:oak_sign')
guest.wait(20)
placed = host.raw('block', *spot).get('block')
host.shot(out, 'sign')
assert placed and 'sign' in placed, f'the guest placed {placed}'
host_unaffected(host, "the guest's sign")
guest.raw('key', 'esc', 'tap')
guest.raw('wait-for', 'screen', 'playing')
return placed
def effect_ids(game):
return [e.get('id', '').removeprefix('minecraft:') for e in game('state')['player'].get('effects', [])]
def check_effects(host, guest, out):
x, y, z = (int(v // 1) for v in pos_of(host))
bx, by, bz = x - 3, y, z - 3
host('command', command=f'fill {bx - 1} {by - 1} {bz - 1} {bx + 1} {by - 1} {bz + 1} minecraft:iron_block')
host('command', command=f'setblock {bx} {by} {bz} minecraft:beacon{{primary_effect:"minecraft:speed"}}')
# BeaconBlockEntity.tick applies its powers every 80 ticks, once the beam has reached the sky.
seen = {}
for _ in range(15):
host.wait(20)
seen = {'host': effect_ids(host), 'guest': effect_ids(guest)}
if all('speed' in v for v in seen.values()):
break
host.shot(out, 'effects')
guest.shot(out, 'effects')
assert 'speed' in seen['host'] and 'speed' in seen['guest'], f'beacon effects: {seen}'
return seen
def wait_dimension(game, dimension, ticks):
"""Wait up to `ticks` for the game to be in `dimension` with its world loaded around it."""
for _ in range(ticks // 20):
state = game('state')
if state.get('dimension') == dimension and state.get('player'):
return state
game.wait(20)
return game('state')
def nether_portal(game, x, y, z):
"""A lit portal along x, two blocks wide and three tall, its inside starting at x y z."""
game('command', command=f'fill {x - 1} {y - 1} {z} {x + 2} {y + 3} {z} minecraft:obsidian')
game('command', command=f'fill {x} {y} {z} {x + 1} {y + 2} {z} minecraft:nether_portal[axis=x]')
def lan_guest(host):
"""The guest's entry in the host's `lan status` (the owner is listed too)."""
guests = host.raw('lan', 'status')['guests']
return next(g for g in guests if g['name'] == GUEST_NAME)
def check_distance(host, guest, out):
"""IntegratedServer.tickServer: the server takes the host's render distance as its view distance
(past 32, which Horizon's options allow) and sends it to every player (SetChunkCacheRadius)."""
seen = {}
try:
for distance in (48, 6):
host.raw('render-distance', distance)
# The pause screen doesn't hold the server here: it follows on its next tick.
host.wait(20)
guest.wait(20)
seen[distance] = (host('status').get('render_distance'), guest('status').get('render_distance'))
finally:
host.raw('render-distance', 12)
# A client streams out to min(its render distance, the server's view distance + 1).
assert seen[48][0] == 48, f'the host at 48 streams {seen[48][0]} chunks out (all: {seen})'
assert seen[6][0] == 6, f'the host at 6 streams {seen[6][0]} chunks out (all: {seen})'
assert seen[6][1] == 7, f'the guest streams {seen[6][1]} with the server at 6 (all: {seen})'
return seen
def check_portals(host, guest, out):
x, y, z = (int(v // 1) for v in pos_of(guest))
hx, hy, hz = (int(v // 1) for v in pos_of(host))
try:
return portals(host, guest, out, x, y, z, hx, hy, hz)
finally:
# The guest comes home into its portal's frame (an obsidian ceiling three blocks up) and the
# host onto its End portal at spawn: take both away so the next checks start in the open.
home = 'execute in minecraft:overworld run'
guest('command', command=f'{home} fill {x - 1} {y} {z + 3} {x + 2} {y + 3} {z + 3} minecraft:air')
host('command', command=f'{home} setblock {hx} {hy} {hz} minecraft:air')
guest('command', command=f'{home} tp @s {x + 0.5} {y} {z + 0.5}')
guest.wait(20)
def portals(host, guest, out, x, y, z, hx, hy, hz):
nether_portal(guest, x, y, z + 3)
guest.wait(5)
guest('command', command=f'tp @s {x + 0.5} {y} {z + 3.5}')
# players_nether_portal_default_delay: 80 ticks in survival.
there = wait_dimension(guest, 'minecraft:the_nether', 600)
# On a server the guest is held where it entered until the chunks around the destination are
# lit, then placed in its portal: wait for that before reading where it arrived.
for _ in range(30):
guest.wait(20)
arrived = [int(v // 1) for v in pos_of(guest)]
if guest.raw('block', *arrived).get('block') == 'minecraft:nether_portal':
break
guest.shot(out, 'portals-nether')
host_dim = host('state').get('dimension')
assert there.get('dimension') == 'minecraft:the_nether', f'the guest is in {there.get("dimension")}'
assert host_dim == 'minecraft:overworld', f'the host went to {host_dim} with the guest'
below = guest.raw('block', arrived[0], arrived[1] - 1, arrived[2])
assert below.get('loaded') and below.get('block') != 'minecraft:air', f'under the guest in the Nether: {below}'
# handleRespawn's LocalPlayer.setPortalCooldown: standing in the arrival portal keeps the cooldown
# going, so the overlay fades out (20 ticks from full) instead of building up again.
guest.wait(40)
effect = guest('state')['player'].get('portal_effect')
assert effect == 0, f'the portal overlay is at {effect} in the arrival portal'
# The host takes an End portal while the guest is in the Nether: three levels at once.
host('command', command=f'setblock {hx} {hy} {hz} minecraft:end_portal')
end = wait_dimension(host, 'minecraft:the_end', 600)
host.wait(40)
host.shot(out, 'portals-end')
guest_dim = guest('state').get('dimension')
assert end.get('dimension') == 'minecraft:the_end', f'the host is in {end.get("dimension")}'
assert guest_dim == 'minecraft:the_nether', f'the guest left the Nether for {guest_dim}'
# Both come back: the host through an exit portal, the guest out of its portal and in again.
ex, ey, ez = (int(v // 1) for v in pos_of(host))
host('command', command=f'setblock {ex + 2} {ey} {ez} minecraft:end_portal')
host('command', command=f'tp @s {ex + 2.5} {ey} {ez + 0.5}')
guest('command', command=f'tp @s {arrived[0] + 0.5} {arrived[1]} {arrived[2] - 2.5}')
guest.wait(30)
guest('command', command=f'tp @s {arrived[0] + 0.5} {arrived[1]} {arrived[2] + 0.5}')
# ServerPlayer.showEndCredits: the host's first exit from the End rolls the poem and credits;
# closing them (WinScreen.onClose sends PERFORM_RESPAWN) takes it home.
credits = False
for _ in range(60):
host.wait(5)
if host('screen').get('screen') == 'win':
credits = True
host.raw('key', 'esc', 'tap')
break
assert credits, 'the host left the End without the credits'
home = wait_dimension(host, 'minecraft:overworld', 600)
back = wait_dimension(guest, 'minecraft:overworld', 600)
guest.wait(40)
host.shot(out, 'portals-back')
guest.shot(out, 'portals-back')
assert home.get('dimension') == 'minecraft:overworld', f'the host stayed in {home.get("dimension")}'
assert back.get('dimension') == 'minecraft:overworld', f'the guest stayed in {back.get("dimension")}'
return {'guest_nether': arrived, 'host_end': [ex, ey, ez], 'credits': credits, 'back': pos_of(guest)}
NETHER, END, OVERWORLD = 'minecraft:the_nether', 'minecraft:the_end', 'minecraft:overworld'
def check_levels(host, guest, out):
"""Each player in its own level (MinecraftServer.levels), the host and the guest
apart: the steps run in order, each reported, and the check fails if any did."""
start = {g.name: pos_of(g) for g in (host, guest)}
steps, failed = {}, []
guest('command', command='gamemode creative')
try:
for name, step in LEVEL_STEPS:
try:
steps[name] = step(host, guest, out)
except (Failed, AssertionError, StopIteration, KeyError, TypeError, IndexError) as e:
steps[name], failed = repr(e), failed + [name]
for g in (host, guest):
back_to_game(g)
print(f'[lan] levels {name}: {"FAIL" if name in failed else "PASS"} {steps[name]}')
finally:
for g in (host, guest):
back_to_game(g)
if g('state').get('screen') == 'dead':
g.raw('respawn')
g.wait(20)
x, y, z = start[g.name]
g('command', command=f'execute in {OVERWORLD} run tp @s {x:.2f} {y:.2f} {z:.2f}')
guest('command', command='gamemode survival')
guest.wait(40)
assert not failed, f'failed: {failed}; {steps}'
return steps
def level_floor(game, x, y, z):
"""An obsidian floor under x y z with air above it, in the level `game` is in (fill needs the
chunks loaded: ServerLevel.hasChunksAt)."""
game('command', command=f'fill {x - 4} {y - 1} {z - 4} {x + 4} {y - 1} {z + 4} minecraft:obsidian')
game('command', command=f'fill {x - 4} {y} {z - 4} {x + 4} {y + 4} {z + 4} minecraft:air')
def into_level(game, dimension, x, y, z):
"""`execute in DIMENSION run tp` to x y z, onto a floor built once the level has loaded around it."""
game('command', command=f'execute in {dimension} run tp @s {x + 0.5} {y} {z + 0.5}')
state = wait_dimension(game, dimension, 400)
game.wait(20)
level_floor(game, x, y, z)
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
game.wait(20)
return state
def dims(host, guest):
return host('state').get('dimension'), guest('state').get('dimension')
def level_execute(host, guest, out):
"""`execute in the_nether run tp` takes the guest alone. From the Nether, the guest's commands
reach the host's level (CommandSourceStack.getLevel): `execute in overworld` and `execute at`
the host put a block beside the host, and `execute if block` reads it."""
x, z = (int(v // 1) for v in pos_of(guest)[::2])
hx, hy, hz = (int(v // 1) for v in pos_of(host))
there = into_level(guest, NETHER, x, 100, z)
below = guest.raw('block', x, 99, z)
gold = f'{hx} {hy} {hz + 2} minecraft:gold_block'
try:
guest('command', command=f'execute in {OVERWORLD} run setblock {gold}')
guest('command', command=f'execute at {host.player} run setblock ~ ~ ~-2 minecraft:diamond_block')
guest('command', command=f'execute in {OVERWORLD} if block {gold} in {NETHER} '
f'run setblock {x} 99 {z} minecraft:gold_block')
host.wait(10)
placed = [host.raw('block', hx, hy, hz + 2), host.raw('block', hx, hy, hz - 2)]
found = guest.raw('block', x, 99, z)
finally:
host('command', command=f'fill {hx} {hy} {hz - 2} {hx} {hy} {hz + 2} minecraft:air')
assert dims(host, guest) == (OVERWORLD, NETHER), f'host, guest in {dims(host, guest)}'
assert there.get('dimension') == NETHER and below.get('block') == 'minecraft:obsidian', f'{there} on {below}'
assert [p.get('block') for p in placed] == ['minecraft:gold_block', 'minecraft:diamond_block'], \
f'beside the host: {placed}'
assert found.get('block') == 'minecraft:gold_block', f'execute in overworld if block: {found}'
return {'guest': pos_of(guest)}
def level_ticking(host, guest, out):
"""Mobs and objects fall in both levels at once (each level with a player ticks); every entity
has its own id across the levels."""
gx, gy, gz = pos_of(guest)
hx, hy, hz = pos_of(host)
# Clear the whole walking area in each level: normal pig AI can leave a
# narrow landing strip during the fall/observation window.
for game, x, y, z in ((host, hx, hy, hz), (guest, gx, gy, gz)):
bx, by, bz = int(x // 1), int(y // 1), int(z // 1)
game('command', command=f'fill {bx - 12} {by - 1} {bz - 12} {bx + 12} {by - 1} {bz + 12} minecraft:stone')
game('command', command=f'fill {bx - 12} {by} {bz - 12} {bx + 12} {by + 6} {bz + 12} minecraft:air')
guest('command', command=f'summon minecraft:pig {gx + 2.5:.2f} {gy + 3:.2f} {gz:.2f}')
guest('command', command=f'summon minecraft:armor_stand {gx - 2.5:.2f} {gy + 3:.2f} {gz:.2f}')
host('command', command=f'summon minecraft:pig {hx + 2.5:.2f} {hy + 3:.2f} {hz - 3:.2f}')
host('command', command=f'summon minecraft:armor_stand {hx - 2.5:.2f} {hy + 3:.2f} {hz - 3:.2f}')
try:
guest.wait(60)
seen = {}
for name, g, floor in (('guest', guest, gy), ('host', host, hy)):
mobs = [m for m in g.raw('mobs', 8)['near'] if m['kind'] == 'minecraft:pig']
stands = [o for o in g('status')['objects'] if o['kind'] == 'minecraft:armor_stand']
seen[name] = {'pigs': [(m['id'], round(m['pos'][1], 2)) for m in mobs],
'pig_positions': [m['pos'] for m in mobs],
'stands': [(o['id'], round(o['position'][1], 2)) for o in stands], 'floor': floor}
finally:
guest('command', command='kill @e[type=!minecraft:player,distance=..8]')
host('command', command='kill @e[type=!minecraft:player,distance=..8]')
for name, s in seen.items():
heights = [y for _, y in s['pigs'] + s['stands']]
assert len(s['pigs']) == 1 and len(s['stands']) == 1, f'{name} sees {s}'
assert all(abs(y - s['floor']) < 0.1 for y in heights), f"{name}'s did not fall to the floor: {seen}"
ids = [i for s in seen.values() for i, _ in s['pigs'] + s['stands']]
assert len(set(ids)) == len(ids), f'ids shared across the levels: {seen}'
return seen
def level_rejoin(host, guest, out):
"""The guest leaves in the Nether and joins again there (its player data keeps its level)."""
before = pos_of(guest)
port = host.raw('lan', 'status')['port']
guest.raw('disconnect')
time.sleep(2)
guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME)
guest.raw('wait-for', 'loaded', '--timeout', 300)
guest.wait(20)
after = pos_of(guest)
assert dims(host, guest) == (OVERWORLD, NETHER), f'host, guest in {dims(host, guest)}'
assert all(abs(a - b) < 1 for a, b in zip(before, after)), f'left at {before}, back at {after}'
return {'at': after}
def level_death(host, guest, out):
"""The guest dies in the Nether and respawns in the Overworld; the host stays where it is."""
guest('command', command='kill @s')
guest.wait(20)
guest.raw('respawn')
back = wait_dimension(guest, OVERWORLD, 400)
guest.wait(20)
assert back.get('dimension') == OVERWORLD, f'the guest respawned in {back.get("dimension")}'
assert dims(host, guest) == (OVERWORLD, OVERWORLD), f'host, guest in {dims(host, guest)}'
return {'respawned': back['player']['position']}
def level_respawn_points(host, guest, out):
"""Respawn points outside the Overworld (ServerPlayer.findRespawnAndUseSpawnBlock): a point
`spawnpoint` sets in the Nether (forced), then a charged respawn anchor the guest uses there,
each bring the guest back in the Nether; the anchor loses a charge."""
home = pos_of(guest)
x, z = (int(v // 1) for v in home[::2])
into_level(guest, NETHER, x, 100, z)
results = {}
try:
guest('command', command=f'spawnpoint @s {x} 100 {z}')
results['spawnpoint'] = die_and_respawn(guest)
guest('command', command=f'setblock {x + 2} 100 {z} minecraft:respawn_anchor[charges=2]')
guest.wait(10)
guest.raw('interact', x + 2, 100, z)
guest.wait(10)
results['anchor'] = die_and_respawn(guest)
results['charges'] = guest.raw('block', x + 2, 100, z).get('state')
finally:
# Leave no Overworld spawnpoint behind: with the anchor removed below, the guest's next death
# falls back to the world spawn and clears its point, which check_world_spawn relies on.
guest('command', command=f'execute in {NETHER} run setblock {x + 2} 100 {z} minecraft:air')
guest('command', command=f'execute in {OVERWORLD} run tp @s {home[0]:.2f} {home[1]:.2f} {home[2]:.2f}')
wait_dimension(guest, OVERWORLD, 400)
point, anchor = results['spawnpoint'], results['anchor']
assert point[0] == NETHER and abs(point[1][0] - x - 0.5) < 0.6 and abs(point[1][2] - z - 0.5) < 0.6, \
f'the spawnpoint brought the guest to {point}'
assert anchor[0] == NETHER and abs(anchor[1][0] - x - 2.5) < 2 and abs(anchor[1][2] - z - 0.5) < 2, \
f'the anchor brought the guest to {anchor}'
assert 'charges=1' in (results['charges'] or ''), f'the anchor after the respawn: {results["charges"]}'
return results
def die_and_respawn(guest):
"""Kill the guest and respawn it: the dimension and position it comes back at, once settled."""
guest('command', command='kill @s')
guest.wait(20)
guest.raw('respawn')
guest.wait(80)
state = guest('state')
return state.get('dimension'), state['player']['position']
def level_host_nether(host, guest, out):
"""The host goes through a Nether portal while the guest stays in the Overworld."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
nether_portal(host, hx, hy, hz - 3)
try:
host('command', command=f'tp @s {hx + 0.5} {hy} {hz - 2.5}')
there = wait_dimension(host, NETHER, 600)
host.wait(20)
result = dims(host, guest)
assert there.get('dimension') == NETHER, f'the host is in {there.get("dimension")}'
assert result == (NETHER, OVERWORLD), f'host, guest in {result}'
return {'host': there['player']['position']}
finally:
host('command', command=f'execute in {OVERWORLD} run fill {hx - 1} {hy} {hz - 3} {hx + 2} {hy + 3} {hz - 3} '
'minecraft:air')
host('command', command=f'execute in {OVERWORLD} run tp @s {hx + 0.5} {hy} {hz + 0.5}')
wait_dimension(host, OVERWORLD, 400)
def level_guest_end(host, guest, out):
"""The guest steps into an End portal alone and lands on the obsidian platform, an End gateway
takes it across the End, and an exit portal takes it back to the Overworld."""
x, y, z = (int(v // 1) for v in pos_of(guest))
guest('command', command=f'setblock {x + 2} {y} {z} minecraft:end_portal')
try:
guest('command', command=f'tp @s {x + 2.5} {y} {z + 0.5}')
wait_dimension(guest, END, 600)
# The arrival waits for the platform's chunks; its portal cooldown (10 ticks) runs out after,
# and a player who stands in a portal while it runs stays (Entity.setAsInsidePortal).
guest.wait(60)
landed = pos_of(guest)
assert dims(host, guest) == (OVERWORLD, END), f'host, guest in {dims(host, guest)}'
assert [int(v // 1) for v in landed] == [100, 49, 0], f'the guest landed at {landed}'
finally:
guest('command', command=f'execute in {OVERWORLD} run setblock {x + 2} {y} {z} minecraft:air')
# A gateway far out with an exact exit at the End spawn, as end_gateway_return's
# (TheEndGatewayBlockEntity.getPortalPosition), takes the guest back there within the End.
guest('command', command='tp @s 700.5 80 0.5')
guest.wait(60)
level_floor(guest, 700, 80, 0)
guest('command', command='tp @s 700.5 80 0.5')
guest.wait(20)
guest('command', command='setblock 702 80 0 minecraft:end_gateway{exit_portal:[I;100,50,0],ExactTeleport:1b}')
guest('command', command='tp @s 702.5 80 0.5')
gateway = None
for _ in range(20):
guest.wait(10)
gateway = pos_of(guest)
if abs(gateway[0] - 702.5) > 50:
break
assert dims(host, guest) == (OVERWORLD, END), f'host, guest in {dims(host, guest)}'
assert abs(gateway[0] - 100.5) < 1 and abs(gateway[2] - 0.5) < 1 and 49 <= gateway[1] <= 50, \
f'the gateway left the guest at {gateway}'
guest.wait(40)
ex, ey, ez = (int(v // 1) for v in pos_of(guest))
guest('command', command=f'setblock {ex + 2} {ey} {ez} minecraft:end_portal')
guest('command', command=f'tp @s {ex + 2.5} {ey} {ez + 0.5}')
# ServerPlayer.showEndCredits: the guest's first exit from the End rolls the credits; closing
# them (WinScreen.onClose sends PERFORM_RESPAWN) takes it home.
credits = False
for _ in range(60):
guest.wait(5)
if guest('screen').get('screen') == 'win':
credits = True
guest.raw('key', 'esc', 'tap')
break
assert credits, 'the guest left the End without the credits'
home = wait_dimension(guest, OVERWORLD, 600)
guest.wait(20)
assert dims(host, guest) == (OVERWORLD, OVERWORLD), f'host, guest in {dims(host, guest)}'
return {'landed': landed, 'gateway': gateway, 'home': home['player']['position']}
def level_reload(host, guest, out):
"""The host saves and quits with the guest in the Nether, opens the world again and opens it to the
LAN; the guest joins again where it was, in the Nether."""
x, z = (int(v // 1) for v in pos_of(guest)[::2])
into_level(guest, NETHER, x, 100, z)
before = pos_of(guest)
host.raw('disconnect')
# The world saves on its server thread before the title screen shows.
for attempt in range(60):
time.sleep(1)
try:
host.raw('open', 'Lan')
break
except Failed:
if attempt == 59:
raise
host('wait_ready', seconds=400)
port = host.raw('lan', 'open', 0, 'commands')['port']
back_to_game(guest)
guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME)
guest.raw('wait-for', 'loaded', '--timeout', 300)
guest.wait(20)
after = pos_of(guest)
assert dims(host, guest) == (OVERWORLD, NETHER), f'host, guest in {dims(host, guest)}'
assert all(abs(a - b) < 1 for a, b in zip(before, after)), f'left at {before}, back at {after}'
return {'at': after}
LEVEL_STEPS = [('execute', level_execute), ('ticking', level_ticking), ('rejoin', level_rejoin),
('death', level_death), ('respawn_points', level_respawn_points),
('host_nether', level_host_nether),
('guest_end', level_guest_end), ('reload', level_reload)]
def check_player_armor_components(host, guest):
"""LivingEntity.doHurtEquipment on a fully ticking LAN player (#7480)."""
slots = [('head', 'helmet'), ('chest', 'chestplate'),
('legs', 'leggings'), ('feet', 'boots')]
cases, failures = {}, []
def server_ticks(ticks):
# Client frames can outrun the integrated server under load. Measure
# the authoritative clock so heal expiry and hurt cooldown really pass.
def clock():
reply = server_reply(guest, 'time query gametime', 'The game time is')
return int(re.search(r'The game time is (\d+)', reply).group(1))
start = clock()
deadline = time.monotonic() + 90
while clock() - start < ticks:
assert time.monotonic() < deadline, 'armor fixture server clock stalled'
guest.wait(5)
def health():
reply = server_reply(guest, 'data get entity @s Health', 'entity data')
return float(re.search(r'entity data: ([0-9.]+)f?\s*$', reply).group(1))
guest('command', command='gamemode survival')
server_reply(guest, 'gamerule natural_health_regeneration false', 'Game rule', 'false')
guest('command', command='fill ~-1 ~ ~-1 ~1 ~2 ~1 minecraft:air')
# This must be player-issued, and followed by real player ticks. The
# command-script oracle does not supply that tick/health setup.
guest('command', command='attribute @s minecraft:max_health base set 1000')
try:
for case, components, damage, present in [
('normal', '', 1, True),
('unbreakable', '[minecraft:unbreakable={}]', None, True),
('custom_limit', '[minecraft:max_damage=10,minecraft:damage=9]', None, False),
]:
for slot, item in slots:
guest('command', command=f'item replace entity @s armor.{slot} with '
f'minecraft:diamond_{item}{components}')
guest('command', command='effect give @s minecraft:instant_health 1 10')
server_ticks(25)
healed = health()
assert healed == 1000, f'invalid armor fixture health: {case}: {healed}'
server_reply(guest, 'effect clear @s minecraft:instant_health', 'effect')
server_ticks(25)
assert health() == 1000, f'armor fixture lost health: {case}'
host('command', command=f'damage {GUEST_NAME} 4 minecraft:cactus')
server_ticks(2)
observed = {}
for slot, item in slots:
reply = server_reply(guest, 'data get entity @s '
f'equipment.{slot}.components."minecraft:damage"')
value = re.search(r'entity data: (-?\d+)\s*$', reply)
read_damage = int(value.group(1)) if value else None
exists = server_reply(guest, f'execute if items entity @s armor.{slot} '
f'minecraft:diamond_{item}')
observed[slot] = {'damage': read_damage, 'present': 'Test passed' in exists}
after_health = health()
cases[case] = {'health': after_health, 'equipment': observed}
want = {slot: {'damage': damage, 'present': present} for slot, _ in slots}
if observed != want:
failures.append(case)
if case != 'custom_limit' and not math.isclose(after_health, 999.04, abs_tol=0.002):
failures.append(f'{case}: armor reduction')
finally:
for slot, _ in slots:
guest('command', command=f'item replace entity @s armor.{slot} with minecraft:air')
guest('command', command='attribute @s minecraft:max_health base set 20')
guest('command', command='effect give @s minecraft:instant_health 1 10')
server_ticks(25)
server_reply(guest, 'effect clear @s minecraft:instant_health', 'effect')
server_reply(guest, 'gamerule natural_health_regeneration true', 'Game rule', 'true')
# Same-hit reduction ordering is recorded independently: a component fix
# must not silently claim to fix the deferred armor-wear health adapter.
order = {'actual_health': cases['custom_limit']['health'], 'vanilla_health': 996.0}
order['matches'] = math.isclose(order['actual_health'], order['vanilla_health'], abs_tol=0.002)
assert not failures, {'armor_component_failures': failures, 'cases': cases,
'same_hit_armor_order': order}
return {'cases': cases, 'same_hit_armor_order': order}
def check_damage(host, guest, out):
guest('command', command='gamemode survival')
guest.wait(10)
before = remote(guest)['health']
x, y, z = pos_of(guest)
guest('command', command=f'tp @s {x:.2f} {y + 12:.2f} {z:.2f}')
guest.wait(50)
after = remote(guest)['health']
guest.shot(out, 'damage')
assert after < before, f'health {before} -> {after} after a 12-block fall'
# Into water: the fall does no damage.
x, y, z = pos_of(guest)
guest('command', command=f'setblock {int(x // 1)} {int(y // 1)} {int(z // 1)} minecraft:water')
guest('command', command=f'tp @s {x:.2f} {y + 12:.2f} {z:.2f}')
guest.wait(50)
water = remote(guest)['health']
assert water >= after, f'health {after} -> {water} after falling into water'
# The host's /damage on the guest by name hurts it (not "Target is invulnerable").
guest('command', command='effect give @s minecraft:instant_health 1 4')
guest.wait(10)
healed = remote(guest)['health']
said = host('command', command=f'damage {GUEST_NAME} 4 minecraft:generic')
guest.wait(20)
hit = remote(guest)['health']
applied = any('Applied' in line for line in said.get('chat', []))
assert applied and hit < healed, f'/damage {GUEST_NAME} 4: health {healed} -> {hit} ({said})'
armor = check_player_armor_components(host, guest)
return {'before': before, 'after': after, 'water': water, 'damage_command': [healed, hit],
'armor_components': armor}
def first_hurt(guest, before, ticks):
"""The guest's health once it drops below `before`, waiting at most `ticks`."""
health = remote(guest)['health']
for _ in range(ticks // 10):
if health < before:
break
guest.wait(10)
health = remote(guest)['health']
return health
def check_mobs(host, guest, out):
host('command', command='gamemode creative')
host('command', command='difficulty normal')
host('command', command='time set midnight')
guest('command', command='gamemode survival')
guest.wait(10)
hurt = {}
try:
for mob, distance in (('zombie', 3), ('skeleton', 7)):
x, y, z = pos_of(guest)
before = remote(guest)['health']
host('command', command=f'summon minecraft:{mob} {x:.2f} {y:.2f} {z + distance:.2f}')
low = first_hurt(guest, before, 300)
guest.shot(out, f'mobs-{mob}')
host.shot(out, f'mobs-{mob}')
host('command', command=f'kill @e[type=minecraft:{mob}]')
hurt[mob] = (before, low)
finally:
host('command', command='gamemode survival')
host('command', command='time set day')
for mob, (before, low) in hurt.items():
assert low < before, f'the {mob} never hurt the guest: health {before} -> {low} (all: {hurt})'
return hurt
def check_hazards(host, guest, out):
guest('command', command='gamemode survival')
# A fresh life: full health, so no regeneration eats food while the guest runs.
guest('command', command='kill @s')
guest.wait(20)
# ServerPlayer.doTick sends the dead player's health of 0 too.
died = {'screen': guest('state').get('screen'), 'health': remote(guest)['health']}
assert died == {'screen': 'dead', 'health': 0.0}, f'the guest was killed: {died}'
guest.raw('respawn')
guest.wait(40)
# Sprint-jumping down a stone lane: ServerPlayer.jumpFromGround and checkMovementStatistics.
guest('command', command='fill ~ ~-1 ~-1 ~60 ~-1 ~1 minecraft:stone')
guest('command', command='fill ~ ~ ~-1 ~60 ~3 ~1 minecraft:air')
guest.wait(10)
x, y, z = pos_of(guest)
guest.raw('look-at', f'{x + 60:.2f}', f'{y + 1.6:.2f}', f'{z:.2f}')
food = lan_guest(host)['food']
for k in ('w', 'ctrl', 'space'):
guest.raw('key', k, 'down')
guest.wait(160)
for k in ('space', 'ctrl', 'w'):
guest.raw('key', k, 'up')
guest.wait(10)
fed = lan_guest(host)['food']
hunger = {'before': food, 'after': fed}
hurt = {}
hx, hy, hz = (int(v // 1) for v in pos_of(host))
x, y, z = hx + 3, hy, hz
for name, fill, ticks in (('lava', 'minecraft:lava', 60), ('wall', 'minecraft:stone', 60),
('drowning', 'minecraft:water', 500), ('void', None, 60)):
if dead(guest):
guest.raw('respawn')
guest.wait(40)
# A stone box around the guest holds the lava and the water in.
guest('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 1} {y + 2} {z + 1} minecraft:stone')
guest('command', command=f'fill {x} {y} {z} {x} {y + 1} {z} minecraft:air')
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
guest.wait(100)
before = remote(guest)['health']
if fill:
guest('command', command=f'fill {x} {y} {z} {x} {y + 1} {z} {fill}')
else:
guest('command', command=f'tp @s {x + 0.5} -140 {z + 0.5}')
hurt[name] = (before, first_hurt(guest, before, ticks))
guest.shot(out, f'hazards-{name}')
# The host tidies up: the void can kill the guest before this, the server drops a dead
# guest's packets (lan_guest_packet), and the box left standing suffocated it in pvp.
# Water puts the lava's fire out.
host('command', command=f'fill {x} {y} {z} {x} {y + 1} {z} minecraft:water')
guest.wait(5)
host('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 1} {y + 2} {z + 1} minecraft:air')
host('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 1} {y - 1} {z + 1} minecraft:stone')
guest.wait(20)
if dead(guest):
guest.raw('respawn')
guest.wait(40)
# [food, saturation, exhaustion]: 8 s of sprint-jumping is about 2 of exhaustion; every 4
# of it takes a point of saturation, or of food once saturation is gone (FoodData.tick).
spent = 4 * (food[0] + food[1] - fed[0] - fed[1]) + fed[2] - food[2]
assert spent > 1.0, f'no hunger from moving: {hunger} ({spent:.2f} spent)'
for name, (before, after) in hurt.items():
assert after < before, f'{name} never hurt the guest: health {before} -> {after} (all: {hurt})'
return {'died': died, 'hunger': hunger, **hurt}
def check_pvp(host, guest, out):
if dead(guest):
guest.raw('respawn')
guest.wait(40)
for g in (host, guest):
g('command', command='gamemode survival')
g('command', command='item replace entity @s weapon.mainhand with minecraft:iron_sword')
face_each_other(host, guest)
hits = {}
try:
for name, rule, attacker in (('guest hits host', 'true', guest), ('host hits guest', 'true', host),
('pvp off', 'false', guest), ('guest hits teammate', 'true', guest),
('host hits teammate', 'true', host)):
host('command', command=f'gamerule pvp {rule}')
if name == 'guest hits teammate':
# Player.canHarmPlayer: the victim's team without friendly fire spares its allies.
host('command', command='team add parity_allies')
host('command', command='team modify parity_allies friendlyFire false')
host('command', command='team join parity_allies @a')
face_each_other(host, guest)
attacker.wait(30) # a full attack charge
victim = (lambda: host('state')['player']['health']) if attacker is guest else (lambda: remote(guest)['health'])
before = victim()
attacker.raw('attack')
attacker.wait(10)
hits[name] = (before, victim())
attacker.shot(out, 'pvp-' + name.replace(' ', '-'))
finally:
host('command', command='gamerule pvp true')
host('command', command='team remove parity_allies')
assert hits['guest hits host'][1] < hits['guest hits host'][0], f'the guest did not hurt the host: {hits}'
assert hits['host hits guest'][1] < hits['host hits guest'][0], f'the host did not hurt the guest: {hits}'
# Natural regeneration may heal the victim meanwhile.
assert hits['pvp off'][1] >= hits['pvp off'][0], f'pvp off still hurts: {hits}'
for name in ('guest hits teammate', 'host hits teammate'):
assert hits[name][1] == hits[name][0], f'friendly fire off still hurts ({name}): {hits}'
return hits
def leave_game_total(host):
"""The leave_game statistic summed over the stats files the host's world holds."""
total = 0
for path in (host.work / 'saves' / 'Lan' / 'players' / 'stats').glob('*.json'):
custom = json.loads(path.read_text()).get('stats', {}).get('minecraft:custom', {})
total += custom.get('minecraft:leave_game', 0)
return total
def check_leave(host, guest, out):
# PlayerList.remove awards Stats.LEAVE_GAME before it saves the player: a guest who leaves has it in
# their saved statistics, and it counts again the next time they leave.
port = host.raw('lan', 'status')['port']
before = leave_game_total(host)
counts = []
for _ in range(2):
guest.raw('disconnect')
for _ in range(20):
time.sleep(0.5)
if leave_game_total(host) > (counts[-1] if counts else before):
break
counts.append(leave_game_total(host))
guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME)
guest.raw('wait-for', 'loaded', '--timeout', 300)
guest.wait(20)
assert counts == [before + 1, before + 2], f'leave_game was {before}, then {counts} after two leaves'
return {'leave_game': counts}
def check_kills(host, guest, out):
# A mob the guest kills is the guest's kill (#484): LivingEntity.die credits lastHurtByPlayer, so the
# killed statistic (and its scoreboard criterion) counts for the guest, not the host.
host('command', command='scoreboard objectives add parity_kills minecraft.killed:minecraft.pig')
for g in (host, guest):
g('command', command='gamemode survival')
guest('command', command='item replace entity @s weapon.mainhand with minecraft:iron_sword')
face_each_other(host, guest)
hx, hy, hz = pos_of(host)
try:
# Between the two, in front of the guest, with one health left.
host('command', command=f'summon minecraft:pig {hx + 1.5:.2f} {hy:.2f} {hz:.2f} {{NoAI:1b,Health:1.0f}}')
guest.wait(30)
guest.raw('look-at', f'{hx + 1.5:.2f}', f'{hy + 0.5:.2f}', f'{hz:.2f}')
guest.wait(10)
guest.raw('attack')
guest.wait(20)
host('command', command=f'scoreboard players get {GUEST_NAME} parity_kills')
host('command', command=f'scoreboard players get {host.player} parity_kills')
host.wait(10)
chat = host.raw('messages', 10)['chat']
guest.shot(out, 'kills')
finally:
host('command', command='kill @e[type=minecraft:pig]')
host('command', command='scoreboard objectives remove parity_kills')
assert any(f'{GUEST_NAME} has 1 [parity_kills]' in line for line in chat), f"the guest's kill is not theirs: {chat}"
assert not any(f'{host.player} has 1 [parity_kills]' in line for line in chat), f"the host got the guest's kill: {chat}"
return chat
def check_looting(host, guest, out):
"""Looting on the guest's sword adds to the drops of a mob they kill (#4496). Deaths are handled
in the server's tick, after the guest's swing, so the killer's weapon has to be read from that
guest, not from the server's own player. Looting 255 adds round(255 x uniform(0, 1)) porkchops
to the pig's 1-3, so a pig dropping 3 or fewer is the bug (or a 1-in-100 roll: two tries)."""
for g in (host, guest):
g('command', command='gamemode survival')
guest('command', command='item replace entity @s weapon.mainhand with '
'minecraft:iron_sword[minecraft:enchantments={"minecraft:looting":255}]')
face_each_other(host, guest)
hx, hy, hz = pos_of(host)
counts = []
def porkchops():
# On the ground, or already picked up by either player (the pig dies at the guest's feet).
items = host.raw('entities', 8, 'minecraft:item')['entities']
ground = sum(e['item']['count'] for e in items if e['item']['item'] == 'minecraft:porkchop')
return ground + sum(g('inventory')['counts'].get('minecraft:porkchop', 0) for g in (host, guest))
try:
for _ in range(2):
for g in (host, guest):
g('command', command='clear @s minecraft:porkchop')
host('command', command=f'summon minecraft:pig {hx + 1.5:.2f} {hy:.2f} {hz:.2f} {{NoAI:1b,Health:1.0f}}')
guest.wait(30)
guest.raw('look-at', f'{hx + 1.5:.2f}', f'{hy + 0.5:.2f}', f'{hz:.2f}')
guest.wait(10)
guest.raw('attack')
guest.wait(30)
counts.append(porkchops())
host('command', command='kill @e[type=minecraft:item]')
if counts[-1] > 3:
break
guest.shot(out, 'looting')
finally:
host('command', command='kill @e[type=minecraft:pig]')
host('command', command='kill @e[type=minecraft:item]')
assert counts[-1] > 3, f"Looting 255 on the guest's sword added nothing: porkchops {counts}"
return counts
def check_arrows(host, guest, out):
# A player's arrow on another player (#484): AbstractArrow.onHitEntity, refused by
# ServerPlayer.canHarmPlayer with the pvp game rule off (the arrow turns back).
for g in (host, guest):
g('command', command='gamemode survival')
g('command', command='clear @s')
g('command', command='item replace entity @s weapon.mainhand with minecraft:bow')
g('command', command='give @s minecraft:arrow 16')
hits = {}
try:
for name, rule, shooter in (('guest shoots host', 'true', guest), ('host shoots guest', 'true', host),
('pvp off', 'false', guest)):
host('command', command=f'gamerule pvp {rule}')
for g in (host, guest):
g('command', command='effect give @s minecraft:instant_health 1 5')
face_each_other(host, guest)
shooter.wait(20)
victim = (lambda: host('state')['player']['health']) if shooter is guest else (lambda: remote(guest)['health'])
before = victim()
shooter.raw('use-item', '--hold', 1.3) # a full draw (BowItem: 20 ticks)
shooter.wait(20)
hits[name] = (before, victim())
shooter.shot(out, 'arrows-' + name.replace(' ', '-'))
finally:
host('command', command='gamerule pvp true')
for g in (host, guest):
g('command', command='clear @s')
assert hits['guest shoots host'][1] < hits['guest shoots host'][0], f"the guest's arrow did not hurt the host: {hits}"
assert hits['host shoots guest'][1] < hits['host shoots guest'][0], f"the host's arrow did not hurt the guest: {hits}"
assert hits['pvp off'][1] >= hits['pvp off'][0], f'pvp off, an arrow still hurts: {hits}'
return hits
def command_says(player, command):
"""What the server printed back to `command` (the lines that are its own reply)."""
since = player.raw('events')['last']
player('command', command=command)
for _ in range(40):
lines = [e.get('text', '') for e in player.raw('events', '--since', since)['events'] if own_reply(e)]
if lines:
return ' '.join(lines)
player.wait(2)
raise Failed(f'no answer to {command}')
def check_projectile_vehicles(host, guest, out):
"""Every projectile kind strikes a boat or a minecart (`VehicleEntity.isPickable`), and
`VehicleEntity.hurtServer` takes their damage (+10 per point, destroyed past 40): an arrow of speed 3
(damage 6) and a trident (8) destroy the boat or cart; a snowball, an egg and an ender pearl (0) stop at it
and leave it whole. Before, boats were no target at all and carts only for arrows: the rest flew on through."""
x, y, z = apart(host, guest)['host']
host('command', command='gamemode creative')
host('command', command=f'fill {x - 2} {y} {z - 4} {x + 3} {y + 3} {z + 7} minecraft:air')
host('command', command=f'fill {x - 2} {y - 1} {z - 4} {x + 3} {y - 1} {z + 7} minecraft:stone')
host('command', command=f'forceload add {x - 2} {z - 4} {x + 3} {z + 7}')
still = 'NoGravity:1b,Silent:1b'
result = {}
try:
for shot, speed, target, destroyed in (
('arrow', 3.0, 'oak_boat', True), ('arrow', 3.0, 'minecart', True),
('trident', 1.5, 'oak_boat', True), ('trident', 1.5, 'minecart', True),
('snowball', 1.5, 'oak_boat', False), ('snowball', 1.5, 'minecart', False),
('egg', 1.5, 'oak_boat', False), ('egg', 1.5, 'minecart', False),
('ender_pearl', 1.5, 'oak_boat', False), ('ender_pearl', 1.5, 'minecart', False),
('arrow', 0.5, 'oak_boat', False)):
name = f'{shot}-{speed}-{target}'
host('command', command=f'kill @e[type=!minecraft:player,x={x},y={y},z={z},distance=..30]')
host('command', command=f'summon minecraft:{target} {x + 2.5} {y + 0.1} {z + 3.5} {{{still},Tags:["hv_v"]}}')
host('command', command=f'summon minecraft:{shot} {x + 2.5} {y + 0.35} {z - 2.5} '
f'{{{still},Tags:["hv_s"],Motion:[0.0d,0.0d,{speed}d]}}')
host.wait(40)
vehicles = [o for o in host('status')['objects'] if o['kind'].endswith(target)
and abs(o['position'][2] - (z + 3.5)) < 4]
shot_left = command_says(host, f'execute if entity @e[tag=hv_s]')
result[name] = {'vehicle left': len(vehicles), 'projectile still flying': 'passed' in shot_left}
where = command_says(host, 'data get entity @e[tag=hv_s,limit=1] Pos') if shot_left and 'passed' in shot_left else ''
assert (not vehicles) == destroyed, (f'{name}: the {target} {"stays" if destroyed else "was destroyed"}: '
f'{result[name]}, projectile {where}')
if shot == 'trident':
# ThrownTrident.onHitEntity: REVERSE (-0.5) times 0.02, so it leaves at -1/100 of its speed
# (a trident that flew on, or bounced twice as hard, has a Motion far from that).
reply = command_says(host, 'data get entity @e[tag=hv_s,limit=1] Motion[2]')
back = float(reply.rsplit(':', 1)[1].strip().rstrip('d'))
result[name]['bounce'] = back
assert -0.02 < back < 0, f'{name}: the trident leaves the {target} at Motion z {back}, vanilla\'s is -0.015'
# An arrow or snowball is spent on the hit; a trident bounces off and stays.
assert shot == 'trident' or 'passed' not in shot_left, \
f'{name}: the {shot} flew through or past the {target}: {result[name]}'
finally:
host('command', command=f'kill @e[type=!minecraft:player,x={x},y={y},z={z},distance=..30]')
host('command', command=f'forceload remove {x - 2} {z - 4} {x + 3} {z + 7}')
return result
def check_flame_arrows(host, guest, out):
# BowItem.releaseUsing -> EnchantmentHelper.onProjectileSpawned: Flame sets the arrow on fire, and
# Entity.baseTick's setSharedFlagOnFire sends it, so every client draws the flames (#7356).
spots = apart(host, guest)
seen = {}
try:
for label, shooter, other in (('host', host, guest), ('guest', guest, host)):
x, y, z = spots[label]
shooter('command', command='gamemode survival')
for bow in ('flame', 'plain'):
item = 'minecraft:bow[minecraft:enchantments={"minecraft:flame":1}]' if bow == 'flame' else 'minecraft:bow'
shooter('command', command=f'item replace entity @s weapon.mainhand with {item}')
shooter('command', command='give @s minecraft:arrow 1')
shooter.wait(10)
# Straight up into open sky: it slows down over the shooter, where the other player looks.
shooter.raw('look-at', f'{x + 0.5:.2f}', f'{y + 60:.2f}', f'{z + 0.52:.2f}')
shooter.raw('use-item', '--hold', 1.3) # a full draw (BowItem: 20 ticks); returns at once
case = {'own': set(), 'other': set()}
shot = False
for _ in range(40):
shooter.wait(2)
for side, game in (('own', shooter), ('other', other)):
for p in game('status')['projectiles']:
if p['kind'] != 'Arrow' or abs(p['position'][0] - (x + 0.5)) > 4:
continue
case[side].add(bool(p.get('on_fire')))
# Near the top of its flight: the other player looks at it and shows it.
if side == 'other' and not shot and abs(p['velocity'][1]) < 0.6:
other.raw('look-at', *(f'{v:.2f}' for v in p['position']))
other.wait(1)
other.shot(out, f'flame-arrows-{label}-{bow}')
shot = True
if shot:
break
seen[f'{label} {bow}'] = {k: sorted(v) for k, v in case.items()}
shooter('command', command='kill @e[type=minecraft:arrow]')
shooter.wait(5)
finally:
for g in (host, guest):
g('command', command='clear @s')
for name, case in seen.items():
want = [True] if name.endswith('flame') else [False]
assert case['own'] == want and case['other'] == want, f'{name}: on_fire seen in flight {seen}'
return seen
def check_deaths(host, guest, out):
def other_player(watcher):
"""The other player's entity id as `watcher` draws it: aiming by an entity, not by
coordinates, holds when a just-respawned client's position lags the server's."""
for _ in range(20):
found = watcher.raw('entities', 6, 'player')['entities']
if found:
return found[0]['id']
watcher.wait(5)
raise AssertionError(f'{watcher.name} does not draw the other player')
def ready():
for g in (host, guest):
if g('state').get('screen') == 'dead':
g.raw('respawn')
g.wait(40)
back_to_game(g)
g('command', command='effect give @s minecraft:instant_health 1 5')
host('command', command='execute at @s run fill ~-5 200 ~-5 ~5 200 ~5 minecraft:stone')
host('command', command='tp @s ~ 201 ~')
host('command', command=f'tp {GUEST_NAME} {host.player}')
guest('command', command='tp @s ~3 ~ ~')
host.wait(20)
host.raw('look-at-entity', other_player(host))
guest.raw('look-at-entity', other_player(guest))
host.wait(5)
said = {}
try:
host('command', command='gamerule pvp true')
host('command', command='gamerule natural_health_regeneration false')
for g in (host, guest):
g('command', command='gamemode survival')
g('command', command='clear @s')
g('command', command='item replace entity @s armor.chest with minecraft:air')
host('command', command='item replace entity @s weapon.mainhand with minecraft:iron_sword')
ready()
host('command', command=f'damage {GUEST_NAME} 18 minecraft:generic')
host.wait(30)
host.raw('attack')
host.wait(20)
said['sword'] = host.raw('messages', 10)['chat']
host.shot(out, 'deaths-sword')
guest('command', command='item replace entity @s weapon.mainhand with minecraft:iron_sword')
ready()
host('command', command=f'damage {host.player} 18 minecraft:generic')
guest.wait(30)
guest.raw('attack')
guest.wait(20)
said['host_death'] = host.raw('messages', 10)['chat']
said['host_death_behind_guest'] = guest.raw('messages', 10)['chat']
guest.shot(out, 'deaths-host')
host('command', command='item replace entity @s weapon.mainhand with minecraft:bow')
host('command', command='give @s minecraft:arrow 16')
ready()
host('command', command=f'damage {GUEST_NAME} 18 minecraft:generic')
host.raw('use-item', '--hold', 1.3)
host.wait(20)
said['arrow'] = host.raw('messages', 10)['chat']
guest('command', command='item replace entity @s weapon.mainhand with minecraft:bow')
guest('command', command='give @s minecraft:arrow 16')
ready()
host('command', command=f'damage {host.player} 18 minecraft:generic')
guest.raw('use-item', '--hold', 1.3)
guest.wait(20)
said['arrow_behind_guest'] = guest.raw('messages', 10)['chat']
host('command', command='item replace entity @s weapon.mainhand with minecraft:air')
host('command', command='item replace entity @s armor.chest with '
'minecraft:diamond_chestplate[minecraft:enchantments={"minecraft:thorns":255}]')
ready()
hx, hy, hz = pos_of(host)
for _ in range(8):
if dead(guest):
break
guest('command', command='effect give @s minecraft:instant_health 1 5')
host('command', command=f'tp @s {hx:.2f} {hy:.2f} {hz:.2f}')
host('command', command=f'tp {GUEST_NAME} {host.player}')
guest('command', command='tp @s ~3 ~ ~')
host('command', command=f'damage {GUEST_NAME} 19 minecraft:generic')
guest.wait(30)
guest.raw('look-at-entity', other_player(guest))
guest.raw('attack')
guest.wait(10)
said['thorns'] = host.raw('messages', 10)['chat']
host.shot(out, 'deaths-thorns')
host('command', command='item replace entity @s weapon.mainhand with minecraft:flint_and_steel')
ready()
gx, gy, gz = pos_of(guest)
host('command', command=f'tp @s {gx - 4:.2f} {gy:.2f} {gz:.2f}')
bx, by, bz = int(gx) - 1, int(gy), int(gz)
host('command', command=f'setblock {bx} {by} {bz} minecraft:tnt')
host.raw('look-at', f'{bx + 0.5:.2f}', f'{by + 0.5:.2f}', f'{bz + 0.5:.2f}')
host.wait(2)
host.raw('use-item')
guest.wait(100)
said['tnt'] = host.raw('messages', 10)['chat']
host.shot(out, 'deaths-tnt')
ready()
hx, hy, hz = pos_of(host)
host('command', command='effect give @s minecraft:resistance 10 5')
host('command', command=f'summon minecraft:pig {hx + 3:.2f} {hy:.2f} {hz:.2f} {{NoAI:1b}}')
host('command', command=f'setblock {int(hx) + 2} {int(hy)} {int(hz)} minecraft:tnt')
host('command', command='item replace entity @s weapon.mainhand with minecraft:flint_and_steel')
host.raw('look-at', f'{int(hx) + 2.5:.2f}', f'{int(hy) + 0.5:.2f}', f'{int(hz) + 0.5:.2f}')
host.wait(2)
host.raw('use-item')
host.wait(100)
said['blast_pig'] = host.raw('entities', 6, 'pig')['entities']
host.shot(out, 'deaths-blast-pig')
finally:
host('command', command='gamerule natural_health_regeneration true')
for g in (host, guest):
g('command', command='item replace entity @s armor.chest with minecraft:air')
g('command', command='clear @s')
name = host.player
want = {'sword': f'{GUEST_NAME} was slain by {name}',
'host_death': f'{name} was slain by {GUEST_NAME}',
'host_death_behind_guest': f'{name} was slain by {GUEST_NAME}',
'arrow': f'{GUEST_NAME} was shot by {name}',
'arrow_behind_guest': f'{name} was shot by {GUEST_NAME}',
'thorns': f'{GUEST_NAME} was killed while trying to hurt {name}',
'tnt': f'{GUEST_NAME} was blown up by {name}'}
for case, line in want.items():
assert line in said[case], f'no "{line}" in the {case} chat: {said[case]}'
assert not said['blast_pig'], f'the blast left the pig standing: {said["blast_pig"]}'
for case, lines in said.items():
assert not any('%1$s' in line or '%2$s' in line for line in lines), \
f'a raw template in the {case} chat: {lines}'
return said
def dead_players(game, want, ticks=60):
"""The other players `game`'s client draws dead, once that is `want` (empty or not) or after `ticks`."""
for _ in range(ticks // 5):
dead = remote(game)['dead_players']
if bool(dead) == want:
return dead
game.wait(5)
return dead
def check_respawn(host, guest, out):
# ServerLevel.removePlayerImmediately and addRespawnedPlayer: a respawned player is removed
# from the other players and paired again as a new entity, so their clients drop the body.
# The host window only has a client's view of the guest on the server thread.
for g in (host, guest):
back_to_game(g)
results = {}
sides = [('host', host, guest), ('guest', guest, host)]
for name, dying, watching in sides:
face_each_other(host, guest)
dying('command', command='kill @s')
dying.wait(30)
dead = dead_players(watching, True)
watching.shot(out, f'respawn-{name}-dead')
dying.raw('respawn')
dying.wait(40)
back_to_game(dying)
# Back within tracking range of the other player, wherever the respawn point is.
x, y, z = pos_of(dying)
watching('command', command=f'tp @s {x + 3:.2f} {y:.2f} {z:.2f}')
watching.wait(40)
alive = dead_players(watching, False)
watching.shot(out, f'respawn-{name}-alive')
results[name] = {'dead': dead, 'after_respawn': alive}
assert dead, f'the {name} was not drawn dead: {results}'
assert not alive, f'the {name} still lies dead after respawning: {results}'
return results
def check_hurt(host, guest, out):
for g in (host, guest):
g('command', command='gamemode survival')
g('command', command='clear @s')
face_each_other(host, guest)
hx, hy, hz = pos_of(host)
# Between the two, within both players' reach; bare hands leave it alive.
host('command', command=f'summon minecraft:pig {hx + 1.5:.2f} {hy:.2f} {hz + 0.8:.2f} {{NoAI:1b}}')
host.wait(30)
def pig(game):
return game.raw('entities', 6, 'pig')['entities'][0]
seen = {}
for name, attacker, watcher in (('guest hits', guest, host), ('host hits', host, guest)):
attacker.wait(30) # a full attack charge
attacker.raw('attack', pig(attacker)['id'])
seen[name] = {'attacker': pig(attacker)['hurt_time'], 'watcher': pig(watcher)['hurt_time']}
attacker.shot(out, 'hurt-' + name.replace(' ', '-'))
watcher.shot(out, 'hurt-' + name.replace(' ', '-') + '-seen')
attacker.wait(20)
host('command', command='kill @e[type=minecraft:pig]')
# A hurt player flashes red for the other one too (#2827).
watched = (('host hurt', host, guest), ('guest hurt', guest, host))
for name, hurt, watcher in watched:
hurt.wait(30)
# Instant damage hurts on the next effect tick.
hurt('command', command='effect give @s minecraft:instant_damage 1 0')
for _ in range(10):
other = watcher.raw('entities', 6, 'player')['entities'][0]
if other['hurt_time'] > 0:
break
watcher.wait(1)
seen[name] = {'watcher': other['hurt_time']}
watcher.shot(out, 'hurt-' + name.replace(' ', '-') + '-seen')
# LivingEntity.hurtTime: 10 when hurt, one less each tick; the reads come a few ticks later.
for name, sides in seen.items():
assert all(t > 0 for t in sides.values()), f'{name}: the hurt flash {seen}'
return seen
def mob_looks(game, centre):
"""What `check_looks` compares of each mob around `centre`, by kind and column."""
found = {}
for m in game.raw('mobs', 32).get('near', []):
if abs(m['pos'][0] - centre[0]) > 12 or abs(m['pos'][2] - centre[2]) > 14:
continue
key = f"{m['kind'].removeprefix('minecraft:')}@{round(m['pos'][0] - centre[0])}"
found[key] = {'baby': m['baby'], 'color': m['color'], 'sheared': m['sheared'], 'size': m['size'],
'tame': m['tame'], 'sitting': m['sitting'], 'burning': m['burning'],
'health': round(m['health'])}
return found
def check_looks(host, guest, out):
x, y, z = (int(v // 1) for v in pos_of(host))
centre = (x + 0.5, y, z + 0.5)
# Each mob in a column of its own (the key), standing on the stone floor.
looks = {
'cow@-5': ('{NoAI:1b,Fire:80s}', {'burning': True}),
'cow@-3': ('{NoAI:1b,Age:-24000}', {'baby': True}),
'cow@-1': ('{NoAI:1b}', {'baby': False}),
'sheep@1': ('{NoAI:1b,Color:14b,Sheared:1b}', {'color': 14, 'sheared': True}),
'slime@3': ('{NoAI:1b,Size:3}', {'size': 4}),
'wolf@5': ('{NoAI:1b,Owner:[I;1,2,3,4],Sitting:1b,CollarColor:11b}',
{'tame': True, 'sitting': True, 'color': 11}),
}
sides = {'host': host, 'guest': guest}
seen = {}
try:
for key, (tag, _) in looks.items():
kind, dx = key.split('@')
host('command', command=f'summon minecraft:{kind} {centre[0] + int(dx)} {y} {z + 4.5} {tag}')
host('command', command=f'summon minecraft:wither {centre[0]} {y + 3} {z + 10.5} {{NoAI:1b,Invul:0}}')
host.wait(30)
seen['spawned'] = {name: mob_looks(g, centre) for name, g in sides.items()}
# Changes after the spawn: the wither hurt, the sheep dyed and grown back, the calf grown up.
host('command', command='damage @e[type=minecraft:wither,limit=1] 150 minecraft:generic')
for kind, dx, tag in (('sheep', 1, '{Color:11b,Sheared:0b}'), ('cow', -3, '{Age:0}')):
host('command', command=f'data merge entity @e[type=minecraft:{kind},x={centre[0] + dx},y={y},'
f'z={z + 4.5},distance=..0.5,limit=1] {tag}')
guest.raw('look-at', centre[0], y + 1, z + 4.5)
host.raw('look-at', centre[0], y + 1, z + 4.5)
# The fire (80 ticks) goes out in the meantime.
host.wait(80)
for name, g in sides.items():
g.shot(out, 'looks')
seen['later'] = {name: mob_looks(g, centre) for name, g in sides.items()}
finally:
# A killed slime splits: shrink it first so no children are left hopping into the next check.
host('command', command=f'data merge entity @e[type=minecraft:slime,x={centre[0] + 3},y={y},z={z + 4.5},'
f'distance=..2,limit=1] {{Size:0}}')
host('command', command=f'kill @e[type=!minecraft:player,x={x - 12},y={y - 2},z={z - 2},dx=24,dy=12,dz=16]')
wrong = []
for name in sides:
spawned, later = seen['spawned'][name], seen['later'][name]
for key, (_, want) in looks.items():
got = spawned.get(key)
if got is None or any(got[k] != v for k, v in want.items()):
wrong.append(f'{name} {key}: {got}, wants {want}')
if later.get('cow@-5', {}).get('burning') is not False:
wrong.append(f"{name}: the cow's fire never went out: {later.get('cow@-5')}")
if [later.get('sheep@1', {}).get(k) for k in ('color', 'sheared')] != [11, False]:
wrong.append(f"{name}: the sheep dyed blue with its wool back: {later.get('sheep@1')}")
if later.get('cow@-3', {}).get('baby') is not False:
wrong.append(f"{name}: the calf grown up: {later.get('cow@-3')}")
withers = [v['health'] for k, v in spawned.items() if k.startswith('wither')]
hurt = [v['health'] for k, v in later.items() if k.startswith('wither')]
if withers != [300] or hurt != [150]:
wrong.append(f"{name}: the wither's health {withers} then {hurt}, wants [300] then [150]")
assert not wrong, '\n'.join(wrong)
return seen['later']['guest']
def tag_check_says(player, command):
"""What the server printed back to `command` (the lines that are its own reply)."""
since = player.raw('events')['last']
player('command', command=command)
for _ in range(40):
lines = [e.get('text', '') for e in player.raw('events', '--since', since)['events'] if own_reply(e)]
if lines:
return ' '.join(lines)
player.wait(2)
raise Failed(f'no answer to {command}')
def check_entity_tags(host, guest, out):
"""`/tag add` reaches a thrown trident, a firework rocket and a falling block, and `@e[tag=]` finds them:
before, `/tag` wrote nothing for these three and still reported success."""
x, y, z = apart(host, guest)['host']
host('command', command=f'fill {x - 2} {y} {z - 4} {x + 3} {y + 3} {z + 7} minecraft:air')
host('command', command=f'fill {x - 2} {y - 1} {z - 4} {x + 3} {y - 1} {z + 7} minecraft:stone')
host('command', command=f'forceload add {x - 2} {z - 4} {x + 3} {z + 7}')
kinds = (('trident', ''), ('firework_rocket', 'LifeTime:200,'),
('falling_block', 'BlockState:{Name:"minecraft:sand"},Time:1,'))
result = {}
try:
for n, (kind, extra) in enumerate(kinds):
at = f'{x + 0.5} {y + 2} {z + 0.5 + n}'
host('command', command=f'summon minecraft:{kind} {at} {{{extra}NoGravity:1b,Silent:1b}}')
host('command', command=f'tag @e[type=minecraft:{kind},x={x + 0.5},y={y + 2},z={z + 0.5 + n},distance=..1,limit=1] add hv_tag{n}')
host.wait(4)
says = tag_check_says(host, f'execute if entity @e[tag=hv_tag{n}]')
result[kind] = says
assert 'passed' in says, f'/tag add on a {kind} did not reach @e[tag=]: {says}'
finally:
host('command', command=f'kill @e[type=!minecraft:player,x={x},y={y},z={z},distance=..30]')
host('command', command=f'forceload remove {x - 2} {z - 4} {x + 3} {z + 7}')
return result
def check_leads(host, guest, out):
spots = apart(host, guest)
result = {}
for label, player, other in (('host', host, guest), ('guest', guest, host)):
x, y, z = spots[label]
at = (x + 0.5, y, z + 2.5)
def cow(game):
found = [m for m in game.raw('mobs', 32).get('near', []) if m['kind'] == 'minecraft:cow'
and abs(m['pos'][0] - at[0]) < 1 and abs(m['pos'][2] - at[2]) < 1]
assert found, f'{game.name} sees no cow at {at}'
return found[0]
player('command', command='gamemode survival')
player('command', command=f'summon minecraft:cow {at[0]} {y} {at[2]} {{NoAI:1b}}')
player('command', command='item replace entity @s weapon.mainhand with minecraft:lead')
player.wait(30)
try:
player.raw('look-at', f'{at[0]:.2f}', f'{y + 0.7:.2f}', f'{at[2]:.2f}')
player.raw('mouse', 'right', 'click')
player.wait(20)
tied = {'own': cow(player)['leash'], 'other': cow(other)['leash']}
player.shot(out, 'leads-' + label)
other.shot(out, f'leads-{label}-seen')
# An empty hand unties it (the lead was used up).
player.raw('mouse', 'right', 'click')
player.wait(20)
untied = {'own': cow(player)['leash'], 'other': cow(other)['leash']}
finally:
player('command', command=f'kill @e[type=!minecraft:player,x={at[0]},y={y},z={at[2]},distance=..6]')
player('command', command='clear @s')
result[label] = {'tied': tied, 'untied': untied}
# The other side draws it held by the player who tied it (another player to it).
assert tied['own'] == 'Player' and tied['other'], f"the {label}'s cow on its lead: {tied}"
assert untied == {'own': None, 'other': None}, f"the {label}'s cow untied: {untied}"
return result
def container_items(game):
"""The open container's stacks by slot (Horizon's container slots are 300 and up) and its size."""
slots = [s for s in game('screen').get('slots', []) if s.get('slot', 0) >= 300]
return {s['slot'] - 300: s['item'] for s in slots if s.get('item')}, len(slots)
def open_container(guest, pos, out, label, take=None):
"""Open the container at `pos` and read it; `take` shift-clicks that container slot out first."""
guest.raw('interact', *pos)
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
# The server's menu (an ender or double chest has no stand-in block entity: key i32::MIN),
# then its contents, can come a few ticks after the screen.
for _ in range(20):
guest.wait(2)
screen = guest('screen')
items = {s['slot']: s.get('item') for s in screen.get('slots', [])}
if screen.get('container') == [-2**31, 0, 0] and (take is None or items.get(300 + take)):
break
guest.wait(5)
if take is not None:
guest.raw('click-slot', 300 + take, 'shift')
guest.wait(5)
items = container_items(guest)
guest.shot(out, label)
guest.raw('key', 'esc', 'tap')
guest.wait(5)
return items
def check_containers(host, guest, out):
host('command', command='item replace entity @s enderchest.0 with minecraft:diamond')
guest('command', command='item replace entity @s enderchest.4 with minecraft:emerald')
x, y, z = (int(v // 1) for v in pos_of(guest))
guest('command', command=f'setblock {x} {y} {z + 2} minecraft:ender_chest')
guest.wait(10)
ender, ender_size = open_container(guest, (x, y, z + 2), out, 'ender-chest')
# Facing north, a left half pairs with the block to its east (ChestBlock.getConnectedDirection).
guest('command', command=f'setblock {x} {y} {z - 2} minecraft:chest[facing=north,type=left]')
guest('command', command=f'setblock {x + 1} {y} {z - 2} minecraft:chest[facing=north,type=right]')
guest('command', command=f'item replace block {x + 1} {y} {z - 2} container.0 with minecraft:gold_ingot')
guest('command', command=f'item replace block {x} {y} {z - 2} container.0 with minecraft:iron_ingot')
guest.wait(10)
double, double_size = open_container(guest, (x, y, z - 2), out, 'double-chest')
# Taking from each writes back to the guest's ender inventory and to the right half.
guest('command', command='clear @s')
open_container(guest, (x, y, z + 2), out, 'ender-chest-take', take=4)
open_container(guest, (x, y, z - 2), out, 'double-chest-take', take=0)
taken = guest('inventory')['counts']
after = (open_container(guest, (x, y, z + 2), out, 'ender-chest-after')[0],
open_container(guest, (x, y, z - 2), out, 'double-chest-after')[0])
host_ender = host('command', command='data get entity @s EnderItems')
for at in ((x, y, z + 2), (x, y, z - 2), (x + 1, y, z - 2)):
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
assert ender == {4: 'minecraft:emerald'} and ender_size == 27, f'the guest\'s ender chest: {ender} ({ender_size})'
assert double_size == 54, f'the double chest opened with {double_size} slots'
assert double == {0: 'minecraft:gold_ingot', 27: 'minecraft:iron_ingot'}, f'the double chest: {double}'
assert 'emerald' not in json.dumps(host_ender), f'the guest\'s item went to the host: {host_ender}'
assert taken.get('minecraft:emerald') == 1 and taken.get('minecraft:gold_ingot') == 1, f'taken: {taken}'
assert after == ({}, {27: 'minecraft:iron_ingot'}), f'after taking: {after}'
return {'ender': ender, 'double': double, 'taken': taken}
def check_names(host, guest, out):
# BlockItem.updateBlockEntityComponents gives the item's custom_name to the block entity, and its
# menu opens with it as the title (ClientboundOpenScreenPacket), shulker boxes and copper chests too.
x, y, z = (int(v // 1) for v in pos_of(guest))
titles = {}
for i, item in enumerate(('red_shulker_box', 'copper_chest', 'chest')):
spot = (x - 2 + 2 * i, y, z + 2)
guest('command', command=f'setblock {spot[0]} {spot[1] - 1} {spot[2]} minecraft:stone')
guest('command', command=f'setblock {spot[0]} {spot[1]} {spot[2]} minecraft:air')
guest('command', command=f'give @s minecraft:{item}[minecraft:custom_name="Named {i}"]')
guest.wait(10)
guest.raw('place', *spot, '--item', f'minecraft:{item}')
guest.wait(10)
guest.raw('interact', *spot)
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
title = guest.until(lambda: guest('screen').get('title'), bool, ticks=40)
titles[item] = ''.join(span['text'] for span in title)
guest.shot(out, f'names-{item}')
guest.raw('key', 'esc', 'tap')
guest.wait(5)
guest('command', command=f'setblock {spot[0]} {spot[1]} {spot[2]} minecraft:air')
want = {'red_shulker_box': 'Named 0', 'copper_chest': 'Named 1', 'chest': 'Named 2'}
assert titles == want, f'the titles of the renamed containers: {titles}'
return titles
def check_machines(host, guest, out):
# After `recipes`: the stacks given here unlock recipes, which would move the book's first one.
guest('command', command='gamemode survival')
x, y, z = (int(v // 1) for v in pos_of(guest))
at = (x, y, z + 2)
seen = {}
for block in ('hopper', 'dispenser', 'dropper'):
guest('command', command='clear @s')
guest('command', command='give @s minecraft:cobblestone 4')
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:{block}')
guest.wait(10)
guest.raw('interact', *at)
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
guest.wait(10)
size = container_items(guest)[1]
guest.shot(out, f'machines-{block}')
guest.raw('click-slot', hotbar_slot(guest, 'minecraft:cobblestone'), 'shift')
guest.wait(10)
guest.raw('key', 'esc', 'tap')
guest.wait(10)
# Opened again, the menu holds what the server has.
seen[block] = (size, open_container(guest, at, out, f'machines-{block}-after')[0])
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
want = {'hopper': (5, {0: 'minecraft:cobblestone'}), 'dispenser': (9, {0: 'minecraft:cobblestone'}),
'dropper': (9, {0: 'minecraft:cobblestone'})}
assert seen == want, f'the machines on the guest: {seen}'
return seen
def check_brewing(host, guest, out):
guest('command', command='gamemode survival')
guest('command', command='clear @s')
loads = ('blaze_powder', 'nether_wart', 'glass_bottle')
for item in loads:
guest('command', command=f'give @s minecraft:{item}')
x, y, z = (int(v // 1) for v in pos_of(guest))
at = (x, y, z + 2)
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:brewing_stand')
guest.wait(10)
guest.raw('interact', *at)
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
guest.wait(10)
size = container_items(guest)[1]
# BrewingStandMenu.quickMoveStack: blaze powder to the fuel slot, an ingredient to its slot,
# a bottle to the bottle slots.
for item in loads:
guest.raw('click-slot', hotbar_slot(guest, f'minecraft:{item}'), 'shift')
guest.wait(5)
# BrewingStandBlockEntity.serverTick burns the blaze powder into 20 uses; the guest sees them
# through the menu's data slots.
guest.wait(10)
info = guest.raw('menu')['info']
guest.shot(out, 'brewing')
guest.raw('key', 'esc', 'tap')
guest.wait(10)
items = open_container(guest, at, out, 'brewing-after')[0]
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
seen = {'size': size, 'items': items, 'fuel': info and info.get('fuel')}
want = {'size': 5, 'items': {0: 'minecraft:glass_bottle', 3: 'minecraft:nether_wart'}, 'fuel': 20}
assert seen == want, f'the brewing stand on the guest: {seen}'
return seen
def open_station(guest, block, out, label):
"""Place `block` two blocks south of the guest and open it: the station's position."""
x, y, z = (int(v // 1) for v in pos_of(guest))
at = (x, y, z + 2)
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:{block}')
guest.wait(10)
guest.raw('interact', *at)
guest.raw('wait-for', 'screen', 'station', '--timeout', 10)
guest.wait(10)
guest.shot(out, label)
return at
def station_slots(guest):
"""The open station's stacks: input slots 600 and up, the result at 650."""
menu = guest.raw('menu')
return {s['slot']: (s['stack']['item'], s['stack']['count']) for s in menu['slots'] if s['slot'] >= 600}
def close_station(guest, at):
guest.raw('key', 'esc', 'tap')
guest.wait(10)
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
def check_loom(host, guest, out):
guest('command', command='gamemode survival')
guest('command', command='clear @s')
for item in ('white_banner', 'red_dye'):
guest('command', command=f'give @s minecraft:{item}')
at = open_station(guest, 'loom', out, 'loom')
# LoomMenu.quickMoveStack: a banner to the banner slot, a dye to the dye slot.
for item in ('white_banner', 'red_dye'):
guest.raw('click-slot', hotbar_slot(guest, f'minecraft:{item}'), 'shift')
guest.wait(5)
loaded = station_slots(guest)
# LoomScreen's first pattern button (clickMenuButton 0): the server makes the result.
guest.raw('click-gui', 67, 20)
guest.wait(10)
made = station_slots(guest).get(650)
guest.shot(out, 'loom-pattern')
guest.raw('click-slot', 650, 'shift')
guest.wait(10)
left = station_slots(guest)
close_station(guest, at)
# The server's copy of the banner the guest took has the red layer.
since = guest.raw('events')['last']
guest('command', command='data get entity @s Inventory[{id:"minecraft:white_banner"}].components')
layer = guest.raw('wait-for', 'chat', 'color: "red"', '--timeout', 10, '--since', since).get('ok')
counts = guest('inventory')['counts']
seen = {'loaded': loaded, 'made': made, 'left': left, 'layer': layer, 'counts': counts}
want = {'loaded': {600: ('minecraft:white_banner', 1), 601: ('minecraft:red_dye', 1)},
'made': ('minecraft:white_banner', 1), 'left': {}, 'layer': True,
'counts': {'minecraft:white_banner': 1}}
assert seen == want, f'the loom on the guest: {seen}'
return seen
def own_reply(event):
"""A chat event that answers the guest's own command. On the server thread the guest also gets
its own command's `[Guest: ...]` operator broadcast before #1373, a moment later, which an answer
to the next command must not be taken for."""
return event.get('event') == 'chat' and not event.get('text', '').startswith(f'[{GUEST_NAME}: ')
def server_count(guest, item):
"""How many of `item` the server says the guest has (`clear` with a count of 0 only counts)."""
since = guest.raw('events')['last']
item = item if ':' in item else f'minecraft:{item}'
guest('command', command=f'clear @s {item} 0')
for n in range(0, 65):
reply = guest.raw('events', '--since', since)
lines = [e.get('text', '') for e in reply['events'] if own_reply(e)]
for line in lines:
if 'matching item' in line or 'No items were found' in line:
return 0 if 'No items' in line else int(line.split('Found ')[1].split(' ')[0])
guest.wait(2)
raise Failed(f'no answer to clear for {item}')
def check_stonecutter(host, guest, out):
guest('command', command='gamemode survival')
guest('command', command='clear @s')
guest('command', command='give @s minecraft:stone 3')
at = open_station(guest, 'stonecutter', out, 'stonecutter')
# StonecutterMenu.quickMoveStack: stone has recipes, so it goes to the input.
guest.raw('click-slot', hotbar_slot(guest, 'minecraft:stone'), 'shift')
guest.wait(10)
recipes = guest.raw('menu')['info']['recipes']
index = recipes.index('minecraft:stone_slab_from_stone_stonecutting')
# StonecutterScreen's recipe grid: four columns of 16x18 cells from (52, 14).
guest.raw('click-gui', 52 + index % 4 * 16 + 8, 14 + index // 4 * 18 + 9)
guest.wait(10)
picked = (guest.raw('menu')['info']['selected'], station_slots(guest).get(650))
guest.shot(out, 'stonecutter-recipe')
# A shift-click on the result cuts while there is stone (doClick repeats quickMoveStack).
guest.raw('click-slot', 650, 'shift')
guest.wait(10)
left = station_slots(guest)
close_station(guest, at)
seen = {'picked': picked, 'left': left,
'server': {item: server_count(guest, item) for item in ('stone_slab', 'stone')}}
want = {'picked': (index, ('minecraft:stone_slab', 2)), 'left': {},
'server': {'stone_slab': 6, 'stone': 0}}
assert seen == want, f'the stonecutter on the guest: {seen}'
return seen
def server_reply(guest, command, *needles):
"""The first chat line holding every needle after the guest runs `command`."""
since = guest.raw('events')['last']
guest('command', command=command)
for _ in range(0, 65):
reply = guest.raw('events', '--since', since)
for e in reply['events']:
line = e.get('text', '')
if own_reply(e) and all(n in line for n in needles):
return line
guest.wait(2)
raise Failed(f'no answer to {command}')
def check_grindstone(host, guest, out):
guest('command', command='gamemode survival')
guest('command', command='clear @s')
guest('command', command='xp set @s 0 levels')
guest('command', command='xp set @s 0 points')
guest('command', command='give @s minecraft:iron_sword[minecraft:enchantments={"minecraft:sharpness":3,'
'"minecraft:vanishing_curse":1},minecraft:damage=100]')
at = open_station(guest, 'grindstone', out, 'grindstone')
# GrindstoneMenu.quickMoveStack: with both inputs empty, into the first.
guest.raw('click-slot', hotbar_slot(guest, 'minecraft:iron_sword'), 'shift')
guest.wait(10)
loaded = station_slots(guest)
guest.shot(out, 'grindstone-result')
guest.raw('click-slot', 650, 'shift')
# The orbs (sharpness III: 12 to 23 points) fly to the guest.
guest.wait(60)
left = station_slots(guest)
close_station(guest, at)
sword = server_reply(guest, 'data get entity @s Inventory[{id:"minecraft:iron_sword"}].components',
'minecraft:damage')
# 16 points make exactly two levels and leave 0 points: count the levels too.
xp = (server_reply(guest, 'xp query @s points', 'experience points')
+ server_reply(guest, 'xp query @s levels', 'experience levels'))
seen = {'loaded': loaded, 'left': left, 'curse kept': 'vanishing_curse' in sword,
'sharpness gone': 'sharpness' not in sword,
'damage': re.search(r'minecraft:damage"?: 100\b', sword) is not None,
'xp': any(int(n) > 0 for n in re.findall(r'has (\d+) experience', xp))}
want = {'loaded': {600: ('minecraft:iron_sword', 1), 650: ('minecraft:iron_sword', 1)}, 'left': {},
'curse kept': True, 'sharpness gone': True, 'damage': True, 'xp': True}
assert seen == want, f'the grindstone on the guest: {seen} ({sword!r}, {xp!r})'
return seen
def check_anvil(host, guest, out):
# AnvilMenu.onTake emits one level event to everyone, including the user.
# Exercise both the host and a remote user (both are clients of the server).
return {actor.name: check_anvil_user(actor, (host, guest), out) for actor in (guest, host)}
def check_anvil_user(guest, listeners, out):
guest('command', command='gamemode survival')
guest('command', command='clear @s')
guest('command', command='xp set @s 5 levels')
guest('command', command='give @s minecraft:iron_sword[minecraft:damage=200]')
guest('command', command='give @s minecraft:iron_ingot 3')
at = open_station(guest, 'anvil', out, 'anvil')
# ItemCombinerMenu.quickMoveStack: into the first input that takes it.
for item in ('iron_sword', 'iron_ingot'):
guest.raw('click-slot', hotbar_slot(guest, f'minecraft:{item}'), 'shift')
guest.wait(5)
# The box shows "Iron Sword": erase it and type the new name.
for _ in range(len('Iron Sword')):
guest.raw('type-back')
guest.raw('type', 'Blade')
guest.wait(10)
menu = guest.raw('menu')
offered = (menu['info']['anvil_cost'], station_slots(guest).get(650))
guest.shot(out, 'anvil-rename')
sounds = ('minecraft:block.anvil.use', 'minecraft:block.anvil.destroy')
before = {g.name: sum(heard(g, s) for s in sounds) for g in listeners}
guest.raw('click-slot', 650, 'shift')
guest.wait(20)
sound_counts = {g.name: sum(heard(g, s) for s in sounds) - before[g.name] for g in listeners}
assert sound_counts == {g.name: 1 for g in listeners}, f'anvil sounds: {sound_counts}'
left = station_slots(guest)
close_station(guest, at)
sword = server_reply(guest, 'data get entity @s Inventory[{id:"minecraft:iron_sword"}].components',
'minecraft:damage')
levels = server_reply(guest, 'xp query @s levels', 'experience levels')
seen = {'offered': offered, 'left': left, 'name': 'Blade' in sword,
'damage': re.search(r'minecraft:damage"?: 14\b', sword) is not None,
'levels': int(re.search(r'has (\d+) experience', levels).group(1)),
'ingots': server_count(guest, 'iron_ingot')}
# Three ingots repair 62 each (a quarter of 250), plus the name: four levels.
want = {'offered': (4, ('minecraft:iron_sword', 1)), 'left': {}, 'name': True, 'damage': True,
'levels': 1, 'ingots': 0}
assert seen == want, f'the anvil on the guest: {seen} ({sword!r})'
return {**seen, 'heard': sound_counts}
def check_smithing(host, guest, out):
guest('command', command='gamemode survival')
guest('command', command='clear @s')
for item in ('netherite_upgrade_smithing_template', 'diamond_sword', 'netherite_ingot'):
guest('command', command=f'give @s minecraft:{item}')
at = open_station(guest, 'smithing_table', out, 'smithing')
# SmithingMenu.canMoveIntoInputSlots: each to the slot whose recipe test takes it.
for item in ('netherite_upgrade_smithing_template', 'diamond_sword', 'netherite_ingot'):
guest.raw('click-slot', hotbar_slot(guest, f'minecraft:{item}'), 'shift')
guest.wait(5)
guest.wait(5)
loaded = station_slots(guest)
guest.shot(out, 'smithing-result')
guest.raw('click-slot', 650, 'shift')
guest.wait(10)
left = station_slots(guest)
close_station(guest, at)
seen = {'loaded': loaded, 'left': left,
'server': {item: server_count(guest, item) for item in ('netherite_sword', 'diamond_sword', 'netherite_ingot')}}
want = {'loaded': {600: ('minecraft:netherite_upgrade_smithing_template', 1), 601: ('minecraft:diamond_sword', 1),
602: ('minecraft:netherite_ingot', 1), 650: ('minecraft:netherite_sword', 1)},
'left': {}, 'server': {'netherite_sword': 1, 'diamond_sword': 0, 'netherite_ingot': 0}}
assert seen == want, f'the smithing table on the guest: {seen}'
return seen
def check_enchanting(host, guest, out):
guest('command', command='gamemode survival')
guest('command', command='clear @s')
guest('command', command='xp set @s 30 levels')
guest('command', command='give @s minecraft:iron_sword')
guest('command', command='give @s minecraft:lapis_lazuli 3')
# EnchantmentMenu awards the enchant_item stat; a stat objective shows the guest's count.
guest('command', command='scoreboard objectives add lanench minecraft.custom:minecraft.enchant_item')
at = open_station(guest, 'enchanting_table', out, 'enchanting')
# EnchantmentMenu.quickMoveStack: the sword to the item slot, the lapis to its own.
for item in ('iron_sword', 'lapis_lazuli'):
guest.raw('click-slot', hotbar_slot(guest, f'minecraft:{item}'), 'shift')
guest.wait(5)
guest.wait(10)
costs = guest.raw('menu')['info']['costs']
guest.shot(out, 'enchanting-offers')
# EnchantmentScreen's first offer button (clickMenuButton 0).
guest.raw('click-gui', 110, 23)
guest.wait(10)
left = station_slots(guest)
close_station(guest, at)
sword = server_reply(guest, 'data get entity @s Inventory[{id:"minecraft:iron_sword"}]', 'iron_sword')
levels = server_reply(guest, 'xp query @s levels', 'experience levels')
stat = server_reply(guest, 'scoreboard players get @s lanench', 'lanench')
guest('command', command='scoreboard objectives remove lanench')
seen = {'offered': costs[0] > 0, 'enchanted': 'minecraft:enchantments' in sword,
'left': left.get(601), 'levels': int(re.search(r'has (\d+) experience', levels).group(1)),
'lapis': server_count(guest, 'lapis_lazuli'), 'stat': ' has 1 ' in stat}
want = {'offered': True, 'enchanted': True, 'left': ('minecraft:lapis_lazuli', 2), 'levels': 29, 'lapis': 2,
'stat': True}
assert seen == want, f'the enchanting table on the guest: {seen} ({costs}, {sword!r})'
return seen
def check_beacon(host, guest, out):
guest('command', command='gamemode survival')
guest('command', command='clear @s')
guest('command', command='effect clear @s')
guest('command', command='give @s minecraft:iron_ingot')
x, y, z = (int(v // 1) for v in pos_of(guest))
at = (x, y, z + 2)
# A one-level pyramid under the beacon; its levels come with the first base check (80 ticks).
guest('command', command=f'fill {x - 1} {y - 1} {z + 1} {x + 1} {y - 1} {z + 3} minecraft:iron_block')
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:beacon')
guest.wait(100)
guest.raw('interact', *at)
guest.raw('wait-for', 'screen', 'station', '--timeout', 10)
guest.wait(10)
# BeaconMenu.quickMoveStack: the ingot to the payment slot.
guest.raw('click-slot', hotbar_slot(guest, 'minecraft:iron_ingot'), 'shift')
guest.wait(10)
paid = station_slots(guest).get(600)
# BeaconScreen (54 px wider, 27 to the left): Haste, the first level's second power, then Done,
# which sends ServerboundSetBeaconPacket and closes the menu.
guest.raw('click-gui', 77 - 27 + 11, 22 + 11)
guest.wait(5)
guest.shot(out, 'beacon')
guest.raw('click-gui', 164 - 27 + 11, 107 + 11)
guest.wait(10)
screen = guest('state').get('screen')
effects = []
# BeaconBlockEntity.applyEffects runs every 80 ticks.
for _ in range(12):
guest.wait(20)
effects = effect_ids(guest)
if 'haste' in effects:
break
seen = {'paid': paid, 'haste': 'haste' in effects, 'ingots': server_count(guest, 'iron_ingot'),
'screen': screen}
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
want = {'paid': ('minecraft:iron_ingot', 1), 'haste': True, 'ingots': 0, 'screen': 'playing'}
assert seen == want, f'the beacon on the guest: {seen} ({effects})'
return seen
def check_crafter(host, guest, out):
guest('command', command='gamemode survival')
guest('command', command='clear @s')
guest('command', command='give @s minecraft:oak_planks 4')
x, y, z = (int(v // 1) for v in pos_of(guest))
at = (x, y, z + 2)
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:crafter')
guest.wait(10)
guest.raw('interact', *at)
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
guest.wait(10)
# CrafterScreen.slotClicked: an empty-handed click disables the first slot
# (ServerboundContainerSlotStateChangedPacket); the planks then skip it.
guest.raw('click-slot', 300)
guest.wait(5)
guest.raw('click-slot', hotbar_slot(guest, 'minecraft:oak_planks'), 'shift')
guest.wait(10)
guest.shot(out, 'crafter')
guest.raw('key', 'esc', 'tap')
guest.wait(10)
# Opening it again shows the server's crafter.
items = open_container(guest, at, out, 'crafter-after')
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
seen = {'items': items[0] if isinstance(items, tuple) else items, 'planks': server_count(guest, 'oak_planks')}
want = {'items': {1: 'minecraft:oak_planks'}, 'planks': 0}
assert seen == want, f'the crafter on the guest: {seen}'
return seen
def check_lectern(host, guest, out):
guest('command', command='gamemode survival')
guest('command', command='clear @s')
x, y, z = (int(v // 1) for v in pos_of(guest))
at = (x, y, z + 2)
book = ('{id:"minecraft:written_book",count:1,components:{"minecraft:written_book_content":'
'{title:"Lan",author:"Host",pages:["one","two","three"]}}}')
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:lectern[has_book=true]{{Book:{book}}}')
guest.wait(10)
guest.raw('interact', *at)
guest.raw('wait-for', 'screen', 'book', '--timeout', 10)
guest.wait(10)
opened = guest.raw('book')
guest.shot(out, 'lectern')
# LecternScreen's page arrows: ContainerButtonClick 2 (forward) and 1 (back); the server's
# LecternMenu.setData clamps to the book's last page and sends the page back (data slot 0).
for button in ('next', 'next', 'next', 'back'):
guest.raw('book', button)
guest.wait(5)
guest.wait(10)
turned = guest.raw('book')
page = server_reply(guest, f'data get block {at[0]} {at[1]} {at[2]} Page', 'block data')
guest.shot(out, 'lectern-page')
# Take Book: ContainerButtonClick 3; the server empties the lectern and closes the menu.
guest.raw('book', 'take')
guest.wait(20)
screen = guest('state').get('screen')
held = server_count(guest, 'written_book')
has_book = server_reply(guest, f'execute if block {at[0]} {at[1]} {at[2]} minecraft:lectern[has_book=false]',
'Test passed')
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
seen = {'opened': (opened.get('page'), opened.get('pages')), 'turned': turned.get('page'),
'server page': int(re.search(r'(-?\d+)\s*$', page).group(1)), 'screen': screen, 'held': held,
'emptied': 'Test passed' in has_book}
want = {'opened': (0, 3), 'turned': 1, 'server page': 1, 'screen': 'playing', 'held': 1, 'emptied': True}
assert seen == want, f'the lectern on the guest: {seen} ({page!r})'
return seen
def check_cartography(host, guest, out):
# The host makes a map (EmptyMapItem.use), the guest gets a copy of it.
host('command', command='clear @s')
host('command', command='item replace entity @s weapon.mainhand with minecraft:map')
host.wait(5)
host.raw('use-item')
host.wait(20)
made = server_reply(host, 'data get entity @s Inventory[{id:"minecraft:filled_map"}].components', 'map_id')
map_id = int(re.search(r'map_id"?: (\d+)', made).group(1))
host('command', command='clear @s')
guest('command', command='gamemode survival')
guest('command', command='clear @s')
guest('command', command=f'give @s minecraft:filled_map[minecraft:map_id={map_id}]')
guest('command', command='give @s minecraft:paper')
at = open_station(guest, 'cartography_table', out, 'cartography')
# CartographyTableMenu.quickMoveStack: a map with an id to the map slot, paper to the other.
for item in ('filled_map', 'paper'):
guest.raw('click-slot', hotbar_slot(guest, f'minecraft:{item}'), 'shift')
guest.wait(5)
guest.wait(10)
loaded = station_slots(guest)
guest.shot(out, 'cartography-result')
# The result's onTake uses the map and the paper; MapItem.onCraftedPostProcess gives the zoomed
# out map a new id on the server.
guest.raw('click-slot', 650, 'shift')
guest.wait(10)
left = station_slots(guest)
close_station(guest, at)
taken = server_reply(guest, 'data get entity @s Inventory[{id:"minecraft:filled_map"}].components', 'map_id')
new_id = int(re.search(r'map_id"?: (\d+)', taken).group(1))
seen = {'loaded': loaded, 'left': left, 'new map': new_id != map_id,
'server': {item: server_count(guest, item) for item in ('filled_map', 'paper')}}
want = {'loaded': {600: ('minecraft:filled_map', 1), 601: ('minecraft:paper', 1),
650: ('minecraft:filled_map', 1)},
'left': {}, 'new map': True, 'server': {'filled_map': 1, 'paper': 0}}
assert seen == want, f'the cartography table on the guest: {seen} ({made!r}, {taken!r})'
return seen
def slot_point(game, slot):
"""The GUI point in the middle of slot `slot` of the open screen (`ui`'s slot-N)."""
for w in game.raw('ui')['widgets']:
if w.get('id') == f'slot-{slot}':
x, y, width, height = w['bounds']
return x + width / 2, y + height / 2
raise Failed(f'no slot {slot} on the screen')
def mouse(game, *slots, right=False):
"""Press over the first slot, move over the others and release: one click, or a drag.
A drag crosses every slot: `mouseDragged` takes the slots the pointer is over as move events
land, so each leg gets a second point a couple of pixels into the same slot -- a single point
drag would skip the start slot no mouse held still on. One slot stays a plain click."""
if len(slots) <= 1:
points = [f'{v:.1f}' for s in slots for v in slot_point(game, s)]
else:
points = []
for s in slots:
x, y = slot_point(game, s)
points += [f'{x:.1f}', f'{y:.1f}', f'{x + 2:.1f}', f'{y + 1:.1f}']
game.raw('drag-at', *(['right'] if right else []), *points)
game.wait(4)
def hotbar_slot(game, item):
return next(s['slot'] for s in game('inventory')['hotbar'] if s and s.get('item') == item)
def check_clicks(host, guest, out):
guest('command', command='gamemode survival')
guest('command', command='clear @s')
guest('command', command='give @s minecraft:cobblestone 10')
guest('command', command='give @s minecraft:coal 3')
guest('command', command='give @s minecraft:raw_iron 2')
guest('command', command='give @s minecraft:oak_planks 2')
guest.wait(10)
x, y, z = (int(v // 1) for v in pos_of(guest))
chest, furnace = (x, y, z + 2), (x, y, z - 2)
guest('command', command=f'setblock {chest[0]} {chest[1]} {chest[2]} minecraft:chest')
guest('command', command=f'setblock {furnace[0]} {furnace[1]} {furnace[2]} minecraft:furnace')
guest.wait(10)
# Pick the cobblestone up and put it down in the chest's first slot.
guest.raw('interact', *chest)
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
guest.wait(10)
mouse(guest, hotbar_slot(guest, 'minecraft:cobblestone'))
mouse(guest, 300)
guest.wait(10)
guest.shot(out, 'clicks-chest')
guest.raw('key', 'esc', 'tap')
guest.wait(10)
in_chest, _ = open_container(guest, chest, out, 'clicks-chest-after')
# Coal into the fuel slot, raw iron into the input.
guest.raw('interact', *furnace)
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
guest.wait(10)
mouse(guest, hotbar_slot(guest, 'minecraft:coal'))
mouse(guest, 301)
mouse(guest, hotbar_slot(guest, 'minecraft:raw_iron'))
mouse(guest, 300)
guest.wait(10)
guest.shot(out, 'clicks-furnace')
guest.raw('key', 'esc', 'tap')
guest.wait(10)
# Opening the furnace again resends the whole menu: the server's own view.
in_furnace, _ = open_container(guest, furnace, out, 'clicks-furnace-after')
# Two planks dragged over the 2x2 grid's left column: one each, four sticks.
guest.raw('key', 'e', 'tap')
guest.raw('wait-for', 'screen', 'inventory')
guest.wait(5)
mouse(guest, hotbar_slot(guest, 'minecraft:oak_planks'))
mouse(guest, 100, 102)
guest.wait(10)
grid = {s['slot']: (s.get('item'), s.get('count')) for s in guest('screen').get('slots', [])
if s.get('slot') in (100, 102, 200) and s.get('item')}
guest.shot(out, 'clicks-drag')
guest.raw('click-slot', 200, 'shift')
guest.wait(10)
guest.raw('key', 'esc', 'tap')
guest.wait(10)
# The chest's menu brings the whole inventory as the server has it.
open_container(guest, chest, out, 'clicks-inventory')
counts = guest('inventory')['counts']
for at in (chest, furnace):
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
assert in_chest == {0: 'minecraft:cobblestone'}, f'the chest holds {in_chest} after the guest put cobblestone in'
assert in_furnace == {0: 'minecraft:raw_iron', 1: 'minecraft:coal'}, f'the furnace holds {in_furnace}'
assert grid.get(100) == ('minecraft:oak_planks', 1) and grid.get(102) == ('minecraft:oak_planks', 1), \
f'the drag left the grid as {grid}'
assert counts.get('minecraft:stick') == 4 and 'minecraft:cobblestone' not in counts, f'inventory after: {counts}'
return {'chest': in_chest, 'furnace': in_furnace, 'grid': grid, 'inventory': counts}
def held(game):
"""The stack on the cursor of the open screen, as (item, count)."""
c = game('screen').get('carried') or {}
return (c.get('item'), c.get('count'))
def container_of(game, n):
"""The open container's (most) slots by container index, Horizon's slots 300 and up."""
return {s['slot'] - 300: (s.get('item'), s.get('count'))
for s in game('screen').get('slots', []) if 300 <= s.get('slot', 0) < 300 + n}
def grid_of(game, first, last):
"""The open screen's slots `first..last-1` (empties included) as (item, count)."""
return {s['slot']: (s.get('item'), s.get('count'))
for s in game('screen').get('slots', []) if first <= s.get('slot', 0) < last}
def result_of(game):
"""The crafting result of the open screen, as (item, count)."""
s = grid_of(game, 200, 201).get(200, (None, None))
return s
def check_modifier_release(host, guest, out):
"""Agent key taps and release must clear Shift before the next mouse pickup."""
guest('command', command='gamemode survival')
grab(host)
seen = {}
for case in ('tap', 'release'):
guest('command', command='clear @s')
guest('command', command='give @s minecraft:diamond 10')
guest.wait(5)
guest.raw('key', 'e', 'tap')
guest.raw('wait-for', 'screen', 'inventory', '--timeout', 10)
guest.wait(5)
try:
if case == 'tap':
guest.raw('key', 'shift', 'tap')
else:
guest.raw('key', 'shift', 'down')
guest.raw('release')
mouse(guest, hotbar_slot(guest, 'minecraft:diamond'))
seen[case] = held(guest)
guest.shot(out, f'modifier-release-{case}')
assert seen[case] == ('minecraft:diamond', 10), \
f'{case} left Shift held: the click carried {seen[case]}'
finally:
guest.raw('release')
guest.raw('key', 'esc', 'tap')
guest.wait(5)
assert server_count(guest, 'diamond') == 10, 'modifier cleanup lost items'
host_unaffected(host, "the guest's modifier cleanup")
return seen
def check_shift_double_click(host, guest, out):
"""A stack on the cursor, Shift held and two left clicks under 250 ms apart on one chest stack:
what 26.3 moves (mouseReleased's double click, #4166). The same pair with the clicks too far
apart is the control: only the first release's Shift click happens."""
guest('command', command='gamemode survival')
x, y, z = (int(v // 1) for v in pos_of(guest))
chest = (x, y, z + 2)
guest('command', command=f'setblock {chest[0]} {chest[1]} {chest[2]} minecraft:chest')
guest.wait(10)
grab(host)
timings = {}
seen = {}
def reset():
guest('command', command='clear @s')
guest('command', command='give @s minecraft:diamond 32')
for i, n in enumerate((8, 13, 21)):
guest('command', command=f'item replace block {chest[0]} {chest[1]} {chest[2]} container.{i} '
f'with minecraft:diamond {n}')
guest('command', command=f'item replace block {chest[0]} {chest[1]} {chest[2]} container.3 '
f'with minecraft:coal 5')
guest.wait(10)
guest.raw('interact', *chest)
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
guest.wait(5)
# Take the 32 on the cursor, then a wait past Horizon's 250 ms double click window.
mouse(guest, hotbar_slot(guest, 'minecraft:diamond'))
time.sleep(0.4)
def clicks(gap):
point = slot_point(guest, 300)
guest.raw('key', 'shift', 'down')
try:
started = time.monotonic()
guest.raw('drag-at', f'{point[0]:.1f}', f'{point[1]:.1f}')
if gap:
time.sleep(gap)
guest.raw('drag-at', f'{point[0]:.1f}', f'{point[1]:.1f}')
return round((time.monotonic() - started) * 1000)
finally:
guest.raw('key', 'shift', 'up')
guest.wait(10)
reset()
timings['slow'] = clicks(0.4)
seen['slow chest'] = container_of(guest, 4)
seen['slow carried'] = held(guest)
guest.raw('key', 'esc', 'tap')
guest.wait(5)
seen['slow after'] = server_count(guest, 'diamond')
reset()
timings['fast'] = clicks(0)
seen['fast chest'] = container_of(guest, 4)
seen['fast carried'] = held(guest)
guest.shot(out, 'shift-double-click')
guest.raw('key', 'esc', 'tap')
guest.wait(5)
# Closing returns the carried stack to the inventory: the server's own count.
seen['fast after'] = server_count(guest, 'diamond')
empty = (None, None)
# Vanilla 26.3's verdict, from AbstractContainerScreen.mouseReleased's double click:
# the pair's first release QUICK_MOVEs the clicked stack (lastQuickMoved), the second
# (Shift held) QUICK_MOVEs every stack of that item in the clicked slot's container;
# the carried stack waits on the cursor and closing hands it back.
want = {'fast chest': {0: empty, 1: empty, 2: empty, 3: ('minecraft:coal', 5)},
'fast carried': ('minecraft:diamond', 32), 'fast after': 74,
'slow chest': {0: empty, 1: ('minecraft:diamond', 13), 2: ('minecraft:diamond', 21),
3: ('minecraft:coal', 5)},
'slow carried': ('minecraft:diamond', 32), 'slow after': 40}
assert seen == want, f'the Shift clicks left {seen}'
for at in (chest,):
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
host_unaffected(host, "the guest's Shift double click")
return {**seen, 'click interval ms': timings}
def check_recipe_shift(host, guest, out):
"""Shift-clicking a recipe book button fills the crafting grid with as many sets as the
inventory makes, and Shift-clicking the result crafts them all (#4166)."""
guest('command', command='gamemode survival')
guest('command', command='clear @s')
guest('command', command='recipe give @s minecraft:diamond_block')
guest('command', command='give @s minecraft:diamond 20')
x, y, z = (int(v // 1) for v in pos_of(guest))
table = (x, y, z + 2)
guest('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:crafting_table')
guest.wait(10)
grab(host)
guest.raw('interact', *table)
guest.raw('wait-for', 'screen', 'inventory', '--timeout', 10) # a table screen reads 'inventory'
guest.wait(5)
assert guest('screen').get('craft_size') == 3, 'the table opened without its 3x3 grid'
book = guest('screen')['recipe_book']
seen = {'book open': book['open']}
# The book button, then the visible recipes.
def recipes():
targets = guest('screen')['recipe_book']['targets']
return [t for t in targets if abs(t[2] - 25.0) < 0.001 and abs(t[3] - 25.0) < 0.001]
if not book['open']:
for t in guest('screen')['recipe_book']['targets']:
if abs(t[2] - 20.0) < 0.001 and abs(t[3] - 18.0) < 0.001:
guest.raw('click-at', f'{t[0] + 10:.1f}', f'{t[1] + 9:.1f}')
guest.wait(5)
seen['book open'] = guest('screen')['recipe_book']['open']
break
# Filter to craftable recipes before choosing the Building Blocks tab.
if not guest('screen')['recipe_book']['filtering']:
for t in guest('screen')['recipe_book']['targets']:
if abs(t[2] - 26.0) < 0.001 and abs(t[3] - 16.0) < 0.001:
guest.raw('click-at', f'{t[0] + 13:.1f}', f'{t[1] + 8:.1f}')
guest.wait(5)
break
# Diamond armour is also craftable; select a tab with only the block recipe.
buttons = recipes()
for t in (t for t in guest('screen')['recipe_book']['targets']
if abs(t[2] - 35.0) < 0.001 and abs(t[3] - 27.0) < 0.001):
guest.raw('click-at', f'{t[0] + 17:.1f}', f'{t[1] + 13:.1f}')
guest.wait(5)
buttons = recipes()
seen['buttons'] = len(buttons)
if len(buttons) == 1:
break
assert len(buttons) == 1, (f'with the craftable filter and its tabs the book shows '
f'{len(buttons)} recipes, not 1',
[t for t in guest('screen')['recipe_book']['targets']],
guest('screen')['recipe_book']['filtering'])
centre = (buttons[0][0] + 12.5, buttons[0][1] + 12.5)
guest.raw('key', 'shift', 'down')
try:
guest.raw('click-at', f'{centre[0]:.1f}', f'{centre[1]:.1f}')
finally:
guest.raw('key', 'shift', 'up')
guest.wait(10)
seen['grid'] = grid_of(guest, 100, 109)
seen['result'] = result_of(guest)
seen['carried'] = held(guest)
seen['inventory'] = guest('inventory')['counts'].get('minecraft:diamond', 0)
guest.shot(out, 'recipe-shift-filled')
# Take all available crafts with a real Shift click on the result.
guest.raw('key', 'shift', 'down')
try:
point = slot_point(guest, 200)
guest.raw('click-at', f'{point[0]:.1f}', f'{point[1]:.1f}')
finally:
guest.raw('key', 'shift', 'up')
guest.wait(10)
seen['after grid'] = grid_of(guest, 100, 109)
seen['after result'] = result_of(guest)
seen['after carried'] = held(guest)
guest.shot(out, 'recipe-shift-crafted')
guest.raw('key', 'esc', 'tap')
guest.wait(5)
seen['after blocks'] = server_count(guest, 'diamond_block')
seen['after diamonds'] = server_count(guest, 'diamond')
guest('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:air')
host_unaffected(host, "the guest's recipe Shift clicks")
filled = {s: ('minecraft:diamond', 2) for s in range(100, 109)}
blank = {s: (None, None) for s in range(100, 109)}
# Vanilla 26.3: RecipeBookComponent's Shift click sends PlaceRecipe with useMaxItems and
# ServerPlaceRecipe fills the grid with as many sets as the inventory makes (18 diamonds,
# 2 left); the result shows one craft. The result's Shift click is QUICK_MOVE: it crafts
# every set the grid allows (2 blocks), leaves the grid empty and 2 diamonds.
want = {'book open': True, 'buttons': 1, 'grid': filled, 'result': ('minecraft:diamond_block', 1),
'carried': (None, None), 'inventory': 2, 'after grid': blank, 'after result': (None, None),
'after carried': (None, None), 'after blocks': 2, 'after diamonds': 2}
assert seen == want, f'the recipe Shift clicks left {seen}'
return seen
def check_shift_drag(host, guest, out):
"""A 20 diamond stack dragged over three slots of a crafting table spreads 6 each with the left
button and 1 each with the right, with Shift held over the drag or not; closing hands the rest
back to the guest's inventory (#4166)."""
guest('command', command='gamemode survival')
guest('command', command='clear @s')
guest('command', command='give @s minecraft:diamond 20')
x, y, z = (int(v // 1) for v in pos_of(guest))
table = (x, y, z + 2)
guest('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:crafting_table')
guest.wait(10)
grab(host)
guest.raw('interact', *table)
guest.raw('wait-for', 'screen', 'inventory', '--timeout', 10) # a table screen reads 'inventory'
guest.wait(5)
assert guest('screen').get('craft_size') == 3, 'the table opened without its 3x3 grid'
def drag(right, shift):
mouse(guest, hotbar_slot(guest, 'minecraft:diamond'))
guest.wait(5)
if shift:
guest.raw('key', 'shift', 'down')
try:
mouse(guest, 100, 101, 102, right=right)
finally:
if shift:
guest.raw('key', 'shift', 'up')
grid = grid_of(guest, 100, 109)
carried = held(guest)
guest.raw('key', 'esc', 'tap')
guest.wait(10)
return grid, carried
seen = {}
seen['left'], seen['left carried'] = drag(False, False)
seen['after'] = server_count(guest, 'diamond')
guest.raw('interact', *table)
guest.raw('wait-for', 'screen', 'inventory', '--timeout', 10) # a table screen reads 'inventory'
guest.wait(5)
assert guest('screen').get('craft_size') == 3, 'the table opened without its 3x3 grid'
seen['right'], seen['right carried'] = drag(True, False)
guest.raw('interact', *table)
guest.raw('wait-for', 'screen', 'inventory', '--timeout', 10) # a table screen reads 'inventory'
guest.wait(5)
assert guest('screen').get('craft_size') == 3, 'the table opened without its 3x3 grid'
seen['left shift'], seen['left shift carried'] = drag(False, True)
guest.raw('interact', *table)
guest.raw('wait-for', 'screen', 'inventory', '--timeout', 10) # a table screen reads 'inventory'
guest.wait(5)
assert guest('screen').get('craft_size') == 3, 'the table opened without its 3x3 grid'
seen['right shift'], seen['right shift carried'] = drag(True, True)
seen['after shift'] = server_count(guest, 'diamond')
guest.shot(out, 'shift-drag')
guest('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:air')
host_unaffected(host, "the guest's Shift drag")
left = {**{s: ('minecraft:diamond', 6) for s in range(100, 103)},
**{s: (None, None) for s in range(103, 109)}}
right = {**{s: ('minecraft:diamond', 1) for s in range(100, 103)},
**{s: (None, None) for s in range(103, 109)}}
# Vanilla 26.3: a carried press starts the quick craft drag whatever Shift does
# (AbstractContainerScreen.mouseClicked); mouseDragged selects slots regardless of Shift.
# The left drag
# over three slots divides the stack as it does without it, the right one drops one
# each; the table hands the rest back when it closes.
want = {'left': left, 'left carried': ('minecraft:diamond', 2), 'after': 20,
'right': right, 'right carried': ('minecraft:diamond', 17),
'left shift': left, 'left shift carried': ('minecraft:diamond', 2),
'right shift': right, 'right shift carried': ('minecraft:diamond', 17),
'after shift': 20}
assert seen == want, f'the Shift drag left {seen}'
return seen
def wheel(game, slot, steps):
"""Real wheel events over slot `slot` of the open screen (positive scrolls up)."""
x, y = slot_point(game, slot)
game.raw('scroll-at', f'{x:.1f}', f'{y:.1f}', steps)
game.wait(4)
def wheel_fast(game, slot, steps):
"""Wheel events with no pause after them."""
x, y = slot_point(game, slot)
game.raw('scroll-at', f'{x:.1f}', f'{y:.1f}', steps)
def set_mouse_tweaks(game, on):
"""The Extras page's switch, as a player flips it."""
game('options', page='extras')
switch = next(w for w in game.raw('ui')['widgets'] if w.get('label', '').startswith('Mouse Tweaks controls'))
if switch['label'].endswith('On') != on:
game('click', id=switch['id'])
game('close')
game.wait(5)
def check_wheel(host, guest, out):
set_mouse_tweaks(guest, True)
# On the host's cleared floor, with the host behind: nothing stands in the way of the look at the
# one spot each block takes in turn.
hx, hy, hz = pos_of(host)
guest('command', command=f'tp @s {hx:.1f} {hy:.1f} {hz + 4:.1f}')
guest.wait(10)
x, y, z = (int(v // 1) for v in pos_of(guest))
spot = (x, y, z + 2)
guest('command', command='gamemode survival')
def place(block):
guest('command', command=f'setblock {spot[0]} {spot[1]} {spot[2]} minecraft:{block}')
guest.wait(20) # until the client has the block, to look at it
def held(*stacks):
guest('command', command='clear @s')
for item, count in stacks:
guest('command', command=f'give @s minecraft:{item} {count}')
guest.wait(10)
def opened(screen='container'):
guest.raw('interact', *spot)
guest.raw('wait-for', 'screen', screen, '--timeout', 10)
guest.wait(10)
def slots():
return {s['slot']: (s['item'].removeprefix('minecraft:'), s['count'])
for s in guest('screen').get('slots', []) if s.get('item')}
def shut():
guest.raw('key', 'esc', 'tap')
guest.wait(10)
def server_view():
"""Closing and opening again resends the whole menu: what the server has."""
shut()
opened()
return slots()
def where(view, item):
return sorted(s for s, (i, _) in view.items() if i == item)
try:
# A chest always worked: the extra is on, and the wheel reaches the screen.
held(('cobblestone', 3))
place('chest')
opened()
wheel(guest, hotbar_slot(guest, 'minecraft:cobblestone'), -1)
view = server_view()
assert view.get(300) == ('cobblestone', 1), f'chest: {view}'
shut()
# The furnace: each item goes to the slot vanilla's quick move picks, never the output.
# Raw copper can't join the ingots in the output, so the furnace stays out and burns nothing.
held(('coal', 3), ('raw_copper', 3), ('dirt', 3))
place('furnace{Items:[{Slot:2b,id:"minecraft:iron_ingot",count:5}]}')
opened()
wheel(guest, hotbar_slot(guest, 'minecraft:coal'), -1)
wheel(guest, hotbar_slot(guest, 'minecraft:raw_copper'), -1)
wheel(guest, hotbar_slot(guest, 'minecraft:dirt'), -1)
view = server_view()
guest.shot(out, 'wheel-furnace')
assert view.get(300) == ('raw_copper', 1), f'smeltable, input: {view}'
assert view.get(301) == ('coal', 1), f'fuel, fuel slot: {view}'
assert view.get(302) == ('iron_ingot', 5), f'the output changed: {view}'
assert sum(c for i, c in view.values() if i == 'dirt') == 3, f'dirt went astray: {view}'
# Up over the fuel slot: one more coal. Down: one back.
wheel(guest, 301, 1)
pulled = server_view()
assert pulled.get(301) == ('coal', 2), f'pulled: {pulled}'
wheel(guest, 301, -1)
pushed = server_view()
assert pushed.get(301) == ('coal', 1), f'pushed: {pushed}'
# Out of the input again, or taking the output would light the furnace.
wheel(guest, 300, -1)
# Up over the output brings nothing in; down takes it, whole, to the inventory.
wheel(guest, 302, 1)
kept = server_view()
assert kept.get(302) == ('iron_ingot', 5), f'wheel up over the output: {kept}'
wheel(guest, 302, -1)
taken = server_view()
assert 302 not in taken and [taken[s] for s in where(taken, 'iron_ingot')] == [('iron_ingot', 5)], \
f'wheel down over the output: {taken}'
# An ingot from the inventory has no way back into the output.
wheel(guest, where(taken, 'iron_ingot')[0], -1)
back = server_view()
assert 302 not in back, f'an item went into the output: {back}'
# The right-drag: neither an empty fuel slot (dirt isn't fuel) nor the output takes it.
wheel(guest, 301, -1)
dirt = where(back, 'dirt')[0]
mouse(guest, dirt)
mouse(guest, 301, 302, right=True)
mouse(guest, dirt)
dragged = server_view()
guest.shot(out, 'wheel-furnace-drag')
assert 301 not in dragged and 302 not in dragged, f'right-drag: {dragged}'
assert sum(c for i, c in dragged.values() if i == 'dirt') == 3, f'right-drag lost items: {dragged}'
shut()
# The crafting table: into the grid; down over the result crafts once, into the inventory.
held(('oak_log', 2))
place('crafting_table')
opened('inventory')
wheel(guest, hotbar_slot(guest, 'minecraft:oak_log'), -1)
grid = slots()
assert grid.get(100) == ('oak_log', 1) and grid.get(200) == ('oak_planks', 4), f'grid: {grid}'
wheel(guest, 200, 1)
assert slots().get(100) == ('oak_log', 1), f'wheel up over the result: {slots()}'
wheel(guest, 200, -1)
crafted = slots()
guest.shot(out, 'wheel-crafting')
assert 100 not in crafted and 200 not in crafted, f'after the result: {crafted}'
assert [crafted[s] for s in where(crafted, 'oak_planks')] == [('oak_planks', 4)], f'after the result: {crafted}'
shut()
counts = guest('inventory')['counts']
assert counts.get('minecraft:oak_planks') == 4 and counts.get('minecraft:oak_log') == 1, f'the server has {counts}'
# Wheel events as fast as they come, with no time for the server to answer: nothing stays in the
# cursor and no item appears or goes.
for mode in ('survival', 'creative'):
guest('command', command=f'gamemode {mode}')
held(('cobblestone', 40), ('coal', 40))
place('air') # a chest placed on a chest keeps its items
place('chest')
opened()
first, second = hotbar_slot(guest, 'minecraft:cobblestone'), hotbar_slot(guest, 'minecraft:coal')
for round_ in range(12):
wheel_fast(guest, first if round_ % 3 else second, -2)
wheel_fast(guest, 300, 1 if round_ % 2 else -1)
guest.wait(30)
assert guest('screen').get('carried') is None, f'{mode}: a stack stayed in the cursor'
total = {}
for item, count in server_view().values():
total[item] = total.get(item, 0) + count
assert total == {'cobblestone': 40, 'coal': 40}, f'{mode}: the items became {total}'
shut()
guest('command', command='gamemode survival')
# A furnace burning the fuel the wheel is taking: the click on the emptied slot takes nothing, and
# what comes next picks up nothing either.
held(('coal', 40), ('raw_copper', 40))
place('furnace')
opened()
coal, copper = hotbar_slot(guest, 'minecraft:coal'), hotbar_slot(guest, 'minecraft:raw_copper')
for round_ in range(10):
wheel_fast(guest, coal, -1)
wheel_fast(guest, copper, -1)
wheel_fast(guest, 301, -1)
guest.wait(4)
guest.wait(30)
assert guest('screen').get('carried') is None, 'a stack stayed in the cursor over a burning furnace'
shut()
# The crafting table, as fast: into the grid and out, and over the result.
held(('oak_log', 20), ('dirt', 20))
place('crafting_table')
opened('inventory')
log, dirt = hotbar_slot(guest, 'minecraft:oak_log'), hotbar_slot(guest, 'minecraft:dirt')
for round_ in range(12):
wheel_fast(guest, log if round_ % 2 else dirt, -2)
wheel_fast(guest, 100 + round_ % 3, -1)
wheel_fast(guest, 200, -1)
guest.wait(30)
assert guest('screen').get('carried') is None, 'a stack stayed in the cursor over a crafting table'
shut()
counts = guest('inventory')['counts']
assert counts.get('minecraft:dirt') == 20, f'the dirt became {counts}'
guest.raw('key', 'e', 'tap')
guest.wait(5)
guest.raw('key', 'esc', 'tap')
guest.wait(5)
# Hopper, dropper: plain slots, both ways.
held(('cobblestone', 3))
for name in ('hopper', 'dropper'):
place(name)
opened()
wheel(guest, hotbar_slot(guest, 'minecraft:cobblestone'), -1)
view = server_view()
assert view.get(300) == ('cobblestone', 1), f'{name}: {view}'
wheel(guest, 300, -1)
view = server_view()
assert 300 not in view, f'{name}, pushed back: {view}'
shut()
finally:
back_to_game(guest)
set_mouse_tweaks(guest, False)
place('air')
return {'furnace': dragged, 'crafting': crafted}
def check_feedback(host, guest, out):
x, y, z = (round(v, 1) for v in pos_of(guest))
guest('command', command='item replace entity @s weapon.mainhand with minecraft:stick')
guest('command', command=f'tp @s {x:.1f} {y:.1f} {z:.1f}')
guest.wait(10)
chat = guest.raw('messages', 5)['chat']
# The host is an operator: broadcastToAdmins tells it, as `[Guest: ...]`.
host_chat = host.raw('messages', 5)['chat']
guest.raw('key', 't', 'tap')
guest.wait(5)
guest.shot(out, 'feedback')
guest.raw('key', 'esc', 'tap')
guest.wait(5)
screen = guest('state').get('screen')
replaced = f'Replaced 1 slot(s) on {GUEST_NAME} with [Stick]'
assert any(replaced in line for line in chat), f'no "{replaced}" in {chat}'
assert any(line.startswith(f'Teleported {GUEST_NAME} to {x:.6f}') for line in chat), f'teleport feedback: {chat}'
assert screen in (None, 'playing'), f'the chat did not close: {screen}'
told = f'[{GUEST_NAME}: Teleported {GUEST_NAME} to {x:.6f}'
assert any(line.startswith(told) for line in host_chat), f'no "{told}" for the host: {host_chat}'
return chat + host_chat
def chat_input(game):
return game('screen').get('chat_input')
def complete(guest, text):
"""Opens the chat on `/`, types `text` and presses Tab: the input after it."""
guest.raw('key', 'slash', 'tap')
guest.until(lambda: guest('screen').get('screen'), lambda s: s == 'chat', ticks=3)
guest.raw('type', text)
guest.until(lambda: guest('screen').get('chat_suggestions'), bool, ticks=2)
guest.raw('key', 'tab', 'tap')
return guest.until(lambda: chat_input(guest), lambda value: value != '/' + text, ticks=2)
def check_commands(host, guest, out):
literal = complete(guest, 'gamem')
# The first Escape hides the suggestions, the next closes the chat.
for _ in range(3):
if guest('screen').get('screen') != 'chat':
break
guest.raw('key', 'esc', 'tap')
guest.wait(3)
argument = complete(guest, 'gamemode cr')
guest.shot(out, 'commands')
guest.raw('key', 'enter', 'tap')
guest.wait(20)
mode = guest('state').get('gamemode')
screen = guest('state').get('screen')
guest('command', command='gamemode survival')
guest.wait(10)
# A node the tree marks ask_server (a bossbar id): the guest asks the
# server, which completes it from its own bossbars.
host('command', command='bossbar add pt6:check "Check"')
host.wait(5)
guest.raw('key', 'slash', 'tap')
guest.wait(3)
guest.raw('type', 'bossbar get pt')
guest.wait(10)
guest.raw('key', 'tab', 'tap')
guest.wait(2)
asked = chat_input(guest)
for _ in range(3):
if guest('screen').get('screen') != 'chat':
break
guest.raw('key', 'esc', 'tap')
guest.wait(3)
host('command', command='bossbar remove pt6:check')
assert literal == '/gamemode', f'Tab after /gamem: {literal!r}'
assert argument == '/gamemode creative', f'Tab after /gamemode cr: {argument!r}'
assert mode == 'creative', f'the completed command did not run: {mode}'
assert screen in (None, 'playing'), f'the chat did not close: {screen}'
assert asked == '/bossbar get pt6:check', f'Tab after /bossbar get pt: {asked!r}'
return {'literal': literal, 'argument': argument, 'mode': mode, 'ask_server': asked}
def centre(rect):
x, y, w, h = rect
return x + w / 2, y + h / 2
def grid_items(game):
slots = game('screen').get('slots') or []
return {s['slot']: s.get('item') for s in slots if 100 <= s['slot'] < 109 and s.get('item')}
def check_recipes(host, guest, out):
guest('command', command='clear @s')
guest('command', command='give @s minecraft:oak_log 4')
guest.wait(40)
guest.raw('key', 'e', 'tap')
guest.wait(5)
try:
book = guest('screen').get('recipe_book')
assert book, 'the guest\'s inventory has no recipe book'
if len(book['targets']) == 1:
guest.raw('click-at', *centre(book['targets'][0]))
guest.wait(5)
book = guest('screen').get('recipe_book')
known = book['known']
assert known > 0, f'the guest\'s book is empty: {book}'
guest.raw('click-at', *centre(book['targets'][1]))
guest.wait(10)
grid = grid_items(guest)
guest.shot(out, 'recipes')
finally:
guest.raw('key', 'esc', 'tap')
guest.wait(5)
assert any('oak_log' in str(item) for item in grid.values()), f'nothing placed in the grid: {grid}'
# #2019: the alternatives popup belongs to one screen. Check both the
# host and the remote guest, with both
# inventory-close keys and a different recipe-book type on reopening.
for game in (host, guest):
game('command', command='recipe give @s minecraft:oak_planks')
game('command', command='recipe give @s minecraft:birch_planks')
game.wait(10)
for close_key, reopen in (('e', 'inventory'), ('esc', 'furnace')):
game.raw('key', 'e', 'tap')
game.wait(5)
book = game('screen')['recipe_book']
if len(book['targets']) == 1:
game.raw('click-at', *centre(book['targets'][0]))
game.wait(5)
book = game('screen')['recipe_book']
for rect in book['targets']:
if rect[2:] != [25.0, 25.0]:
continue
game.raw('click-at', *centre(rect), 'right')
game.wait(2)
targets = game('screen')['recipe_book']['targets']
if targets and all(r[2:] == [24.0, 24.0] for r in targets):
break
else:
raise AssertionError(f'{game.name}: no alternatives popup opened: {book}')
game.shot(out, f'recipe-overlay-{close_key}-before')
game.raw('key', close_key, 'tap')
game.wait(5)
if reopen == 'inventory':
game.raw('key', 'e', 'tap')
else:
x, y, z = (int(v // 1) for v in pos_of(game))
game('command', command=f'setblock {x} {y} {z - 2} minecraft:furnace')
game.wait(5)
game.raw('interact', x, y, z - 2)
game.wait(5)
targets = game('screen')['recipe_book']['targets']
if reopen == 'furnace' and len(targets) == 1 and targets[0][2:] == [20.0, 18.0]:
game.raw('click-at', *centre(targets[0]))
game.wait(5)
targets = game('screen')['recipe_book']['targets']
game.shot(out, f'recipe-overlay-{close_key}-after')
assert any(r[2:] == [20.0, 18.0] for r in targets), f'{game.name}: stale popup: {targets}'
if reopen == 'inventory':
assert len(targets) > 1, f'{game.name}: the book forgot its open setting'
game.raw('key', 'esc', 'tap')
game.wait(5)
return {'known': known, 'grid': grid}
def check_titles(host, guest, out):
# TitleCommand sends the guest its title packets; its HUD shows and clears them.
for command in ('title @s times 10 400 10', 'title @s subtitle "Under"', 'title @s title "Over"',
'title @s actionbar "Bar"'):
guest('command', command=command)
guest.wait(5)
shown = {k: guest.raw('messages', 1)[k] for k in ('title', 'subtitle', 'actionbar')}
guest.shot(out, 'titles')
guest('command', command='title @s clear')
guest.wait(5)
cleared = {k: guest.raw('messages', 1)[k] for k in ('title', 'subtitle')}
assert shown == {'title': 'Over', 'subtitle': 'Under', 'actionbar': 'Bar'}, f'titles shown: {shown}'
assert cleared == {'title': None, 'subtitle': None}, f'titles cleared: {cleared}'
return {'shown': shown, 'cleared': cleared}
def check_chat(host, guest, out):
# The agent's event log follows a remote client's chat: `wait-for chat` sees the line another
# player sends (and on the server thread the host window's own line, back from the server).
line = f'chat check {int(time.time())}'
host.raw('chat', line)
seen = {g.name: g.raw('wait-for', 'chat', line, '--timeout', 10, '--since', 0).get('ok')
for g in (guest, host)}
guest.shot(out, 'chat')
return seen
def styled_parts(component):
"""Every component in a chat line's tree (the line, its `extra` and `with` parts)."""
if isinstance(component, dict):
yield component
for key in ('extra', 'with'):
for part in component.get(key) or []:
yield from styled_parts(part)
elif isinstance(component, list):
for part in component:
yield from styled_parts(part)
def check_styles(host, guest, out):
# ChatComponent keeps what the server sent: the style, the click and the hover event.
stamp = f'STYLE {int(time.time())}'
line = {'text': stamp, 'color': 'green', 'bold': True,
'click_event': {'action': 'suggest_command', 'command': '/say clicked'},
'hover_event': {'action': 'show_text', 'value': 'Tip'}}
seen = {}
for g in (host, guest):
# Each to itself: tellraw reaches only the player whose command it is here.
since = g.raw('events')['last']
g('command', command='tellraw @s ' + json.dumps(line))
g.raw('wait-for', 'chat', stamp, '--timeout', 10, '--since', since)
part = next((p for c in g.raw('messages', 10)['styled'] for p in styled_parts(c)
if stamp in str(p.get('text', ''))), None) or {}
seen[g.name] = {k: part.get(k) for k in ('color', 'bold', 'click_event', 'hover_event')}
hover = seen[g.name]['hover_event'] or {}
seen[g.name]['hover_event'] = [hover.get('action'), 'Tip' in str(hover.get('value'))]
# InventoryChangeTrigger fires inside /give, before its message; the operator echo
# (broadcastToAdmins) skips the player whose command it was.
order = {}
for g in (host, guest):
for command in ('advancement revoke @s everything', 'clear @s'):
g('command', command=command)
g.wait(5)
since = g.raw('events')['last']
g('command', command='give @s minecraft:diamond')
g.raw('wait-for', 'chat', 'Diamonds!', '--timeout', 10, '--since', since)
g.wait(20)
chat = g.raw('messages', 8)['chat']
name = GUEST_NAME if g is guest else host.player
gave = next((i for i, l in enumerate(chat) if l.startswith('Gave 1 [Diamond]')), None)
made = next((i for i, l in enumerate(chat) if '[Diamonds!]' in l), None)
echo = [l for l in chat if l.startswith(f'[{name}: ')]
# Newest first: the give's message is newer than the advancement's.
order[g.name] = {'gave': gave, 'advancement': made, 'echo': echo}
guest.raw('key', 't', 'tap')
guest.wait(5)
guest.shot(out, 'styles')
guest.raw('key', 'esc', 'tap')
want = {'color': 'green', 'bold': True,
'click_event': {'action': 'suggest_command', 'command': '/say clicked'},
'hover_event': ['show_text', True]}
for name, got in seen.items():
assert got == want, f'{name} styled line: {got}'
for name, o in order.items():
assert o['gave'] is not None and o['advancement'] is not None, f'{name} chat: {o}'
assert o['gave'] < o['advancement'], f'{name}: the give came before the advancement: {o}'
assert not o['echo'], f'{name} heard its own command: {o}'
return {'styles': seen, 'order': order}
def png_rows(path):
"""The rows of an 8-bit RGB or RGBA PNG, unfiltered, as bytes of 4 (or 3) per pixel."""
data = Path(path).read_bytes()
pos, idat, head = 8, b'', None
while pos < len(data):
size = int.from_bytes(data[pos:pos + 4], 'big')
kind, body = data[pos + 4:pos + 8], data[pos + 8:pos + 8 + size]
if kind == b'IHDR':
head = body
elif kind == b'IDAT':
idat += body
pos += 12 + size
width, height = int.from_bytes(head[:4], 'big'), int.from_bytes(head[4:8], 'big')
assert head[8] == 8 and head[9] in (2, 6) and head[12] == 0, f'unsupported PNG {head.hex()}'
bpp = 4 if head[9] == 6 else 3
raw, stride, rows, prev = zlib.decompress(idat), width * bpp, [], bytearray(width * bpp)
for y in range(height):
kind, line = raw[y * (stride + 1)], bytearray(raw[y * (stride + 1) + 1:(y + 1) * (stride + 1)])
for i in range(stride):
a = line[i - bpp] if i >= bpp else 0
b, c = prev[i], prev[i - bpp] if i >= bpp else 0
if kind == 1:
line[i] = (line[i] + a) & 255
elif kind == 2:
line[i] = (line[i] + b) & 255
elif kind == 3:
line[i] = (line[i] + (a + b) // 2) & 255
elif kind == 4:
pa, pb, pc = abs(b - c), abs(a - c), abs(a + b - 2 * c)
line[i] = (line[i] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)) & 255
rows.append(bytes(line))
prev = line
return rows, bpp
def changed_pixels(a, b):
(ra, bpp), (rb, _) = png_rows(a), png_rows(b)
return sum(1 for la, lb in zip(ra, rb) for i in range(0, len(la), bpp)
if max(abs(la[i + k] - lb[i + k]) for k in range(3)) > 24)
def sneaking_on_host(host, want, ticks=40):
"""Whether the host sees the guest crouching, once it matches `want` or after `ticks`."""
for _ in range(ticks // 5):
sneaking = lan_guest(host)['sneaking']
if sneaking == want:
return sneaking
host.wait(5)
return sneaking
def check_walls(host, guest, out):
# A stone wall between the two: the host still sees the guest's name through it
# (TEXT_SEE_THROUGH), but not once the guest crouches (Entity.isDiscrete).
for g in (host, guest):
back_to_game(g)
# The trial spawner's zombies may have killed the guest.
if dead(guest):
guest.raw('respawn')
guest.wait(40)
face_each_other(host, guest)
hx, hy, hz = pos_of(host)
gx, gy, gz = pos_of(guest)
# Halfway: the players stand 3 apart, so the wall's block touches neither.
wall = int((hx + gx) / 2 // 1)
host('command', command=f'fill {wall} {int(hy // 1)} {int(hz // 1) - 3} {wall} {int(hy // 1) + 4} '
f'{int(hz // 1) + 3} minecraft:stone')
try:
host.raw('look-at', f'{gx:.2f}', f'{gy + 2.3:.2f}', f'{gz:.2f}')
# Chat lines fade out after 200 ticks; let them go so only the name tag can differ.
host.wait(210)
standing = host.shot(out, 'walls-standing')
guest.raw('key', 'shift', 'down')
crouched = sneaking_on_host(host, True)
guest.wait(10)
crouching = host.shot(out, 'walls-crouching')
guest.raw('key', 'shift', 'up')
stood = not sneaking_on_host(host, False)
guest.wait(10)
again = host.shot(out, 'walls-standing-again')
finally:
guest.raw('key', 'shift', 'up')
host('command', command=f'fill {wall} {int(hy // 1)} {int(hz // 1) - 3} {wall} {int(hy // 1) + 4} '
f'{int(hz // 1) + 3} minecraft:air')
face_each_other(host, guest)
assert crouched and stood, f'the host never saw the guest crouch and stand: {crouched}, {stood}'
seen, steady = changed_pixels(standing, crouching), changed_pixels(standing, again)
assert seen > 50, f'no name tag through the wall: {seen} pixels differ standing vs crouching'
assert steady < seen // 4, f'the view itself changed: {steady} pixels between two standing shots ({seen})'
return {'standing_vs_crouching': seen, 'standing_twice': steady}
def state_of(game, pos, key):
state = game.raw('block', *pos)['state']
return dict(kv.split('=') for kv in state.partition('[')[2].rstrip(']').split(',') if kv).get(key)
def wait_state(game, pos, key, want, ticks):
for _ in range(ticks // 10):
if state_of(game, pos, key) == want:
return want
game.wait(10)
return state_of(game, pos, key)
def check_piston_entities(host, guest, out):
"""A moving piston pushes a falling block and an arrow standing in front of it."""
hx, _, hz = pos_of(host)
x, y, z = (int(hx // 16) * 16 + 8, 100, int(hz // 16) * 16 + 8)
host('command', command=f'fill {x-4} {y-1} {z-4} {x+7} {y-1} {z+4} minecraft:stone')
host('command', command=f'fill {x-4} {y} {z-4} {x+7} {y+5} {z+4} minecraft:air')
host('command', command=f'setblock {x} {y} {z} minecraft:piston[facing=east]')
host('command', command=f'tp @s {x+5.5} {y} {z+3.5}')
kinds = {
'falling_block': '{BlockState:{Name:"minecraft:sand"},Time:1,NoGravity:1b}',
'arrow': '{}',
}
for i, (kind, nbt) in enumerate(kinds.items()):
host('command', command=f'summon minecraft:{kind} {x+1.5} {y} {z+0.5} {nbt}')
host.wait(20)
def xs():
got = {}
for kind in kinds:
objs = [o for o in host('status')['projectiles'] if kind in str(o.get('kind', '')).lower()
and abs(o["position"][2] - z - 0.5) < 0.6]
assert objs, f'{kind} not found before the push'
got[kind] = objs[0]['position'][0]
return got
before = xs()
host('command', command=f'setblock {x-1} {y} {z} minecraft:redstone_block')
host.wait(30)
after = xs()
results = {}
for kind in kinds:
results[kind] = after[kind] - before[kind]
assert results[kind] > 0.3, f'piston did not push {kind}: {before[kind]} -> {after[kind]}'
return results
def check_piston_push(host, guest, out):
"""A moving piston pushes both players and a stationary mob on each host path."""
hx, _, hz = pos_of(host)
x, y, z = (int(hx // 16) * 16 + 8, 100, int(hz // 16) * 16 + 8)
host('command', command=f'fill {x-4} {y-1} {z-4} {x+7} {y-1} {z+4} minecraft:stone')
host('command', command=f'fill {x-4} {y} {z-4} {x+7} {y+5} {z+4} minecraft:air')
host('command', command=f'setblock {x} {y} {z} minecraft:piston[facing=east]')
results = {}
for player, name in ((host, host.player), (guest, GUEST_NAME)):
host('command', command=f'setblock {x-1} {y} {z} minecraft:air')
host.wait(10)
host('command', command=f'tp @s {x+5.5} {y} {z+2.5}')
host('command', command=f'tp {GUEST_NAME} {x+5.5} {y} {z-1.5}')
host('command', command=f'tp {"@s" if player is host else GUEST_NAME} {x+1.65} {y} {z+0.5}')
player.wait(15)
before = pos_of(player)
assert abs(before[0] - (x + 1.65)) < 0.1, f'{name} did not reach piston: {before}'
host('command', command=f'setblock {x-1} {y} {z} minecraft:redstone_block')
player.wait(30)
after = pos_of(player)
results[name] = after[0] - before[0]
assert results[name] > 0.3, f'piston did not push {name}: {before} -> {after}'
host('command', command=f'setblock {x-1} {y} {z} minecraft:air')
host.wait(10)
host('command', command=f'tp {GUEST_NAME} {x+5.5} {y} {z-1.5}')
host('command', command=f'tp @s {x+1.65} {y} {z+0.5}')
host.wait(10)
host.raw('key', 'e', 'tap')
host.raw('wait-for', 'screen', 'inventory')
before = pos_of(host)
guest('command', command=f'setblock {x-1} {y} {z} minecraft:redstone_block')
host.wait(30)
after = pos_of(host)
results['inventory'] = after[0] - before[0]
assert results['inventory'] > 0.3, f'piston did not push player with inventory open: {before} -> {after}'
host.raw('key', 'esc', 'tap')
host('command', command=f'setblock {x-1} {y} {z} minecraft:air')
host.wait(10)
host('command', command=f'tp @s {x+5.5} {y} {z+2.5}')
host('command', command=f'summon minecraft:pig {x+1.65} {y} {z+0.5} {{NoAI:1b}}')
host.wait(10)
pigs = host('entities', radius=16, type='pig')['entities']
pig = min(pigs, key=lambda p: abs(p['pos'][0] - (x + 1.65)) + abs(p['pos'][2] - (z + 0.5)))
before = pig['pos'][0]
host('command', command=f'setblock {x-1} {y} {z} minecraft:redstone_block')
host.wait(30)
pigs = host('entities', radius=16, type='pig')['entities']
after = next(p['pos'][0] for p in pigs if p['id'] == pig['id'])
results['pig'] = after - before
assert results['pig'] > 0.3, f'piston did not push pig: {before} -> {after}'
results.update(piston_push_at_border(host, guest))
return results
def piston_push_at_border(host, guest):
"""A piston next to the world border: the border's wall stops the pushed
body, as it stops ordinary movement (`Entity.collectCollidersIgnoringWorldBorder`)."""
hx, _, hz = pos_of(host)
x, y, z = (int(hx // 16) * 16 + 8, 100, int(hz // 16) * 16 + 8)
host('command', command=f'fill {x-4} {y-1} {z-4} {x+7} {y-1} {z+4} minecraft:stone')
host('command', command=f'fill {x-4} {y} {z-4} {x+7} {y+5} {z+4} minecraft:air')
host('command', command=f'setblock {x} {y} {z} minecraft:piston[facing=east]')
# The border's east wall stands at `x + 2`, the east face of the block the
# head extends into: a body pushed east stops there instead of being
# pushed out through the border. `worldborder center` centres integers on
# the block, so the centre is `x - 8.5` and the wall lands on `x + 2`.
wall = x + 2
center = [x - 8.5, z + 0.5]
results = {}
try:
host('command', command=f'worldborder center {x - 9} {z}')
host('command', command='worldborder set 20.5')
seen = None
for _ in range(10):
host.wait(2)
seen = [g('status').get('border') for g in (host, guest)]
if all(b and b['size'] == 20.5 and b['center'] == center for b in seen):
break
results['border'] = seen
for player, name in ((host, host.player), (guest, GUEST_NAME)):
host('command', command=f'setblock {x-1} {y} {z} minecraft:air')
host.wait(10)
host('command', command=f'tp @s {x+5.5} {y} {z+2.5}')
host('command', command=f'tp {GUEST_NAME} {x+5.5} {y} {z-1.5}')
host('command', command=f'tp {"@s" if player is host else GUEST_NAME} {x+1.65} {y} {z+0.5}')
player.wait(15)
before = pos_of(player)
assert abs(before[0] - (x + 1.65)) < 0.1, f'{name} did not reach the piston: {before}'
host('command', command=f'setblock {x-1} {y} {z} minecraft:redstone_block')
player.wait(30)
after = pos_of(player)
# The wall's inner face is at `wall`: the body's box stops there.
results[name] = round(after[0] - before[0], 3)
assert after[0] > before[0], f'the piston did not push {name}: {before} -> {after}'
assert after[0] <= wall - 0.3 + 0.01, f'the border did not stop {name}: {before} -> {after}'
host('command', command=f'setblock {x-1} {y} {z} minecraft:air')
host.wait(10)
finally:
host('command', command='worldborder center 0 0')
host('command', command='worldborder set 59999968')
return results
def check_piston_slime(host, guest, out):
"""A piston pushing a slime block: the block pushes the body in front of
it and bounces it (`PistonMovingBlockEntity.moveCollidedEntities`)."""
hx, _, hz = pos_of(host)
x, y, z = (int(hx // 16) * 16 + 8, 100, int(hz // 16) * 16 + 8)
results = {}
def setup():
# The previous case may have left a mob where the body stands.
host('command', command='kill @e[type=minecraft:pig]')
host('command', command=f'setblock {x} {y} {z} minecraft:air')
host.wait(10)
host('command', command=f'fill {x-4} {y-1} {z-4} {x+7} {y-1} {z+4} minecraft:stone')
host('command', command=f'fill {x-4} {y} {z-4} {x+7} {y+5} {z+4} minecraft:air')
host('command', command=f'setblock {x} {y} {z} minecraft:piston[facing=east]')
host.wait(5)
for player, name in ((host, host.player), (guest, GUEST_NAME)):
setup()
host('command', command=f'setblock {x+1} {y} {z} minecraft:slime_block')
host('command', command=f'tp @s {x+5.5} {y} {z+2.5}')
host('command', command=f'tp {GUEST_NAME} {x+5.5} {y} {z-1.5}')
host('command', command=f'tp {"@s" if player is host else GUEST_NAME} {x+2.5} {y} {z+0.5}')
player.wait(15)
before = pos_of(player)
assert abs(before[0] - (x + 2.5)) < 0.1, f'{name} did not reach the slime: {before}'
host('command', command=f'setblock {x-1} {y} {z} minecraft:redstone_block')
# The bounce sets the velocity along the push axis; it decays fast, so
# sample it while the block is still moving.
speed = 0.0
for _ in range(6):
player.wait(2)
speed = max(speed, player('state')['player']['velocity'][0])
after = pos_of(player)
results[name] = round(after[0] - before[0], 3)
# The block pushes the body out of its way, and the bounce launches it.
assert results[name] > 1.5, f'the slime block did not push and bounce {name}: {before} -> {after}'
assert speed > 0.05, f'the slime block did not bounce {name}: {speed}'
host('command', command=f'setblock {x-1} {y} {z} minecraft:air')
player.wait(10)
# A plain block only pushes: the body is not launched.
setup()
host('command', command=f'setblock {x+1} {y} {z} minecraft:stone')
host('command', command=f'tp @s {x+2.5} {y} {z+0.5}')
host.wait(15)
before = pos_of(host)
host('command', command=f'setblock {x-1} {y} {z} minecraft:redstone_block')
speed = 0.0
for _ in range(6):
host.wait(2)
speed = max(speed, host('state')['player']['velocity'][0])
after = pos_of(host)
results['stone'] = round(after[0] - before[0], 3)
assert results['stone'] < 1.5, f'a plain block bounced the player: {before} -> {after}'
host('command', command=f'setblock {x-1} {y} {z} minecraft:air')
host.wait(10)
return results
def check_plates(host, guest, out):
guest('command', command='gamemode survival')
x, y, z = (int(v // 1) for v in pos_of(guest))
plate = (x + 2, y, z)
guest('command', command=f'setblock {plate[0]} {plate[1]} {plate[2]} minecraft:stone_pressure_plate')
guest.wait(10)
guest('command', command=f'tp @s {plate[0] + 0.5} {plate[1]} {plate[2] + 0.5}')
pressed = {'host': wait_state(host, plate, 'powered', 'true', 40),
'guest': wait_state(guest, plate, 'powered', 'true', 40)}
guest.shot(out, 'plate-pressed')
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
# PressurePlateBlock.getPressedTime: a stone plate checks again every 20 ticks.
released = {'host': wait_state(host, plate, 'powered', 'false', 60),
'guest': wait_state(guest, plate, 'powered', 'false', 60)}
guest('command', command=f'setblock {plate[0]} {plate[1]} {plate[2]} minecraft:air')
detail = {'pressed': pressed, 'released': released}
assert pressed == {'host': 'true', 'guest': 'true'}, f'the plate under the guest: {detail}'
assert released == {'host': 'false', 'guest': 'false'}, f'the plate after the guest left: {detail}'
return detail
def check_plate_projectiles(host, guest, out):
guest('command', command='gamemode creative')
x, y, z = (int(v // 1) for v in pos_of(guest))
def press(block, dx, entity, nbt):
plate = (x + dx, y, z + 5)
guest('command', command=f'setblock {plate[0]} {plate[1]} {plate[2]} minecraft:{block}')
guest.wait(5)
at = f'{plate[0] + 0.5} {plate[1] + 0.05} {plate[2] + 0.5}'
guest('command', command=f'summon minecraft:{entity} {at} {nbt}')
state = wait_state(host, plate, 'powered', 'true', 15)
guest('command', command=f'kill @e[type=minecraft:{entity}]')
guest('command', command=f'setblock {plate[0]} {plate[1]} {plate[2]} minecraft:air')
return state
detail = {'rocket': press('oak_pressure_plate', 0, 'firework_rocket', '{LifeTime:60}')}
# A field of plates under the cast, so the bobber lands on one wherever it falls.
field = (x - 4, y, z + 2, x + 4, y, z + 7)
guest('command', command='fill {} {} {} {} {} {} minecraft:oak_pressure_plate'.format(*field))
guest('command', command='item replace entity @s weapon.mainhand with minecraft:fishing_rod')
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 25')
guest.wait(10)
guest('use_item')
guest.wait(40)
detail['bobber'] = sum('powered=true' in b[3] for b in host.raw('blocks', *field)['blocks'])
guest('use_item')
# A real throw on each kind of plate: a stone plate senses only players and mobs.
for block, key in (('oak_pressure_plate', 'trident'), ('stone_pressure_plate', 'stone')):
guest('command', command='fill {} {} {} {} {} {} minecraft:air'.format(*field))
guest.wait(30)
guest('command', command='fill {} {} {} {} {} {} minecraft:{}'.format(*field, block))
guest('command', command='item replace entity @s weapon.mainhand with minecraft:trident')
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 30')
guest.wait(10)
guest('use_item', hold=1)
guest.wait(30)
detail[key] = sum('powered=true' in b[3] for b in host.raw('blocks', *field)['blocks'])
guest('command', command='kill @e[type=minecraft:trident]')
guest('command', command='fill {} {} {} {} {} {} minecraft:air'.format(*field))
assert detail['rocket'] == 'true', f'a rocket on a wooden plate: {detail}'
assert detail['bobber'] > 0, f'no plate under the bobber: {detail}'
assert detail['trident'] > 0, f'no plate under the trident: {detail}'
assert detail['stone'] == 0, f'a stone plate senses no trident: {detail}'
return detail
def check_trials(host, guest, out):
# The host watches as a spectator, whom neither block detects: only the guest can wake them.
hx, hy, hz = pos_of(host)
host('command', command='gamemode spectator')
host('command', command='difficulty normal')
host('command', command='gamerule spawn_mobs true')
guest('command', command='gamemode survival')
x, y, z = (int(v // 1) for v in pos_of(guest))
spawner, vault = (x, y, z + 3), (x, y, z - 2)
# Open air from the vault to the spawner: the spawner wants to see the guest's eyes.
guest('command', command=f'fill {x - 1} {y} {z - 2} {x + 1} {y + 2} {z + 3} minecraft:air')
try:
guest('command', command=f'setblock {spawner[0]} {spawner[1]} {spawner[2]} minecraft:trial_spawner'
'{spawn_data:{entity:{id:"minecraft:zombie"}},required_player_range:6}')
guest('command', command=f'setblock {vault[0]} {vault[1]} {vault[2]} minecraft:vault'
'{config:{key_item:{id:"minecraft:trial_key",count:1}}}')
trial = wait_state(host, spawner, 'trial_spawner_state', 'active', 100)
active = wait_state(host, vault, 'vault_state', 'active', 60)
host.shot(out, 'trials-active')
guest('command', command='item replace entity @s weapon.mainhand with minecraft:trial_key')
guest.wait(5)
guest.raw('interact', *vault)
unlocking = wait_state(host, vault, 'vault_state', 'unlocking', 20)
guest.shot(out, 'trials-unlocking')
rewarded = host('command', command=f'data get block {vault[0]} {vault[1]} {vault[2]} server_data.rewarded_players')
host_uuid = host('command', command='data get entity @s UUID')
finally:
host('command', command='gamerule spawn_mobs false')
host('command', command='kill @e[type=minecraft:zombie]')
host('command', command='gamemode survival')
# Back where it stood: a spectator drifts, and may be inside the ground when it lands.
host('command', command=f'tp @s {hx:.2f} {hy:.2f} {hz:.2f}')
guest('command', command=f'setblock {spawner[0]} {spawner[1]} {spawner[2]} minecraft:air')
guest('command', command=f'setblock {vault[0]} {vault[1]} {vault[2]} minecraft:air')
uuid = lambda reply: re.findall(r'-?\d+', json.dumps(reply).partition('[I;')[2].partition(']')[0])
assert trial == 'active', f'the trial spawner never saw the guest: {trial}'
assert active == 'active', f'the vault never saw the guest: {active}'
assert unlocking == 'unlocking', f'the guest\'s key did not unlock the vault: {unlocking}'
assert uuid(rewarded) and uuid(rewarded) != uuid(host_uuid), f'rewarded {rewarded}, host {host_uuid}'
return {'spawner': trial, 'vault': active, 'rewarded': uuid(rewarded), 'host': uuid(host_uuid)}
def bed_at(game, foot):
"""A bed whose foot is at `foot`, its head one block east."""
x, y, z = foot
game('command', command=f'setblock {x} {y} {z} minecraft:red_bed[facing=east,part=foot]')
game('command', command=f'setblock {x + 1} {y} {z} minecraft:red_bed[facing=east,part=head]')
return [x + 1, y, z]
def check_sleep(host, guest, out):
for g in (host, guest):
g('command', command='gamemode survival')
host('command', command='time set midnight')
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
hx, hy, hz = (int(v // 1) for v in pos_of(host))
guest_bed = bed_at(guest, (gx, gy, gz + 2))
host_bed = bed_at(host, (hx, hy, hz + 2))
guest.wait(10)
guest.raw('interact', *guest_bed)
guest.wait(10)
asleep = {'guest': guest('state').get('sleeping'), 'host': host('state').get('sleeping'),
'server': lan_guest(host).get('sleeping')}
guest.shot(out, 'sleep-guest')
# The host draws the guest lying in its bed (#1045): its client's (SLEEPING_POS), and a client
# pairing with the sleeper later too.
host.wait(5)
lying = {'host': bool(remote(host)['lying_players'])}
host.shot(out, 'sleep-host-sees-guest')
# Alone in bed: still night 120 ticks later (everyone must sleep).
guest.wait(120)
night = host('state')['time']['time_of_day']
# Escape gets the guest up (InBedChatScreen), then back to bed.
guest.raw('key', 'esc', 'tap')
guest.wait(10)
up = guest('state').get('sleeping')
guest.raw('interact', *guest_bed)
host.raw('interact', *host_bed)
host.wait(5)
# Both in bed: the guest draws the host lying too.
lying['guest'] = bool(remote(guest)['lying_players'])
host.wait(125)
after = {'guest': guest('state').get('sleeping'), 'host': host('state').get('sleeping'),
'time': host('state')['time']['time_of_day']}
lying['after'] = bool(remote(guest)['lying_players'])
guest.shot(out, 'sleep-morning')
for at in (guest_bed, host_bed):
guest('command', command=f'fill {at[0] - 1} {at[1]} {at[2]} {at[0]} {at[1]} {at[2]} minecraft:air')
host('command', command='time set day')
detail = {'asleep': asleep, 'night': night, 'up': up, 'after': after, 'lying': lying}
assert asleep['guest'] == guest_bed and asleep['server'] == guest_bed, f'the guest is not in its bed: {detail}'
assert asleep['host'] is None, f'the guest\'s bed put the host to sleep: {detail}'
assert 13000 < night < 23000, f'one of two players asleep ended the night: {detail}'
assert up is None, f'Escape did not get the guest up: {detail}'
assert after['guest'] is None and after['host'] is None and after['time'] < 1000, f'no morning: {detail}'
assert lying['host'] and lying['guest'], f'a sleeping player is drawn standing: {detail}'
assert not lying['after'], f'a player up again is still drawn lying: {detail}'
return detail
def check_sleep_villager(host, guest, out):
"""AbstractBedBlock.kickVillagerOutOfBed: using a bed a villager sleeps in wakes the villager and
leaves the player up; the next use lies down. The sleeper's own body is drawn in its bed, in first
person too (LevelExtractor skips the camera entity unless it sleeps)."""
guest('command', command='gamemode survival')
host('command', command='time set midnight')
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
x, z = gx, gz + 3
guest('command', command=f'fill {x - 3} {gy - 1} {z - 2} {x + 4} {gy - 1} {z + 3} minecraft:stone')
guest('command', command=f'fill {x - 3} {gy} {z - 2} {x + 4} {gy + 3} {z + 3} minecraft:air')
bed = bed_at(guest, (x, gy, z))
guest('command', command=f'summon minecraft:villager {x - 1.5} {gy} {z + 1.5}')
def villager():
found = guest('entities', radius=12, type='villager')['entities']
return found[0]['pos'] if found else None
def in_bed(at):
# Lying at the head (setPosToBed), not standing beside it.
return at is not None and abs(at[0] - bed[0] - 0.5) < 0.5 and abs(at[2] - bed[2] - 0.5) < 0.5 \
and at[1] - gy > 0.4
seen = {}
try:
for _ in range(90):
if in_bed(villager()):
break
guest.wait(20)
seen['villager_asleep'] = villager()
assert in_bed(seen['villager_asleep']), f'the villager never lay down in the bed: {seen}'
guest.raw('interact', *bed)
guest.wait(10)
seen['villager_kicked'] = villager()
seen['up'] = guest('state').get('sleeping')
assert not in_bed(seen['villager_kicked']), f'the bed did not wake its villager: {seen}'
assert seen['up'] is None, f'the player lay down beside the villager: {seen}'
guest.raw('interact', *bed)
guest.wait(10)
seen['asleep'] = guest('state').get('sleeping')
seen['own_body_asleep'] = remote(guest).get('own_body')
guest.shot(out, 'sleep-villager')
guest.raw('key', 'esc', 'tap')
guest.wait(10)
seen['own_body_up'] = remote(guest).get('own_body')
finally:
guest('command', command='kill @e[type=minecraft:villager,distance=..16]')
guest('command', command=f'fill {bed[0] - 1} {bed[1]} {bed[2]} {bed[0]} {bed[1]} {bed[2]} minecraft:air')
host('command', command='time set day')
assert seen['asleep'] == bed, f'the emptied bed did not take the player: {seen}'
assert seen['own_body_asleep'] and not seen['own_body_up'], f'the sleeper\'s own body: {seen}'
return seen
def check_bed_occupied(host, guest, out):
"""BedBlock.OCCUPIED (LivingEntity.startSleeping / stopSleeping): the bed the host sleeps in is
occupied, head and foot, and the guest cannot lie down in it (AbstractBedBlock.useWithoutItem's
"This bed is occupied"); the bed is free again after each way out of it: Escape, death, the bed
broken, and the sleeper leaving the game. Then the guest sleeps in it."""
port = host.raw('lan', 'status')['port']
for g in (host, guest):
g('command', command='gamemode survival')
host('command', command='time set midnight')
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
x, z = gx, gz + 3
guest('command', command=f'fill {x - 3} {gy - 1} {z - 2} {x + 4} {gy - 1} {z + 3} minecraft:stone')
guest('command', command=f'fill {x - 3} {gy} {z - 2} {x + 4} {gy + 3} {z + 3} minecraft:air')
bed = bed_at(guest, (x, gy, z))
foot = [bed[0] - 1, bed[1], bed[2]]
hx, hy, hz = pos_of(host)
beside = f'tp @s {x + 0.5} {gy} {z + 1.5}'
def occupied():
return [state_of(host, bed, 'occupied'), state_of(host, foot, 'occupied')]
def lie_down(game):
game('command', command=beside)
game.wait(10)
game.raw('interact', *bed)
game.wait(10)
return game('state').get('sleeping')
seen = {}
try:
host.wait(5)
seen['free'] = occupied()
seen['host_asleep'] = lie_down(host)
seen['occupied'] = occupied()
guest('command', command=f'tp @s {x + 0.5} {gy} {z - 0.5}')
guest.wait(10)
# The foot: the sleeper's body lies over the head.
guest.raw('interact', *foot)
guest.wait(5)
seen['refused'] = {'sleeping': guest('state').get('sleeping'), 'actionbar': guest.raw('messages', 1)['actionbar']}
guest.shot(out, 'bed-occupied')
host.raw('key', 'esc', 'tap')
host.wait(10)
seen['after_escape'] = occupied()
seen['asleep_again'] = lie_down(host)
host('command', command='kill @s')
host.wait(20)
respawn_when_ready(host)
host.wait(20)
seen['after_death'] = occupied()
seen['asleep_before_break'] = lie_down(host)
guest('command', command=f'setblock {foot[0]} {foot[1]} {foot[2]} minecraft:air')
guest.wait(10)
seen['up_after_break'] = host('state').get('sleeping')
host('command', command=f'tp @s {hx:.2f} {hy:.2f} {hz:.2f}')
bed_at(guest, (x, gy, z))
guest.wait(5)
seen['replaced'] = occupied()
seen['guest_asleep'] = lie_down(guest)
seen['occupied_by_guest'] = occupied()
guest.raw('disconnect')
time.sleep(2)
seen['after_leave'] = occupied()
guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME)
guest.raw('wait-for', 'loaded', '--timeout', 300)
guest.wait(20)
seen['screen_back'] = guest('state').get('screen')
back_to_game(guest)
seen['guest_back'] = lie_down(guest)
guest.raw('key', 'esc', 'tap')
guest.wait(10)
finally:
back_to_game(host)
back_to_game(guest)
host('command', command=f'tp @s {hx:.2f} {hy:.2f} {hz:.2f}')
guest('command', command=f'fill {foot[0]} {foot[1]} {foot[2]} {bed[0]} {bed[1]} {bed[2]} minecraft:air')
host('command', command='time set day')
free, taken = ['false', 'false'], ['true', 'true']
assert seen['free'] == free and seen['host_asleep'] == bed, f'the host did not lie down: {seen}'
assert seen['occupied'] == taken, f'the bed the host sleeps in is not occupied: {seen}'
assert seen['refused']['sleeping'] is None, f'two players share a bed: {seen}'
assert 'occupied' in (seen['refused']['actionbar'] or ''), f'no "This bed is occupied": {seen}'
for way in ('after_escape', 'after_death', 'replaced', 'after_leave'):
assert seen[way] == free, f'the bed stays occupied {way}: {seen}'
assert seen['asleep_again'] == bed and seen['asleep_before_break'] == bed, f'the host could not lie down again: {seen}'
assert seen['up_after_break'] is None, f'the host sleeps on in a broken bed: {seen}'
assert seen['guest_asleep'] == bed and seen['occupied_by_guest'] == taken, f'the guest\'s bed: {seen}'
assert seen['guest_back'] == bed, f'the bed the guest left is not free for it: {seen}'
return seen
def check_sleep_reload(host, guest, out):
"""LivingEntity's sleeping_pos is saved: a villager asleep in its bed when the host saves and quits
is still asleep in it, its bed still occupied, when the world opens again; the bed the guest slept
in is saved free (ServerPlayer.disconnect gets every player up before the last save)."""
guest('command', command='gamemode survival')
host('command', command='time set midnight')
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
x, z = gx, gz + 3
guest('command', command=f'fill {x - 3} {gy - 1} {z - 2} {x + 4} {gy - 1} {z + 6} minecraft:stone')
guest('command', command=f'fill {x - 3} {gy} {z - 2} {x + 4} {gy + 3} {z + 6} minecraft:air')
villager_bed = bed_at(guest, (x, gy, z))
guest_bed = bed_at(guest, (x, gy, z + 4))
guest('command', command=f'summon minecraft:villager {x - 1.5} {gy} {z + 1.5}')
sleeping_pos = lambda: host('command', command='data get entity @e[type=minecraft:villager,limit=1,sort=nearest] '
'sleeping_pos')
seen = {}
try:
for _ in range(90):
seen['villager_asleep'] = sleeping_pos()
if str(villager_bed[0]) in json.dumps(seen['villager_asleep']):
break
guest.wait(20)
seen['villager_bed'] = state_of(guest, villager_bed, 'occupied')
guest('command', command=f'tp @s {x + 0.5} {gy} {z + 5.5}')
guest.wait(10)
guest.raw('interact', *guest_bed)
guest.wait(10)
seen['guest_asleep'] = guest('state').get('sleeping')
seen['guest_bed'] = state_of(guest, guest_bed, 'occupied')
host.raw('disconnect')
for attempt in range(60):
time.sleep(1)
try:
host.raw('open', 'Lan')
break
except Failed:
if attempt == 59:
raise
host('wait_ready', seconds=400)
port = host.raw('lan', 'open', 0, 'commands')['port']
back_to_game(guest)
guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME)
guest.raw('wait-for', 'loaded', '--timeout', 300)
guest.wait(20)
seen['reopened'] = {'sleeping_pos': sleeping_pos(), 'villager_bed': state_of(host, villager_bed, 'occupied'),
'guest_bed': state_of(host, guest_bed, 'occupied'),
'guest': guest('state').get('sleeping')}
guest.shot(out, 'sleep-reload')
finally:
back_to_game(guest)
guest('command', command='kill @e[type=minecraft:villager,distance=..16]')
for at in (villager_bed, guest_bed):
guest('command', command=f'fill {at[0] - 1} {at[1]} {at[2]} {at[0]} {at[1]} {at[2]} minecraft:air')
host('command', command='time set day')
pos = lambda reply: [int(v) for v in re.findall(r'-?\d+', json.dumps(reply).partition('[I;')[2].partition(']')[0])]
assert pos(seen['villager_asleep']) == villager_bed and seen['villager_bed'] == 'true', \
f'the villager never lay down in its bed: {seen}'
assert seen['guest_asleep'] == guest_bed and seen['guest_bed'] == 'true', f'the guest did not lie down: {seen}'
reopened = seen['reopened']
assert pos(reopened['sleeping_pos']) == villager_bed, f'the villager woke up with the reload: {seen}'
assert reopened['villager_bed'] == 'true', f'the sleeping villager\'s bed is free after the reload: {seen}'
assert reopened['guest_bed'] == 'false' and reopened['guest'] is None, f'the guest\'s bed stays occupied: {seen}'
return seen
def check_predict(host, guest, out):
# MultiPlayerGameMode.useItemOn under startPrediction (#869): the guest's door opens on its own screen
# at once, with the host (the server) frozen, and the server's answer later agrees; an iron door
# (no hand use) stays shut on both sides.
guest('command', command='gamemode survival')
guest('command', command='clear @s')
x, y, z = (int(v // 1) for v in pos_of(guest))
wood, iron = (x - 1, y, z + 2), (x + 1, y, z + 2)
for (bx, by, bz), kind in ((wood, 'oak_door'), (iron, 'iron_door')):
for half, dy in (('lower', 0), ('upper', 1)):
host('command', command=f'setblock {bx} {by + dy} {bz} minecraft:{kind}[facing=north,hinge=right,half={half}]')
guest.wait(20)
state = lambda game, at: game.raw('block', *at)['state']
seen = {}
creaks = lambda game: sum(n for s, n in game('status')['heard'].items() if 'door' in s)
heard = {'guest': creaks(guest), 'host': creaks(host)}
host.raw('server-hold', 'on')
held_at = host.raw('simulation')['game_time']
try:
guest.raw('interact', *wood)
seen['guest_frozen'] = (state(guest, wood), state(guest, (wood[0], wood[1] + 1, wood[2])))
# level.playSound(player, ...): the clicker hears its own door at once.
seen['guest_heard_frozen'] = creaks(guest) - heard['guest']
guest.raw('interact', *iron)
seen['guest_iron_frozen'] = state(guest, iron)
assert host.raw('simulation')['game_time'] == held_at, 'server barrier advanced the world'
finally:
host.raw('server-hold', 'off')
guest.wait(20)
seen['guest_after'] = state(guest, wood)
seen['host_after'] = state(host, wood)
seen['iron_after'] = (state(guest, iron), state(host, iron))
# ... and not a second time from the server; the host hears it once.
seen['heard_after'] = {'guest': creaks(guest) - heard['guest'], 'host': creaks(host) - heard['host']}
guest.shot(out, 'predict')
for (bx, by, bz) in (wood, iron):
host('command', command=f'fill {bx} {by} {bz} {bx} {by + 1} {bz} minecraft:air')
assert all('open=true' in s for s in seen['guest_frozen']), f'the door waited for the server: {seen}'
assert 'open=true' in seen['guest_after'] and 'open=true' in seen['host_after'], f'the door did not stay open: {seen}'
assert 'open=false' in seen['guest_iron_frozen'], f'the guest opened an iron door by hand: {seen}'
assert all('open=false' in s for s in seen['iron_after']), f'an iron door opened: {seen}'
assert seen['guest_heard_frozen'] == 1 and seen['heard_after'] == {'guest': 1, 'host': 1}, \
f'the door is not heard once on each side: {seen}'
return seen
def check_push(host, guest, out):
guest('command', command='gamemode survival')
x, y, z = (int(v // 1) + 0.5 for v in pos_of(guest))
guest('command', command=f'tp @s {x} {y - 0.5} {z}')
guest.wait(20)
x, y, z = pos_of(guest)
# A cow that doesn't walk, 0.3 east of the guest: the two boxes overlap until their centres are
# 0.75 apart, so the push stops a fraction of a block away.
guest('command', command=f'summon minecraft:cow {x + 0.3:.2f} {y:.2f} {z:.2f} {{NoAI:1b}}')
guest.wait(60)
nx, _, nz = pos_of(guest)
guest.shot(out, 'push')
guest('command', command='kill @e[type=minecraft:cow]')
moved = ((nx - x) ** 2 + (nz - z) ** 2) ** 0.5
detail = {'from': [round(x, 3), round(z, 3)], 'to': [round(nx, 3), round(nz, 3)], 'moved': round(moved, 3)}
assert nx < x, f'the cow did not push the guest west: {detail}'
assert moved < 1.5, f'the cow pushed the guest too far: {detail}'
return detail
def check_eat(host, guest, out):
guest('command', command='gamemode survival')
guest('command', command='clear @s')
# Hungry: the eating needs food below 20 (Player.canEat).
guest('command', command='effect give @s minecraft:hunger 3 255')
guest.wait(70)
guest('command', command='effect clear @s')
guest('command', command='give @s minecraft:bread 3')
guest.wait(5)
guest.raw('select', 'minecraft:bread')
hungry = guest('state')['player']['food']
start = guest('status')
# use-item --hold returns once released: watch the guest's screen meanwhile.
hold = threading.Thread(target=guest.raw, args=('use-item', '--hold', 2.2))
hold.start()
using = []
for _ in range(4):
time.sleep(0.2)
using.append(guest('state').get('using'))
guest.shot(out, 'eat')
hold.join()
guest.wait(10)
fed = guest('state')['player']['food']
left = guest('inventory')['counts'].get('minecraft:bread', 0)
end = guest('status')
# Every bite the guest's client draws (5 crumbs, 16 for the last) plays its own sound; the
# server plays them for the others only (Player.playSound) and adds FoodProperties.onConsume's
# sound for each loaf, so the eater hears each bite once (#3129).
eat = 'minecraft:entity.generic.eat'
heard = end['heard'].get(eat, 0) - start['heard'].get(eat, 0)
crumbs = end['particles'].get('item', 0) - start['particles'].get('item', 0)
eaten = 3 - left
bites = (crumbs - 16 * eaten) / 5
detail = {'hungry': hungry, 'fed': fed, 'bread': left, 'using': using, 'after': guest('state').get('using'),
'heard': heard, 'crumbs': crumbs}
assert hungry < 20, f'the guest is not hungry: {detail}'
ticks = [u['ticks'] for u in using if u]
assert len(ticks) >= 3 and ticks == sorted(ticks) and ticks[-1] > ticks[0], f'no eating on the guest\'s screen: {detail}'
assert left == 2 and fed > hungry, f'the server did not eat one loaf: {detail}'
assert detail['after'] is None, f'still eating after the release: {detail}'
assert heard == bites + 2 * eaten, f'the guest heard {heard} bites for {bites:g} crumb bursts: {detail}'
return detail
def check_eat_on_block(host, guest, out):
guest('command', command='gamemode survival')
guest('command', command='clear @s')
guest('command', command='effect give @s minecraft:hunger 3 255')
guest.wait(70)
guest('command', command='effect clear @s')
guest('command', command='give @s minecraft:sweet_berries 4')
x, y, z = (int(v // 1) for v in pos_of(guest))
stone, dirt = (x, y - 1, z + 1), (x + 1, y - 1, z)
guest('command', command=f'fill {x - 1} {y} {z - 1} {x + 1} {y + 2} {z + 1} minecraft:air')
guest('command', command=f'setblock {stone[0]} {stone[1]} {stone[2]} minecraft:stone')
guest('command', command=f'setblock {dirt[0]} {dirt[1]} {dirt[2]} minecraft:dirt')
guest.wait(10)
guest.raw('select', 'minecraft:sweet_berries')
berries = lambda: guest('inventory')['counts'].get('minecraft:sweet_berries', 0)
hungry = guest('state')['player']['food']
# Held on stone: the use runs on this client as well as on the server.
guest.raw('look-at-block', *stone, 'up')
guest.wait(2)
guest.raw('mouse', 'right', 'down')
using = []
for _ in range(4):
time.sleep(0.2)
using.append(guest('state').get('using'))
time.sleep(0.8)
guest.raw('mouse', 'right', 'up')
guest.wait(10)
held = {'using': using, 'after': guest('state').get('using'), 'left': berries(),
'food': guest('state')['player']['food']}
# Let go at once: the release stops the server's use too.
guest.raw('mouse', 'right', 'down')
guest.wait(2)
started = guest('state').get('using')
guest.raw('mouse', 'right', 'up')
guest.wait(50)
short = {'started': started, 'after': guest('state').get('using'), 'left': berries()}
# Dirt is where they are planted, and a planted berry isn't eaten.
guest.raw('look-at-block', *dirt, 'up')
guest.wait(2)
guest.raw('mouse', 'right', 'click')
guest.wait(50)
planted = {'block': guest('block', x=dirt[0], y=y, z=dirt[2]), 'using': guest('state').get('using'),
'left': berries()}
detail = {'hungry': hungry, 'held': held, 'short': short, 'planted': planted}
ticks = [u['ticks'] for u in using if u]
assert hungry < 20, f'the guest is not hungry: {detail}'
assert len(ticks) >= 3 and ticks == sorted(ticks) and ticks[-1] > ticks[0], f"no eating on the guest's screen: {detail}"
assert held['left'] == 3 and held['food'] > hungry and held['after'] is None, f'the server did not eat one berry: {detail}'
assert short['started'] and short['after'] is None and short['left'] == 3, f'an early release still ate: {detail}'
assert 'sweet_berry_bush' in str(planted['block']) and planted['left'] == 2 and planted['using'] is None, f'not planted on dirt: {detail}'
return detail
def check_cauldron(host, guest, out):
"""A bucket used on a cauldron's side fills or empties the cauldron only: the guest's
client ends the click at the block (CauldronInteractions answers SUCCESS there too), so
no UseItem follows that would pour the bucket again beside it."""
x, y, z = (int(v // 1) for v in pos_of(guest))
cauldron, beside = (x + 2, y, z), (x + 1, y, z)
guest('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 3} {y - 1} {z + 1} minecraft:stone')
guest('command', command=f'fill {x - 1} {y} {z - 1} {x + 3} {y + 2} {z + 1} minecraft:air')
block = lambda p: guest.raw('block', *p).get('block')
def use(item, gamemode, start):
guest('command', command=f'gamemode {gamemode}')
guest('command', command='clear @s')
guest('command', command=f'give @s {item}')
guest('command', command=f'setblock {beside[0]} {beside[1]} {beside[2]} minecraft:air')
guest('command', command=f'setblock {cauldron[0]} {cauldron[1]} {cauldron[2]} {start}')
for _ in range(20):
guest.wait(5)
if guest.raw('block', *cauldron)['state'].startswith(start.partition('[')[0]):
break
guest.raw('select', item)
guest.raw('look-at-block', *cauldron, 'west')
guest.wait(2)
# A pour the client predicts beside it lasts only until the server's ack.
replies = guest.script(['mouse right click'] + [f'block {beside[0]} {beside[1]} {beside[2]}'] * 12,
every=True)
seen = {r['block'] for r in replies if 'block' in r}
guest.wait(20)
seen.add(block(beside))
return {'cauldron': guest.raw('block', *cauldron)['state'], 'beside': sorted(seen),
'counts': guest('inventory')['counts']}
detail = {
'creative': use('minecraft:water_bucket', 'creative', 'minecraft:cauldron'),
'survival': use('minecraft:lava_bucket', 'survival', 'minecraft:cauldron'),
'fill': use('minecraft:bucket', 'survival', 'minecraft:water_cauldron[level=3]'),
}
guest('command', command='gamemode creative')
creative, survival, fill = detail['creative'], detail['survival'], detail['fill']
assert 'water_cauldron' in creative['cauldron'] and creative['beside'] == ['minecraft:air'], \
f'the water bucket was poured beside the cauldron too: {detail}'
assert 'lava_cauldron' in survival['cauldron'] and survival['beside'] == ['minecraft:air'] \
and survival['counts'].get('minecraft:bucket') == 1, f'the lava bucket was not emptied into the cauldron: {detail}'
assert fill['cauldron'].startswith('minecraft:cauldron') and fill['beside'] == ['minecraft:air'] \
and fill['counts'].get('minecraft:water_bucket') == 1 and 'minecraft:bucket' not in fill['counts'], \
f'the bucket was not filled from the cauldron alone: {detail}'
return detail
def watch_mining(miner, pos, seen):
"""Mine the block at `pos` (by hand, for seconds) and sample `seen()` meanwhile."""
dig = threading.Thread(target=miner.raw, args=('break', *pos))
dig.start()
samples = []
for _ in range(8):
time.sleep(0.25)
samples.append(seen())
dig.join()
return samples
def check_swings(host, guest, out):
for g in (host, guest):
g('command', command='gamemode survival')
g('command', command='clear @s')
face_each_other(host, guest)
hx, hy, hz = (int(v // 1) for v in pos_of(host))
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
# Stone by hand takes 7.5 seconds: ServerboundPunchPacket every tick keeps the arm swinging.
guest('command', command=f'setblock {gx} {gy} {gz + 1} minecraft:stone')
host('command', command=f'setblock {hx} {hy} {hz + 1} minecraft:stone')
host.wait(10)
def swinging(game):
# The host's window is a client of its server too.
return any(e.get('swinging') for e in remote(game).get('player_equipment', []))
# Most samples: a swing restarts only past its first half (SwingState.startIfAble), so a sample can
# fall between two.
host_sees = watch_mining(guest, (gx, gy, gz + 1), lambda: swinging(host))
guest_sees = watch_mining(host, (hx, hy, hz + 1), lambda: swinging(guest))
# A hoe on dirt: the server tills it and swings the guest's hand, on the guest's screen too.
# Where the guest stands now (an earlier check can leave it on a ledge it then drops from).
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
soil = (gx, gy - 1, gz + 1)
guest('command', command=f'setblock {soil[0]} {soil[1]} {soil[2]} minecraft:dirt')
# HoeItem.useOn tills only under air.
guest('command', command=f'setblock {soil[0]} {soil[1] + 1} {soil[2]} minecraft:air')
guest('command', command='give @s minecraft:wooden_hoe')
guest.wait(10)
guest.raw('select', 'minecraft:wooden_hoe')
before = guest('state')['swings']
guest.raw('look-at', soil[0] + 0.5, soil[1] + 1, soil[2] + 0.5)
guest.wait(1)
target = guest('state')['looking_at']['block']
assert target and target['pos'] == list(soil) and target['face'] == 'up', target
guest.raw('mouse', 'right', 'click')
guest.wait(10)
hoe = {'swings': guest('state')['swings'] - before, 'soil': host.raw('block', *soil)['block']}
guest('command', command=f'setblock {soil[0]} {soil[1]} {soil[2]} minecraft:stone')
detail = {'host_sees_guest': host_sees, 'guest_sees_host': guest_sees, 'hoe': hoe}
assert sum(map(bool, host_sees)) >= 4, f'the host does not see the guest mine: {detail}'
assert sum(map(bool, guest_sees)) >= 4, f'the guest does not see the host mine: {detail}'
assert hoe == {'swings': 1, 'soil': 'minecraft:farmland'}, f'the guest\'s hoe: {detail}'
return detail
def check_cracks(host, guest, out):
# Another player's crack (#1096): ServerLevel.destroyBlockProgress sends each new stage to the
# others (ClientboundBlockDestructionPacket), drawn by LevelRenderer from destroyingBlocks.
for g in (host, guest):
g('command', command='gamemode survival')
g('command', command='clear @s')
face_each_other(host, guest)
hx, hy, hz = (int(v // 1) for v in pos_of(host))
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
guest('command', command=f'setblock {gx} {gy} {gz + 1} minecraft:stone')
host('command', command=f'setblock {hx} {hy} {hz + 1} minecraft:stone')
host.wait(10)
shots = []
def cracks(game, pos, name):
stage = max((c['stage'] for c in game('status')['destroying'] if tuple(c['pos']) == pos), default=None)
if stage is not None and stage >= 2 and name not in shots:
shots.append(name)
game.shot(out, name)
return stage
def watch(miner, watcher, pos, name):
# The watcher looks at the block the other mines.
watcher.raw('look-at', f'{pos[0] + 0.5:.2f}', f'{pos[1] + 0.5:.2f}', f'{pos[2] + 0.5:.2f}')
watcher.wait(5)
return watch_mining(miner, pos, lambda: cracks(watcher, pos, name))
host_sees = watch(guest, host, (gx, gy, gz + 1), 'cracks-guest-mines')
face_each_other(host, guest)
guest_sees = watch(host, guest, (hx, hy, hz + 1), 'cracks-host-mines')
host.wait(10)
# The breaker's -1 (ServerLevel.destroyBlockProgress once the block is broken) clears each crack.
gone = {name: [(c['pos'], game.raw('block', *c['pos'])['block']) for c in game('status')['destroying']]
for name, game in (('host', host), ('guest', guest))}
detail = {'host_sees_guest': host_sees, 'guest_sees_host': guest_sees, 'after': gone}
stages = lambda s: [x for x in s if x is not None]
assert len(stages(host_sees)) >= 3 and stages(host_sees) == sorted(stages(host_sees)), \
f'the host does not see the guest\'s crack grow: {detail}'
assert len(stages(guest_sees)) >= 3 and stages(guest_sees) == sorted(stages(guest_sees)), \
f'the guest does not see the host\'s crack grow: {detail}'
assert not any(gone.values()), f'a crack stays after the block broke: {detail}'
return detail
def check_eating(host, guest, out):
"""Each player eats a loaf in front of the other: the other side draws the eater eating
(DATA_LIVING_ENTITY_FLAGS) and stops when it is done. The eater and the player beside it
each hear every bite once (#3129): the eater's own client plays its bites and the server
sends them to the others only (Player.playSound), and FoodProperties.onConsume's sound
reaches both. One bite per crumb burst the eater draws (5 crumbs, 16 for the last) plus
FoodProperties' sound, for each loaf eaten. One loaf each: with a second, the use held on
starts it and lets go a few ticks in, where the eater's client and the server can disagree
on whether its first bite came (each times the use on its own ticks)."""
eat = 'minecraft:entity.generic.eat'
def sounds(game):
status = game('status')
return status['heard'].get(eat, 0), status['particles'].get('item', 0)
def settled(eater, watcher):
"""Both sides' sounds once neither has changed for 20 ticks: the last bite can still be on
its way to the watcher when the eater is done."""
last, still = None, 0
for _ in range(100):
now = sounds(eater), sounds(watcher)
still = still + 1 if now == last else 0
if still >= 10:
break
last = now
eater.wait(2)
return now
for g in (host, guest):
g('command', command='gamemode survival')
g('command', command='clear @s')
# Hungry: Player.canEat.
g('command', command='effect give @s minecraft:hunger 3 255')
host.wait(70)
for g in (host, guest):
g('command', command='effect clear @s')
g('command', command='give @s minecraft:bread')
host.wait(5)
face_each_other(host, guest)
def using(game):
# The host's window is a client of its server too.
return max((e.get('using') or 0 for e in remote(game).get('player_equipment', [])), default=0)
seen = {}
for name, eater, watcher in (('host eats', host, guest), ('guest eats', guest, host)):
eater.raw('select', 'minecraft:bread')
loaves = eater('inventory')['counts'].get('minecraft:bread', 0)
(eater_heard, crumbs), (watcher_heard, _) = sounds(eater), sounds(watcher)
hold = threading.Thread(target=eater.raw, args=('use-item', '--hold', 2.2))
hold.start()
samples = []
for _ in range(4):
time.sleep(0.3)
samples.append(using(watcher))
watcher.shot(out, 'eating-' + name.replace(' ', '-'))
hold.join()
(eater_after, crumbs_after), (watcher_after, _) = settled(eater, watcher)
eaten = loaves - eater('inventory')['counts'].get('minecraft:bread', 0)
seen[name] = {'during': samples, 'after': using(watcher), 'eaten': eaten,
'bites': (crumbs_after - crumbs - 16 * eaten) / 5,
'eater_heard': eater_after - eater_heard, 'watcher_heard': watcher_after - watcher_heard}
for name, s in seen.items():
assert sum(1 for u in s['during'] if u > 0) >= 3 and s['after'] == 0, f'{name}: {seen}'
assert s['eaten'] == 1, f'{name}: the server did not eat one loaf: {seen}'
want = s['bites'] + 2 * s['eaten']
assert s['eater_heard'] == want, f'{name}: the eater heard {s["eater_heard"]} bites, wants {want:g}: {seen}'
assert s['watcher_heard'] == want, \
f'{name}: the player beside the eater heard {s["watcher_heard"]} bites, wants {want:g}: {seen}'
return seen
def check_xp(host, guest, out):
for g in (host, guest):
g('command', command='gamemode survival')
g('command', command='xp set @s 0 levels')
g('command', command='xp set @s 0 points')
# Out of the orbs' way wherever an earlier check left the host (ExperienceOrb follows the
# nearest player who isn't a spectator).
hx, hy, hz = pos_of(host)
host('command', command='gamemode spectator')
try:
guest.wait(10)
# `xp set` leaves Player.totalExperience (the score): experience from earlier checks stays in it.
total = guest('state')['player']['xp']['total']
host_total = host('state')['player']['xp']['total']
x, y, z = pos_of(guest)
# Three orbs of 7 points 4 blocks east of the guest (out of reach, within the 8 blocks orbs
# follow from): they fly to the guest.
# Freeze setup so command/IPC latency cannot let all three be collected
# before the two-tick in-flight observation.
host('command', command='tick freeze')
for _ in range(3):
guest('command', command=f'summon minecraft:experience_orb {x + 4:.2f} {y + 0.5:.2f} {z:.2f} {{Value:7s}}')
host('command', command='tick step 2')
guest.wait(2)
seen = remote(guest).get('entities', {}).get('minecraft:experience_orb', 0)
guest.shot(out, 'xp-orbs')
host('command', command='tick unfreeze')
guest.wait(60)
xp = guest('state')['player']['xp']
host_xp = host('state')['player']['xp']
left = remote(guest).get('entities', {}).get('minecraft:experience_orb', 0)
finally:
host('command', command='tick unfreeze')
host('command', command='gamemode survival')
# Back where it stood: a spectator sinks (Player.travel keeps 0.6 of its fall speed each tick,
# about 0.2 blocks from standing), and a later check that makes it survival again would land
# it inside the floor, with the guest teleported beside it (swings).
host('command', command=f'tp @s {hx:.2f} {hy:.2f} {hz:.2f}')
detail = {'seen': seen, 'guest': xp, 'host': host_xp, 'left': left}
# ExperienceOrb.scanForMerges: orbs of one value side by side can merge into one.
assert 1 <= seen <= 3, f'the guest saw {seen} orbs: {detail}'
assert xp['total'] - total == 21 and xp['level'] == 2, f'the guest\'s experience after three 7-point orbs: {detail}'
assert host_xp['total'] == host_total, f'the host took the orbs: {detail}'
assert left == 0, f'orbs left on the guest\'s screen: {detail}'
return {'seen': seen, 'guest': xp, 'host': host_xp}
def check_time(host, guest, out):
def times():
return host('state')['time']['day_time'], guest('state')['time']['day_time']
host('command', command='time set 1000')
host('command', command='time pause')
host.wait(60)
paused = times()
host('command', command='time resume')
guest.wait(20)
# A stalled game runs at most 10 missed ticks when it wakes (DeltaTracker); the server's
# game time, sent every second, moves the guest's clock on (ClientClockManager.tick).
guest.raw('stall', 4000)
guest.wait(60)
stalled = times()
detail = {'paused': paused, 'after_stall': stalled}
assert paused[0] == paused[1] and 1000 <= paused[0] < 1010, f'the clock while paused: {detail}'
# The two reads are a tick or two apart.
assert abs(stalled[0] - stalled[1]) <= 3, f'the guest\'s clock after its game stalled: {detail}'
return detail
def objects_near(game, centre, key='objects'):
"""`status`'s objects within 8 blocks of centre, without their ids (each side numbers its own)."""
out = []
for o in game('status')[key]:
if all(abs(o['position'][i] - centre[i]) <= 8 for i in (0, 2)):
o = dict(o)
o.pop('id')
o['position'] = [round(v, 2) for v in o['position']]
if 'yaw' in o: # as the packets carry it, in 256ths of a turn
o['yaw'] = round(o['yaw'] * 256 / 360) % 256
out.append(o)
return sorted(out, key=json.dumps)
def check_objects(host, guest, out):
x, y, z = (int(v // 1) for v in pos_of(host))
commands = [
f'fill {x - 6} {y} {z - 6} {x + 6} {y + 3} {z - 6} minecraft:stone',
f'summon minecraft:minecart {x - 4.5} {y} {z + 3.5}',
f'summon minecraft:chest_minecart {x - 2.5} {y} {z + 3.5}',
f'summon minecraft:furnace_minecart {x + 1.5} {y} {z + 3.5}',
f'summon minecraft:tnt_minecart {x + 4.5} {y} {z + 3.5}',
f'summon minecraft:oak_boat {x - 4.5} {y} {z + 6.5} {{Rotation:[45f,0f]}}',
f'summon minecraft:birch_chest_boat {x - 1.5} {y} {z + 6.5}',
f'summon minecraft:bamboo_raft {x + 2.5} {y} {z + 6.5}',
f'summon minecraft:armor_stand {x - 2.5} {y} {z - 2.5} {{Rotation:[90f,0f]}}',
f'summon minecraft:armor_stand {x - 4.5} {y} {z - 2.5} {{ShowArms:1b,Small:1b,NoBasePlate:1b,'
'equipment:{head:{id:"minecraft:diamond_helmet",count:1}}}',
# Falls to the floor: the guest's copy has to follow the server's moves.
f'summon minecraft:armor_stand {x + 2.5} {y + 3} {z - 2.5} {{Rotation:[200f,0f]}}',
f'summon minecraft:item_frame {x - 3.5} {y + 1} {z - 4.97} {{block_pos:[I;{x - 4},{y + 1},{z - 5}],Facing:3b,'
'Item:{id:"minecraft:diamond",count:1},ItemRotation:3b}',
f'summon minecraft:glow_item_frame {x - 1.5} {y + 1} {z - 4.97} {{block_pos:[I;{x - 2},{y + 1},{z - 5}],Facing:3b}}',
f'summon minecraft:painting {x + 1.5} {y + 1} {z - 4.97} {{block_pos:[I;{x + 1},{y + 1},{z - 5}],facing:0b,'
'variant:"minecraft:kebab"}',
f'summon minecraft:painting {x + 4} {y + 1} {z - 4.97} {{block_pos:[I;{x + 4},{y + 1},{z - 5}],facing:0b,'
'variant:"minecraft:courbet"}',
]
try:
for command in commands:
host('command', command=command)
host.wait(60)
guest.raw('look-at', x, y, z - 5)
guest.wait(10)
guest.shot(out, 'objects')
host.shot(out, 'objects')
mine, theirs = objects_near(host, (x, y, z)), objects_near(guest, (x, y, z))
finally:
host('command', command=f'kill @e[type=!minecraft:player,x={x - 8},y={y - 2},z={z - 8},dx=16,dy=8,dz=16]')
host('command', command=f'fill {x - 6} {y} {z - 6} {x + 6} {y + 3} {z - 6} minecraft:air')
assert len(mine) == len(commands) - 1, f'the host has {len(mine)} objects: {mine}'
assert theirs == mine, f'the guest sees {theirs}\nthe host has {mine}'
return f'{len(theirs)} objects identical'
def check_projectiles(host, guest, out):
x, y, z = (int(v // 1) for v in pos_of(host))
commands = [
f'summon minecraft:tnt {x + 4.5} {y} {z + 4.5} {{fuse:2000s}}',
f'summon minecraft:end_crystal {x - 4.5} {y} {z + 4.5} {{ShowBottom:0b}}',
f'summon minecraft:tnt {x + 2.5} {y + 5} {z - 3.5} {{fuse:2000s}}',
f'summon minecraft:arrow {x - 2.5} {y + 4} {z - 2.5} {{Motion:[0.2d,0d,0d]}}',
f'summon minecraft:falling_block {x - 0.5} {y + 6} {z - 4.5} {{BlockState:{{Name:"minecraft:sand"}}}}',
]
try:
for command in commands:
host('command', command=command)
host.wait(80)
guest.raw('look-at', x, y, z)
guest.wait(5)
guest.shot(out, 'projectiles')
mine = objects_near(host, (x, y, z), 'projectiles')
theirs = objects_near(guest, (x, y, z), 'projectiles')
finally:
host('command', command=f'kill @e[type=!minecraft:player,x={x - 8},y={y - 2},z={z - 8},dx=16,dy=10,dz=16]')
host('command', command=f'setblock {x - 1} {y} {z - 5} minecraft:air')
assert len(mine) == len(commands) - 1, f'the host has {len(mine)}: {mine}'
# The fuse counts down every tick: the guest's, read a moment later, may be a tick or two behind.
fuses = [(m.pop('fuse', 0), t.pop('fuse', 0)) for m, t in zip(mine, theirs)]
assert theirs == mine, f'the guest sees {theirs}\nthe host has {mine}'
assert all(abs(a - b) <= 2 for a, b in fuses), f'fuses (host, guest): {fuses}'
return f'{len(theirs)} identical, fuses (host, guest) {fuses}'
def check_thrown(host, guest, out):
spots = apart(host, guest)
result = {}
for label, player, other in (('host', host, guest), ('guest', guest, host)):
x, y, z = spots[label]
player('command', command='gamemode survival')
for item, kind in (('splash_potion', 'minecraft:splash_potion'), ('ender_eye', 'minecraft:eye_of_ender')):
player('command', command=f'item replace entity @s weapon.mainhand with minecraft:{item}')
player.wait(10)
# Up at 45 degrees, into open sky.
player.raw('look-at', f'{x + 0.5:.2f}', f'{y + 11.6:.2f}', f'{z + 10.5:.2f}')
player.raw('use-item')
seen = {'own': 0, 'other': 0}
for _ in range(10):
player.wait(2)
for side, game in (('own', player), ('other', other)):
if any(p['kind'] == kind for p in game('status')['projectiles']):
seen[side] += 1
if all(seen.values()):
break
player.shot(out, f'thrown-{label}-{item}')
other.shot(out, f'thrown-{label}-{item}-seen')
player.wait(100) # it lands or drops
result[f'{label} {item}'] = seen
for name, seen in result.items():
assert all(seen.values()), f'{name}: seen in flight (samples) {result}'
return result
def made(game, kind):
"""How many `kind` particles `game` has made so far."""
return game('status').get('particles', {}).get(kind, 0)
def sparked(player, other, kinds, act, ticks=20):
"""The `kinds` particles each side makes while `act` runs and for up to `ticks` after."""
before = {(side, k): made(g, k) for side, g in (('own', player), ('other', other)) for k in kinds}
act()
seen = {}
for _ in range(ticks // 2):
player.wait(2)
seen = {f'{side} {k}': made(g, k) - before[(side, k)]
for side, g in (('own', player), ('other', other)) for k in kinds}
if all(seen.values()):
break
return seen
def copy_recreate_command(game, check):
"""F3+I (KeyboardHandler.copyRecreateCommand): what it copied once `check` accepts it."""
game.raw('key', 'f3', 'down')
game.raw('key', 'i', 'tap')
game.raw('key', 'f3', 'up')
return game.until(lambda: game('status')['debug']['clipboard'], lambda c: bool(c) and check(c), ticks=40)
def check_tag_query(host, guest, out):
"""An operator guest's F3+I copies the server's data (#3518): DebugQueryHandler's tag queries,
answered by ServerGamePacketListenerImpl.handleBlockEntityTagQuery and handleEntityTagQuery.
A chest's items and an entity's tags never reach the client, so only the server's answer has them."""
x, y, z = (int(v // 1) for v in pos_of(guest))
chest = (x, y, z + 2)
guest('command', command=f'setblock {chest[0]} {chest[1]} {chest[2]} '
'minecraft:chest{Items:[{Slot:0b,id:"minecraft:diamond",count:5}]}')
guest.wait(10)
guest.raw('look-at-block', *chest)
guest.wait(2)
block = copy_recreate_command(guest, lambda c: c.startswith(f'/setblock {chest[0]} {chest[1]} {chest[2]} '))
block_said = guest.raw('messages', 1)['chat']
guest('command', command=f'setblock {chest[0]} {chest[1]} {chest[2]} minecraft:air')
guest('command', command=f'summon minecraft:pig {x + 0.5} {y} {z + 2.5} {{NoAI:1b,Tags:["tag_query"]}}')
guest.wait(20)
pig = guest.raw('entities', 6, 'pig')['entities'][0]
guest.raw('look-at-entity', pig['id'])
guest.wait(2)
entity = copy_recreate_command(guest, lambda c: c.startswith('/summon minecraft:pig '))
entity_said = guest.raw('messages', 1)['chat']
guest.shot(out, 'tag-query')
guest('command', command='kill @e[type=minecraft:pig,tag=tag_query]')
assert 'id:"minecraft:diamond"' in block, f"the chest's items are missing: {block}"
assert block_said == ['[Debug]: Copied server-side block data to clipboard'], block_said
assert 'Tags: ["tag_query"]' in entity, f"the pig's tags are missing: {entity}"
assert entity_said == ['[Debug]: Copied server-side entity data to clipboard'], entity_said
return {'block': block, 'entity': entity}
def check_fireworks(host, guest, out):
"""Event 17 must arrive before removal, with the item's colored, trailing explosion (#1478).
FireworkRocketEntity.explode / handleEntityEvent.
The flash proves a configured explosion reached both clients; red pixels in later screenshots
check that its sparks keep rendering after the tracked rocket disappears.
"""
x, y, z = (int(v // 1) for v in pos_of(host))
at = (x, y - 1, z + 3)
host('command', command=f'fill {x - 5} {y - 1} {z - 2} {x + 5} {y - 1} {z + 8} minecraft:stone')
host('command', command=f'fill {x - 5} {y} {z - 2} {x + 5} {y + 4} {z + 8} minecraft:air')
host('command', command=f'fill {x - 2} {y + 2} {z + 1} {x + 2} {y + 2} {z + 5} minecraft:stone')
for game, dx in ((host, -2), (guest, 2)):
game('command', command='gamemode creative')
game('command', command=f'tp @s {x + dx + 0.5} {y} {z + 0.5}')
game.raw('look-at', x + 0.5, y + 1.5, z + 3.5)
def reds(path):
"""The red pixels' positions, above the hotbar."""
rows, bpp = png_rows(path)
return {(y, i) for y, row in enumerate(rows[:len(rows) * 3 // 4]) for i in range(0, len(row), bpp)
if row[i] > 80 and row[i] > 2 * row[i + 1] and row[i] > 2 * row[i + 2]}
# Red where the view had none: the setup's own red chat lines (a fill that changed nothing) are in the
# baseline, and fade out before the later shots, which a plain count difference would take as missing sparks.
baseline = {g.name: reds(g.shot(out, 'fireworks-before')) for g in (host, guest)}
before = {g.name: made(g, 'flash') for g in (host, guest)}
host('command', command='item replace entity @s weapon.mainhand with minecraft:firework_rocket'
'[fireworks={flight_duration:0,explosions:[{shape:"large_ball",colors:[I;16711680],has_trail:true}]}]')
host.raw('interact', *at)
# The held item's red texture must not count as a burst or a later spark.
host('command', command='item replace entity @s weapon.mainhand with minecraft:air')
for _ in range(30):
host.wait(2)
if all(made(g, 'flash') > before[g.name] for g in (host, guest)):
break
flashes = {g.name: made(g, 'flash') - before[g.name] for g in (host, guest)}
assert all(n == 1 for n in flashes.values()), f'configured burst missing or duplicated: {flashes}'
# Capture both views before decoding PNGs: decoding must not consume the sparks' lifetime (48-59 ticks). A
# screenshot takes ~0.6 s where the frame is drawn on the CPU (lavapipe), so the later shots come 4 ticks after
# the burst's, not more: by then the tracked rocket is gone.
burst_shots = {g.name: g.shot(out, 'fireworks-burst') for g in (host, guest)}
host.wait(4)
trail_shots = {g.name: g.shot(out, 'fireworks-trail-fade') for g in (host, guest)}
burst = {name: len(reds(path) - baseline[name]) for name, path in burst_shots.items()}
trails = {name: len(reds(path) - baseline[name]) for name, path in trail_shots.items()}
rockets = {g.name: g('status')['rockets'] for g in (host, guest)}
assert not any(rockets.values()), f'exploded rocket remains: {rockets}'
assert all(n > 20 for n in burst.values()), f'no red burst: {burst}'
assert all(n > 20 for n in trails.values()), f'no later red trail/fade: {trails}'
return {'flashes': flashes, 'red_burst': burst, 'red_trail_fade': trails, 'rockets': rockets}
def check_piston_slide(host, guest, out):
"""A piston's head reaches the clients after their own copy of it finished moving (#8430).
The block event makes each client move the blocks itself (PistonBaseBlock.moveBlocks), for three ticks of
PistonMovingBlockEntity.tick. ServerLevel.tick broadcasts the changed blocks before the block events and the
block entities, so what those change goes out at the start of the next tick: the landed head three ticks
after the block event, not two, and at a slow tick rate the client's head slides all the way instead of
jumping the rest. That broadcast comes before the tick's block events, with the blocks as they are then:
the tick after the retraction put the base back, a repeating command block puts a stone in the head's cell
and its chain powers the piston, and the client must have both the base and the stone (not the moving block
the push makes of it) to run that extension itself. The host reads its own copy of the head's cell once a
tick (each read follows the client tick that saw what arrived), at /tick rate 1, and whether the client
animates the move itself (`block`'s `moving`).
"""
x, y, z = (int(v // 1) for v in pos_of(host))
y += 3
head = f'{x + 1} {y} {z}'
host('command', command=f'fill {x - 2} {y - 1} {z - 3} {x + 4} {y + 1} {z + 1} minecraft:air')
host('command', command=f'setblock {x} {y} {z} minecraft:piston[facing=east]')
host('command', command=f'setblock {head} minecraft:stone')
again = (f'execute if block {head} minecraft:air if block {x} {y} {z} minecraft:piston[extended=false] '
f'if block {x - 1} {y} {z} minecraft:air run setblock {head} minecraft:stone')
host('command', command=f'setblock {x} {y} {z - 2} minecraft:repeating_command_block[facing=up]'
f'{{Command:"{again}"}}')
host('command', command=f'setblock {x} {y + 1} {z - 2} minecraft:chain_command_block[facing=up,conditional=true]'
f'{{auto:1b,Command:"setblock {x - 1} {y} {z} minecraft:redstone_block"}}')
# Powered from below, two blocks from the piston: it runs every tick.
host('command', command=f'setblock {x} {y - 1} {z - 2} minecraft:redstone_block')
host.wait(5)
host('command', command='tick rate 1')
sample = ['wait 1', f'block {head}', 'status']
try:
replies = host.script([f'command setblock {x - 1} {y} {z} minecraft:redstone_block'] + sample * 6
+ [f'command setblock {x - 1} {y} {z} minecraft:air'] + sample * 9, every=True)
finally:
host('command', command='tick rate 20')
host('command', command=f'fill {x - 1} {y - 1} {z - 2} {x + 3} {y + 1} {z} minecraft:air')
def moves(replies):
seen = [(status['ticks'], block['block'].removeprefix('minecraft:'), 'moving' in block)
for block, status in zip(replies[1::3], replies[2::3])]
start = next((i for i, (_, name, _) in enumerate(seen) if name == 'moving_piston'), None)
# The head that lands after this move: one left from the last move (the second
# extension starts with it) comes before the moving block.
end = None if start is None else next(
(i for i in range(start, len(seen)) if seen[i][1] == 'piston_head'), None)
# The count is exact when the read before the first moving one, and the read before
# the landed one, are each one client tick earlier.
skipped = start is not None and any(
0 < i < len(seen) and seen[i][0] - seen[i - 1][0] != 1 for i in (start, end or start))
return {'seen': seen, 'own': start is not None and seen[start][2],
'ticks_moving': None if end is None else seen[end][0] - seen[start][0],
'skipped': skipped}
result = {'extend': moves(replies[1:19]), 'extend_again': moves(replies[20:])}
for label, move in result.items():
# A loaded machine can run two client ticks between reads: the count is then a guess.
assert not move['skipped'], f'{label}: a read skipped a client tick, rerun on a quieter machine: {result}'
assert move['own'], f'{label}: the client did not move the head itself: {result}'
assert move['ticks_moving'] == 3, (
f'{label}: the landed head came {move["ticks_moving"]} ticks after the move: {result}')
return result
def check_rocket_walls(host, guest, out):
"""A rocket shot at an angle explodes on a block only with explosions (#4281).
FireworkRocketEntity.onHitBlock: `hasExplosion()`; a plain one flies on to its lifetime (20-31 ticks). A
dispenser shoots at an angle as a crossbow does, at a wall three blocks away (about 7 ticks of flight). The
host samples its status in one ctl process; a rocket's span is the client ticks between the first and the
last sample that saw it, never more than it lived, and short of it by at most two gaps between samples.
"""
x, y, z = (int(v // 1) for v in pos_of(host))
y += 3
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 2} {y + 2} {z + 6} minecraft:air')
host('command', command=f'fill {x - 2} {y - 1} {z + 5} {x + 2} {y + 2} {z + 5} minecraft:stone')
plain = 'minecraft:firework_rocket'
star = plain + '[fireworks={flight_duration:1,explosions:[{shape:"ball",colors:[I;16711680]}]}]'
result = {}
for label, item in (('plain', plain), ('star', star)):
host('command', command=f'setblock {x} {y + 1} {z} minecraft:air')
host('command', command=f'setblock {x} {y} {z} minecraft:dispenser[facing=south]')
host('command', command=f'item replace block {x} {y} {z} container.0 with {item}')
host.wait(5)
replies = host.script([f'command setblock {x} {y + 1} {z} minecraft:redstone_block']
+ ['wait 1', 'status'] * 45, every=True)
samples = [(r['ticks'], r['rockets']) for r in replies if 'rockets' in r]
seen = [tick for tick, n in samples if n]
result[label] = {'span': seen[-1] - seen[0] if seen else None, 'samples': len(samples),
'last': samples[-1][1], 'guest_last': guest('status')['rockets']}
host('command', command=f'setblock {x} {y + 1} {z} minecraft:air')
assert all(r['last'] == 0 and r['guest_last'] == 0 for r in result.values()), (
f'a rocket outlived its lifetime: {result}')
# A plain rocket lives 20-31 ticks; one with explosions is gone at the wall.
assert (result['plain']['span'] or 0) >= 14, f'plain rocket went off on the wall: {result}'
assert result['star']['span'] is not None and result['star']['span'] < 14, (
f'star rocket did not explode on the wall: {result}')
return result
def check_rocket_ceilings(host, guest, out):
"""A rising rocket explodes on the block above only with explosions.
FireworkRocketEntity.tick: every rocket that isn't attached checks its move against blocks
(`getHitResultOnMoveVector`), not only one shot at an angle; onHitBlock: `hasExplosion()`. Launched from
the floor under a ceiling two blocks up, it reaches it in about 9 ticks; a plain one lives 20-31. Spans
are measured as in check_rocket_walls.
"""
x, y, z = (int(v // 1) for v in pos_of(host))
host('command', command='gamemode creative')
host('command', command=f'fill {x - 2} {y - 1} {z + 1} {x + 2} {y - 1} {z + 5} minecraft:stone')
host('command', command=f'fill {x - 2} {y} {z + 1} {x + 2} {y + 1} {z + 5} minecraft:air')
host('command', command=f'fill {x - 2} {y + 2} {z + 1} {x + 2} {y + 2} {z + 5} minecraft:stone')
plain = 'minecraft:firework_rocket'
star = plain + '[fireworks={flight_duration:1,explosions:[{shape:"ball",colors:[I;16711680]}]}]'
result = {}
for label, item in (('plain', plain), ('star', star)):
host('command', command=f'item replace entity @s weapon.mainhand with {item}')
host.wait(5)
replies = host.script([f'interact {x} {y - 1} {z + 3}'] + ['wait 1', 'status'] * 45, every=True)
samples = [(r['ticks'], r['rockets']) for r in replies if 'rockets' in r]
seen = [tick for tick, n in samples if n]
result[label] = {'span': seen[-1] - seen[0] if seen else None, 'samples': len(samples),
'last': samples[-1][1], 'guest_last': guest('status')['rockets']}
host('command', command='item replace entity @s weapon.mainhand with minecraft:air')
assert all(r['last'] == 0 and r['guest_last'] == 0 for r in result.values()), (
f'a rocket outlived its lifetime: {result}')
assert (result['plain']['span'] or 0) >= 14, f'plain rocket went off on the ceiling: {result}'
# It rises about 8 ticks first: gone at once would be a burst at launch.
assert result['star']['span'] is not None and 3 <= result['star']['span'] < 14, (
f'star rocket did not explode on the ceiling: {result}')
return result
def check_sparks(host, guest, out):
spots = apart(host, guest)
result = {}
for label, player, other in (('host', host, guest), ('guest', guest, host)):
x, y, z = spots[label]
at = (x, y, z + 2)
player('command', command='gamemode survival')
try:
player('command', command=f'setblock {at[0]} {at[1] - 1} {at[2]} minecraft:farmland')
player('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:wheat')
player('command', command='item replace entity @s weapon.mainhand with minecraft:bone_meal 8')
player.wait(10)
def click(height):
player.raw('look-at', f'{at[0] + 0.5:.2f}', f'{at[1] + height:.2f}', f'{at[2] + 0.5:.2f}')
player.raw('mouse', 'right', 'click')
result[f'{label} bone meal'] = sparked(player, other, ['happy_villager'], lambda: click(0.1))
player('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:copper_block')
player('command', command='item replace entity @s weapon.mainhand with minecraft:honeycomb')
player.wait(10)
result[f'{label} wax'] = sparked(player, other, ['wax_on'], lambda: click(0.5))
player('command', command='item replace entity @s weapon.mainhand with minecraft:iron_axe')
player.wait(10)
result[f'{label} wax off'] = sparked(player, other, ['wax_off'], lambda: click(0.5))
block = player.raw('block', *at).get('block', '')
result[f'{label} copper'] = block
player('command', command=f'fill {at[0]} {at[1] - 1} {at[2]} {at[0] + 1} {at[1] - 1} {at[2]} minecraft:stone')
player('command', command=f'fill {at[0]} {at[1]} {at[2]} {at[0] + 1} {at[1]} {at[2]} minecraft:lava')
player('command', command='item replace entity @s weapon.mainhand with minecraft:water_bucket')
player.wait(10)
hiss = 'minecraft:block.lava.extinguish'
hissed = {side: heard(g, hiss) for side, g in (('own', player), ('other', other))}
result[f'{label} lava'] = sparked(player, other, ['large_smoke'], lambda: click(0.1))
player.wait(10)
result[f'{label} hiss'] = {side: heard(g, hiss) - hissed[side]
for side, g in (('own', player), ('other', other))}
result[f'{label} obsidian'] = player.raw('block', at[0] + 1, at[1], at[2]).get('block', '')
finally:
player('command', command=f'fill {at[0] - 4} {at[1] - 3} {at[2] - 4} {at[0] + 5} {at[1] + 1} {at[2] + 4} '
'minecraft:air replace minecraft:water')
player('command', command=f'fill {at[0]} {at[1] - 1} {at[2]} {at[0] + 1} {at[1]} {at[2]} minecraft:air')
player('command', command=f'setblock {at[0]} {at[1] - 1} {at[2]} minecraft:stone')
player('command', command='clear @s')
# A spawner beside the host, at night: its burst when the zombie comes.
x, y, z = spots['host']
host('command', command='time set midnight')
try:
host('command', command=f'setblock {x} {y} {z + 3} minecraft:spawner{{Delay:20s,SpawnCount:1s,'
'SpawnData:{entity:{id:"minecraft:zombie",NoAI:1b}}}')
result['spawner'] = sparked(host, guest, ['flame', 'smoke'], lambda: None, ticks=200)
finally:
host('command', command=f'setblock {x} {y} {z + 3} minecraft:air')
host('command', command=f'kill @e[type=minecraft:zombie,x={x},y={y},z={z},distance=..12]')
host('command', command='time set day')
wrong = {k: v for k, v in result.items() if isinstance(v, dict) and not all(n > 0 for n in v.values())}
assert not wrong, f'no particles: {wrong}\n{result}'
hisses = {k: v for k, v in result.items() if k.endswith(' hiss') and set(v.values()) != {1}}
assert not hisses, f'the lava fizz is heard once on each side: {hisses}\n{result}'
obsidian = {k: v for k, v in result.items() if k.endswith(' obsidian') and v != 'minecraft:obsidian'}
assert not obsidian, f'the water turns the lava beside it to obsidian: {obsidian}\n{result}'
return result
def hearts(game):
"""The player's maximum and current health as its own client holds them."""
p = game('status')['player']
return p.get('max_health'), round(p['health'], 1)
def check_hud(host, guest, out):
result = {}
for label, player in (('host', host), ('guest', guest)):
player('command', command='gamemode survival')
try:
for maximum in (40, 10):
player('command', command=f'attribute @s minecraft:max_health base set {maximum}')
player.wait(10)
player('command', command='effect give @s minecraft:instant_health 1 10')
seen = None
for _ in range(15):
player.wait(2)
seen = hearts(player)
if seen == (maximum, maximum):
break
result[f'{label} {maximum}'] = seen
player.shot(out, f'hearts-{label}-{maximum}')
finally:
player('command', command='attribute @s minecraft:max_health base reset')
player('command', command='effect give @s minecraft:instant_health 1 10')
# BossBarCommands' new bar starts visible (ServerBossEvent's constructor).
host('command', command='bossbar add lan_e2e:bar "LAN bar"')
try:
host('command', command='bossbar get lan_e2e:bar visible')
host.wait(5)
result['bar'] = [line for line in host.raw('messages', 5)['chat'] if 'lan_e2e:bar' in line or 'LAN bar' in line]
finally:
host('command', command='bossbar remove lan_e2e:bar')
wrong = {k: v for k, v in result.items() if k != 'bar' and v != (int(k.split()[1]),) * 2}
assert not wrong, f'hearts: {wrong}\n{result}'
assert any('currently visible' in line for line in result['bar']), f'bar: {result["bar"]}'
return result
def sidebars(host, guest, want, ticks=40):
"""Each side's sidebar (objective and scores) once both show `want` (host, guest), or after `ticks`."""
seen = None
for _ in range(ticks // 2):
host.wait(2)
seen = tuple(g('status').get('sidebar') for g in (host, guest))
got = tuple(s and (s['objective'], s['scores']) for s in seen)
if got == want:
break
return seen
def check_sidebar(host, guest, out):
result = {}
run = lambda *cmds: [host('command', command=c) for c in cmds]
try:
run('scoreboard objectives add audit dummy {"text":"Audit board","color":"aqua"}',
'scoreboard players set Alpha audit 7', 'scoreboard players set Beta audit 3',
'scoreboard objectives setdisplay sidebar audit')
both = ('audit', {'Alpha': 7, 'Beta': 3})
result['shown'] = sidebars(host, guest, (both, both))
host.shot(out, 'sidebar')
guest.shot(out, 'sidebar')
run('scoreboard players set Alpha audit 12', 'scoreboard players reset Beta audit')
both = ('audit', {'Alpha': 12})
result['changed'] = sidebars(host, guest, (both, both))
# The guest's team's sidebar replaces the sidebar for the guest only.
run('team add red', 'team modify red color red', f'team join red {GUEST_NAME}',
'scoreboard objectives add reds dummy', f'scoreboard players set {GUEST_NAME} reds 5',
'scoreboard objectives setdisplay sidebar.team.red reds')
result['team'] = sidebars(host, guest, (both, ('reds', {GUEST_NAME: 5})))
run('scoreboard objectives remove reds', 'scoreboard objectives setdisplay sidebar')
result['cleared'] = sidebars(host, guest, (None, None))
finally:
run('scoreboard objectives remove audit', 'scoreboard objectives remove reds', 'team remove red')
want = {'shown': (('audit', {'Alpha': 7, 'Beta': 3}),) * 2, 'changed': (('audit', {'Alpha': 12}),) * 2,
'team': (('audit', {'Alpha': 12}), ('reds', {GUEST_NAME: 5})), 'cleared': (None, None)}
got = {k: tuple(s and (s['objective'], s['scores']) for s in v) for k, v in result.items()}
wrong = {k: v for k, v in got.items() if v != want[k]}
assert not wrong, f'sidebars: {wrong}\n{result}'
return result
def fluid_pig_position(host, tag):
"""Read the tagged pig even after the current carries it past its spawn."""
reply = server_reply(host, f'data get entity @e[type=pig,tag={tag},limit=1] Pos', 'entity data')
values = re.findall(r'-?\d+(?:\.\d+)?', reply.rsplit(':', 1)[-1])
assert len(values) == 3, f'no position for {tag}: {reply}'
return tuple(map(float, values))
def clear_fluid_arena(host, x, y, z, tags, floor='minecraft:stone'):
"""Kill the tagged pigs and empty the arena, up in the air at y 100 so its
walls never close around a player; the last clear takes the floor away too."""
for tag in tags:
host('command', command=f'kill @e[type=pig,tag={tag}]')
host('command', command=f'fill {x-2} {y} {z-2} {x+9} {y+6} {z+7} minecraft:air')
host('command', command=f'fill {x-2} {y-1} {z-2} {x+9} {y-1} {z+7} {floor}')
host.wait(10)
def check_flow_push(host, guest, out):
"""A current pushes a tagged pig farther than a pool of source blocks."""
hx, _, hz = pos_of(host)
x, y, z = (int(hx // 16) * 16 + 8, 100, int(hz // 16) * 16 + 8)
tags = ('horizon_flow_test', 'horizon_still_test')
host('command', command='gamemode survival')
try:
clear_fluid_arena(host, x, y, z, tags)
# Enclosed trough: its walls keep water inside the area cleaned below.
host('command', command=f'fill {x-1} {y} {z-1} {x+8} {y+1} {z+1} minecraft:stone')
host('command', command=f'fill {x} {y} {z} {x+7} {y+2} {z} minecraft:air')
host('command', command=f'setblock {x} {y} {z} minecraft:water')
# Enclosed source pool, whose centre has no horizontal current.
host('command', command=f'fill {x+2} {y} {z+2} {x+6} {y+1} {z+6} minecraft:stone')
host('command', command=f'fill {x+3} {y} {z+3} {x+5} {y} {z+5} minecraft:water')
host('command', command=f'fill {x+3} {y+1} {z+3} {x+5} {y+3} {z+5} minecraft:air')
host.wait(40)
for tag, px, pz in [(tags[0], x+1.5, z+0.5), (tags[1], x+4.5, z+4.5)]:
host('command', command=f'summon minecraft:pig {px} {y+0.1} {pz} {{attributes:[{{id:"minecraft:movement_speed",base:0.0d}}],Tags:["{tag}"]}}')
host.wait(40)
flowing = fluid_pig_position(host, tags[0])[0] - (x + 1.5)
still = fluid_pig_position(host, tags[1])[0] - (x + 4.5)
assert flowing - still > 0.2, f'the current did not carry the mob: {flowing} vs {still}'
return {'flowing': round(flowing, 3), 'still': round(still, 3)}
finally:
clear_fluid_arena(host, x, y, z, tags, floor='minecraft:air')
def check_bubble_column(host, guest, out):
"""A bubble column lifts a tagged pig farther than an ordinary water source."""
hx, _, hz = pos_of(host)
x, y, z = (int(hx // 16) * 16 + 8, 100, int(hz // 16) * 16 + 8)
tags = ('horizon_bubble_test', 'horizon_plain_test')
host('command', command='gamemode survival')
try:
clear_fluid_arena(host, x, y, z, tags)
for px in (x, x+3):
host('command', command=f'fill {px-1} {y} {z-1} {px+1} {y} {z+1} minecraft:stone')
host('command', command=f'setblock {px} {y} {z} minecraft:water')
host('command', command=f'setblock {x} {y-1} {z} minecraft:soul_sand')
host.wait(20)
for tag, px in [(tags[0], x+0.5), (tags[1], x+3.5)]:
host('command', command=f'summon minecraft:pig {px} {y+0.1} {z+0.5} {{attributes:[{{id:"minecraft:movement_speed",base:0.0d}}],Tags:["{tag}"]}}')
samples = {tag: [] for tag in tags}
for _ in range(8):
host.wait(5)
for tag in tags:
samples[tag].append(fluid_pig_position(host, tag)[1])
column, plain = (max(samples[tag]) for tag in tags)
assert column - plain > 0.2, f'the bubble column did not carry the pig up: {column} vs {plain}'
return {'column': round(column-y, 3), 'plain': round(plain-y, 3)}
finally:
clear_fluid_arena(host, x, y, z, tags, floor='minecraft:air')
def check_border(host, guest, out):
spots = apart(host, guest)
result = {}
try:
for label, player, other in (('host', host, guest), ('guest', guest, host)):
x, y, z = spots[label]
# A whole-block centre: the wall's collision box is the border's, rounded out to blocks.
cx, cz = float(x), float(z)
# The other player keeps out of harm outside the border meanwhile.
other('command', command='gamemode creative')
host('command', command=f'fill {x - 3} {y - 1} {z} {x + 4} {y - 1} {z + 10} minecraft:stone')
host('command', command=f'fill {x - 3} {y} {z} {x + 4} {y + 3} {z + 10} minecraft:air')
# A border 10 wide around the player, its wall 5 blocks ahead: both sides know it.
host('command', command=f'worldborder center {cx} {cz}')
host('command', command='worldborder set 10')
seen = None
for _ in range(10):
player.wait(2)
seen = [g('status').get('border') for g in (player, other)]
if all(b and b['size'] == 10 and b['center'] == [cx, cz] for b in seen):
break
result[f'{label} border'] = seen
# Sprint-jumping into the wall: it stops the player at the border.
player('command', command=f'tp @s {cx} {y} {cz} 0 0')
player.wait(10)
player.raw('look-at', f'{cx:.2f}', f'{y + 1.6:.2f}', f'{cz + 20:.2f}')
for k in ('w', 'ctrl', 'space'):
player.raw('key', k, 'down')
player.wait(40)
for k in ('space', 'ctrl', 'w'):
player.raw('key', k, 'up')
player.wait(10)
result[f'{label} reached'] = round(pos_of(player)[2] - cz, 2)
player.shot(out, f'border-{label}')
# Two blocks outside, with no safe zone: the border hurts.
host('command', command='worldborder damage buffer 0')
host('command', command='worldborder damage amount 1')
player('command', command='effect give @s minecraft:instant_health 1 5')
player.wait(20)
player('command', command=f'tp @s {cx} {y} {cz + 7}')
player.wait(5)
before = player('status')['player']['health']
player.wait(40)
result[f'{label} hurt'] = [before, player('status')['player']['health']]
player('command', command=f'tp @s {cx} {y} {cz}')
player('command', command='effect give @s minecraft:instant_health 1 5')
host('command', command='worldborder damage amount 0.2')
host('command', command='worldborder damage buffer 5')
other('command', command='gamemode survival')
finally:
guest('command', command='gamemode survival')
host('command', command='gamemode survival')
host('command', command='worldborder damage amount 0.2')
host('command', command='worldborder damage buffer 5')
host('command', command='worldborder center 0 0')
host('command', command='worldborder set 59999968')
wrong = {}
for label in ('host', 'guest'):
x, y, z = spots[label]
known = result[f'{label} border']
if not all(b and b['size'] == 10 and b['center'] == [x, z] for b in known):
wrong[f'{label} border'] = known
if not 4.0 < result[f'{label} reached'] <= 4.71:
wrong[f'{label} reached'] = result[f'{label} reached']
before, after = result[f'{label} hurt']
if not after < before:
wrong[f'{label} hurt'] = result[f'{label} hurt']
assert not wrong, f'border: {wrong}\n{result}'
return result
def check_particles(host, guest, out):
spots = apart(host, guest)
result = {}
for label, player, other in (('host', host, guest), ('guest', guest, host)):
# For the player alone (`@s`), then for every player near.
before = {side: made(g, 'flame') for side, g in (('own', player), ('other', other))}
player('command', command='particle minecraft:flame ~ ~1 ~3 .2 .2 .2 0 60 force @s')
player.wait(10)
result[f'{label} @s'] = {side: made(g, 'flame') - before[side] for side, g in (('own', player), ('other', other))}
player.shot(out, f'flame-{label}')
# No spread: one point, wherever the player stands.
player('command', command='particle minecraft:flame ~ ~1 ~3 0 0 0 0 200 force @s')
player.wait(3)
player.shot(out, f'zero-spread-{label}')
result[f'{label} all'] = sparked(player, other, ['dust'], lambda: player(
'command', command='particle minecraft:dust{color:[1,0,0],scale:2} ~ ~1 ~3 0 0 0 0 10'))
# A TNT's blast between the players: its burst on both sides.
x, y, z = spots['host']
host('command', command='gamemode creative')
guest('command', command='gamemode creative')
try:
result['blast'] = sparked(host, guest, ['explosion_emitter'], lambda: host(
'command', command=f'summon minecraft:tnt {x + 6.5} {y} {z + 4.5} {{fuse:10s}}'), ticks=40)
host.shot(out, 'blast')
finally:
host('command', command='gamemode survival')
guest('command', command='gamemode survival')
host('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 16} {y - 1} {z + 8} minecraft:stone')
wrong = {k: v for k, v in result.items() if not k.endswith('@s') and not all(n > 0 for n in v.values())}
wrong.update({k: v for k, v in result.items() if k.endswith('@s') and not (v['own'] >= 30 and v['other'] == 0)})
assert not wrong, f'particles: {wrong}\n{result}'
return result
def check_blast_projectiles(host, guest, out):
host('command', command='gamemode creative')
x, y, z = (int(v // 1) for v in pos_of(host))
gx, gz = x + 6, z
floor = f'fill {gx - 6} {y - 1} {gz - 4} {gx + 6} {y - 1} {gz + 4} minecraft:bedrock'
detail = {}
try:
host('command', command=floor)
host('command', command=f'fill {gx - 6} {y} {gz - 4} {gx + 6} {y + 3} {gz + 4} minecraft:air')
host('command', command='kill @e[type=minecraft:arrow]')
host('command', command='kill @e[type=minecraft:snowball]')
# A snowball hanging north of the blast (the host stands west of it), and an arrow that settles into the floor.
host('command', command=f'summon minecraft:snowball {gx + 0.5} {y + 1.2} {gz - 1.5} {{NoGravity:1b}}')
host('command', command=f'summon minecraft:arrow {gx + 0.5} {y} {gz + 2.5}')
host.wait(20)
# `entities` lists mobs, items and orbs only: the server's own projectiles come from `simulation`.
def flying():
return host.raw('simulation')['projectiles']
def find(kind, near):
found = [e for e in flying() if e['kind'] == kind]
return min(found, key=lambda e: abs(e['position'][0] - near[0]) + abs(e['position'][2] - near[1]))
ball = find('Snowball', (gx + 0.5, gz - 1.5))
stuck = find('Arrow', (gx + 0.5, gz + 2.5))
# Arrows ignore NoGravity (arrow_from_nbt), so the flying one is summoned beside the fuse it shares.
host('command', command=f'summon minecraft:arrow {gx + 2.5} {y + 2.2} {gz + 0.5}')
host('command', command=f'summon minecraft:tnt {gx + 0.5} {y + 1} {gz + 0.5} {{fuse:5s}}')
air = find('Arrow', (gx + 2.5, gz + 0.5))
before = {'air': air['position'], 'ball': ball['position'], 'stuck': stuck['position']}
host.wait(8) # the fuse is 5 ticks; a snowball that flies on would soon hit the wall and vanish
left = {e['id']: e['position'] for e in flying()}
after = {name: left.get(e['id']) for name, e in (('air', air), ('ball', ball), ('stuck', stuck))}
detail = {name: (None if after[name] is None else
[round(after[name][i] - before[name][i], 2) for i in range(3)]) for name in before}
host.shot(out, 'blast-projectiles')
finally:
host('command', command='kill @e[type=minecraft:arrow]')
host('command', command='kill @e[type=minecraft:snowball]')
host('command', command=floor)
host('command', command='gamemode survival')
assert detail['air'] and detail['air'][0] > 0.5, f'the blast never pushed the flying arrow east: {detail}'
assert detail['ball'] and detail['ball'][2] < -0.5, f'the blast never pushed the snowball north: {detail}'
assert detail['stuck'] and max(abs(v) for v in detail['stuck']) < 0.05, f'an arrow in the floor moved: {detail}'
return detail
def check_blast(host, guest, out):
guest('command', command='gamemode survival')
guest('command', command='clear @s')
host('command', command='gamemode creative')
x, y, z = (int(v // 1) for v in pos_of(host))
gx, gz = x + 6, z
floor = f'fill {gx - 6} {y - 1} {gz - 4} {gx + 6} {y - 1} {gz + 4} minecraft:bedrock'
detail = {}
try:
for shielded in (False, True):
if remote(guest)['health'] <= 0:
guest.raw('respawn')
guest.wait(40)
host('command', command=floor)
host('command', command=f'fill {gx - 6} {y} {gz - 4} {gx + 6} {y + 3} {gz + 4} minecraft:air')
guest('command', command=f'tp @s {gx + 0.5} {y} {gz + 0.5}')
guest('command', command='effect give @s minecraft:instant_health 1 5')
guest.wait(30)
hold = None
if shielded:
guest('command', command='item replace entity @s weapon.offhand with minecraft:shield')
guest.wait(5)
guest.raw('look-at', gx + 5.5, y + 0.5, gz + 0.5)
hold = threading.Thread(target=guest.raw, args=('use-item', '--hold', 2.5))
hold.start()
time.sleep(0.5)
before = remote(guest)['health']
at = pos_of(guest)
# Five blocks off: a blast that hurts without killing (about 14 at full exposure).
host('command', command=f'summon minecraft:tnt {gx + 5.5} {y} {gz + 0.5} {{fuse:10s}}')
# The guest's own controller is busy holding the shield up: the host waits.
host.wait(14)
after = remote(guest)['health']
moved = pos_of(guest)
if hold:
hold.join()
guest.shot(out, f'blast-{"shield" if shielded else "open"}')
detail['shield' if shielded else 'open'] = {
'before': before, 'after': after, 'pushed': round(at[0] - moved[0], 2)}
finally:
host('command', command='gamemode survival')
guest('command', command='clear @s')
host('command', command=floor)
open_, shield = detail['open'], detail['shield']
assert open_['after'] < open_['before'], f'the blast never hurt the guest: {detail}'
assert open_['pushed'] > 0, f'the blast never pushed the guest away: {detail}'
assert shield['after'] >= shield['before'], f'the shield did not stop the blast: {detail}'
return detail
def light_differences(host, guest, lo, hi):
"""Positions of the box loaded on both sides, and those whose sky or block light differ."""
h, g = host.raw('light', *lo, *hi), guest.raw('light', *lo, *hi)
both = [i for i in range(len(h['sky'])) if h['sky'][i] != '.' and g['sky'][i] != '.']
differ = [i for i in both if (h['sky'][i], h['block'][i]) != (g['sky'][i], g['block'][i])]
return h, g, both, differ
def same_light(host, guest, lo, hi, what, ticks=100):
"""Wait until the guest's light over the box is the host's; the number of positions compared."""
for _ in range(ticks // 10):
h, g, both, differ = light_differences(host, guest, lo, hi)
if not differ:
assert both, f'{what}: no position of the box is loaded on both sides'
return len(both)
host.wait(10)
i = differ[0]
nx, nz = hi[0] - lo[0] + 1, hi[2] - lo[2] + 1
pos = (lo[0] + i % nx, lo[1] + i // (nx * nz), lo[2] + i // nx % nz)
raise AssertionError(f'{what}: {len(differ)} of {len(both)} positions differ, first {pos}: host sky '
f'{h["sky"][i]} block {h["block"][i]}, guest sky {g["sky"][i]} block {g["block"][i]}')
def check_flight(host, guest, out):
def double_jump(game):
# LocalPlayer.aiStep: a second jump press within 7 ticks of the first toggles flight.
lines = []
for hold in (1, 4):
lines += ['key space down', f'wait {hold}', 'key space up', 'wait 2']
game.script(lines + ['wait 10'])
return game('state')['player']
def set_mode(game, mode):
# On a client of a server the mode and its abilities (may fly) come back with the server's
# answer, later when the machine is busy: a double jump before them is two plain jumps.
game('command', command=f'gamemode {mode}')
for _ in range(100):
if game('state').get('gamemode') == mode:
break
game.wait(1)
game.wait(10)
players = [('guest', guest), ('host', host)]
seen = {}
# The rule is seven simulation ticks, independent of wall time. A slow
# server clock leaves enough time to deliver the key events even on busy CI.
host('command', command='tick rate 4')
try:
for name, game in players:
set_mode(game, 'creative')
ground = game('state')['player']
up = double_jump(game)
game.wait(20)
later = game('state')['player']
set_mode(game, 'survival')
fell = game('state')['player']
seen[name] = {'flying': up['flying'], 'rose': round(up['position'][1] - ground['position'][1], 2),
'stayed': round(later['position'][1] - up['position'][1], 2),
'survival_flying': fell['flying']}
finally:
host('command', command='tick rate 20')
for _, game in players:
game('command', command='gamemode survival')
for name, s in seen.items():
assert s['flying'] and s['rose'] > 1 and abs(s['stayed']) < 0.1, f'{name} does not fly in creative: {seen}'
assert not s['survival_flying'], f'{name} still flies in survival: {seen}'
return seen
def check_drops(host, guest, out):
"""A dropped item lying where a block is then placed comes out of it (#1252): the server moves
it towards the closest open side (ItemEntity.tick's noPhysics, Entity.moveTowardsClosestSpace),
and both sides see it outside the block. The host places one, then the guest."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
seen = {}
for game, dz in ((host, -3), (guest, 3)):
x, y, z = hx + 3, hy, hz + dz
game('command', command=f'fill {x - 1} {y} {z - 1} {x + 1} {y + 2} {z + 1} minecraft:air')
game('command', command=f'summon minecraft:item {x + 0.5} {y} {z + 0.5} '
'{Item:{id:"minecraft:diamond",count:1},PickupDelay:32767}')
game.wait(20)
game('command', command=f'setblock {x} {y} {z} minecraft:stone')
game.wait(60)
for viewer in (host, guest):
near = [e['pos'] for e in viewer.raw('entities', 24, 'minecraft:item')['entities']
if abs(e['pos'][0] - x - 0.5) < 2 and abs(e['pos'][2] - z - 0.5) < 2]
seen[f'{game.name} places, {viewer.name} sees'] = ([x, y, z], near)
game.shot(out, 'drops')
host('command', command='kill @e[type=minecraft:item]')
host('command', command=f'setblock {x} {y} {z} minecraft:air')
for name, (block, near) in seen.items():
inside = [p for p in near if all(block[i] <= p[i] < block[i] + 1 for i in range(3))]
assert near and not inside, f'{name}: items {near} in block {block} (all: {seen})'
return seen
def check_underwater(host, guest, out):
"""Digging dirt by hand underwater, standing on the bottom of a pool: five times slower than in
the air (SUBMERGED_MINING_SPEED), on the player's own client as on the server. Halfway through
the client is about halfway (it doesn't break the block early, which the server would put
back), and the block breaks (#1254). The dirt is at eye height, the player's head under water.
The host, then the guest."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
seen = {}
for game, dz in ((host, -4), (guest, 4)):
x, y, z = hx + 3, hy, hz + dz
game('command', command='gamemode survival')
game('command', command='item replace entity @s weapon.mainhand with minecraft:air')
game('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 2} {y + 3} {z + 1} minecraft:stone')
game('command', command=f'fill {x} {y} {z} {x} {y + 2} {z} minecraft:water')
game('command', command=f'setblock {x + 1} {y + 1} {z} minecraft:dirt')
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
game.wait(40)
game.raw('look-at', f'{x + 1.5:.2f}', f'{y + 1.5:.2f}', f'{z + 0.5:.2f}')
game.wait(5)
game.raw('mouse', 'left', 'down')
# Dirt by hand: 15 ticks in the air, 75 under water.
game.wait(35)
halfway = {'mining': game('state').get('mining'), 'client': game.raw('block', x + 1, y + 1, z).get('block'),
'server': host.raw('block', x + 1, y + 1, z).get('block'), 'y': y + 1}
game.wait(70)
game.raw('mouse', 'left', 'up')
game.wait(10)
done = host.raw('block', x + 1, y + 1, z).get('block')
game.shot(out, 'underwater')
seen[game.name] = {'halfway': halfway, 'done': done}
host('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 2} {y + 3} {z + 1} minecraft:air')
host('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 2} {y - 1} {z + 1} minecraft:stone')
for name, s in seen.items():
h = s['halfway']
assert h['client'] == 'minecraft:dirt' == h['server'], f'{name} broke it early: {seen}'
assert h['mining'] and h['mining']['pos'][1] == h['y'] and 0.3 < h['mining']['progress'] < 0.7, f'{name} digs at the wrong speed: {seen}'
# Broken: air, or the pool's water flowing in.
assert s['done'] in ('minecraft:air', 'minecraft:water'), f'{name} never broke it: {seen}'
return seen
def own(game):
"""The player's own state, as its client has it."""
return game('state')['player']
def check_contacts(host, guest, out):
"""Blocks that hurt what touches them, on the server: standing on magma (but not sneaking on it)
and on a lit campfire, walking against a cactus and through grown sweet berry bushes, for the
host and the guest (#1305, #1311, #1312, #1313); a young bush pricks no one."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
host('command', command='gamerule natural_health_regeneration false')
seen = {}
try:
for game, dz in ((host, -4), (guest, 4)):
x, y, z = hx + 2, hy, hz + dz
game('command', command='gamemode survival')
game('command', command='effect clear @s')
for case, blocks, walk in (
('magma', [(0, -1, 'minecraft:magma_block')], False),
('sneaking', [(0, -1, 'minecraft:magma_block')], False),
('campfire', [(0, 0, 'minecraft:campfire[lit=true]')], False),
('cactus', [(2, -1, 'minecraft:sand'), (2, 0, 'minecraft:cactus')], True),
('berries', [(k, -1, 'minecraft:grass_block') for k in range(1, 4)]
+ [(k, 0, 'minecraft:sweet_berry_bush[age=3]') for k in range(1, 4)], True),
('young', [(k, -1, 'minecraft:grass_block') for k in range(1, 4)]
+ [(k, 0, 'minecraft:sweet_berry_bush[age=0]') for k in range(1, 4)], True)):
game('command', command='effect give @s minecraft:instant_health 1 5 true')
game('command', command=f'fill {x - 1} {y - 2} {z - 1} {x + 4} {y - 1} {z + 1} minecraft:stone')
game('command', command=f'fill {x - 1} {y} {z - 1} {x + 4} {y + 2} {z + 1} minecraft:air')
# A wall ends the walk before the player leaves the floor.
game('command', command=f'fill {x + 5} {y - 1} {z - 1} {x + 5} {y + 2} {z + 1} minecraft:stone')
for dx, dy, block in blocks:
game('command', command=f'setblock {x + dx} {y + dy} {z} {block}')
stand = 0.5 if case == 'campfire' else 0.0
# Sneaking from before the player lands on the magma.
held = ['shift'] if case == 'sneaking' else []
for k in held:
game.raw('key', k, 'down')
game.wait(5)
game('command', command=f'tp @s {x + 0.5} {y + stand} {z + 0.5}')
game.raw('look-at', f'{x + 10:.2f}', f'{y + 1.6:.2f}', f'{z + 0.5:.2f}')
game.wait(30)
before = own(game)['health']
keys = ['w'] if walk else []
for k in keys:
game.raw('key', k, 'down')
game.wait(60)
# Read before letting go: standing up on the magma would hurt.
after = own(game)['health']
for k in keys + held:
game.raw('key', k, 'up')
game.wait(10)
seen[f'{game.name}-{case}'] = (before, after)
game.shot(out, f'contacts-{case}')
host('command', command=f'fill {x - 1} {y} {z - 1} {x + 5} {y + 2} {z + 1} minecraft:air')
finally:
host('command', command='gamerule natural_health_regeneration true')
for case, (before, after) in seen.items():
safe = case.endswith(('sneaking', 'young'))
assert (after == before) if safe else (after < before), f'{case}: {before} -> {after} (all: {seen})'
return seen
def server_player(host, game):
"""The player's entry on the server: the host's `lan status` lists the guest and the host."""
name = GUEST_NAME if game is not host else host.player
return next(g for g in host.raw('lan', 'status')['guests'] if g['name'] == name)
def check_shield(host, guest, out):
"""An axe disables a raised shield for 5 s (100 ticks), counted down once a tick on the
server: a vindicator's axe hits each player's shield, then the cooldown left falls by about 40
over 40 ticks (#1316)."""
host('command', command='gamerule natural_health_regeneration false')
rates = {}
try:
for game, dz in ((host, -4), (guest, 4)):
x, y, z = (int(v // 1) for v in pos_of(host))
z += dz
game('command', command='gamemode survival')
game('command', command='effect give @s minecraft:resistance 60 4 true')
game('command', command='effect give @s minecraft:instant_health 1 5 true')
game('command', command='item replace entity @s weapon.mainhand with minecraft:air')
game('command', command='item replace entity @s weapon.offhand with minecraft:shield')
game('command', command=f'fill {x - 1} {y} {z - 1} {x + 4} {y + 2} {z + 1} minecraft:air')
game('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 4} {y - 1} {z + 1} minecraft:stone')
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
game.wait(10)
game.raw('look-at', f'{x + 2.5:.2f}', f'{y + 1.5:.2f}', f'{z + 0.5:.2f}')
game.raw('mouse', 'right', 'down')
game.wait(10)
host('command', command=f'summon minecraft:vindicator {x + 2.5} {y} {z + 0.5} '
'{PersistenceRequired:1b,Rotation:[90f,0f]}')
host('command', command='item replace entity @e[type=minecraft:vindicator] weapon.mainhand '
'with minecraft:iron_axe')
first = None
for _ in range(60):
game.wait(5)
first = server_player(host, game).get('shield_cooldown', 0)
if first:
break
host('command', command='kill @e[type=minecraft:vindicator]')
game.raw('mouse', 'right', 'up')
assert first, f'{game.name}: the vindicator never disabled the shield'
start = server_player(host, game)['shield_cooldown']
game.wait(40)
end = server_player(host, game)['shield_cooldown']
rates[game.name] = {'first': first, 'start': start, 'end': end}
game.shot(out, 'shield')
game('command', command='item replace entity @s weapon.offhand with minecraft:air')
game('command', command='effect clear @s')
finally:
host('command', command='kill @e[type=minecraft:vindicator]')
host('command', command='gamerule natural_health_regeneration true')
for name, r in rates.items():
assert r['first'] <= 100, f'{name}: {rates}'
# Once a tick: 40 down over 40 ticks, give or take the ticks the two queries straddle.
assert 30 <= r['start'] - r['end'] <= 50, f'{name}: {rates}'
return rates
def respawn_when_ready(game):
"""Press Respawn once the death screen lets it (DeathScreen's 20-tick delay, counted from when
the screen opens)."""
for _ in range(20):
try:
return game.raw('respawn')
except Failed as e:
if 'not active yet' not in str(e):
raise
game.wait(10)
return game.raw('respawn')
def check_world_spawn(host, guest, out):
"""`setworldspawn` moves the world spawn: the host and the guest, with no bed of their own,
respawn there once killed (#1310)."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
sx, sz = hx + 24, hz
host('command', command='gamerule respawn_radius 0')
host('command', command=f'fill {sx - 2} {hy - 1} {sz - 2} {sx + 2} {hy - 1} {sz + 2} minecraft:stone')
host('command', command=f'fill {sx - 2} {hy} {sz - 2} {sx + 2} {hy + 3} {sz + 2} minecraft:air')
seen = {}
try:
host('command', command=f'setworldspawn {sx} {hy} {sz}')
for game in (host, guest):
game('command', command='gamemode survival')
game('command', command='kill @s')
game.wait(20)
respawn_when_ready(game)
game.wait(60)
seen[game.name] = [round(v, 2) for v in pos_of(game)]
game.shot(out, 'world-spawn')
finally:
host('command', command=f'setworldspawn {hx} {hy} {hz}')
host('command', command='gamerule respawn_radius 10')
for game in (host, guest):
game('command', command=f'tp @s {hx + 0.5} {hy} {hz + 0.5}')
guest.wait(20)
for name, at in seen.items():
assert abs(at[0] - (sx + 0.5)) < 1 and abs(at[2] - (sz + 0.5)) < 1, f'{name}: {seen}, spawn {sx} {sz}'
return seen
def check_beds(host, guest, out):
"""A bed sets the respawn point (day or night): respawning stands the player up beside it; a bed
that is gone, or boxed in by stone so there is nowhere to stand up, sends the player to the world
spawn instead (#1308). The host, then the guest, each for the three beds."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
host('command', command='gamerule respawn_radius 0')
host('command', command=f'setworldspawn {hx} {hy} {hz}')
host('command', command='time set 1000')
seen = {}
try:
for game, dz in ((host, -6), (guest, 6)):
game('command', command='gamemode survival')
x, y, z = hx + 12, hy, hz + dz
for case in ('intact', 'gone', 'boxed'):
game('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 4} {y - 1} {z + 3} minecraft:stone')
game('command', command=f'fill {x - 3} {y} {z - 3} {x + 4} {y + 3} {z + 3} minecraft:air')
game('command', command=f'setblock {x} {y} {z} minecraft:red_bed[facing=east,part=foot]')
game('command', command=f'setblock {x + 1} {y} {z} minecraft:red_bed[facing=east,part=head]')
game('command', command=f'tp @s {x + 0.5} {y} {z + 1.5}')
game.wait(20)
game.raw('interact', x + 1, y, z)
game.wait(10)
# Die elsewhere, by the world spawn.
game('command', command=f'tp @s {hx + 0.5} {hy} {hz + 3.5}')
game.wait(10)
if case == 'gone':
game('command', command=f'fill {x} {y} {z} {x + 1} {y} {z} minecraft:air')
elif case == 'boxed':
game('command', command=f'fill {x - 2} {y} {z - 2} {x + 3} {y + 2} {z + 2} minecraft:stone replace minecraft:air')
game.wait(5)
game('command', command='kill @s')
game.wait(20)
respawn_when_ready(game)
game.wait(60)
at = [round(v, 2) for v in pos_of(game)]
seen[f'{game.name}-{case}'] = {'at': at, 'bed': [x + 1, y, z], 'health': own(game)['health']}
game.shot(out, f'beds-{case}')
finally:
host('command', command='gamerule respawn_radius 10')
host('command', command='time set 1000')
for game in (host, guest):
game('command', command=f'tp @s {hx + 0.5} {hy} {hz + 0.5}')
guest.wait(20)
for case, s in seen.items():
(px, py, pz), (bx, by, bz) = s['at'], s['bed']
if case.endswith('intact'):
near = ((px - bx - 0.5) ** 2 + (pz - bz - 0.5) ** 2) ** 0.5
assert near < 2.6 and abs(py - by) < 0.6, f'{case}: not beside its bed: {seen}'
else:
assert abs(px - (hx + 0.5)) < 1 and abs(pz - (hz + 0.5)) < 1, f'{case}: not at the world spawn: {seen}'
assert s['health'] > 0, f'{case}: died again after respawning: {seen}'
return seen
def check_death_mining(host, guest, out):
"""A player who dies while breaking obsidian, and lets go of attack on the death screen, doesn't
break it once respawned: death forgets the digging (#1315)."""
host('command', command='gamerule keep_inventory true')
hx, hy, hz = (int(v // 1) for v in pos_of(host))
seen = {}
try:
for game, dz in ((host, -3), (guest, 3)):
x, y, z = hx + 2, hy, hz + dz
game('command', command='gamemode survival')
game('command', command='item replace entity @s weapon.mainhand with minecraft:diamond_pickaxe')
game('command', command=f'setblock {x + 2} {y} {z} minecraft:obsidian')
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
game.wait(10)
game.raw('look-at', f'{x + 2.5:.2f}', f'{y + 0.5:.2f}', f'{z + 0.5:.2f}')
game.wait(5)
game.raw('mouse', 'left', 'down')
game.wait(10)
game('command', command='kill @s')
game.wait(10)
# The death screen lets go of the button (Minecraft.setScreen); let go again once back.
try:
game.raw('mouse', 'left', 'up')
except Failed:
pass
game.wait(10)
respawn_when_ready(game)
game.wait(2)
try:
game.raw('mouse', 'left', 'up')
except Failed:
pass
# Obsidian takes a diamond pickaxe about 190 ticks.
game.wait(240)
seen[game.name] = host.raw('block', x + 2, y, z).get('block')
game.shot(out, 'death-mining')
host('command', command=f'setblock {x + 2} {y} {z} minecraft:air')
game('command', command='item replace entity @s weapon.mainhand with minecraft:air')
finally:
host('command', command='gamerule keep_inventory false')
for name, block in seen.items():
assert block == 'minecraft:obsidian', f'{name}: {seen}'
return seen
def check_absorption(host, guest, out):
"""Absorption II's golden hearts on each player's own HUD: 8 of them, and 5 once a hit of 3 took
its share: the client shows what the server's player has (#1306)."""
seen = {}
for game in (host, guest):
game('command', command='gamemode survival')
game('command', command='effect give @s minecraft:absorption 120 1')
game.wait(10)
given = own(game)['absorption']
game('command', command='damage @s 3 minecraft:generic')
game.wait(20)
me = own(game)
server = server_player(host, game)
game.shot(out, 'absorption')
game('command', command='effect clear @s minecraft:absorption')
seen[game.name] = {'given': given, 'left': me['absorption'], 'health': me['health'],
'server': server.get('absorption')}
for name, s in seen.items():
assert s['given'] == 8.0 and s['left'] == 5.0 == s['server'] and s['health'] == 20.0, \
f'{name}: {seen}'
return seen
def check_breath(host, guest, out):
"""Underwater in a Respiration III helmet for 15 s: the enchantment makes the air last on the
server, and each player's own bubbles follow the server's air (#1304)."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
seen = {}
for game, dz in ((host, -4), (guest, 4)):
x, y, z = hx + 3, hy, hz + dz
game('command', command='gamemode survival')
game('command', command='item replace entity @s armor.head with '
'minecraft:diamond_helmet[minecraft:enchantments={"minecraft:respiration":3}]')
game('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 1} {y + 2} {z + 1} minecraft:stone')
game('command', command=f'fill {x} {y} {z} {x} {y + 1} {z} minecraft:water')
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
host.wait(300)
for game in (host, guest):
game.shot(out, 'breath')
seen[game.name] = {'own': own(game)['air'], 'server': server_player(host, game).get('air')}
for game, dz in ((host, -4), (guest, 4)):
x, y, z = hx + 3, hy, hz + dz
game('command', command='item replace entity @s armor.head with minecraft:air')
host('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 1} {y + 2} {z + 1} minecraft:air')
host('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 1} {y - 1} {z + 1} minecraft:stone')
guest.wait(40)
# 300 ticks lose about 75 of the 300 with Respiration III, all of it without.
for name, s in seen.items():
assert s['server'] > 150 and abs(s['own'] - s['server']) <= 10, f'{name}: {seen}'
return seen
def check_lava(host, guest, out):
"""Standing in a settled Nether lava current with Fire Resistance: the
Nether's FAST_LAVA pushes each player about three times as hard as the Overworld's (#1309)."""
seen = {}
back = {g.name: pos_of(g) for g in (host, guest)}
try:
for game, z in ((host, 0), (guest, 6)):
game('command', command=f'execute in minecraft:the_nether run tp @s 0.5 100 {z + 0.5}')
state = wait_dimension(game, 'minecraft:the_nether', 600)
assert state.get('dimension') == 'minecraft:the_nether', f'{game.name} is in {state.get("dimension")}'
game.wait(40)
# A one-wide obsidian channel along x: a source at x = -3, the current flowing east.
game('command', command=f'fill -5 98 {z - 2} 6 104 {z + 2} minecraft:air')
game('command', command=f'fill -5 98 {z - 1} 6 98 {z + 1} minecraft:obsidian')
game('command', command=f'fill -5 99 {z - 1} 6 99 {z - 1} minecraft:obsidian')
game('command', command=f'fill -5 99 {z + 1} 6 99 {z + 1} minecraft:obsidian')
game('command', command=f'fill -4 99 {z} -4 99 {z} minecraft:obsidian')
game('command', command='effect give @s minecraft:fire_resistance 120 0 true')
game('command', command='gamemode survival')
game('command', command=f'setblock -3 99 {z} minecraft:lava')
host.wait(100)
for game, z in ((host, 0), (guest, 6)):
game('command', command=f'tp @s 0.5 99 {z + 0.5}')
host.wait(60)
for game, z in ((host, 0), (guest, 6)):
v = own(game)['velocity']
seen[game.name] = {'vx': v[0], 'level': game.raw('block', 0, 99, z).get('state')}
game.shot(out, 'lava')
finally:
for game, z in ((host, 0), (guest, 6)):
host('command', command=f'execute in minecraft:the_nether run fill -5 98 {z - 2} 6 104 {z + 2} minecraft:air')
x, y, zz = back[game.name]
game('command', command='effect clear @s minecraft:fire_resistance')
game('command', command=f'execute in minecraft:overworld run tp @s {x:.2f} {y:.2f} {zz:.2f}')
for game in (host, guest):
wait_dimension(game, 'minecraft:overworld', 600)
guest.wait(40)
# The Overworld's current settles at about 0.0047 a tick, the Nether's at about 0.014.
for name, s in seen.items():
assert s['vx'] > 0.009, f'{name}: {seen}'
return seen
def check_ladder(host, guest, out):
"""#1255: climbing down a ladder doesn't hurt where the guest lands."""
x, y, z = (int(v // 1) for v in pos_of(guest))
lx, lz = x + 6, z
guest('command', command='gamemode survival')
guest('command', command=f'fill {lx - 2} {y} {lz - 2} {lx + 2} {y + 12} {lz + 2} minecraft:air')
guest('command', command=f'fill {lx - 2} {y - 1} {lz - 2} {lx + 2} {y - 1} {lz + 2} minecraft:stone')
guest('command', command=f'fill {lx} {y} {lz + 1} {lx} {y + 10} {lz + 1} minecraft:stone')
guest('command', command=f'fill {lx} {y} {lz} {lx} {y + 10} {lz} minecraft:ladder[facing=north]')
guest.wait(10)
try:
before = remote(guest)['health']
# No input on a ladder: the guest slides down at 0.15 a tick (LivingEntity.handleOnClimbable).
guest('command', command=f'tp @s {lx + 0.5} {y + 10} {lz + 0.4} 0 0')
for _ in range(40):
guest.wait(5)
if pos_of(guest)[1] < y + 0.01:
break
guest.wait(20)
landed, after = pos_of(guest), remote(guest)['health']
assert landed[1] < y + 0.01, f'the guest stayed on the ladder at {landed}'
assert after >= before, f'climbing down ten blocks of ladder took the guest from {before} to {after} health'
return {'landed': landed, 'health': after}
finally:
guest('command', command=f'fill {lx} {y} {lz} {lx} {y + 10} {lz + 1} minecraft:air')
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
def apart(host, guest):
"""The host where it stands and the guest 12 blocks east, each on a stone floor with room
around: neither stands in the other's way (a ray from inside a player's box hits that player)."""
x, y, z = (int(v // 1) for v in pos_of(host))
spots = {}
for label, player, dx in (('host', host, 0), ('guest', guest, 12)):
px = x + dx
host('command', command=f'fill {px - 3} {y - 1} {z - 3} {px + 4} {y - 1} {z + 6} minecraft:stone')
host('command', command=f'fill {px - 3} {y} {z - 3} {px + 4} {y + 3} {z + 6} minecraft:air')
player('command', command=f'tp @s {px + 0.5} {y} {z + 0.5} 0 0')
spots[label] = (px, y, z)
host.wait(10)
guest.wait(10)
return spots
def board(player, kind, at):
"""Right-click the `kind` object near `at` and check `player` rides it."""
found = [o for o in player('status')['objects'] if o['kind'].endswith(kind)
and abs(o['position'][0] - at[0]) < 2 and abs(o['position'][2] - at[2]) < 2]
assert found, f'no {kind} near {at}'
vehicle = found[0]
vp = vehicle['position']
player.raw('look-at', f'{vp[0]:.2f}', f'{vp[1] + 0.3:.2f}', f'{vp[2]:.2f}')
player.raw('mouse', 'right', 'click')
player.wait(10)
riding = player('status').get('riding')
assert riding == vehicle['id'], f'right-clicking the {kind} {vehicle["id"]}: riding {riding}'
return vehicle
def leave_vehicle(player, kind):
player.raw('key', 'shift', 'down')
player.wait(10)
player.raw('key', 'shift', 'up')
player.wait(10)
riding = player('status').get('riding')
assert riding is None, f'sneaking out of the {kind}: riding {riding}'
def break_vehicle(player, vehicle_id, kind):
"""Hit it with fists until it breaks. VehicleEntity.hurtServer adds the hit's damage times 10 and
breaks it past 40; AbstractBoat.tick and AbstractMinecart.tick take 1 off every tick.
Player.attack scales a hit by its charge, 0.2 + s * s * 0.8, and a fist charges in
20 / 4 = 5 ticks. Punches 2 ticks apart do 0.4 each, a net 2 after decay, and break it only on
the 17th. Punches 5 ticks apart are fully charged: 10, then a net 5 each, broken on the 8th. A
tick of drift either way still nets at least 4 a punch."""
here = [o for o in player('status')['objects'] if o['id'] == vehicle_id]
if not here:
return
vp = here[0]['position']
# One connection: a ctl process per hit spaces the hits by however long it takes to start.
player.script([f'look-at {vp[0]:.2f} {vp[1] + 0.3:.2f} {vp[2]:.2f}',
*[line for _ in range(12) for line in ('mouse left click', 'wait 5')]])
assert not any(o['id'] == vehicle_id for o in player('status')['objects']), \
f'twelve punches 5 ticks apart left the {kind} in place'
def check_boats(host, guest, out):
"""#1222: the host (single player, on its server thread or not) and the guest each board a boat
on water with a right click, paddle it forward (the other side sees it move), sneak out, and
break it with their fists."""
result = {}
spots = apart(host, guest)
for label, player, other in (('host', host, guest), ('guest', guest, host)):
x, y, z = spots[label]
bx = x + 3
player('command', command='gamemode survival')
# A pool ten blocks long in a stone basin (the world may float), with a path beside it to
# walk back to the boat.
player('command', command=f'fill {bx - 3} {y} {z - 2} {bx + 2} {y + 3} {z + 10} minecraft:air')
player('command', command=f'fill {bx - 3} {y - 2} {z - 2} {bx + 2} {y - 1} {z + 10} minecraft:stone')
player('command', command=f'fill {bx - 1} {y - 1} {z - 1} {bx + 1} {y - 1} {z + 9} minecraft:water')
player('command', command=f'summon minecraft:oak_boat {bx + 0.5} {y - 0.1} {z + 0.5}')
player.wait(30)
try:
boat = board(player, 'oak_boat', (bx + 0.5, y, z + 0.5))
start = boat['position']
# The summoned boat faces south: forward paddles it along the pool.
player.raw('key', 'w', 'down')
player.wait(30)
player.raw('key', 'w', 'up')
player.wait(20)
mine = [o for o in player('status')['objects'] if o['id'] == boat['id']]
assert mine, f'the {label} lost its boat'
moved = math.dist(start, mine[0]['position'])
assert moved > 0.5, f'paddling forward moved the {label}\'s boat {moved:.2f} blocks'
# The other side sees the boat where its rider took it (the server accepted its moves).
theirs = [o for o in objects_near(other, mine[0]['position'], 'objects')
if o['kind'].endswith('oak_boat')]
assert theirs, f'the other side sees no boat near the {label}\'s'
far = min(math.dist(o['position'], mine[0]['position']) for o in theirs)
assert far < 1.0, f'the other side sees the {label}\'s boat {far:.2f} blocks off'
leave_vehicle(player, 'boat')
player('command', command=f'tp @s {bx - 1.5} {y} {mine[0]["position"][2]:.2f}')
player.wait(5)
break_vehicle(player, boat['id'], 'boat')
result[label] = {'boat': boat['id'], 'paddled': round(moved, 2), 'broken': True}
finally:
player('command', command=f'kill @e[type=minecraft:oak_boat,x={bx},y={y},z={z + 4},distance=..10]')
player('command', command=f'fill {bx - 1} {y - 1} {z - 1} {bx + 1} {y - 1} {z + 9} minecraft:stone')
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
player.wait(5)
return result
def check_boat_ice_reboard(host, guest, out):
"""A boat's cached spin belongs to its controlling client only (AbstractBoat.tick)."""
hx, _, hz = pos_of(host)
x, y, z = int(hx // 16) * 16 + 8, 100, int(hz // 16) * 16 + 8
tag = 'ice_reboard'
seen = {}
host('command', command=f'fill {x-30} {y} {z-30} {x+30} {y+5} {z+30} minecraft:air')
host('command', command=f'fill {x-30} {y-1} {z-30} {x+30} {y-1} {z+30} minecraft:ice')
try:
for player in (host, guest):
other = guest if player is host else host
other('command', command=f'tp @s {x+25.5} {y} {z+25.5}')
for kind in ('oak_boat', 'bamboo_raft'):
label = f'{player.name}-{kind}'
host('command', command=f'kill @e[tag={tag}]')
player('command', command='gamemode survival')
player('command', command=f'tp @s {x+0.5} {y} {z-0.5}')
player('command', command=f'summon minecraft:{kind} {x+0.5} {y} {z+2.5} {{Tags:["{tag}"]}}')
player.wait(10)
boat = board(player, kind, (x+0.5, y, z+2.5))
player.script(['key w down', 'key d down', 'wait 30',
'key shift down', 'wait 2', 'key w up', 'key d up', 'key shift up', 'wait 10'])
assert player('status').get('riding') is None, f'{label}: did not dismount while steering'
current = next(o for o in player('status')['objects'] if o['id'] == boat['id'])
bx, by, bz = current['position']
player('command', command=f'tp @s {bx-2} {by} {bz}')
player.wait(5)
current = next(o for o in player('status')['objects'] if o['id'] == boat['id'])
board(player, kind, current['position'])
player.wait(5)
replies = player.script(['status', 'state'] + ['wait 2', 'status', 'state'] * 15, every=True)
boat_yaws = [next(o for o in r['objects'] if o['id'] == boat['id'])['yaw']
for r in replies if 'objects' in r]
camera_yaws = [r['player']['yaw'] for r in replies if r.get('player') and 'yaw' in r['player']]
assert len(boat_yaws) == len(camera_yaws) == 16, f'{label}: missing rotation samples'
turn = lambda angles: sum(abs((b-a+180) % 360-180) for a, b in zip(angles, angles[1:]))
seen[label] = dict(boat_turn=turn(boat_yaws), camera_turn=turn(camera_yaws),
boat_yaws=boat_yaws, camera_yaws=camera_yaws,
ticks=[r['ticks'] for r in replies if 'objects' in r])
lab.write_json(out / 'boat-ice-reboard.json', seen)
player.shot(out, 'boat-ice-reboard-' + label)
# One previously latched steering input can still add one
# degree of turn momentum; ice then damps it by 0.98 per tick.
limit = sum(0.98 ** tick for tick in range(30)) + 2
assert seen[label]['boat_turn'] < limit, f'{label}: reboarded boat keeps spinning: {seen[label]}'
assert seen[label]['camera_turn'] < limit, f'{label}: camera keeps spinning: {seen[label]}'
player.wait(50)
late = player.script(['status', 'state'] + ['wait 2', 'status', 'state'] * 15, every=True)
boat_late = [next(o for o in r['objects'] if o['id'] == boat['id'])['yaw']
for r in late if 'objects' in r]
camera_late = [r['player']['yaw'] for r in late if r.get('player') and 'yaw' in r['player']]
assert len(boat_late) == len(camera_late) == 16, f'{label}: missing settled rotation samples'
seen[label].update(settled_boat_turn=turn(boat_late), settled_camera_turn=turn(camera_late),
settled_boat_yaws=boat_late, settled_camera_yaws=camera_late,
settled_ticks=[r['ticks'] for r in late if 'objects' in r])
lab.write_json(out / 'boat-ice-reboard.json', seen)
assert seen[label]['settled_boat_turn'] < 5, f'{label}: boat never settles: {seen[label]}'
assert seen[label]['settled_camera_turn'] < 5, f'{label}: camera never settles: {seen[label]}'
leave_vehicle(player, 'boat')
return seen
finally:
for player in (host, guest):
player.raw('release')
host('command', command=f'kill @e[tag={tag}]')
def check_boat_shoved(host, guest, out):
"""A piston shoves the boat its rider drives, then `/tp` moves it. The server moved it, so its
last good position is where it is now (`ServerGamePacketListenerImpl.tick` resets it every
tick). Before, it kept the old one: every move the rider's client sent failed, was sent back,
and failed again on each answer, and the boat stayed stuck (up to 6,700 "moved wrongly" lines a
second on the official server)."""
result = {}
spots = apart(host, guest)
log = host.socket.with_suffix('.log')
def wrongly():
return log.read_text(errors='replace').count('moved wrongly') if log.exists() else 0
for label, player, other in (('host', host, guest), ('guest', guest, host)):
x, y, z = spots[label]
# The boat east of the player, a piston north of it that shoves it south (out of the way
# of the player's right click).
bx = x + 3
player('command', command='gamemode survival')
player('command', command=f'fill {x - 1} {y} {z - 4} {bx + 2} {y + 3} {z + 3} minecraft:air')
player('command', command=f'fill {x - 1} {y - 1} {z - 4} {bx + 2} {y - 1} {z + 3} minecraft:stone')
player('command', command=f'setblock {bx} {y} {z - 2} minecraft:piston[facing=south]')
player('command', command=f'summon minecraft:oak_boat {bx + 0.5} {y} {z - 0.4}')
player.wait(30)
try:
boat = board(player, 'oak_boat', (bx + 0.5, y, z - 0.4))
player.wait(20)
before, start = wrongly(), boat['position']
player('command', command=f'setblock {bx} {y} {z - 3} minecraft:redstone_block')
player.wait(40)
assert player('status').get('riding') == boat['id'], f'the {label} left its boat'
shoved = [o for o in player('status')['objects'] if o['id'] == boat['id']][0]['position']
# Then it paddles on: the server takes these moves.
player.raw('key', 'w', 'down')
player.wait(30)
player.raw('key', 'w', 'up')
player.wait(20)
# A command moves it too (`/tp`, as an operator would): it paddles on from there.
player('command', command=f'tp @e[type=minecraft:oak_boat,x={bx},y={y},z={z},distance=..8] '
f'{bx - 1.5} {y} {z - 2.5}')
player.wait(20)
moved = [o for o in player('status')['objects'] if o['id'] == boat['id']][0]['position']
player.raw('key', 'w', 'down')
player.wait(30)
player.raw('key', 'w', 'up')
player.wait(20)
lines = wrongly() - before
mine = [o for o in player('status')['objects'] if o['id'] == boat['id']][0]['position']
# The server and the other side agree with the rider about where the boat is.
theirs = [o for o in objects_near(other, mine, 'objects') if o['kind'].endswith('oak_boat')]
assert theirs, f'the other side sees no boat near the {label}\'s'
far = min(math.dist(o['position'], mine) for o in theirs)
result[label] = {'shoved': round(shoved[2] - start[2], 2),
'paddled': round(math.dist(moved, mine), 2), 'moved_wrongly_lines': lines,
'other_side_off': round(far, 2)}
assert lines <= 5, f'moving it made {lines} "moved wrongly" lines: {result[label]}'
assert result[label]['paddled'] > 0.5, f'the {label}\'s boat stays where it was shoved: {result[label]}'
assert far < 1.0, f'the other side sees the {label}\'s boat {far:.2f} blocks off'
leave_vehicle(player, 'boat')
finally:
player('command', command=f'kill @e[type=minecraft:oak_boat,x={bx},y={y},z={z},distance=..8]')
player('command', command=f'fill {x - 1} {y} {z - 4} {bx + 2} {y + 3} {z + 3} minecraft:air')
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
player.wait(5)
return result
def check_minecarts(host, guest, out):
"""#1222, #5812: the host and the guest each get into a minecart on rails with a right click, sneak out
beside it, and break it with their fists; also check opening a chest minecart's container."""
result = {}
spots = apart(host, guest)
for label, player in (('host', host), ('guest', guest)):
x, y, z = spots[label]
cx = x + 3
player('command', command='gamemode survival')
player('command', command=f'fill {cx - 2} {y} {z - 2} {cx + 2} {y + 3} {z + 6} minecraft:air')
player('command', command=f'fill {cx - 2} {y - 1} {z - 2} {cx + 2} {y - 1} {z + 6} minecraft:stone')
player('command', command=f'fill {cx} {y} {z} {cx} {y} {z + 4} minecraft:rail')
player('command', command=f'summon minecraft:minecart {cx + 0.5} {y} {z + 0.5}')
player.wait(30)
try:
cart = board(player, 'minecart', (cx + 0.5, y, z + 0.5))
seated = pos_of(player)
assert abs(seated[0] - (cx + 0.5)) < 0.6, f'the {label} sits at {seated}, not in the cart'
leave_vehicle(player, 'minecart')
out_at = pos_of(player)
assert math.dist(out_at, seated) > 0.3, f'the {label} stayed in its seat at {out_at}'
player.wait(5)
break_vehicle(player, cart['id'], 'minecart')
result[label] = {'cart': cart['id'], 'out_at': [round(v, 2) for v in out_at], 'broken': True}
player('command', command=f'summon minecraft:chest_minecart {cx + 0.5} {y} {z + 0.5}')
player.wait(10)
player.raw('look-at', f'{cx + 0.5:.2f}', f'{y + 0.3:.2f}', f'{z + 0.5:.2f}')
player.raw('mouse', 'right', 'click')
player.raw('wait-for', 'screen', 'container', '--timeout', 10)
items, size = container_items(player)
assert size == 27, f'the {label} opened chest minecart with {size} slots, expected 27'
player.raw('key', 'esc', 'tap')
player.wait(10)
player('command', command=f'kill @e[type=minecraft:chest_minecart,x={cx},y={y},z={z},distance=..8]')
finally:
player('command', command=f'kill @e[type=minecraft:chest_minecart,x={cx},y={y},z={z},distance=..8]')
player('command', command=f'kill @e[type=minecraft:minecart,x={cx},y={y},z={z},distance=..8]')
player('command', command=f'kill @e[type=minecraft:item,x={cx},y={y},z={z},distance=..8]')
player('command', command=f'fill {cx} {y} {z} {cx} {y} {z + 4} minecraft:air')
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
player.wait(5)
return result
def check_frames(host, guest, out):
"""#1222: the host and the guest each put a diamond in an item frame and turn it, and put a
helmet on an armor stand and take it back, with right clicks."""
result = {}
spots = apart(host, guest)
for label, player, other in (('host', host, guest), ('guest', guest, host)):
x, y, z = spots[label]
player('command', command='gamemode survival')
# Within a survival player's 3 blocks of reach: a frame on a wall, an armor stand beside it.
player('command', command=f'fill {x - 2} {y} {z + 1} {x + 2} {y + 2} {z + 2} minecraft:air')
player('command', command=f'fill {x - 2} {y} {z + 3} {x + 2} {y + 2} {z + 3} minecraft:stone')
frame_kind = 'item_frame' if label == 'host' else 'glow_item_frame'
player('command', command=f'summon minecraft:{frame_kind} {x + 0.5} {y + 1.5} {z + 2.97} '
f'{{block_pos:[I;{x},{y + 1},{z + 2}],Facing:2b}}')
stand_tag = f'frame_stand_{label}'
player('command', command=f'summon minecraft:armor_stand {x - 1.5} {y} {z + 1.5} '
f'{{Tags:["{stand_tag}"]}}')
player('command', command='clear @s')
frame_name = f'{label} framed diamond'
player('command', command='item replace entity @s weapon.mainhand with '
f"minecraft:diamond[minecraft:custom_name='{frame_name}']")
player.wait(30)
try:
frame = [o for o in player('status')['objects'] if o['kind'].endswith('item_frame')
and o['position'] == [x, y + 1, z + 2]]
assert frame, f'the {label} sees no item frame at {[x, y + 1, z + 2]}'
player.raw('look-at', f'{x + 0.5:.2f}', f'{y + 1.5:.2f}', f'{z + 2.95:.2f}')
assert frame_name not in (player('status').get('name_tags') or []), 'empty frame name visible'
player.raw('mouse', 'right', 'click')
player.wait(10)
player.raw('mouse', 'right', 'click')
player.wait(10)
frame = [o for o in player('status')['objects'] if o['id'] == frame[0]['id']][0]
assert frame['item'] == 'minecraft:diamond', f'the {label}\'s frame holds {frame["item"]}'
assert frame['rotation'] == 1, f'the {label}\'s frame turned to {frame["rotation"]}'
assert frame_name in (player('status').get('name_tags') or []), \
f"the {label}'s targeted frame has no item name"
player.shot(out, f'{label}-framed-item-name')
player.raw('key', 'f1', 'tap')
player.wait(2)
assert frame_name not in (player('status').get('name_tags') or []), 'F1 left the frame name visible'
player.raw('key', 'f1', 'tap')
player.raw('look-at', f'{x + 2:.2f}', f'{y + 1.5:.2f}', f'{z + 2.95:.2f}')
assert frame_name not in (player('status').get('name_tags') or []), 'untargeted frame name visible'
theirs = [o for o in other('status')['objects'] if o['kind'].endswith('item_frame')
and o['position'] == [x, y + 1, z + 2]]
assert theirs and theirs[0]['item'] == 'minecraft:diamond' and theirs[0]['rotation'] == 1, \
f'the other side sees the {label}\'s frame as {theirs}'
player('command', command='item replace entity @s weapon.mainhand with minecraft:iron_helmet')
player.wait(5)
stand = [o for o in player('status')['objects'] if o['kind'] == 'minecraft:armor_stand'
and abs(o['position'][0] - (x - 1.5)) < 0.5 and abs(o['position'][2] - (z + 1.5)) < 0.5]
assert stand, f'the {label} sees no armor stand'
player.raw('look-at', f'{x - 1.5:.2f}', f'{y + 1.7:.2f}', f'{z + 1.5:.2f}')
player.raw('mouse', 'right', 'click')
player.wait(10)
worn = [o for o in player('status')['objects'] if o['id'] == stand[0]['id']][0]['equipment']
assert 'minecraft:iron_helmet' in worn, f'the {label}\'s armor stand wears {worn}'
player.raw('look-at', f'{x - 1.5:.2f}', f'{y + 1.7:.2f}', f'{z + 1.5:.2f}')
player.raw('mouse', 'right', 'click')
player.wait(10)
worn = [o for o in player('status')['objects'] if o['id'] == stand[0]['id']][0]['equipment']
assert 'minecraft:iron_helmet' not in worn, f'taking the helmet back left {worn}'
selector = f'@e[type=minecraft:armor_stand,tag={stand_tag},limit=1]'
player('command', command='item replace entity @s weapon.mainhand with minecraft:name_tag 3')
player.wait(5)
player.raw('look-at', f'{x - 1.5:.2f}', f'{y + 1:.2f}', f'{z + 1.5:.2f}')
player.raw('mouse', 'right', 'click')
player.wait(10)
assert player('inventory')['counts'].get('minecraft:name_tag') == 3, 'unnamed tag was consumed'
server_reply(player, f'execute unless data entity {selector} CustomName', 'Test passed')
name = f'{label} Bob'
player('command', command='item replace entity @s weapon.mainhand with '
f'minecraft:name_tag[minecraft:custom_name="{name}"] 3')
player.wait(5)
player.raw('mouse', 'right', 'click')
player.wait(10)
assert player('inventory')['counts'].get('minecraft:name_tag') == 2, 'named tag was not consumed once'
server_reply(player, f'data get entity {selector} CustomName', name)
server_reply(other, f'data get entity {selector} CustomName', name)
# NameTagItem shrinks its stack in creative too.
player('command', command='gamemode creative')
player('command', command='item replace entity @s weapon.mainhand with '
f'minecraft:name_tag[minecraft:custom_name="{name}"] 3')
player.wait(5)
player.raw('mouse', 'right', 'click')
player.wait(10)
assert player('inventory')['counts'].get('minecraft:name_tag') == 2, 'creative tag was not consumed once'
player('command', command='gamemode survival')
result[label] = {'frame': 'diamond, turned', 'stand': 'helmet on and off; named tag consumed'}
finally:
player('command', command=f'kill @e[type=!minecraft:player,x={x - 3},y={y - 1},z={z},dx=6,dy=4,dz=6]')
player('command', command=f'fill {x - 2} {y} {z + 3} {x + 2} {y + 2} {z + 3} minecraft:air')
player('command', command='clear @s')
return result
def check_hands(host, guest, out):
"""#1350, #1355, #1362: right clicks a cow passes go on to the item, the off hand gets its
turn on the cow, and a frame punched empty is empty for everyone."""
result = {}
spots = apart(host, guest)
for label, player, other in (('host', host, guest), ('guest', guest, host)):
x, y, z = spots[label]
player('command', command='gamemode survival')
player('command', command='clear @s')
player('command', command='effect clear @s')
# A cow two blocks ahead that stays put, and a framed diamond on a wall behind the player.
player('command', command=f'summon minecraft:cow {x + 0.5} {y} {z + 3.5} {{NoAI:1b}}')
player('command', command=f'fill {x - 1} {y} {z - 2} {x + 1} {y + 2} {z - 2} minecraft:stone')
player('command', command=f'summon minecraft:item_frame {x + 0.5} {y + 1.5} {z - 0.97} '
f'{{block_pos:[I;{x},{y + 1},{z - 1}],Facing:3b,Item:{{id:"minecraft:diamond",count:1}}}}')
player.wait(20)
counts = lambda: player('inventory')['counts']
aim_cow = lambda: player.raw('look-at', f'{x + 0.5:.2f}', f'{y + 0.8:.2f}', f'{z + 3.5:.2f}')
try:
player('command', command='give @s minecraft:bow')
player('command', command='give @s minecraft:arrow 6')
player.wait(5)
player.raw('select', 'minecraft:bow')
aim_cow()
assert player('looking_at')['kind'] == 'entity', f'the {label} does not aim at the cow'
# Half drawn: a full draw is a crit, up to 10 damage (AbstractArrow.onHitEntity), which
# kills the 10 health cow one shot in five. Under 20 ticks the arrow does at most 6.
player.raw('mouse', 'right', 'down')
player.wait(10)
drawn = player('state')['using']
player.raw('mouse', 'right', 'up')
player.wait(20)
assert drawn, f'the {label}\'s bow is not drawn at the cow'
assert counts().get('minecraft:arrow') == 5, f'the {label} shot no arrow: {counts()}'
aim_cow()
cow = player('looking_at').get('entity') or {}
assert cow.get('health', 0) > 0, f'the {label}\'s arrow killed the cow: {cow}'
player('command', command='clear @s')
player('command', command='give @s minecraft:golden_apple')
player.wait(5)
player.raw('select', 'minecraft:golden_apple')
aim_cow()
player.raw('mouse', 'right', 'down')
player.wait(5)
player.raw('mouse', 'right', 'up')
player.wait(40)
assert counts().get('minecraft:golden_apple') == 1, f'the {label} ate the apple let go of: {counts()}'
# The main hand empty, a bucket and then wheat in the off hand.
player('command', command='clear @s')
player('command', command='item replace entity @s weapon.offhand with minecraft:bucket')
player.wait(5)
aim_cow()
player.raw('mouse', 'right', 'click')
player.wait(15)
offhand = player('inventory')['offhand']
assert offhand and offhand['item'] == 'minecraft:milk_bucket', f'the {label}\'s off hand holds {offhand}'
player('command', command='item replace entity @s weapon.offhand with minecraft:wheat 3')
player.wait(5)
aim_cow()
player.raw('mouse', 'right', 'click')
player.wait(15)
offhand = player('inventory')['offhand']
assert offhand and offhand['count'] == 2, f'the {label}\'s cow ate no off hand wheat: {offhand}'
# The framed diamond, punched out.
frame = [o for o in player('status')['objects'] if o['kind'].endswith('item_frame')
and o['position'] == [x, y + 1, z - 1]]
assert frame and frame[0]['item'] == 'minecraft:diamond', f'the {label} sees the frame as {frame}'
player.raw('look-at', f'{x + 0.5:.2f}', f'{y + 1.5:.2f}', f'{z - 0.95:.2f}')
player.raw('mouse', 'left', 'click')
player.wait(20)
for side, game in ((label, player), ('other side', other)):
seen = [o for o in game('status')['objects'] if o['kind'].endswith('item_frame')
and o['position'] == [x, y + 1, z - 1]]
assert seen and seen[0].get('item') is None, f'the {side} sees the punched frame as {seen}'
result[label] = {'bow': 'shot', 'apple': 'kept', 'off hand': 'milked, fed', 'frame': 'emptied'}
finally:
player('command', command=f'kill @e[type=!minecraft:player,x={x - 3},y={y - 1},z={z - 3},dx=6,dy=4,dz=8]')
player('command', command=f'fill {x - 1} {y} {z - 2} {x + 1} {y + 2} {z - 2} minecraft:air')
player('command', command='clear @s')
return result
# Animals a player rides: (kind, summon NBT, what readies it, what is held to ride, the test
# while riding). `move` walks forward, `jump` charges the horse's jump bar, `rise` holds jump on
# a happy ghast, `sit` only rides (a llama goes where it likes).
RIDES = [
('horse', '{Tame:1b}', 'minecraft:saddle', None, 'move jump'),
('pig', '', 'minecraft:saddle', 'minecraft:carrot_on_a_stick', 'move'),
('strider', '', 'minecraft:saddle', 'minecraft:warped_fungus_on_a_stick', 'move'),
('camel', '', 'minecraft:saddle', None, 'move'),
('donkey', '{Tame:1b}', 'minecraft:saddle', None, 'move'),
('llama', '{Tame:1b}', None, None, 'sit'),
('happy_ghast', '', 'minecraft:white_harness', None, 'rise'),
]
HEIGHTS = {'horse': 1.6, 'donkey': 1.5, 'pig': 0.9, 'strider': 1.7, 'camel': 2.375, 'llama': 1.87,
'happy_ghast': 4.0}
def mob_seen(game, mob_id):
found = [m for m in game.raw('mobs', 96).get('near', []) if m['id'] == mob_id]
return found[0] if found else None
def hold(game, key, ticks):
game.raw('key', key, 'down')
game.wait(ticks)
game.raw('key', key, 'up')
def ride(player, other, label, spot, kind, nbt, ready, stick, test):
"""Summon `kind` in front of `player`, ready it (saddle, harness), get on with a right click,
run `test`, and get off by sneaking. The other side sees the animal move."""
x, y, z = spot
# In reach (3 blocks), clear of the player: a happy ghast is 4 blocks wide.
at = (x + 0.5, z + {'happy_ghast': 3.2, 'camel': 3.0}.get(kind, 2.5))
# The first item in hand before it appears: a happy ghast drifts out of reach quickly.
player('command', command=f'item replace entity @s weapon.mainhand with {ready or stick or "minecraft:air"}')
player('command', command=f'summon minecraft:{kind} {at[0]} {y} {at[1]} {nbt}')
player.wait(3)
found = [m for m in player.raw('mobs', 16).get('near', []) if m['kind'].endswith(kind) and m['health'] > 0]
assert found, f'the {label} sees no {kind}'
mob = found[0]
def click(item):
player('command', command=f'item replace entity @s weapon.mainhand with {item or "minecraft:air"}')
player.wait(2)
m = mob_seen(player, mob['id'])
assert m, f'the {label} lost its {kind}'
p = m['pos']
# Back in front of it if it wandered off (a happy ghast drifts away fast).
if abs(p[0] - (x + 0.5)) > 0.5 or abs(p[2] - at[1]) > 0.5:
player('command', command=f'tp @s {p[0]:.2f} {y} {p[2] - (at[1] - z - 0.5):.2f} 0 0')
player.wait(1)
player.raw('look-at', f'{p[0]:.2f}', f'{p[1] + HEIGHTS[kind] / 2:.2f}', f'{p[2]:.2f}')
player.raw('mouse', 'right', 'click')
player.wait(5)
try:
if ready:
click(ready)
click(stick)
riding = player('status').get('riding')
assert riding == mob['id'], f'the {label} right-clicked the {kind} {mob["id"]}: riding {riding}'
result = {'rode': True}
# South, along the cleared floor.
player.raw('look-at', f'{x + 0.5:.2f}', f'{y + 1.6:.2f}', f'{z + 40.5:.2f}')
player.wait(5)
start = mob_seen(player, mob['id'])['pos']
if 'move' in test:
hold(player, 'w', 60)
player.wait(5)
mine = mob_seen(player, mob['id'])['pos']
theirs = mob_seen(other, mob['id'])
moved = math.dist(start, mine)
assert moved > 1.0, f"the {label}'s {kind} moved {moved:.2f} blocks in 3 s of forward"
assert theirs and math.dist(theirs['pos'], mine) < 1.5, \
f"the other side sees the {label}'s {kind} at {theirs and theirs['pos']}, not {mine}"
seated = pos_of(player)
assert math.dist(seated[::2], mine[::2]) < 1.5, f'the {label} sits at {seated}, its {kind} at {mine}'
result['moved'] = round(moved, 2)
if 'jump' in test:
base = mob_seen(player, mob['id'])['pos'][1]
player.raw('key', 'space', 'down')
player.wait(10)
player.raw('key', 'space', 'up')
top = base
for _ in range(10):
player.wait(2)
top = max(top, mob_seen(player, mob['id'])['pos'][1])
assert top - base > 0.4, f"the {label}'s {kind} jumped {top - base:.2f} blocks"
result['jumped'] = round(top - base, 2)
if 'rise' in test:
base = mob_seen(player, mob['id'])['pos'][1]
hold(player, 'space', 40)
player.wait(5)
rose = mob_seen(player, mob['id'])['pos'][1] - base
assert rose > 0.5, f"the {label}'s {kind} rose {rose:.2f} blocks while it held jump"
result['rose'] = round(rose, 2)
hold(player, 'shift', 10)
player.wait(10)
riding = player('status').get('riding')
assert riding is None, f'sneaking off the {kind}: the {label} still rides {riding}'
return result
finally:
player('command', command=f'kill @e[type=minecraft:{kind},x={x},y={y},z={z},distance=..48]')
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
player.wait(5)
def check_riding(host, guest, out):
"""The host (single player, on its server thread or not) and the guest each saddle and ride a
horse (walk and jump), a pig and a strider (with their sticks), a camel and a donkey, sit on a
llama, and harness and fly a happy ghast; they get off by sneaking."""
spots = apart(host, guest)
result = {}
for label, player, other in (('host', host, guest), ('guest', guest, host)):
x, y, z = spots[label]
player('command', command='gamemode survival')
player('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 4} {y - 1} {z + 40} minecraft:stone')
player('command', command=f'fill {x - 3} {y} {z - 3} {x + 4} {y + 12} {z + 40} minecraft:air')
player.wait(10)
result[label] = {}
# LAN_E2E_RIDES=pig,horse picks some of them.
only = [k for k in os.environ.get('LAN_E2E_RIDES', '').split(',') if k]
for kind, nbt, ready, stick, test in (r for r in RIDES if not only or r[0] in only):
result[label][kind] = ride(player, other, label, (x, y, z), kind, nbt, ready, stick, test)
player('command', command='item replace entity @s weapon.mainhand with minecraft:air')
return result
def check_occupied_horse(host, guest, out):
"""#1759: a mob passenger fills a zombie horse's seat, for both real players."""
spots = apart(host, guest)
result = {}
tag = 'occupied_horse'
host('command', command='time set midnight')
for label, player in (('host', host), ('guest', guest)):
x, y, z = spots[label]
at = (x + 0.5, y, z + 2.5)
player('command', command='gamemode creative')
player('command', command='clear @s')
result[label] = {}
for occupied in (True, False):
passengers = ',Passengers:[{id:"minecraft:zombie",NoAI:1b,PersistenceRequired:1b}]' if occupied else ''
try:
player('command', command=f'summon minecraft:zombie_horse {at[0]} {y} {at[2]} '
f'{{Tame:1b,NoAI:1b,NoGravity:1b,Tags:["{tag}"]{passengers}}}')
player.wait(5)
horse = next(m for m in player.raw('mobs', 16)['near']
if m['kind'] == 'minecraft:zombie_horse' and m['health'] > 0
and math.dist(m['pos'], at) < 0.1)
if occupied:
# Assert the setup too: a missing passenger would make this a false positive.
passengers_nbt = host('command', command=f'data get entity @e[tag={tag},limit=1] Passengers')
assert 'minecraft:zombie' in json.dumps(passengers_nbt), passengers_nbt
player.raw('look-at', at[0], y + 0.8, at[2])
player.raw('mouse', 'right', 'click')
player.wait(10)
riding = player('status').get('riding')
expected = None if occupied else horse['id']
assert riding == expected, f'{label}: occupied={occupied}, riding={riding}, expected={expected}'
result[label]['occupied' if occupied else 'free'] = riding
player.shot(out, f'occupied-horse-{label}-{occupied}')
if not occupied:
hold(player, 'shift', 5)
finally:
player('command', command=f'kill @e[tag={tag}]')
player('command', command=f'kill @e[type=minecraft:zombie,x={x},y={y},z={z},distance=..6]')
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
player.wait(25)
return result
# Mount inventories: (kind, summon NBT, what goes in by shift-click and the menu slot it lands
# in: 700 the saddle, 701 the body armour or carpet, 710 the chest's first slot).
MOUNT_MENUS = [
('horse', '{Tame:1b,NoAI:1b}', [('minecraft:saddle', 700), ('minecraft:iron_horse_armor', 701)]),
('donkey', '{Tame:1b,NoAI:1b,ChestedHorse:1b}', [('minecraft:saddle', 700), ('minecraft:dirt', 710)]),
('llama', '{Tame:1b,NoAI:1b,ChestedHorse:1b,Strength:3}',
[('minecraft:red_carpet', 701), ('minecraft:dirt', 710)]),
]
def mount_screen(game, ticks=20):
"""The open mount menu's slots and items ({700: 'minecraft:saddle', ...}), waiting up to
`ticks` for it to open; None when no mount menu opened."""
for _ in range(ticks // 2):
slots = {}
for w in game.raw('ui').get('widgets', []):
wid = w.get('id', '')
if wid.startswith('slot-') and wid[5:].isdigit() and int(wid[5:]) >= 700:
slots[int(wid[5:])] = (w.get('stack') or {}).get('item')
if slots:
return slots
game.wait(2)
return None
def check_mounts(host, guest, out):
"""The host and the guest each open a tamed horse's, donkey's and llama's inventory with
shift + right click (`ClientboundMountScreenOpenPacket` for a server's player), put on a
saddle, horse armour or a carpet and fill the chest by shift-clicking, close it, then ride the
mount and open its inventory with the inventory key (OPEN_INVENTORY): it shows what was put
in, and the server took the items from the inventory."""
spots = apart(host, guest)
result = {}
for label, player in (('host', host), ('guest', guest)):
x, y, z = spots[label]
player('command', command='gamemode survival')
result[label] = {}
for kind, nbt, puts in MOUNT_MENUS:
player('command', command='clear @s')
for item, _ in puts:
player('command', command=f'give @s {item}')
player('command', command=f'summon minecraft:{kind} {x + 0.5} {y} {z + 2.5} {nbt}')
player.wait(5)
found = [m for m in player.raw('mobs', 16).get('near', [])
if m['kind'].endswith(kind) and m['health'] > 0]
assert found, f'the {label} sees no {kind}'
mob = found[0]
p = mob['pos']
aim = (f'{p[0]:.2f}', f'{p[1] + HEIGHTS[kind] / 2:.2f}', f'{p[2]:.2f}')
try:
player.raw('look-at', *aim)
player.raw('key', 'shift', 'down')
player.wait(3)
player.raw('mouse', 'right', 'click')
player.wait(5)
player.raw('key', 'shift', 'up')
slots = mount_screen(player)
assert slots is not None, f"the {label}'s shift + right click on the {kind} opened no inventory"
for item, _ in puts:
player.raw('click-slot', hotbar_slot(player, item), 'shift')
player.wait(5)
slots = mount_screen(player)
for item, slot in puts:
assert slots.get(slot) == item, f"the {label}'s {kind}: {item} shift-clicked, slot {slot} holds {slots.get(slot)}"
player.raw('key', 'esc', 'tap')
player.wait(5)
for item, _ in puts:
left = server_count(player, item.split(':')[1])
assert left == 0, f'the server still counts {left} {item} on the {label}'
player.raw('look-at', *aim)
player.raw('mouse', 'right', 'click')
player.wait(8)
riding = player('status').get('riding')
assert riding == mob['id'], f'the {label} right-clicked its {kind}: riding {riding}'
player.raw('key', 'e', 'tap')
again = mount_screen(player)
assert again is not None, f'the inventory key on the {label}\'s {kind} opened no inventory'
for item, slot in puts:
assert again.get(slot) == item, f"the {label}'s {kind} reopened: slot {slot} holds {again.get(slot)}, not {item}"
player.raw('key', 'esc', 'tap')
player.wait(3)
hold(player, 'shift', 10)
player.wait(5)
result[label][kind] = {slot: item for item, slot in puts}
finally:
back_to_game(player)
player('command', command=f'kill @e[type=minecraft:{kind},x={x},y={y},z={z},distance=..16]')
player.wait(2)
player('command', command=f'kill @e[type=minecraft:item,x={x},y={y},z={z},distance=..16]')
player('command', command='clear @s')
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
player.wait(5)
return result
def check_lightning(host, guest, out):
"""A bolt summoned beside each player: both sides get the bolt entity and flash the sky (the
host's window too on its server thread, FEATURES 4z(f))."""
result = {}
for label, player in (('host', host), ('guest', guest)):
x, y, z = (int(v // 1) for v in pos_of(player))
before = {g.name: g('status')['lightning'] for g in (host, guest)}
# No fire where it strikes: on stone with nothing around.
player('command', command=f'fill {x + 4} {y - 1} {z - 2} {x + 8} {y - 1} {z + 2} minecraft:stone')
player('command', command=f'fill {x + 4} {y} {z - 2} {x + 8} {y + 3} {z + 2} minecraft:air')
player('command', command=f'summon minecraft:lightning_bolt {x + 6.5} {y} {z + 0.5}')
seen = {}
for _ in range(40):
player.wait(1)
seen = {g.name: g('status')['lightning'] for g in (host, guest)}
if all(seen[n]['struck'] > before[n]['struck'] and seen[n]['flashes'] > before[n]['flashes']
for n in seen):
break
player('command', command=f'fill {x + 4} {y} {z - 2} {x + 8} {y + 3} {z + 2} minecraft:air')
for n in seen:
assert seen[n]['struck'] > before[n]['struck'], f"{label}'s bolt: the {n} got no bolt ({seen[n]})"
assert seen[n]['flashes'] > before[n]['flashes'], f"{label}'s bolt: the {n}'s sky didn't flash ({seen[n]})"
result[label] = {n: {'bolts': seen[n]['struck'] - before[n]['struck'],
'flashes': seen[n]['flashes'] - before[n]['flashes']} for n in seen}
return result
def check_growth(host, guest, out):
"""Block ticks near a guest 1000 blocks from the host: only the guest's tickets tick them."""
x0, y0, z0 = pos_of(guest)
guest('command', command='gamemode creative')
guest('command', command=f'tp @s {x0 + 1000:.2f} 200 {z0:.2f}')
guest.wait(100)
x, y, z = (int(v // 1) for v in pos_of(guest))
# A platform in the air over wherever the guest landed: a water source two blocks east of the
# guest, wheat on farmland two blocks west. Vanilla's water spreads only towards the nearest
# drop (FlowingFluid.getSpread), so any of the source's four neighbours may take it. Wheat
# grows only in daylight (raw brightness 9 or more).
top = max(y + 12, 120)
water = (x + 2, top + 1, z)
beside = [(water[0] + dx, water[1], water[2] + dz) for dx, dz in ((1, 0), (-1, 0), (0, 1), (0, -1))]
wheat = (x - 2, top + 1, z)
flowed = []
try:
host('command', command='time set noon')
guest('command', command=f'fill {x - 4} {top} {z - 2} {x + 4} {top} {z + 2} minecraft:stone')
guest('command', command=f'setblock {wheat[0]} {top} {wheat[2]} minecraft:farmland[moisture=7]')
guest('command', command=f'setblock {wheat[0]} {wheat[1]} {wheat[2]} minecraft:wheat[age=0]')
guest('command', command='gamerule random_tick_speed 1000')
guest('command', command=f'setblock {water[0]} {water[1]} {water[2]} minecraft:water')
for _ in range(40):
guest.wait(10)
flowed = [p for p in beside if guest.raw('block', *p).get('block') == 'minecraft:water']
if flowed:
break
age = wait_state(guest, wheat, 'age', '7', 2400)
guest.shot(out, 'growth')
finally:
guest('command', command='gamerule random_tick_speed 3')
host('command', command='time set day')
guest('command', command=f'tp @s {x0:.2f} {y0:.2f} {z0:.2f}')
guest('command', command='gamemode survival')
guest.wait(40)
detail = {'flowed': flowed, 'wheat age': age}
assert flowed, f'the water beside the guest did not flow: {detail}'
assert age == '7', f'the wheat beside the guest did not grow: {detail}'
return detail
def check_doors(host, guest, out):
"""A villager shut in a room opens its oak door on its way to a free composter outside
(InteractWithDoor), takes the composter as its job site and closes the door behind it; the
guest sees the door open and close again."""
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
x, y, z = gx + 4, gy, gz - 8
lower, upper, composter, marker = (x + 2, y, z), (x + 2, y + 1, z), (x + 7, y, z), (x + 7, y, z + 2)
tag = 'dg_door_villager'
farmer = (f'execute if entity @e[type=minecraft:villager,tag={tag},'
f'nbt={{VillagerData:{{profession:"minecraft:farmer"}}}}] run setblock {marker[0]} {marker[1]} {marker[2]} '
'minecraft:gold_block')
seen = {'opened': False, 'farmer': False, 'closed': None}
try:
host('command', command='time set 2000')
host('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 9} {y - 1} {z + 3} minecraft:stone')
host('command', command=f'fill {x - 3} {y} {z - 3} {x + 9} {y + 3} {z + 3} minecraft:air')
host('command', command=f'fill {x - 2} {y} {z - 2} {x + 2} {y + 2} {z + 2} minecraft:oak_planks')
host('command', command=f'fill {x - 1} {y} {z - 1} {x + 1} {y + 1} {z + 1} minecraft:air')
for (bx, by, bz), half in ((lower, 'lower'), (upper, 'upper')):
host('command', command=f'setblock {bx} {by} {bz} minecraft:oak_door[facing=east,half={half},'
'hinge=left,open=false,powered=false]')
host('command', command=f'setblock {composter[0]} {composter[1]} {composter[2]} minecraft:composter')
host('command', command=f'summon minecraft:villager {x + 0.5} {y} {z + 0.5} {{Tags:["{tag}"],'
'PersistenceRequired:1b,VillagerData:{profession:"minecraft:none",level:1,'
'type:"minecraft:plains"}}')
for _ in range(120):
guest.wait(10)
seen['opened'] = seen['opened'] or state_of(guest, lower, 'open') == 'true'
host('command', command=farmer)
if host.raw('block', *marker).get('block') == 'minecraft:gold_block':
seen['farmer'] = True
break
seen['closed'] = wait_state(guest, lower, 'open', 'false', 200) == 'false'
seen['server_door'] = state_of(host, lower, 'open')
guest.raw('look-at', f'{lower[0] + 0.5:.2f}', f'{lower[1] + 1:.2f}', f'{lower[2] + 0.5:.2f}')
guest.wait(10)
guest.shot(out, 'doors')
finally:
host('command', command=f'kill @e[type=minecraft:villager,tag={tag}]')
host('command', command=f'fill {x - 3} {y} {z - 3} {x + 9} {y + 3} {z + 3} minecraft:air')
host('command', command='time set day')
assert seen['opened'], f'the villager never opened its door: {seen}'
assert seen['farmer'], f'the villager did not take the composter outside: {seen}'
assert seen['closed'] and seen['server_door'] == 'false', f'the villager left its door open: {seen}'
return seen
def check_light(host, guest, out):
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
results = {}
# A torch near the guest: the guest lights it itself, as vanilla's client does.
torch = (gx + 3, gy, gz + 3)
host('command', command=f'setblock {torch[0]} {torch[1]} {torch[2]} minecraft:torch')
try:
results['torch'] = same_light(host, guest, (gx - 12, gy - 1, gz - 12), (gx + 16, gy + 3, gz + 16), 'torch')
finally:
host('command', command=f'setblock {torch[0]} {torch[1]} {torch[2]} minecraft:air')
# A roof over the guest, then a hole in it: sky light falls through.
roof = f'{gx - 3} {gy + 4} {gz - 3} {gx + 3} {gy + 4} {gz + 3}'
box = (gx - 6, gy - 1, gz - 6), (gx + 6, gy + 5, gz + 6)
host('command', command=f'fill {roof} minecraft:stone')
try:
results['roof'] = same_light(host, guest, *box, 'roof')
host('command', command=f'setblock {gx} {gy + 4} {gz} minecraft:air')
results['hole'] = same_light(host, guest, *box, 'hole in the roof')
finally:
host('command', command=f'fill {roof} minecraft:air')
# The last chunk the guest tracks eastwards, and glowstone high up just past it in a chunk it
# doesn't track: the guest never sees that block, only the light updates of its border chunk.
y = 200
row = guest.raw('light', gx, y, gz, gx + 16 * 24, y, gz)['sky']
assert '.' in row, 'the guest has every chunk 24 chunks east'
untracked = (gx + row.index('.')) >> 4
near = untracked * 16 + 1
# The host's own game is a client too, sent the same light updates.
host('command', command=f'setblock {near} {y} {gz} minecraft:glowstone')
try:
lo, hi = (untracked * 16 - 16, y - 3, gz - 8), (untracked * 16 - 1, y + 3, gz + 8)
# On the server thread the host's game is a client too, as dark as the guest until the light
# update arrives: wait for it before comparing them.
for _ in range(10):
lit = guest.raw('light', near - 2, y, gz, near - 2, y, gz)['block']
if lit == 'd':
break
host.wait(10)
assert lit == 'd', f'the border chunk by the glowstone has block light {lit}, not 13, after 100 ticks'
results['border'] = same_light(host, guest, lo, hi, 'border chunk')
finally:
host('command', command=f'setblock {near} {y} {gz} minecraft:air')
results['border_cleared'] = same_light(host, guest, lo, hi, 'border chunk without the glowstone')
return results
def check_fireball(host, guest, out):
x, y, z = (int(v // 1) for v in pos_of(host))
fx, fz = x + 4, z
# A stone floor, a dispenser facing down three blocks over it, a fire charge in it, powered.
host('command', command=f'fill {fx - 2} {y - 1} {fz - 2} {fx + 2} {y - 1} {fz + 2} minecraft:stone')
host('command', command=f'fill {fx - 2} {y} {fz - 2} {fx + 2} {y + 4} {fz + 2} minecraft:air')
host('command', command=f'setblock {fx} {y + 3} {fz} minecraft:dispenser[facing=down]')
host('command', command=f'item replace block {fx} {y + 3} {fz} container.0 with minecraft:fire_charge')
seen, gone = {}, None
try:
host('command', command=f'setblock {fx} {y + 4} {fz} minecraft:redstone_block')
host.wait(10)
seen = {'host': host.raw('block', fx, y, fz).get('block'),
'guest': guest.raw('block', fx, y, fz).get('block')}
# FireBlock.tick every 30-39 ticks ages it by one a third of the time; past age 3 it goes.
for waited in range(40, 1240, 40):
host.wait(40)
if host.raw('block', fx, y, fz).get('block') == 'minecraft:air':
gone = waited
break
guest.wait(5)
seen['guest_after'] = guest.raw('block', fx, y, fz).get('block')
finally:
host('command', command=f'fill {fx - 2} {y} {fz - 2} {fx + 2} {y + 4} {fz + 2} minecraft:air')
assert seen.get('host') == 'minecraft:fire' and seen.get('guest') == 'minecraft:fire', \
f'no fire where the fire charge landed: {seen}'
assert gone is not None, f'the fire on stone never went out (1200 ticks): {seen}'
assert seen['guest_after'] == 'minecraft:air', f'the guest still sees the fire: {seen}'
return {'out_after_ticks': gone, **seen}
def check_deflect(host, guest, out):
"""The host punches a ghast's fireball back: it flies along the host's view and kills the ghast
(Player.attack: AIM_DEFLECT on #redirectable_projectile; Ghast.isReflectedFireball). The ghast is caged
(a window toward the host); the game runs at 4 ticks a second, and once the fireball is within 4.5 blocks
the host looks at the ghast and clicks."""
back = pos_of(host)
x, y, z = (int(v // 1) for v in back)
guest('command', command='gamemode creative')
host('command', command='gamemode survival')
host('command', command='difficulty normal')
gx, gz = x, z + 30
gy = y + 20
host('command', command=f'fill {gx - 3} {gy - 1} {gz - 3} {gx + 24} {gy - 1} {gz + 3} minecraft:glass')
host('command', command=f'fill {gx - 3} {gy} {gz - 3} {gx + 24} {gy + 8} {gz + 3} minecraft:air')
host('command', command=f'tp @s {gx + 0.5} {gy} {gz + 0.5} -90 0')
host.wait(20)
host.raw('hurtlog')
# The ghast in a glass cage it can't drift out of, a 3 x 3 window toward the host.
host('command', command=f'fill {gx + 13} {gy} {gz - 3} {gx + 19} {gy + 6} {gz + 3} minecraft:glass hollow')
host('command', command=f'fill {gx + 13} {gy + 2} {gz - 1} {gx + 13} {gy + 4} {gz + 1} minecraft:air')
host('command', command=f'summon minecraft:ghast {gx + 16.5} {gy + 1} {gz + 0.5}')
eye = (gx + 0.5, gy + 1.62, gz + 0.5)
host('command', command='tick rate 4')
turned, near, ghast, hurt = None, None, None, []
try:
deadline = time.monotonic() + 120
while time.monotonic() < deadline:
# Aim and click at the projectile the client actually sees. Holding
# non-player simulation keeps the reach window open while ordinary
# player input and its attack packet cross the connection.
balls = [q for q in host('status')['projectiles'] if q['kind'] == 'LargeFireball']
near = min((math.dist(q['position'], eye) for q in balls), default=None)
if near is not None and near < 4.5:
ball = min(balls, key=lambda q: math.dist(q['position'], eye))
host('command', command='tick freeze')
held_tick = host.raw('simulation')['game_time']
g = [m['pos'] for m in host.raw('mobs', 60).get('near', []) if m['kind'].endswith('ghast')][0]
host.script([f'look-at {g[0]:.2f} {g[1] + 2:.2f} {g[2]:.2f}',
'wait 1', 'mouse left click', 'wait 1'])
host.until(lambda: host.raw('simulation')['projectiles'],
lambda qs: any(q.get('id') == ball['id'] and q.get('player_owned') for q in qs),
ticks=60)
assert host.raw('simulation')['game_time'] == held_tick, 'the deflection barrier advanced simulation'
host('command', command='tick unfreeze')
turned = near
break
host('command', command='tick rate 20')
host.wait(60)
ghast = [m for m in host.raw('mobs', 60).get('near', []) if m['kind'].endswith('ghast') and m['health'] > 0]
hurt = [h for pl in host.raw('hurtlog')['players'] if pl['name'] == host.player for h in pl['hurts']
if 'fireball' in h['source'] or 'explosion' in h['source']]
finally:
host('command', command='tick unfreeze')
host('command', command='tick rate 20')
host('command', command='kill @e[type=minecraft:ghast]')
host('command', command=f'tp @s {back[0]} {back[1]} {back[2]}')
host.until(lambda: pos_of(host), lambda p: math.dist(p, back) < 0.1)
host('command', command=f'fill {gx - 3} {gy - 1} {gz - 3} {gx + 24} {gy + 8} {gz + 3} minecraft:air')
guest('command', command='gamemode survival')
assert turned is not None, f'no fireball came within 4.5 blocks (nearest {near})'
assert not hurt, f'the host was hit: {hurt}'
assert not ghast, f'the ghast lived: the fireball was not turned back ({ghast})'
return {'struck_at': round(turned, 2)}
def check_piglin_offspring(host, guest, out):
"""SpawnEggItem.spawnOffspringFromSpawnEgg constructs a baby piglin without adult gear (#5867)."""
x, y, z = (int(v // 1) for v in pos_of(host))
for game in (host, guest):
game('command', command='gamemode creative')
host('command', command=f'tp @s {x - 4.5} {y} {z + 0.5}')
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} -90 0')
host('command', command=f'summon minecraft:piglin {x + 2.5} {y} {z + 0.5} '
'{NoAI:1b,IsImmuneToZombification:1b,Tags:["egg_parent"]}')
try:
guest('command', command='item replace entity @s weapon.mainhand with minecraft:piglin_spawn_egg')
guest.wait(20)
# The suite clears natural terrain on startup, which leaves items a
# newborn piglin can legitimately equip as soon as it is created.
host('command', command='kill @e[type=minecraft:item]')
guest.raw('look-at', x + 2.5, y + 1, z + 0.5)
guest.raw('use-item')
guest.wait(20)
seen = []
for game in (host, guest):
babies = [m for m in game.raw('mobs', 20)['near']
if m['kind'] == 'minecraft:piglin' and m['baby']]
assert len(babies) == 1, f'expected one baby piglin on {game.player}: {babies}'
assert babies[0]['held'] is None, f"baby piglin has adult gear: {babies[0]}"
seen.append(babies[0]['id'])
assert seen[0] == seen[1], f'clients disagree about the baby piglin: {seen}'
guest.shot(out, 'piglin_offspring')
return {'baby': seen[0], 'held': None}
finally:
host('command', command=f'kill @e[type=minecraft:piglin,x={x - 5},y={y - 2},z={z - 5},dx=15,dy=8,dz=10]')
guest('command', command='clear @s')
for game in (host, guest):
game('command', command='gamemode survival')
def check_barter(host, guest, out):
"""The guest, in gold boots, drops a gold ingot to a piglin: the piglin stays calm, takes it, and throws its
barter toward the nearest player, the guest (PiglinAi.throwItemsTowardPlayer: NEAREST_VISIBLE_PLAYER), who
picks it up. The host is nearby on the other side, so a barter thrown at the host misses the guest. A glass
corridor (the hollow box) keeps the piglin between them."""
x, y, z = (int(v // 1) for v in pos_of(host))
cz = z + 4
host('command', command='gamemode creative')
guest('command', command='gamemode survival')
guest('command', command='clear @s')
guest('command', command='effect give @s minecraft:instant_health 1 5')
host('command', command=f'fill {x + 1} {y - 1} {cz - 1} {x + 8} {y + 2} {cz + 1} minecraft:glass hollow')
host('command', command=f'fill {x + 2} {y - 1} {cz} {x + 7} {y - 1} {cz} minecraft:stone')
host('command', command=f'tp @s {x - 0.5} {y} {cz + 0.5} -90 0')
guest('command', command=f'tp @s {x + 7.3} {y} {cz + 0.5} 90 30')
guest('command', command='item replace entity @s armor.feet with minecraft:golden_boots')
guest('command', command='give @s minecraft:gold_ingot 1')
host('command', command=f'summon minecraft:piglin {x + 4.5} {y} {cz + 0.5} {{IsImmuneToZombification:1b}}')
guest.wait(20)
before = remote(guest)['health']
kept = dict(guest('inventory')['counts'])
try:
guest.raw('select', 'minecraft:gold_ingot')
guest.raw('key', 'q', 'tap')
bought, trace = {}, []
for _ in range(20):
guest.wait(20)
trace.append([(e['type'].split(':')[-1], round(e['pos'][0] - x, 1), round(e['pos'][2] - cz, 1))
for e in host.raw('entities', 24)['entities'] if e['type'] in ('minecraft:item', 'minecraft:piglin')])
counts = guest('inventory')['counts']
bought = {k: n - kept.get(k, 0) for k, n in counts.items()
if k not in ('minecraft:gold_ingot', 'minecraft:golden_boots') and n > kept.get(k, 0)}
if bought:
guest.wait(40)
break
counts = guest('inventory')['counts']
bought = {k: n - kept.get(k, 0) for k, n in counts.items()
if k not in ('minecraft:gold_ingot', 'minecraft:golden_boots') and n > kept.get(k, 0)}
piglins = [m for m in host.raw('mobs', 20).get('near', []) if m['kind'].split(':')[-1] == 'piglin']
health = remote(guest)['health']
finally:
host('command', command=f'kill @e[type=minecraft:piglin,x={x},y={y - 2},z={cz - 2},dx=10,dy=6,dz=4]')
# What lies on the floor, on the host's side of the piglin's start and on the guest's.
floor = [host('command', command=f'kill @e[type=minecraft:item,x={x0},y={y - 2},z={cz - 3},dx={dx},dy=6,dz=6]')
['chat'][0] for x0, dx in ((x - 2, 6.5), (x + 4.5, 6))]
host('command', command=f'fill {x + 1} {y - 1} {cz - 1} {x + 8} {y + 2} {cz + 1} minecraft:air')
host('command', command=f'fill {x - 8} {y - 1} {z - 8} {x + 8} {y - 1} {z + 8} minecraft:stone')
guest('command', command='clear @s')
host('command', command='gamemode survival')
assert piglins, 'the piglin is gone'
assert health >= before, f'the piglin hurt the guest in gold boots: {before} -> {health}'
assert bought, f'no barter reached the guest (on the floor, host side then guest side: {floor}; items and the piglin, every second: {trace})'
return {'bought': bought, 'floor': floor}
def check_worn(host, guest, out):
pieces = ['iron_helmet', 'iron_chestplate', 'iron_leggings', 'iron_boots']
guest('command', command='gamemode survival')
taken = {}
try:
for worn in (False, True):
guest('command', command='clear @s')
if remote(guest)['health'] <= 0:
guest.raw('respawn')
guest.wait(40)
if worn:
for piece in pieces:
guest('command', command=f'give @s minecraft:{piece}')
guest.wait(10)
# Shift-clicked from the hotbar onto the body in the inventory menu (the hub moves them).
guest.raw('key', 'e', 'tap')
guest.raw('wait-for', 'screen', 'inventory')
for piece in pieces:
guest.raw('click-slot', hotbar_slot(guest, f'minecraft:{piece}'), 'shift')
guest.wait(2)
guest.raw('key', 'esc', 'tap')
guest.wait(5)
guest('command', command='effect give @s minecraft:instant_health 1 5')
guest.wait(30)
before = remote(guest)['health']
# A mob's blow (no attacker: no difficulty scaling); armour counts against it.
guest('command', command='damage @s 10 minecraft:mob_attack')
# The server's hurt log isn't on a control socket here: the health the guest is
# sent, read as soon as it drops (natural regeneration heals it again after).
health = before
for _ in range(40):
guest.wait(1)
health = remote(guest)['health']
if health < before:
break
taken['worn' if worn else 'bare'] = [round(before - health, 2)]
finally:
guest('command', command='clear @s')
guest('command', command='effect give @s minecraft:instant_health 1 5')
# Natural regeneration may heal up to a point before the health arrives.
assert len(taken['bare']) == 1 and 9.0 <= taken['bare'][0] <= 10.0, f'10 damage on no armour: {taken}'
assert len(taken['worn']) == 1 and 5.0 <= taken['worn'][0] <= 6.0, \
f'10 damage through iron armour (15) should take 6: {taken}'
return taken
CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour,
'nametags': check_nametags, 'chest': check_chest, 'menu-transfer': check_menu_transfer, 'crafting': check_crafting,
'spawned': check_spawned,
'sign': check_sign, 'damage': check_damage, 'mobs': check_mobs,
'flow-push': check_flow_push,
'bubble-column': check_bubble_column,
'hazards': check_hazards, 'pvp': check_pvp, 'deaths': check_deaths, 'respawn': check_respawn,
'containers': check_containers, 'names': check_names, 'feedback': check_feedback, 'titles': check_titles,
'chat': check_chat, 'styles': check_styles, 'machines': check_machines, 'brewing': check_brewing,
'loom': check_loom, 'stonecutter': check_stonecutter, 'grindstone': check_grindstone,
'anvil': check_anvil, 'smithing': check_smithing, 'enchanting': check_enchanting,
'beacon': check_beacon, 'crafter': check_crafter, 'lectern': check_lectern,
'cartography': check_cartography, 'hurt': check_hurt, 'commands': check_commands,
'recipes': check_recipes, 'trials': check_trials, 'walls': check_walls,
'effects': check_effects, 'portals': check_portals, 'distance': check_distance,
'clicks': check_clicks, 'shift-double-click': check_shift_double_click, 'recipe-shift': check_recipe_shift,
'shift-drag': check_shift_drag, 'wheel': check_wheel, 'sleep': check_sleep, 'sleep-villager': check_sleep_villager,
'modifier-release': check_modifier_release,
'bed-occupied': check_bed_occupied, 'sleep-reload': check_sleep_reload,
'push': check_push, 'piston-push': check_piston_push, 'piston-entities': check_piston_entities,
'piston-slime': check_piston_slime,
'eat': check_eat, 'eat-on-block': check_eat_on_block, 'cauldron': check_cauldron, 'eating': check_eating, 'xp': check_xp,
'objects': check_objects, 'projectiles': check_projectiles, 'blast': check_blast, 'blast-projectiles': check_blast_projectiles,
'light': check_light, 'plates': check_plates, 'plate-projectiles': check_plate_projectiles, 'swings': check_swings,
'cracks': check_cracks, 'time': check_time, 'growth': check_growth, 'doors': check_doors,
'flight': check_flight, 'fireball': check_fireball, 'deflect': check_deflect,
'barter': check_barter, 'piglin_offspring': check_piglin_offspring,
'worn': check_worn, 'kills': check_kills, 'looting': check_looting, 'leave': check_leave,
'arrows': check_arrows, 'projectile-vehicles': check_projectile_vehicles, 'flame-arrows': check_flame_arrows, 'predict': check_predict, 'ladder': check_ladder,
'boats': check_boats, 'boat_ice_reboard': check_boat_ice_reboard, 'boat_shoved': check_boat_shoved, 'minecarts': check_minecarts, 'frames': check_frames, 'hands': check_hands,
'lightning': check_lightning, 'riding': check_riding, 'occupied_horse': check_occupied_horse, 'mounts': check_mounts,
'worldspawn': check_world_spawn, 'beds': check_beds, 'shield': check_shield, 'deathmining': check_death_mining,
'contacts': check_contacts, 'absorption': check_absorption, 'breath': check_breath,
'lava': check_lava, 'levels': check_levels, 'drops': check_drops,
'underwater': check_underwater,
'looks': check_looks,
'leads': check_leads, 'entity-tags': check_entity_tags,
'thrown': check_thrown,
'sparks': check_sparks,
'hud': check_hud,
'sidebar': check_sidebar,
'border': check_border,
'particles': check_particles, 'fireworks': check_fireworks,
'rocket-walls': check_rocket_walls, 'rocket-ceilings': check_rocket_ceilings,
'piston-slide': check_piston_slide,
'tag-query': check_tag_query}
def check_raid_captain_fields(host, guest, out):
"""Real first-wave leader initialization, observed through saved fields (#7849)."""
def command(text):
return server_reply(host, text)
def game_time():
text = server_reply(host, 'time query gametime', 'time is')
return int(re.search(r'time is (\d+)', text).group(1))
def poll(observe, predicate, label, seconds=90):
deadline = time.monotonic() + seconds
while True:
value = observe()
if predicate(value):
return value
assert time.monotonic() < deadline, (label, value)
time.sleep(0.05)
def test(text):
line = server_reply(host, text, 'Test ')
assert 'Test passed' in line or 'Test failed' in line, line
return 'Test passed' in line
def saved(target, path):
line = command(f'data get entity {target} {path}')
assert 'No entity was found' not in line, (target, path, line)
if 'Found no elements matching' in line:
return None
prefix = 'has the following entity data: '
assert prefix in line, (target, path, line)
return line.split(prefix, 1)[1]
positions = [(g, pos_of(g)) for g in (host, guest)]
rule_names = ['spawn_mobs', 'spawn_patrols', 'advance_time', 'mob_griefing', 'mob_drops']
rules = {}
for name in rule_names:
line = command(f'gamerule {name}')
value = re.search(r'\b(true|false)\b', line)
assert value, line
rules[name] = value.group(1)
try:
for g in (host, guest):
g('command', command='gamemode creative')
g('command', command='effect clear @s')
for text in ['difficulty normal', 'gamerule spawn_mobs false',
'gamerule spawn_patrols false', 'gamerule advance_time false',
'gamerule mob_griefing false', 'gamerule mob_drops false',
'time set noon', 'forceload add -128 -128 127 127']:
command(text)
for x, z in [(-128, -128), (-128, 127), (127, -128), (127, 127)]:
poll(lambda: test(f'execute if loaded {x} 199 {z}'), bool, 'loaded raid floor')
for x0, x1 in [(-128, -1), (0, 127)]:
for z0, z1 in [(-128, -1), (0, 127)]:
line = command(f'fill {x0} 199 {z0} {x1} 199 {z1} stone')
assert 'filled' in line.lower(), line
for text in ['fill -3 199 -3 5 203 5 glass hollow',
'setblock 2 200 2 red_bed[facing=south,part=head]',
'setblock 2 200 1 red_bed[facing=south,part=foot]',
'summon villager 0.5 200 0.5 {PersistenceRequired:1b,NoAI:0b,Tags:["h163_village"]}',
'tick unfreeze', 'tick rate 200']:
command(text)
start = game_time()
prepared = poll(game_time, lambda t: t >= start + 300, 'bed-claim ticks')
command('tick rate 20')
command('tick freeze')
frozen = game_time()
for x in (4.5, 3.5):
guest('command', command=f'tp @s {x} 200 3.5')
guest.wait(5)
line = server_reply(guest, 'data get entity @s Pos', f'[{x}d, 200.0d, 3.5d]')
assert f'[{x}d, 200.0d, 3.5d]' in line, line
command('tick rate 1000')
omen = f'execute if entity @a[name={GUEST_NAME},nbt={{active_effects:[{{id:"minecraft:raid_omen"}}]}}]'
guest('command', command='effect give @s minecraft:bad_omen 100 0')
poll(lambda: test(omen), bool, 'Raid Omen appeared', seconds=60)
poll(lambda: test(omen), lambda present: not present, 'Raid Omen expired', seconds=60)
assert game_time() == frozen, 'omen processing must leave world ticks frozen'
elapsed = 0
for ticks in (1, 149, 150):
command(f'tick step {ticks}')
elapsed += ticks
actual = poll(game_time, lambda t: t >= frozen + elapsed, 'completed raid step')
assert actual == frozen + elapsed, (ticks, actual, frozen + elapsed)
captain = '@e[nbt={PatrolLeader:1b},limit=1]'
line = server_reply(host, 'execute if entity @e[nbt={PatrolLeader:1b}]', 'Test ')
assert 'Count: 1' in line, line
for tag, extra in [('h163_ordinary', ''), ('h163_explicit', ',drop_chances:{head:2.0f}')]:
command('summon pillager 4.5 200 4.5 '
f'{{NoAI:1b,NoGravity:1b,PersistenceRequired:1b,Tags:["{tag}"]{extra}}}')
rows = {}
for name, target in [('captain', captain), ('ordinary', '@e[tag=h163_ordinary,limit=1]'),
('explicit', '@e[tag=h163_explicit,limit=1]')]:
exists = test(f'execute if entity {target}')
assert exists, name
head, chance = saved(target, 'equipment.head'), saved(target, 'drop_chances')
present = test(f'execute as {target} if data entity @s drop_chances.head')
has_chance = name != 'ordinary'
checks = {'exists': exists,
'head': (head is not None and 'white_banner' in head and 'banner_patterns' in head)
if name == 'captain' else head is None,
'chance': chance == '{head: 2.0f}' if has_chance else chance is None,
'presence': present == has_chance}
rows[name] = {'head': head, 'drop_chances': chance, 'presence': present, 'checks': checks}
failures = [f'{name}/{key}' for name, row in rows.items()
for key, passed in row['checks'].items() if not passed]
detail = {'passed': 12 - len(failures), 'observations': 12, 'failures': failures,
'bed_claim_ticks': prepared - start, 'raid_ticks': elapsed, 'subjects': rows}
(out / 'raid-captain-fields.json').write_text(json.dumps(detail, indent=2) + '\n')
assert not failures, detail
return detail
finally:
for text in ['effect clear @a', 'tick rate 20', 'tick unfreeze',
'kill @e[type=pillager]', 'kill @e[tag=h163_village]', 'forceload remove all']:
host('command', command=text)
for name, value in rules.items():
host('command', command=f'gamerule {name} {value}')
for game, at in positions:
game('command', command='tp @s ' + ' '.join(str(v) for v in at))
game('command', command='gamemode survival')
def run(ctl, out, resources, checks):
host, guest = Game('host', ctl, resources), Game('guest', ctl, resources)
# A headless run (HORIZON_E2E_HEADLESS, or a Linux without vscreen) has no focused window to draw
# in: the games render offscreen and never pause on the focus loss, or the world would stand still
# (a host paused before its world loads waits at "Connecting to the server..." for ever).
headless = bool(os.environ.get('HORIZON_E2E_HEADLESS')) or (
sys.platform.startswith('linux') and not shutil.which('vscreen'))
launch = ['launch']
if headless or sys.platform == 'darwin':
launch.append('--offscreen')
if headless:
launch.append('--no-pause')
results = {}
try:
if 'recipes-hmod' in checks:
prepare_recipes_hmod(host, guest)
host.pid = host.raw(*launch)['pid']
# Guests' commands need the world's Allow Commands as well as the LAN's (#372): a creative
# world has them; everyone plays in survival.
host.raw('open', 'Lan', 5, 'creative')
host('wait_ready', seconds=400)
host('command', command='defaultgamemode survival')
host('command', command='gamemode survival')
host('command', command='time set day')
# An open stone floor around the host, so the players see each other and nothing is in the way.
host('command', command='fill ~-8 ~-1 ~-8 ~8 ~-1 ~8 minecraft:stone')
host('command', command='fill ~-8 ~ ~-8 ~8 ~6 ~8 minecraft:air')
host('command', command='gamerule spawn_mobs false')
port = host.raw('lan', 'open', 0, 'commands')['port']
host.player = host.raw('lan', 'status').get('host', 'Player') # the host's profile name
guest.pid = guest.raw(*launch)['pid']
guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME)
guest.raw('wait-for', 'loaded', '--timeout', 300)
guest.wait(40)
# Where each joined, before anything moves them (check_spawned).
host.joined_at, guest.joined_at = pos_of(host), pos_of(guest)
face_each_other(host, guest)
host.shot(out, 'joined')
guest.shot(out, 'joined')
for name in checks:
try:
# Cases assert their own deaths, menus and held actions before returning.
# Do not let those states turn later independent cases into false failures.
for game in (host, guest):
game.raw('release')
back_to_game(game)
state = game('state')
assert state.get('player') is not None, f'{game.name} lost its session before {name}: {state}'
if state.get('screen') == 'dead' or state['player']['health'] <= 0:
game.raw('respawn')
game.until(lambda: game('state'),
lambda s: s.get('screen') == 'playing' and s['player']['health'] > 0,
ticks=40)
detail = CHECK_FNS[name](host, guest, out)
skipped = isinstance(detail, str) and detail.startswith('skipped')
results[name] = {'pass': not skipped, 'skipped': skipped, 'detail': detail}
except (Failed, AssertionError, StopIteration, KeyError, TypeError, IndexError) as e:
results[name] = {'pass': False, 'detail': repr(e)}
for g in (host, guest):
back_to_game(g)
print(f'[lan] {name}: {"PASS" if results[name]["pass"] else "FAIL"} {results[name]["detail"]}')
finally:
logs = {g.name: g.close() for g in (guest, host)}
for name, text in logs.items():
(out / f'{name}.log').write_text(text)
for g in (host, guest):
shutil.rmtree(g.work, ignore_errors=True)
return results
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--bin-dir', help='prebuilt horizon-client and horizon-ctl (default: build with the fast profile)')
parser.add_argument('--check', action='append', choices=CHECKS, help='run only these checks (repeatable)')
parser.add_argument('--out', default=str(ROOT / '.local/lan-e2e'), help='screenshots and game logs go here')
# The host's world always runs on its server thread; the flag stays for existing callers.
parser.add_argument('--server-thread', action='store_true', help=argparse.SUPPRESS)
args = parser.parse_args()
if args.bin_dir:
bins = Path(args.bin_dir).resolve()
else:
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '-p', 'horizon-client-lib',
'--bin', 'horizon-client', '--bin', 'horizon-ctl'],
cwd=ROOT, check=True)
meta = json.loads(subprocess.run(['cargo', 'metadata', '--format-version', '1', '--no-deps'], cwd=ROOT,
capture_output=True, text=True, check=True).stdout)
bins = Path(meta['target_directory']) / 'fast'
resources = lab.RESOURCES.resolve()
if not (resources / 'horizon-resources.json').exists():
sys.exit(f'no prepared resources in {resources}')
try:
require_server_reports(resources)
except ValueError as error:
sys.exit(str(error))
out = Path(args.out).resolve()
out.mkdir(parents=True, exist_ok=True)
# Recipes is an opt-in native mod; this check requires a game built with it.
checks = args.check or [name for name in CHECKS if name != 'recipes-hmod']
results = run(bins / 'horizon-ctl', out, resources, checks)
(out / 'results.json').write_text(json.dumps({'checks': results}, indent=2) + '\n')
print(json.dumps(results, indent=1))
sys.exit(0 if results and all(r['pass'] for r in results.values()) else 1)
# Checks added after the list above (kept apart so open branches that extend it don't conflict).
def heard(game, sound):
return game('status')['heard'].get(sound, 0)
def check_totem(host, guest, out):
"""LivingEntity.checkTotemDeathProtection on the server for each player: 100 damage holding a
totem in the off hand, then in the main hand, uses the totem up, leaves 1 health with
Regeneration II, Absorption II and Fire Resistance, and broadcasts entity event 35, which both
clients play once (#1292)."""
sound = 'minecraft:item.totem.use'
seen = {}
for game in (host, guest):
for hand in ('offhand', 'mainhand'):
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='effect clear @s')
game('command', command='effect give @s minecraft:instant_health 1 5 true')
game('command', command=f'item replace entity @s weapon.{hand} with minecraft:totem_of_undying')
game.wait(10)
before = {g.name: heard(g, sound) for g in (host, guest)}
game('command', command='damage @s 100 minecraft:generic')
game.wait(10)
me = own(game)
case = f'{game.name}-{hand}'
seen[case] = {
'screen': game('state').get('screen'),
'health': me['health'],
'server_health': server_player(host, game)['health'],
'effects': sorted(effect_ids(game)),
'totems': server_count(game, 'totem_of_undying'),
'heard': {g.name: heard(g, sound) - before[g.name] for g in (host, guest)},
}
game.shot(out, f'totem-{hand}')
game('command', command='effect clear @s')
for case, s in seen.items():
# 1 health, and what Regeneration II and the full food bar gave back since.
assert s['screen'] == 'playing' and 0 < s['health'] < 5 and 0 < s['server_health'] < 5, f'{case}: {seen}'
assert s['effects'] == ['absorption', 'fire_resistance', 'regeneration'], f'{case}: {seen}'
assert s['totems'] == 0 and s['heard'] == {'host': 1, 'guest': 1}, f'{case}: {seen}'
return seen
def check_keep_inventory(host, guest, out):
"""ServerPlayer.die's dropAllDeathLoot for each player (Player.dropEquipment): with keep_inventory
off, the diamonds and the helmet a player dies with lie where it died and it respawns with
neither; with it on, nothing drops and it respawns with both (#1284)."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
seen = {}
# Totem coverage leaves both players near one health. Protect the waiting
# player from residual fire/damage while the other player's death is tested.
for game in (host, guest):
game('command', command='gamemode creative')
game('command', command='effect clear @s')
game('command', command='effect give @s minecraft:instant_health 1 5 true')
host('command', command=f'setworldspawn {hx} {hy} {hz}')
host('command', command='gamerule respawn_radius 0')
# Keep the inactive player alive and away from the death drops. Earlier
# fixtures may leave a personal respawn point or an unsafe waiting position.
host('command', command=f'fill {hx - 6} {hy - 1} {hz - 6} {hx + 6} {hy - 1} {hz + 6} minecraft:stone')
host('command', command=f'fill {hx - 6} {hy} {hz - 6} {hx + 6} {hy + 3} {hz + 6} minecraft:air')
for game, dz in ((host, -3), (guest, 3)):
waiting = (hx - 3.5, hy, hz + dz + 0.5)
game('command', command=f'spawnpoint @s {hx} {hy} {hz}')
game('command', command=f'tp @s {waiting[0]} {waiting[1]} {waiting[2]}')
game.until(lambda: pos_of(game), lambda p: math.dist(p, waiting) < 0.1)
try:
for keep in ('false', 'true'):
host('command', command=f'gamerule keep_inventory {keep}')
for game, dz in ((host, -3), (guest, 3)):
# 14 blocks from the world spawn: nothing dropped is picked up on respawning.
x, z = hx + 14, hz + dz
# Death drops scatter several blocks before landing. Keep the
# entire observation area solid so they cannot fall off a ledge.
host('command', command=f'fill {x - 6} {hy - 1} {z - 6} {x + 6} {hy - 1} {z + 6} minecraft:stone')
host('command', command=f'fill {x - 6} {hy} {z - 6} {x + 6} {hy + 3} {z + 6} minecraft:air')
host('command', command='kill @e[type=minecraft:item]')
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='give @s minecraft:diamond 7')
game('command', command='item replace entity @s armor.head with minecraft:iron_helmet')
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5}')
game.wait(20)
assert game('state').get('screen') == 'playing', f'{game.name} died during setup'
game('command', command='kill @s')
game.wait(30)
assert game('state').get('screen') == 'dead', f'{game.name} did not die'
dropped = sorted((e['item']['item'], e['item']['count'])
for e in game.raw('entities', 8, 'minecraft:item')['entities'])
game.shot(out, f'keepinv-{keep}')
respawn_when_ready(game)
game.wait(40)
back_to_game(game)
seen[f'{game.name}-keep-{keep}'] = {
'dropped': dropped,
'kept': {item: server_count(game, item) for item in ('diamond', 'iron_helmet')}}
game('command', command='gamemode creative')
finally:
host('command', command='gamerule keep_inventory false')
host('command', command='gamerule respawn_radius 10')
host('command', command='kill @e[type=minecraft:item]')
for game in (host, guest):
game('command', command='clear @s')
game('command', command='gamemode survival')
face_each_other(host, guest)
for case, s in seen.items():
want = ({'dropped': [], 'kept': {'diamond': 7, 'iron_helmet': 1}} if case.endswith('true') else
{'dropped': [('minecraft:diamond', 7), ('minecraft:iron_helmet', 1)],
'kept': {'diamond': 0, 'iron_helmet': 0}})
assert s == want, f'{case}: {seen}'
return seen
def check_signs(host, guest, out):
"""SignBlock.openTextEdit on the placing player's client, for the host and the guest: placing
an oak sign opens its editor there and nowhere else, the lines typed into it go to the server
when it closes, and both sides then show them (#1380)."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
seen = {}
for game, other, dz in ((host, guest, -2), (guest, host, 2)):
spot = (hx + 2, hy, hz + dz)
for viewer in (game, other):
back_to_game(viewer)
# Earlier death/travel checks leave the players at different spawn points.
# Give this placement case its own floor, line of sight and reachable target.
host('command', command=f'fill {spot[0] - 3} {hy - 1} {spot[2] - 1} {spot[0] + 3} {hy - 1} {spot[2] + 1} minecraft:stone')
host('command', command=f'fill {spot[0] - 3} {hy} {spot[2] - 1} {spot[0] + 3} {hy + 3} {spot[2] + 1} minecraft:air')
for viewer, dx in ((game, -2), (other, 2)):
at = (spot[0] + dx + 0.5, hy, spot[2] + 0.5)
viewer('command', command=f'tp @s {at[0]} {at[1]} {at[2]}')
viewer.until(lambda: pos_of(viewer), lambda p: math.dist(p, at) < 0.1)
game('command', command=f'setblock {spot[0]} {spot[1] - 1} {spot[2]} minecraft:stone')
game('command', command=f'setblock {spot[0]} {spot[1]} {spot[2]} minecraft:air')
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='give @s minecraft:oak_sign 1')
game.wait(10)
game.raw('place', *spot, '--item', 'minecraft:oak_sign')
game.until(lambda: game('state').get('screen'), lambda s: s == 'sign', ticks=10)
# Drain the placement's initial block-entity data before typing. The
# reply travels on the same connection after that data, unlike a local
# screen check which may see the editor before the initial data arrives.
server_reply(game, f'data get block {spot[0]} {spot[1]} {spot[2]} front_text.messages',
'following block data')
opened = {g.name: g('state').get('screen') for g in (game, other)}
game.shot(out, 'signs-editor')
lines = [f'{game.name} wrote', 'line two']
game.raw('type', lines[0])
game.raw('key', 'enter', 'tap')
game.raw('type', lines[1])
game.raw('key', 'esc', 'tap')
game.until(lambda: game('state').get('screen'), lambda s: s == 'playing', ticks=20)
back_to_game(other)
shown = {g.name: g.until(lambda: (g.raw('block', *spot).get('sign') or {}).get('front'),
lambda front: front == lines + ['', ''], ticks=20)
for g in (game, other)}
seen[game.name] = {'opened': opened,
'shown': shown}
other.shot(out, 'signs-shown')
for name, s in seen.items():
other = 'guest' if name == 'host' else 'host'
want = [f'{name} wrote', 'line two', '', '']
assert s['opened'] == {name: 'sign', other: 'playing'}, f'{name}: {seen}'
assert s['shown'] == {name: want, other: want}, f'{name}: {seen}'
return seen
def check_sign_copy(host, guest, out):
"""A sign copied with Ctrl + Pick Block keeps its text when placed, for the host and the guest:
the client's own BlockItem.place gives the predicted sign the item's sign_text_front
(updateBlockEntityComponents), so the editor that opens shows the copied lines and Done sends
them back unchanged (#8102)."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
lines = ['Line one', 'Line two', 'Line three', 'Line four']
seen = {}
for game, other, dz in ((host, guest, -3), (guest, host, 3)):
for viewer in (game, other):
back_to_game(viewer)
source, copy = (hx + 2, hy, hz + dz), (hx + 4, hy, hz + dz)
host('command', command=f'fill {hx - 1} {hy - 1} {hz + dz - 2} {hx + 6} {hy - 1} {hz + dz + 2} minecraft:stone')
host('command', command=f'fill {hx - 1} {hy} {hz + dz - 2} {hx + 6} {hy + 3} {hz + dz + 2} minecraft:air')
messages = ','.join(f'"{line}"' for line in lines)
host('command', command=f'setblock {source[0]} {source[1]} {source[2]} minecraft:oak_sign[rotation=8]'
f'{{front_text:{{messages:[{messages}]}}}}')
# Two blocks in front of the signs, towards the middle.
at = (hx + 3.5, hy, hz + dz + 0.5 - (2 if dz > 0 else -2))
game('command', command=f'tp @s {at[0]} {at[1]} {at[2]}')
game.until(lambda: pos_of(game), lambda p: math.dist(p, at) < 0.1)
game('command', command='gamemode creative')
game('command', command='clear @s')
game.wait(10)
# Ctrl + middle click: ServerboundPickItemFromBlockPacket with includeData.
game.raw('look-at', 'block', *source)
game.raw('select', 1)
game.raw('key', 'ctrl', 'down')
game.wait(3)
game.raw('mouse', 'middle', 'click')
game.wait(3)
game.raw('key', 'ctrl', 'up')
picked = game.until(lambda: game('inventory').get('held') or {},
lambda h: h.get('item') == 'minecraft:oak_sign', ticks=20)
game.raw('place', *copy)
game.until(lambda: game('state').get('screen'), lambda s: s == 'sign', ticks=10)
game.shot(out, 'sign-copy-editor')
game.raw('key', 'esc', 'tap')
game.until(lambda: game('state').get('screen'), lambda s: s == 'playing', ticks=20)
reply = server_reply(game, f'data get block {copy[0]} {copy[1]} {copy[2]} front_text.messages',
'following block data')
shown = {g.name: g.until(lambda: (g.raw('block', *copy).get('sign') or {}).get('front'),
lambda front: front == lines, ticks=20)
for g in (game, other)}
seen[game.name] = {'picked': picked, 'server': reply, 'shown': shown}
other.shot(out, 'sign-copy-shown')
for name, s in seen.items():
assert all(line in s['server'] for line in lines), f'{name}: {seen}'
assert all(front == lines for front in s['shown'].values()), f'{name}: {seen}'
return seen
CHECKS += ['sign_copy']
CHECK_FNS.update(sign_copy=check_sign_copy)
def book_screen(game):
"""The open book (`book`'s page and page count), once `game` shows it."""
game.raw('wait-for', 'screen', 'book', '--timeout', 10)
return game.raw('book')
def check_books(host, guest, out):
"""Books in hand for the host and the guest: a book and quill opens its editor on the player's
own client (LocalPlayer.openItemGui), what it writes and signs reaches the server
(ServerboundEditBookPacket), and the written book opens on the player's client when the server
says so (ServerPlayer.openItemGui's ClientboundOpenBookPacket); dropped, the other side sees the
server's book (#1366)."""
seen = {}
for game, other in ((host, guest), (guest, host)):
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='item replace entity @s weapon.mainhand with minecraft:writable_book')
x, y, z = pos_of(game)
game.raw('look-at', f'{x + 0.5:.2f}', f'{y + 30:.2f}', f'{z:.2f}')
game.wait(10)
case = seen[game.name] = {}
game.raw('use-item')
case['edit'] = book_screen(game)
case['other_screen'] = other('state').get('screen')
game.raw('type', f'{game.name} page one')
game.shot(out, 'books-edit')
game.raw('book', 'sign')
game.raw('type', f'{game.name} book')
game.raw('book', 'sign')
game.wait(20)
case['signed'] = {item: server_count(game, item) for item in ('writable_book', 'written_book')}
game.raw('use-item')
case['view'] = book_screen(game)
game.shot(out, 'books-view')
game.raw('book', 'done')
game.wait(5)
game.raw('key', 'q', 'tap')
game.wait(30)
books = [e['item'].get('components', '') for e in other.raw('entities', 16, 'minecraft:item')['entities']
if e['item']['item'] == 'minecraft:written_book']
case['dropped'] = [all(f'{game.name} {t}' in c for t in ('page one', 'book')) for c in books]
host('command', command='kill @e[type=minecraft:item]')
for name, case in seen.items():
assert case['edit'] == {'page': 0, 'pages': 1, 'lectern': None}, f'{name}: {seen}'
assert case['other_screen'] == 'playing', f'{name}: {seen}'
assert case['signed'] == {'writable_book': 0, 'written_book': 1}, f'{name}: {seen}'
assert case['view'] == {'page': 0, 'pages': 1, 'lectern': None}, f'{name}: {seen}'
assert case['dropped'] == [True], f'{name}: {seen}'
return seen
CHECKS += ['totem', 'keepinv', 'signs', 'books']
CHECK_FNS.update(totem=check_totem, keepinv=check_keep_inventory, signs=check_signs, books=check_books)
def check_elytra(host, guest, out):
"""LivingEntity.updateFallFlying for each player: a jump while falling with an Elytra on starts a
glide that the server takes too (Player.tryToStartFallFlying), the glide carries the player
forward rather than down, and landing ends it on both sides (canGlide fails on the ground)."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
seen = {}
try:
for game, dx in ((host, -2), (guest, 2)):
x, z = hx + dx, hz
host('command', command=f'fill {x - 1} {hy - 1} {z - 1} {x + 1} {hy - 1} {z + 1} minecraft:stone')
host('command', command=f'fill {x - 1} {hy} {z - 1} {x + 1} {hy + 3} {z + 1} minecraft:air')
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='effect give @s minecraft:resistance 60 4 true')
game('command', command='item replace entity @s armor.chest with minecraft:elytra')
game('command', command=f'tp @s {x + 0.5} {hy + 60} {z + 0.5} 0 0')
game.wait(6)
start = pos_of(game)
game.raw('key', 'space', 'down')
game.wait(1)
game.raw('key', 'space', 'up')
game.wait(30)
end = pos_of(game)
glide = {'client': own(game).get('fall_flying'),
'server': server_player(host, game).get('fall_flying'),
'forward': round(math.hypot(end[0] - start[0], end[2] - start[2]), 1),
'down': round(start[1] - end[1], 1)}
game.shot(out, 'elytra')
game('command', command=f'tp @s {x + 0.5} {hy + 1} {z + 0.5}')
game.wait(30)
landed = {'on_ground': own(game).get('on_ground'),
'client': own(game).get('fall_flying'),
'server': server_player(host, game).get('fall_flying')}
seen[game.name] = {'glide': glide, 'landed': landed}
finally:
for game in (host, guest):
game('command', command='clear @s')
game('command', command='effect clear @s')
face_each_other(host, guest)
for name, s in seen.items():
g, l = s['glide'], s['landed']
# Falling alone covers about 25 blocks in those 30 ticks and goes nowhere.
assert g['client'] and g['server'] and g['forward'] > 8 and g['down'] < 20, f'{name} glide: {seen}'
assert l['on_ground'] and not l['client'] and not l['server'], f'{name} landed: {seen}'
return seen
CHECKS += ['elytra']
CHECK_FNS.update(elytra=check_elytra)
def server_using(host, game):
"""The item the server has the player using ({slot, ticks}), or None."""
return server_player(host, game).get('using')
def flying(game, kind):
"""How many of the projectiles this side draws are of `kind` (a trident, an arrow)."""
return sum(kind in q['kind'].lower() for q in game('status')['projectiles'])
CHECKS += ['cart_mob_rider']
def check_cart_mob_rider(host, guest, out):
"""A minecart with a mob in it slows like a ridden one (AbstractMinecart.getSlowdownFactor: 0.997
when isVehicle, else 0.96), so with the same push it runs far past an empty one. 40 ticks on a
straight rail from Motion 0.4: the empty cart ends 2.7 to 3.3 blocks short of the occupied one."""
x, y, z = (int(v // 1) for v in pos_of(host))
z += 8
host('command', command=f'fill {x - 2} {y - 1} {z - 1} {x + 40} {y - 1} {z + 4} minecraft:stone')
host('command', command=f'fill {x - 2} {y} {z - 1} {x + 40} {y + 3} {z + 4} minecraft:air')
host('command', command=f'fill {x} {y} {z} {x + 40} {y} {z} minecraft:rail[shape=east_west]')
host('command', command=f'fill {x} {y} {z + 3} {x + 40} {y} {z + 3} minecraft:rail[shape=east_west]')
host('command', command='tick freeze')
try:
host('command', command=f'summon minecraft:minecart {x + 0.5} {y + 0.1} {z + 0.5} {{Motion:[0.4d,0.0d,0.0d],Tags:["cmr_empty"]}}')
host('command', command=f'summon minecraft:minecart {x + 2.5} {y + 0.1} {z + 3.5} {{Motion:[0.4d,0.0d,0.0d],'
'Tags:["cmr_pig"],Passengers:[{id:"minecraft:pig",NoAI:1b}]}')
host('command', command='tick step 40')
host.wait(40)
carts = [o for o in host('status')['objects'] if o['kind'].endswith('minecart')]
empty = [o for o in carts if abs(o['position'][2] - (z + 0.5)) < 1][0]['position'][0] - (x + 0.5)
laden = [o for o in carts if abs(o['position'][2] - (z + 3.5)) < 1][0]['position'][0] - (x + 0.5)
finally:
host('command', command='tick unfreeze')
host('command', command='kill @e[type=minecraft:minecart,tag=cmr_empty]')
host('command', command='kill @e[type=minecraft:minecart,tag=cmr_pig]')
host('command', command='kill @e[type=minecraft:pig,distance=..60]')
detail = {'empty': round(empty, 3), 'with_pig': round(laden, 3)}
assert laden - empty > 2.0, f'a cart with a pig should outrun the empty one by more than 2 blocks: {detail}'
return detail
CHECK_FNS.update(cart_mob_rider=check_cart_mob_rider)
def check_held_use(host, guest, out):
"""Items used until the button lets go, for each player: the client starts the use too
(Item.use runs on both sides), so letting go sends RELEASE_USE_ITEM and the server ends it."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
seen = {}
try:
for game, dz in ((host, -3), (guest, 3)):
x, z = hx, hz + dz
host('command', command=f'fill {x - 1} {hy - 1} {z - 1} {x + 12} {hy - 1} {z + 1} minecraft:stone')
host('command', command=f'fill {x - 1} {hy} {z - 1} {x + 12} {hy + 3} {z + 1} minecraft:air')
host('command', command=f'fill {x + 12} {hy} {z - 1} {x + 12} {hy + 3} {z + 1} minecraft:stone')
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} -90 0')
game.wait(10)
s = {}
# ShieldItem: raised while held, lowered once let go.
game('command', command='item replace entity @s weapon.offhand with minecraft:shield')
game.wait(5)
game.raw('mouse', 'right', 'down')
game.wait(10)
s['shield'] = {'held': (bool(game('state').get('using')), bool(server_using(host, game)))}
game.raw('mouse', 'right', 'up')
game.wait(10)
s['shield']['let_go'] = (bool(game('state').get('using')), bool(server_using(host, game)))
game('command', command='item replace entity @s weapon.offhand with minecraft:air')
# SpyglassItem: scoping while held.
game('command', command='item replace entity @s weapon.mainhand with minecraft:spyglass')
game.wait(5)
game.raw('mouse', 'right', 'down')
game.wait(10)
s['spyglass'] = {'held': (bool(game('state').get('using')), bool(server_using(host, game)))}
game.raw('mouse', 'right', 'up')
game.wait(10)
s['spyglass']['let_go'] = (bool(game('state').get('using')), bool(server_using(host, game)))
# TridentItem.releaseUsing after 10 ticks: thrown.
host('command', command='kill @e[type=minecraft:trident]')
game('command', command='item replace entity @s weapon.mainhand with minecraft:trident')
game.wait(5)
# /kill finds no thrown trident (Horizon's selectors miss them): count what's new.
before = flying(game, 'trident')
game.raw('use-item', '--hold', 1.2)
game.wait(10)
s['trident'] = {'kept': server_count(game, 'trident'),
'thrown': flying(game, 'trident') - before}
host('command', command='kill @e[type=minecraft:trident]')
# CrossbowItem: charged by holding (25 ticks), fired by the next use.
host('command', command='kill @e[type=minecraft:arrow]')
game('command', command='clear @s')
game('command', command='item replace entity @s weapon.mainhand with minecraft:crossbow')
game('command', command='give @s minecraft:arrow 16')
game.wait(5)
before = flying(game, 'arrow')
game.raw('use-item', '--hold', 1.6)
game.wait(10)
held = game('state').get('held') or {}
s['crossbow'] = {'loaded': 'charged_projectiles' in str(held.get('components', '')),
'arrows': server_count(game, 'arrow')}
game.raw('use-item')
game.wait(10)
held = game('state').get('held') or {}
s['crossbow'].update(
fired=flying(game, 'arrow') - before,
emptied='charged_projectiles' not in str(held.get('components', '')))
game.shot(out, 'helduse')
seen[game.name] = s
finally:
host('command', command='kill @e[type=minecraft:arrow]')
host('command', command='kill @e[type=minecraft:trident]')
for game in (host, guest):
back_to_game(game)
game.raw('mouse', 'right', 'up')
game('command', command='clear @s')
face_each_other(host, guest)
for name, s in seen.items():
for item in ('shield', 'spyglass'):
assert s[item] == {'held': (True, True), 'let_go': (False, False)}, f'{name} {item}: {seen}'
assert s['trident'] == {'kept': 0, 'thrown': 1}, f'{name} trident: {seen}'
assert s['crossbow'] == {'loaded': True, 'arrows': 15, 'fired': 1, 'emptied': True}, \
f'{name} crossbow: {seen}'
return seen
CHECKS += ['helduse']
CHECK_FNS.update(helduse=check_held_use)
def check_eat_full(host, guest, out):
"""Holding right click past a full food bar starts no other bite; chorus fruit's cooldown
doesn't leave the use stuck (#2471)."""
guest('command', command='gamemode survival')
guest('command', command='clear @s')
# Full health: natural regeneration at 18 food or more would eat into the bar.
guest('command', command='effect give @s minecraft:instant_health 1 10')
guest('command', command='effect give @s minecraft:hunger 60 127')
for _ in range(400):
if remote(guest)['food'] <= 17:
break
guest.wait(1)
guest('command', command='effect clear @s')
guest('command', command='give @s minecraft:melon_slice 16')
guest.wait(5)
guest.raw('select', 'minecraft:melon_slice')
hungry = remote(guest)['food']
# A slice (2 food) takes 32 ticks (1.6 s): the button stays down well past the slices that fill
# the bar, with room for a slow server.
slices = -(-(20 - hungry) // 2)
hold = threading.Thread(target=guest.raw, args=('use-item', '--hold', slices * 4.0 + 6.0))
hold.start()
deadline = time.monotonic() + slices * 4.0 + 2.0
while remote(guest)['food'] < 20 and time.monotonic() < deadline:
time.sleep(0.1)
# The bar is full and the button is still down: nothing is in use over the next 20 ticks
# (sleeps, as `wait` is refused while the held use-item runs).
using = []
for _ in range(4):
time.sleep(0.25)
using.append(guest('state').get('using'))
guest.shot(out, 'eat-full')
hold.join()
guest.wait(10)
full = {'hungry': hungry, 'fed': remote(guest)['food'], 'using': using,
'eaten': 16 - guest('inventory')['counts'].get('minecraft:melon_slice', 0), 'needed': slices}
assert hungry < 20, f'the guest is not hungry: {full}'
assert full['fed'] == 20 and full['eaten'] == slices, f'the bar did not fill: {full}'
assert using == [None] * 4, f'another bite started with a full food bar: {full}'
# Chorus fruit can always be eaten, then its group cools down for 20 ticks: the client waits
# it out (MultiPlayerGameMode.useItem) and eats the second fruit with the button still held.
guest('command', command='clear @s')
guest('command', command='give @s minecraft:chorus_fruit 2')
guest.wait(5)
guest.raw('select', 'minecraft:chorus_fruit')
guest.raw('use-item', '--hold', 7.0)
guest.wait(10)
chorus = {'left': guest('inventory')['counts'].get('minecraft:chorus_fruit', 0),
'using': guest('state').get('using')}
assert chorus == {'left': 0, 'using': None}, f'the held use did not eat both chorus fruits: {chorus}'
return {'full': full, 'chorus': chorus}
CHECKS += ['eat_full']
CHECK_FNS.update(eat_full=check_eat_full)
def check_consume_release(host, guest, out):
"""#1293: releasing a consumable in either hand cancels it on client and server, while
holding it to completion applies its effects and remainder (Consumable.onConsume)."""
apart(host, guest)
seen = {}
try:
for game in (host, guest):
game('command', command='gamemode survival')
x, y, z = pos_of(game)
game.raw('look-at', x, y + 1.6, z + 2)
for hand in ('mainhand', 'offhand'):
for item in ('golden_apple', 'milk_bucket'):
case = f'{game.name}-{hand}-{item}'
game('command', command='clear @s')
game('command', command='item replace entity @s weapon.offhand with minecraft:air')
game('command', command='effect clear @s')
if item == 'milk_bucket':
game('command', command='effect give @s minecraft:speed 60')
game('command', command=f'item replace entity @s weapon.{hand} with minecraft:{item}')
game.wait(10)
assert game('looking_at')['kind'] == 'none', f'{case} is not aimed into the air'
before = set(effect_ids(game))
assert before == ({'speed'} if item == 'milk_bucket' else set()), f'{case}: {before}'
game.raw('mouse', 'right', 'down')
game.wait(5)
during = {'client': game('state').get('using'), 'server': server_using(host, game)}
game.raw('mouse', 'right', 'up')
game.wait(10)
released = {'client': game('state').get('using'), 'server': server_using(host, game)}
# Wait past the full use duration: the server must not finish eating after release.
game.wait(60)
effects = set(effect_ids(game))
inventory = game('inventory')
stack = inventory['offhand'] if hand == 'offhand' else inventory['held']
cancelled = {'during': during, 'released': released,
'stack': stack, 'effects': sorted(effects)}
seen[case] = {'cancelled': cancelled}
assert during['client'] and during['server'], f'{case} did not start on both sides: {cancelled}'
assert released == {'client': None, 'server': None}, f'{case} did not cancel: {cancelled}'
assert stack and stack['item'] == f'minecraft:{item}' and stack['count'] == 1, \
f'{case} consumed the released item: {cancelled}'
assert effects == before, f'{case} applied consumption effects after release: {cancelled}'
game.shot(out, f'consume-release-{hand}-{item}')
# A sustained press must still consume normally, including the milk's empty bucket.
# The use ends with the server's USE_ITEM_COMPLETE, 32 server ticks after its
# UseItem: room for a busy server before letting go.
game.raw('mouse', 'right', 'down')
game.wait(50)
game.raw('mouse', 'right', 'up')
game.wait(10)
inventory = game('inventory')
stack = inventory['offhand'] if hand == 'offhand' else inventory['held']
completed = {'stack': stack, 'effects': sorted(effect_ids(game)),
'using': (game('state').get('using'), server_using(host, game))}
seen[case]['completed'] = completed
assert completed['using'] == (None, None), \
f'{case} did not finish normally: {completed}'
if item == 'golden_apple':
assert inventory['counts'].get('minecraft:golden_apple', 0) == 0, \
f'{case} kept the eaten apple: {completed}'
assert set(completed['effects']) == {'regeneration', 'absorption'}, f'{case}: {completed}'
else:
assert not completed['effects'] and stack and stack['item'] == 'minecraft:bucket' \
and stack['count'] == 1, f'{case}: {completed}'
finally:
for game in (host, guest):
game.raw('mouse', 'right', 'up')
game('command', command='clear @s')
game('command', command='item replace entity @s weapon.offhand with minecraft:air')
game('command', command='effect clear @s')
face_each_other(host, guest)
return seen
CHECKS += ['consume-release']
CHECK_FNS.update({'consume-release': check_consume_release})
def check_wind(host, guest, out):
"""A wind charge each player throws at its own feet (ServerExplosion with TRIGGER_BLOCK): the
server's burst pushes the thrower through its ClientboundExplodePacket (hitPlayers), which
launches it about six blocks up, and onExplosionHit spares it the fall damage where it lands
(#1386)."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
host('command', command='gamerule natural_health_regeneration false')
seen = {}
try:
for game, dz in ((host, -4), (guest, 4)):
x, z = hx, hz + dz
host('command', command=f'fill {x - 2} {hy - 1} {z - 2} {x + 2} {hy - 1} {z + 2} minecraft:stone')
host('command', command=f'fill {x - 2} {hy} {z - 2} {x + 2} {hy + 10} {z + 2} minecraft:air')
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='effect clear @s')
game('command', command='effect give @s minecraft:instant_health 1 5 true')
game('command', command='give @s minecraft:wind_charge 2')
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} 0 90')
game.wait(20)
start = pos_of(game)[1]
game.raw('use-item')
peak = start
for _ in range(30):
game.wait(2)
peak = max(peak, pos_of(game)[1])
game.wait(40)
game.shot(out, 'wind')
me = own(game)
seen[game.name] = {'rose': round(peak - start, 1),
'landed': round(me['position'][1] - start, 1),
'health': me['health'],
'server_health': server_player(host, game)['health'],
'charges': server_count(game, 'wind_charge')}
finally:
host('command', command='gamerule natural_health_regeneration true')
for game in (host, guest):
game('command', command='clear @s')
face_each_other(host, guest)
for name, s in seen.items():
# Vanilla rises about 6 blocks from a charge at the feet.
assert 4 < s['rose'] < 8 and s['landed'] == 0, f'{name}: {seen}'
assert s['health'] == 20 and s['server_health'] == 20 and s['charges'] == 1, f'{name}: {seen}'
return seen
CHECKS += ['wind']
CHECK_FNS.update(wind=check_wind)
def bobbers(game):
"""The fishing bobbers this side draws, as (owner) ids: 0 for its own player's."""
return [q.get('owner') for q in game('status')['projectiles'] if q['kind'] == 'minecraft:fishing_bobber']
def check_fishing(host, guest, out):
"""FishingHook for each player, on the server: a cast hook stays out (ticked by the server and
tracked to the clients as a fishing bobber under its owner), lands on the ground and reels back
in; cast into open water with Lure III, a fish bites and reeling in
during the bite catches it for 1 (#1348)."""
# Both players need a safe floor for the entire other player's fishing wait.
apart(host, guest)
hx, hy, hz = (int(v // 1) for v in pos_of(host))
host('command', command='weather clear')
for game in (host, guest):
game('command', command='effect give @s minecraft:instant_health 1 5 true')
seen = {}
try:
for game, other, dz in ((host, guest, -5), (guest, host, 5)):
x, z = hx, hz + dz
host('command', command=f'fill {x - 1} {hy - 1} {z - 1} {x + 14} {hy - 1} {z + 1} minecraft:stone')
host('command', command=f'fill {x - 1} {hy} {z - 1} {x + 14} {hy + 4} {z + 1} minecraft:air')
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='item replace entity @s weapon.mainhand with minecraft:fishing_rod')
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} -90 0')
game.wait(10)
# On the ground: the floor four blocks ahead.
game.raw('look-at', f'{x + 4.5:.2f}', f'{hy:.2f}', f'{z + 0.5:.2f}')
game.raw('use-item')
game.wait(40)
s = {'own': bobbers(game), 'other': bobbers(other)}
game.shot(out, 'fishing-cast')
game.raw('use-item')
game.wait(10)
s['reeled'] = {'own': bobbers(game), 'damage': (game('state').get('held') or {}).get('damage')}
# Open water: a pool 7x7 and 3 deep, ahead.
host('command', command=f'fill {x + 3} {hy - 3} {z - 3} {x + 9} {hy - 1} {z + 3} minecraft:water')
game('command', command='item replace entity @s weapon.mainhand with minecraft:fishing_rod[enchantments={lure:3}]')
game.wait(10)
game.raw('look-at', f'{x + 6.5:.2f}', f'{hy - 1:.2f}', f'{z + 0.5:.2f}')
game.raw('use-item')
bit = None
for t in range(0, 900, 5):
game.wait(5)
if (game('status').get('fishing') or {}).get('nibble', 0) > 0:
bit = t
break
game.raw('use-item')
game.wait(40)
counts = game.raw('inventory')['counts']
s['caught'] = {'bit': bit is not None,
'damage': (game('state').get('held') or {}).get('damage'),
'items': sum(counts.values()) - 1}
game.shot(out, 'fishing-caught')
host('command', command=f'fill {x + 3} {hy - 3} {z - 3} {x + 9} {hy - 2} {z + 3} minecraft:stone')
host('command', command=f'fill {x + 3} {hy - 1} {z - 3} {x + 9} {hy - 1} {z + 3} minecraft:stone')
seen[game.name] = s
finally:
host('command', command='kill @e[type=minecraft:item]')
for game in (host, guest):
game('command', command='clear @s')
face_each_other(host, guest)
for name, s in seen.items():
other = guest if name == 'host' else host
assert s['own'] == [0], f'{name}: {seen}'
assert len(s['other']) == 1 and s['other'][0] != 0, f'{name}: {seen}'
# FishingHook.retrieve costs 2 on the ground, but a resting hook's onGround
# alternates every tick on the server (Entity.move with no vertical motion
# clears it, the next tick's gravity sets it again): 2 or nothing.
assert s['reeled']['own'] == [] and s['reeled']['damage'] in (None, 0, 2), f'{name}: {seen}'
assert s['caught']['bit'] and s['caught']['damage'] == 1 and s['caught']['items'] >= 1, f'{name}: {seen}'
return seen
CHECKS += ['fishing']
CHECK_FNS.update(fishing=check_fishing)
def check_fishing_tnt(host, guest, out):
"""A fishing hook cast at a primed TNT hooks it (Projectile.canHitEntity: PrimedTnt.isPickable),
and reeling in pulls the TNT toward the player (#6601)."""
apart(host, guest)
hx, hy, hz = (int(v // 1) for v in pos_of(host))
host('command', command='weather clear')
x, z = hx, hz + 5
host('command', command=f'fill {x - 1} {hy - 1} {z - 1} {x + 14} {hy - 1} {z + 1} minecraft:stone')
host('command', command=f'fill {x - 1} {hy} {z - 1} {x + 14} {hy + 4} {z + 1} minecraft:air')
host('command', command='gamemode survival')
host('command', command='clear @s')
host('command', command='item replace entity @s weapon.mainhand with minecraft:fishing_rod')
host('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} -90 0')
try:
host('command', command=f'summon minecraft:tnt {x + 4.5} {hy} {z + 0.5} {{fuse:2000s}}')
host.wait(20)
host.raw('look-at', f'{x + 4.5:.2f}', f'{hy + 0.5:.2f}', f'{z + 0.5:.2f}')
host.raw('use-item')
host.wait(60)
host.shot(out, 'fishing-tnt')
host.raw('use-item')
motion = server_reply(host, 'data get entity @e[type=minecraft:tnt,limit=1] Motion')
finally:
host('command', command='kill @e[type=minecraft:tnt]')
host('command', command='clear @s')
# FishingHook.pullEntity pushes 0.1 of the way to the owner, 4 blocks west (-0.4); ground friction
# eats most of it before the read, but an unhooked TNT's x motion stays 0.
pulled = float(re.search(r'\[(-?[0-9.eE-]+)d', motion).group(1))
assert pulled < 0, f'reeling in did not pull the hooked primed TNT toward the player: {motion}'
return {'motion': motion}
CHECKS += ['fishing-tnt']
CHECK_FNS['fishing-tnt'] = check_fishing_tnt
def check_fishing_item(host, guest, out):
"""Reeling an item sends its velocity to both clients immediately; they move it smoothly."""
spots = apart(host, guest)
x, y, z = spots['host']
item_x, item_z = x + 4.5, z + 0.5
item_id = None
def item_position(game):
items = game.raw('entities', 16, 'item')['entities']
found = [item for item in items if item['id'] == item_id]
assert len(found) == 1, f'{game.name} does not see hooked item {item_id}: {items}'
return found[0]['pos']
try:
host('command', command='kill @e[type=minecraft:item]')
host('command', command='gamemode survival')
host('command', command='clear @s')
host('command', command='item replace entity @s weapon.mainhand with minecraft:fishing_rod')
host.raw('look-at', f'{item_x:.2f}', f'{y + 1.5:.2f}', f'{item_z:.2f}')
host.raw('use-item')
for _ in range(10):
status = host('status')
bobbers = [
projectile for projectile in status.get('projectiles', [])
if projectile.get('kind') == 'minecraft:fishing_bobber'
]
if bobbers:
break
host.wait(1)
else:
raise AssertionError(f'fishing bobber did not spawn: {status}')
# Spawn on the moving bobber so the test covers hooking/reeling and
# client motion sync, not the rod's ballistic aim or item fall timing.
hook_x, hook_y, hook_z = bobbers[0]['position']
host('command', command=f'summon minecraft:item {hook_x:.4f} {hook_y:.4f} {hook_z:.4f} '
'{Item:{id:"minecraft:stone",count:1},PickupDelay:32767}')
host.wait(1)
guest.wait(1)
items = host.raw('entities', 16, 'item')['entities']
target = next(item for item in items
if item['item']['item'] == 'minecraft:stone'
and math.dist(item['pos'], [hook_x, hook_y, hook_z]) < 1)
item_id = target['id']
for _ in range(10):
host_items = host.raw('entities', 16, 'item')['entities']
guest_items = guest.raw('entities', 16, 'item')['entities']
if (any(item['id'] == item_id for item in host_items)
and any(item['id'] == item_id for item in guest_items)):
break
host.wait(1)
guest.wait(1)
start = item_position(host)
guest_start = item_position(guest)
assert math.dist(guest_start, start) < 0.5, \
f'clients disagree before reeling: {start}, {guest_start}'
hook_path = []
for _ in range(80):
status = host('status')
fishing = status.get('fishing')
if _ % 10 == 0:
hook_path.append([
projectile for projectile in status.get('projectiles', [])
if projectile.get('kind') == 'minecraft:fishing_bobber'
])
if fishing and fishing['state'] == f'Hooked({item_id})':
break
host.wait(1)
else:
raise AssertionError(
f'fishing hook did not catch item {item_id}: {fishing}; '
f'item={item_position(host)} player={host("state")["player"]} '
f'aim=({item_x}, {item_y}, {item_z}) path={hook_path}'
)
host.raw('use-item')
samples = {'host': [item_position(host)], 'guest': [item_position(guest)]}
for _ in range(5):
host.wait(1)
samples['host'].append(item_position(host))
samples['guest'].append(item_position(guest))
host.shot(out, 'fishing-item-pull')
guest.shot(out, 'fishing-item-pull')
progress = {}
for name, positions in samples.items():
path = [start if name == 'host' else guest_start, *positions]
steps = [path[i][0] - path[i + 1][0] for i in range(len(path) - 1)]
moved = path[0][0] - path[-1][0]
progress[name] = {'x': [p[0] for p in path], 'moved_toward_player': moved}
assert moved >= 0.5, f'{name} client did not receive item motion promptly: {progress[name]}'
assert all(-0.02 <= step < 1.0 for step in steps), \
f'{name} client saw a teleport or reversed movement: {progress[name]}'
return progress
finally:
host('command', command='kill @e[type=minecraft:item]')
host('command', command='clear @s')
face_each_other(host, guest)
CHECKS += ['fishing-item']
CHECK_FNS['fishing-item'] = check_fishing_item
def check_maps(host, guest, out):
"""MapItem.update for each player, on the server: a map the player makes and holds reveals the
terrain around them and shows their marker, which reach their client in
ClientboundMapItemDataPacket (#1382)."""
seen = {}
try:
for game in (host, guest):
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='item replace entity @s weapon.mainhand with minecraft:map')
x, y, z = pos_of(game)
game.raw('look-at', f'{x + 5:.2f}', f'{y + 1.6:.2f}', f'{z:.2f}')
game.wait(5)
game.raw('use-item')
# 16 ticks sweep every column once; the marker goes out every 5th tick.
game.wait(60)
maps = game('status').get('maps', [])
game.raw('look-at', f'{x + 1:.2f}', f'{y:.2f}', f'{z:.2f}')
game.wait(5)
game.shot(out, 'maps-held')
seen[game.name] = [{'pixels': m['pixels'], 'decorations': m['decorations']} for m in maps]
finally:
for game in (host, guest):
game('command', command='clear @s')
face_each_other(host, guest)
for name, maps in seen.items():
# Revealed out to the server's view distance, at least a 32-block disk.
assert len(maps) == 1 and maps[0]['pixels'] > 3000, f'{name}: {seen}'
assert maps[0]['decorations'] == ['map/decorations/player'], f'{name}: {seen}'
return seen
CHECKS += ['maps']
CHECK_FNS.update(maps=check_maps)
def check_body(host, guest, out):
"""What each player's own client decides for its body (#1278, #1303, #1296): walking while
drawing a bow is slowed to a fifth (LocalPlayer.modifyInput), sprint-swimming follows the look
down (Player.travel's look angle) and leather boots hold on powder snow
(PowderSnowBlock.canEntityWalkOnPowderSnow)."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
seen = {}
def walked(game, x, z, use):
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} -90 0')
game.wait(10)
if use:
game.raw('mouse', 'right', 'down')
game.wait(5)
a = pos_of(game)
game.raw('move', 'forward', '0.8')
game.wait(10)
b = pos_of(game)
game.raw('mouse', 'right', 'up')
game.wait(5)
return round(math.dist((a[0], a[2]), (b[0], b[2])), 2)
try:
for game, dz in ((host, -4), (guest, 4)):
x, z = hx, hz + dz
host('command', command=f'fill {x - 1} {hy - 1} {z - 1} {x + 10} {hy - 1} {z + 1} minecraft:stone')
host('command', command=f'fill {x - 1} {hy} {z - 1} {x + 10} {hy + 3} {z + 1} minecraft:air')
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='effect clear @s')
s = {'walk': walked(game, x, z, False)}
game('command', command='item replace entity @s weapon.mainhand with minecraft:bow')
game('command', command='give @s minecraft:arrow 4')
game.wait(5)
s['drawing'] = walked(game, x, z, True)
game('command', command='clear @s')
# Powder snow under the feet: leather boots stand on it, bare feet sink.
host('command', command=f'setblock {x + 3} {hy - 1} {z} minecraft:powder_snow')
for boots in ('leather_boots', 'air'):
game('command', command=f'item replace entity @s armor.feet with minecraft:{boots}')
game('command', command=f'tp @s {x + 3.5} {hy} {z + 0.5} -90 0')
game.wait(30)
s[boots] = round(pos_of(game)[1] - hy, 2)
host('command', command=f'setblock {x + 3} {hy - 1} {z} minecraft:stone')
# Deep water: sprint-swimming while looking down dives.
wx, wz = x, z + 2 * (1 if dz > 0 else -1) * 3
host('command', command=f'fill {wx - 1} {hy - 14} {wz - 2} {wx + 12} {hy + 2} {wz + 2} minecraft:stone')
host('command', command=f'fill {wx} {hy - 13} {wz - 1} {wx + 11} {hy - 1} {wz + 1} minecraft:water')
game.wait(10)
game('command', command=f'tp @s {wx + 1.5} {hy - 6} {wz + 0.5} -90 60')
game.wait(10)
a = pos_of(game)
game.raw('move', 'forward', '1.5', '--sprint')
game.wait(5)
s['dive'] = round(pos_of(game)[1] - a[1], 2)
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} -90 0')
game.wait(5)
host('command', command=f'fill {wx - 1} {hy - 14} {wz - 2} {wx + 12} {hy + 2} {wz + 2} minecraft:air')
host('command', command=f'fill {wx - 1} {hy - 14} {wz - 2} {wx + 12} {hy - 1} {wz + 2} minecraft:stone')
game.shot(out, 'body')
seen[game.name] = s
finally:
for game in (host, guest):
back_to_game(game)
game.raw('mouse', 'right', 'up')
game('command', command='clear @s')
face_each_other(host, guest)
for name, s in seen.items():
assert s['walk'] > 2.5 and s['drawing'] < s['walk'] * 0.4, f'{name} drawing a bow: {seen}'
assert s['leather_boots'] > -0.01 and s['air'] < -0.5, f'{name} powder snow: {seen}'
assert s['dive'] < -2, f'{name} sprint-swimming down: {seen}'
return seen
CHECKS += ['body']
CHECK_FNS.update(body=check_body)
def check_strikes(host, guest, out):
"""What a mob or the sky does to every player in reach, the server's players too (#1480,
#1484): a bolt striking a player standing in water costs them health (Entity.thunderHit), and
a cave spider's bite poisons them (CaveSpider.doHurtTarget on Normal)."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
seen = {}
try:
host('command', command='difficulty normal')
host('command', command='time set 18000')
for game, dz in ((host, -5), (guest, 5)):
x, z = hx, hz + dz
host('command', command=f'fill {x - 3} {hy - 1} {z - 3} {x + 3} {hy + 3} {z + 3} minecraft:stone')
host('command', command=f'fill {x - 2} {hy} {z - 2} {x + 2} {hy + 3} {z + 2} minecraft:air')
host('command', command=f'fill {x - 2} {hy} {z - 2} {x + 2} {hy} {z + 2} minecraft:water')
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='effect clear @s')
game('command', command='effect give @s minecraft:instant_health 1 5')
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5}')
game.wait(20)
before = game('state')['player'].get('health')
game('command', command=f'summon minecraft:lightning_bolt {x + 0.5} {hy} {z + 0.5}')
game.wait(20)
s = {'bolt': (before, game('state')['player'].get('health'))}
host('command', command=f'summon minecraft:cave_spider {x + 1.5} {hy} {z + 0.5}')
poisoned = False
for _ in range(40):
game.wait(5)
if 'poison' in effect_ids(game):
poisoned = True
break
s['poisoned'] = poisoned
s['bitten'] = game('state')['player'].get('health')
host('command', command='kill @e[type=minecraft:cave_spider]')
game.wait(5)
game.shot(out, 'strikes')
host('command', command=f'fill {x - 3} {hy - 1} {z - 3} {x + 3} {hy + 3} {z + 3} minecraft:air')
host('command', command=f'fill {x - 3} {hy - 1} {z - 3} {x + 3} {hy - 1} {z + 3} minecraft:stone')
seen[game.name] = s
finally:
host('command', command='kill @e[type=minecraft:cave_spider]')
host('command', command='time set day')
for game in (host, guest):
back_to_game(game)
game('command', command='effect clear @s')
game('command', command='effect give @s minecraft:instant_health 1 5')
face_each_other(host, guest)
for name, s in seen.items():
a, b = s['bolt']
assert a is not None and b is not None and a - b >= 3, f'{name} struck by lightning: {seen}'
assert s['poisoned'], f'{name} bitten by a cave spider: {seen}'
return seen
CHECKS += ['strikes']
CHECK_FNS.update(strikes=check_strikes)
def check_cursor(host, guest, out):
"""Closing the inventory with a stack on the cursor puts it back into the player's inventory
(AbstractContainerMenu.removed, placeItemBackInInventory) and empties the cursor, for each
player: nothing is thrown out and no copy stays on the cursor (#1283)."""
seen = {}
try:
for game in (host, guest):
game('command', command='clear @s')
host('command', command='kill @e[type=minecraft:item]')
game('command', command='item replace entity @s hotbar.0 with minecraft:diamond 7')
game.wait(10)
game.raw('open-inventory')
game.raw('wait-for', 'screen', 'inventory', '--timeout', 10)
game.raw('click-slot', 0)
game.wait(5)
held = (game('inventory').get('carried') or {}).get('count')
game.raw('key', 'esc', 'tap')
game.wait(40)
inv = game('inventory')
seen[game.name] = {'held': held, 'carried': inv.get('carried'),
'client': inv['counts'].get('minecraft:diamond', 0),
'server': server_count(game, 'diamond'),
'thrown': len(game.raw('entities', 8, 'minecraft:item')['entities'])}
finally:
for game in (host, guest):
back_to_game(game)
game('command', command='clear @s')
host('command', command='kill @e[type=minecraft:item]')
for name, s in seen.items():
assert s == {'held': 7, 'carried': None, 'client': 7, 'server': 7, 'thrown': 0}, f'{name}: {seen}'
return seen
CHECKS += ['cursor']
CHECK_FNS.update(cursor=check_cursor)
def check_digging(host, guest, out):
"""The server breaks a block a player digs only once that player's client is done
(ServerPlayerGameMode: STOP_DESTROY_BLOCK, or the delayed destroy), never on its own count, so
the player's client always breaks it first, with its particles and sound. The guest holds
attack across a wall of six oak logs with a copper axe and hears each one break; then it digs
stone with a wooden pickaxe and its game stalls for a second halfway: the stone is still there
on both sides when it resumes, and the guest's own crack finishes it."""
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
x, y, z = gx + 3, gy, gz + 4
heard = lambda sound: guest('status')['heard'].get(sound, 0)
at = lambda b: (f'{b[0] + 0.02:.2f}', f'{b[1] + 0.5:.2f}', f'{b[2] + 0.5:.2f}')
guest('command', command='gamemode survival')
guest('command', command='item replace entity @s weapon.mainhand with minecraft:copper_axe')
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 4} {y + 3} {z + 2} minecraft:air')
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 4} {y - 1} {z + 2} minecraft:stone')
host('command', command=f'fill {x + 2} {y} {z - 1} {x + 2} {y + 1} {z + 1} minecraft:oak_log')
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
guest.wait(30)
logs = [(x + 2, y + dy, z + dz) for dz in (-1, 0, 1) for dy in (0, 1)]
before = heard('minecraft:block.wood.break')
guest.raw('look-at', *at(logs[0]))
guest.raw('mouse', 'left', 'down')
left = list(logs)
deadline = time.time() + 20
while left and time.time() < deadline:
if guest.raw('block', *left[0]).get('block') != 'minecraft:oak_log':
left.pop(0)
if left:
guest.raw('look-at', *at(left[0]))
guest.raw('mouse', 'left', 'up')
guest.wait(10)
seen = {'logs_left': left, 'log_breaks_heard': heard('minecraft:block.wood.break') - before}
# Stone with a wooden pickaxe: 23 ticks. The guest stalls for a second after about 8.
stone = (x + 2, y, z)
guest('command', command='item replace entity @s weapon.mainhand with minecraft:wooden_pickaxe')
host('command', command=f'setblock {stone[0]} {stone[1]} {stone[2]} minecraft:stone')
guest.wait(10)
before = heard('minecraft:block.stone.break')
guest.raw('look-at', *at(stone))
guest.raw('mouse', 'left', 'down')
guest.wait(8)
guest.raw('stall', 1000)
resumed = {'guest': guest.raw('block', *stone).get('block'), 'server': host.raw('block', *stone).get('block'),
'mining': guest('state').get('mining')}
# Let go once it breaks: past it the crosshair reaches the floor.
deadline = time.time() + 5
while guest.raw('block', *stone).get('block') == 'minecraft:stone' and time.time() < deadline:
pass
guest.raw('mouse', 'left', 'up')
guest.wait(10)
seen.update(resumed=resumed, stone_after=host.raw('block', *stone).get('block'),
stone_breaks_heard=heard('minecraft:block.stone.break') - before)
guest.shot(out, 'digging')
assert not left and seen['log_breaks_heard'] == 6, f'the server broke logs before the guest did: {seen}'
assert resumed['guest'] == resumed['server'] == 'minecraft:stone', f'the server broke the stone while the guest stalled: {seen}'
assert seen['stone_after'] == 'minecraft:air' and seen['stone_breaks_heard'] == 1, f'the guest did not break the stone: {seen}'
return seen
CHECKS += ['digging']
CHECK_FNS.update(digging=check_digging)
def check_instant_digging(host, guest, out):
"""#1908: an instant survival START has no STOP. ServerPlayerGameMode.tick
forgets a dig once its block is air; it must neither hit air nor break a replacement.
Exercise the host and the network guest (both are clients of the server).
"""
result = {}
for label, player in (('host', host), ('guest', guest)):
x, y, z = (int(v // 1) for v in pos_of(player))
cell = (x, y + 1, z + 2)
player('command', command='gamemode survival')
player('command', command='clear @s')
host('command', command=f'fill {x - 1} {y} {z + 1} {x + 1} {y + 3} {z + 4} minecraft:air')
host('command', command=f'setblock {x} {y} {z + 2} minecraft:sand')
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:dead_bush')
player.wait(10)
before = heard(player, 'minecraft:block.stone.hit')
player.raw('look-at', x + 0.5, y + 1.4, z + 2.5)
player.raw('mouse', 'left', 'click')
player.wait(20)
broken = host.raw('block', *cell).get('block')
hits = heard(player, 'minecraft:block.stone.hit') - before
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:dead_bush')
player.wait(20)
replacement = host.raw('block', *cell).get('block')
result[label] = {'broken': broken, 'stone_hits': hits, 'replacement': replacement}
assert broken == 'minecraft:air', f'{label}: the bush did not break: {result}'
assert hits == 0, f'{label}: hitting air after an instant break: {result}'
assert replacement == 'minecraft:dead_bush', f'{label}: the server kept digging: {result}'
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:air')
return result
CHECKS += ['instant_digging']
CHECK_FNS.update(instant_digging=check_instant_digging)
def check_vehicles(host, guest, out):
"""The host and the guest each (#1551):
- in creative, break a block and have it put back: the server's dig ends with the break
(ServerPlayerGameMode.handleBlockBreakAction), so the block stays;
- land on a boat on the ground and stand on it (AbstractBoat.canBeCollidedWith);
- sit in a boat that lava reaches: lavaHurt twice a tick breaks it at once, and the rider is out;
- see a minecart in lava break, and a TNT minecart in lava prime and go off;
- stand half in a minecart off rails and push it away (AbstractMinecart.push from the server's
player)."""
result = {}
spots = apart(host, guest)
for label, player in (('host', host), ('guest', guest)):
x, y, z = spots[label]
seen = result[label] = {}
near = lambda kind, at: [o for o in host('status')['objects'] if o['kind'].endswith(kind)
and abs(o['position'][0] - at[0]) < 2 and abs(o['position'][2] - at[2]) < 2]
try:
cell = (x, y + 1, z + 2)
player('command', command='gamemode creative')
player('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:stone')
player.wait(5)
player.raw('look-at', f'{x + 0.5:.2f}', f'{y + 1.5:.2f}', f'{z + 2.5:.2f}')
player.raw('mouse', 'left', 'click')
player.wait(5)
seen['broken'] = host.raw('block', *cell).get('block')
player('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:stone')
player.wait(20)
seen['put_back'] = [g.raw('block', *cell).get('block') for g in (host, player)]
player('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:air')
player('command', command='gamemode survival')
player('command', command='effect give @s minecraft:fire_resistance 300 0 true')
boat_at = (x + 0.5, y, z + 3.5)
player('command', command=f'summon minecraft:oak_boat {boat_at[0]} {y} {boat_at[2]}')
player.wait(10)
player('command', command=f'tp @s {boat_at[0]} {y + 1.2:.2f} {boat_at[2]}')
player.wait(20)
seen['on_boat'] = [round(pos_of(player)[1] - y, 3), round(server_player(host, player)['position'][1] - y, 3)]
player('command', command=f'tp @s {x + 0.5} {y} {z + 1.5} 0 0')
player.wait(10)
board(player, 'oak_boat', boat_at)
player('command', command=f'setblock {x} {y} {z + 3} minecraft:lava')
player.wait(10)
seen['rider'] = {'riding': player('status').get('riding'), 'boats': len(near('oak_boat', boat_at))}
player('command', command='gamemode creative')
player('command', command=f'summon minecraft:minecart {boat_at[0]} {y} {boat_at[2]}')
player.wait(10)
seen['minecart_in_lava'] = len(near('minecart', boat_at))
booms = player('status')['heard'].get('minecraft:entity.generic.explode', 0)
player('command', command=f'summon minecraft:tnt_minecart {boat_at[0]} {y} {boat_at[2]}')
player.wait(60)
seen['tnt_minecart'] = {'left': len(near('tnt_minecart', boat_at)),
'booms': player('status')['heard'].get('minecraft:entity.generic.explode', 0) - booms}
player('command', command=f'fill {x - 3} {y} {z - 3} {x + 4} {y + 3} {z + 6} minecraft:air')
player('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 4} {y - 1} {z + 6} minecraft:stone')
player('command', command='gamemode survival')
cart_at = (x + 0.5, y, z + 1.6)
player('command', command=f'summon minecraft:minecart {cart_at[0]} {y} {cart_at[2]}')
player.wait(10)
before = near('minecart', cart_at)[0]['position']
# Half into the cart from the north: it is pushed south until they are apart.
player('command', command=f'tp @s {x + 0.5} {y} {before[2] - 0.4:.2f} 0 0')
player.wait(20)
after = near('minecart', cart_at)[0]['position']
seen['cart_pushed'] = round(after[2] - before[2], 3)
player.shot(out, f'vehicles-{label}')
finally:
player('command', command='kill @e[type=minecraft:minecart]')
player('command', command='kill @e[type=minecraft:oak_boat]')
player('command', command='kill @e[type=minecraft:item]')
player('command', command=f'fill {x - 3} {y} {z - 3} {x + 4} {y + 3} {z + 6} minecraft:air')
player('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 4} {y - 1} {z + 6} minecraft:stone')
player('command', command='effect clear @s')
player('command', command='gamemode survival')
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
player.wait(5)
for label, seen in result.items():
assert seen['broken'] == 'minecraft:air', f'{label}: the creative click broke nothing: {seen}'
assert seen['put_back'] == ['minecraft:stone'] * 2, f'{label}: the server kept digging: {seen}'
assert all(0.5 < dy < 0.65 for dy in seen['on_boat']), f'{label}: not standing on the boat: {seen}'
assert seen['rider'] == {'riding': None, 'boats': 0}, f'{label}: lava left the boat or its rider: {seen}'
assert seen['minecart_in_lava'] == 0, f'{label}: a minecart outlived lava: {seen}'
assert seen['tnt_minecart'] == {'left': 0, 'booms': 1}, f'{label}: the TNT minecart in lava: {seen}'
assert seen['cart_pushed'] > 0.1, f'{label}: the minecart stayed in the player: {seen}'
return result
CHECKS += ['vehicles']
CHECK_FNS.update(vehicles=check_vehicles)
# How far a plain wooden sword hit slides a standing player in vanilla: LivingEntity.knockback(0.4) from
# the server copy's (0, -0.0784, 0), then the victim's own client.
VANILLA_PUSH = 1.989
def check_knockback(host, guest, out):
if dead(guest):
guest.raw('respawn')
guest.wait(40)
host('command', command='gamerule pvp true')
for g in (host, guest):
g('command', command='gamemode survival')
g('command', command='item replace entity @s weapon.mainhand with minecraft:wooden_sword')
g('command', command='effect give @s minecraft:resistance 1000 4 true')
face_each_other(host, guest)
pushes = {}
try:
for name, attacker, victim in (('host hits guest', host, guest), ('guest hits host', guest, host)):
# The victim is never teleported (a teleport resets its velocity): the attacker steps up to it from
# the west, then the east, then the west again.
for side in (-3, 3, -3):
vx, vy, vz = pos_of(victim)
attacker('command', command=f'tp @s {vx + side:.2f} {vy:.2f} {vz:.2f}')
attacker.wait(40) # a full charge, and the last push long settled
attacker.raw('look-at', f'{vx:.2f}', f'{vy + 1.6:.2f}', f'{vz:.2f}')
attacker.wait(5)
before = pos_of(victim)
attacker.raw('attack')
victim.wait(30)
after = pos_of(victim)
pushes.setdefault(name, []).append(round(math.dist(before[0::2], after[0::2]), 3))
attacker.shot(out, 'knockback-' + name.replace(' ', '-'))
finally:
for g in (host, guest):
g('command', command='effect clear @s minecraft:resistance')
for name, distances in pushes.items():
assert all(abs(d - VANILLA_PUSH) < 0.03 for d in distances), f'{name}: not {VANILLA_PUSH} each time: {pushes}'
return pushes
CHECKS += ['knockback']
CHECK_FNS.update(knockback=check_knockback)
def check_sprint_hit_slowdown(host, guest, out):
"""A sprinting guest's full-strength hit on the host slows the guest and ends its sprint.
Player.attack runs on the attacker's client too, and RemotePlayer.hurtClient lets it reach
causeExtraKnockback: the horizontal speed times 0.6 and setSprinting(false). Ctrl is released
before the hit (the sprint holds while W stays down), so only the hit can end it. Input and
the click run in one control script so command latency cannot carry the guest past the host.
"""
if dead(guest):
guest.raw('respawn')
guest.wait(40)
host('command', command='gamerule pvp true')
host('command', command='gamemode survival')
host('command', command='effect give @s minecraft:resistance 1000 4 true')
guest('command', command='gamemode survival')
guest('command', command='effect clear @s')
guest('command', command='item replace entity @s weapon.mainhand with minecraft:air')
# Drain the server's attack log before the tick-tight click. The raw mouse
# click deliberately keeps W held; the `attack` agent command would release
# movement keys before its deferred result arrives.
host('hurtlog')
hx, hy, hz = pos_of(host)
x, y, z = math.floor(hx), math.floor(hy), math.floor(hz)
host('command', command=f'fill {x - 6} {y} {z - 2} {x + 14} {y + 3} {z + 2} minecraft:air')
host('command', command=f'fill {x - 6} {y - 1} {z - 2} {x + 14} {y - 1} {z + 2} minecraft:stone')
host('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
guest('command', command=f'tp @s {x + 12.5} {y} {z + 0.5} 90 0')
guest.wait(40) # a full attack charge
guest.raw('look-at', f'{x + 0.5}', f'{y + 1.6}', f'{z + 0.5}')
try:
replies = guest.script([
'key w down',
'key ctrl down',
'wait 1',
f'command tp @s {x + 3.5} {y} {z + 0.5} 90 0',
'state',
'key ctrl up',
'mouse left click',
'state',
'wait 1',
'state',
'wait 1',
'state',
'wait 1',
'state',
'key w up',
], every=True)
horizontal = lambda p: round(math.hypot(p['velocity'][0], p['velocity'][2]), 4)
states = [r['player'] for r in replies if 'player' in r]
logs = host('hurtlog').get('players', [])
victim_log = next((p for p in logs if p.get('name') == host.player), {})
attack = next(
(h for h in victim_log.get('hurts', [])
if h.get('source') == 'minecraft:player_attack' and h.get('hurt')),
None,
)
before_index = next(
(i for i, p in enumerate(states) if p['sprinting'] and horizontal(p) > 0.1),
None,
)
before = states[before_index] if before_index is not None else None
after = states[before_index + 1 :] if before_index is not None else []
finally:
guest.raw('key', 'ctrl', 'up')
guest.raw('key', 'w', 'up')
host('command', command='effect clear @s minecraft:resistance')
guest.shot(out, 'sprint-hit-slowdown')
seen = {
'before': None if before is None else {'sprinting': before['sprinting'], 'speed': horizontal(before)},
'after': [{'sprinting': p['sprinting'], 'speed': horizontal(p)} for p in after],
'attack': attack,
}
assert attack, f'the guest did not hit the host: {seen}'
assert before and seen['before']['speed'] > 0.1, f'the guest was not sprinting at the host: {seen}'
# The click reaches the game on its next frame, so the samples may start before the hit.
ended = next((i for i, a in enumerate(seen['after']) if not a['sprinting']), None)
assert ended is not None and not any(a['sprinting'] for a in seen['after'][ended:]), \
f'the hit did not end the guest\'s sprint: {seen}'
# The first post-hit sample includes one local movement step after the 0.6 cut. Without the cut,
# it would remain at sprint speed until the server's later sprint flag update.
first = seen['after'][ended]
assert first['speed'] < 0.9 * seen['before']['speed'], f'the guest sprints on after the hit: {seen}'
return seen
CHECKS += ['sprint-hit-slowdown']
CHECK_FNS['sprint-hit-slowdown'] = check_sprint_hit_slowdown
def check_fixture_input(host, guest, out, kind, role):
"""Complement seeded rules with real client input and authoritative observations."""
player = host if role == 'host' else guest
hx, y, hz = (int(v // 1) for v in pos_of(host))
x, z = hx + 6, hz + (-8 if role == 'host' else 8)
sensor, dispenser, button = (x + 3, y, z + 1), (x, y, z + 3), (x, y, z + 2)
player('command', command='gamemode survival')
player('command', command='item replace entity @s weapon.mainhand with minecraft:air')
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 5} {y - 1} {z + 5} minecraft:stone')
host('command', command=f'fill {x - 2} {y} {z - 2} {x + 5} {y + 3} {z + 5} minecraft:air')
start = (x + 0.5, y, z + 0.5)
player('command', command=f'tp @s {start[0]} {start[1]} {start[2]} 0 0')
player.until(lambda: pos_of(player), lambda p: math.dist(p, start) < 0.1)
player.raw('look', 0, 0)
try:
if kind == 'sculk':
player.wait(2) # finish setup movement before installing the listener
host.raw('setblock', *sensor, 'minecraft:sculk_sensor')
phase = lambda: host.raw('getblock', *sensor)['block']
host.until(phase, lambda state: 'sculk_sensor_phase=inactive' in state)
player.wait(10)
assert 'sculk_sensor_phase=inactive' in phase(), 'setup activated the sensor before player input'
player.raw('key', 'w', 'down')
try:
player.wait(15) # defined input duration; no wall-clock simulation sleep
finally:
player.raw('key', 'w', 'up')
moved = {'client': math.dist(pos_of(player), start),
'server': math.dist(server_player(host, player)['position'], start)}
assert all(d > 1 for d in moved.values()), f'{role} did not walk on both sides: {moved}'
active = host.until(phase, lambda state: 'sculk_sensor_phase=active' in state, ticks=85)
for viewer in (host, guest):
viewer.until(lambda: state_of(viewer, sensor, 'sculk_sensor_phase'), lambda v: v == 'active')
return {'moved': moved, 'phase': active}
host.raw('setblock', *dispenser, 'minecraft:dispenser[facing=east]')
host.raw('container', *dispenser, 'minecraft:diamond', 1)
host.raw('setblock', *button, 'minecraft:stone_button[face=wall,facing=north]')
player.until(lambda: player.raw('block', *button)['block'], lambda v: v == 'minecraft:stone_button')
player.raw('interact', *button)
slots = host.until(lambda: host.raw('container', *dispenser)['slots'], lambda v: all(s is None for s in v))
dropped = {}
for viewer in (host, guest):
dropped[viewer.name] = viewer.until(
lambda: [e for e in viewer.raw('entities', 24, 'minecraft:item')['entities']
if e.get('item', {}).get('item') == 'minecraft:diamond'
and math.dist(e['pos'], (x + 1.5, y + 0.5, z + 3.5)) < 3],
lambda drops: len(drops) == 1 and drops[0]['item']['count'] == 1)
player.shot(out, f'{kind}-input')
return {'slots': slots, 'drops': dropped}
finally:
player.raw('key', 'w', 'up')
host('command', command=f'fill {x - 2} {y} {z - 2} {x + 5} {y + 3} {z + 5} minecraft:air')
for _kind in ('sculk', 'dispenser'):
for _role in ('host', 'guest'):
_name = f'{_kind}_input_{_role}'
CHECKS.append(_name)
CHECK_FNS[_name] = lambda h, g, o, kind=_kind, role=_role: check_fixture_input(h, g, o, kind, role)
def check_hero_trading(host, guest, out):
"""Villager.updateSpecialPrices keeps the trading player's Hero effect: the offer sent to the
client and the server's payment consumption agree, independently of the other player's effect."""
x, y, z = (int(v // 1) for v in pos_of(host))
tag = 'hero_trade'
seen = {}
cases = [('ordinary', None, 20), ('hero-i', 0, 14), ('hero-iii', 2, 12), ('cleared', None, 20)]
try:
host('command', command=f'fill {x - 4} {y - 1} {z - 2} {x + 4} {y - 1} {z + 2} minecraft:stone')
host('command', command=f'fill {x - 4} {y} {z - 2} {x + 4} {y + 3} {z + 2} minecraft:air')
for game, other in ((host, guest), (guest, host)):
back_to_game(game)
back_to_game(other)
other('command', command='effect clear @s')
other('command', command='effect give @s minecraft:hero_of_the_village 600 4 true')
other('command', command=f'tp @s {x - 3.5} {y} {z + 0.5}')
game('command', command='gamemode survival')
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
for label, amplifier, price in cases:
game('command', command='effect clear @s')
if amplifier is not None:
game('command', command=f'effect give @s minecraft:hero_of_the_village 600 {amplifier} true')
game('command', command='clear @s')
game('command', command='give @s minecraft:wheat 20')
# A fresh, fixed offer avoids reputation, demand and level-ups between cases.
host('command', command=f'summon minecraft:villager {x + 2.5} {y} {z + 0.5} {{Tags:["{tag}"],'
'NoAI:1b,PersistenceRequired:1b,Xp:1,VillagerData:{type:"minecraft:plains",'
'profession:"minecraft:farmer",level:1},Offers:{Recipes:[{buy:{id:"minecraft:wheat",count:20},'
'sell:{id:"minecraft:emerald",count:1},maxUses:16,uses:0,xp:0,priceMultiplier:0f,demand:0}]}}')
game.wait(20)
villager = next(e['id'] for e in game.raw('entities', 8, 'minecraft:villager')['entities']
if math.dist(e['pos'], [x + 2.5, y, z + 0.5]) < 0.5)
game.raw('interact-entity', villager)
game.raw('wait-for', 'screen', 'station', '--timeout', 10)
assert game('screen')['station'] == 'Merchant', f'{game.name}-{label}: no merchant screen'
# The first offer button is 50 pixels left of the usual menu origin. Selecting it
# sends SelectTrade on a client; taking the result uses the authoritative menu.
game.raw('click-gui', -1, 28)
game.wait(10)
assert station_slots(game).get(650) == ('minecraft:emerald', 1), f'{game.name}-{label}: no result'
game.shot(out, f'hero-trading-{label}')
game.raw('click-slot', 650, 'shift')
game.wait(10)
game.raw('key', 'esc', 'tap')
game.wait(10)
offer = server_reply(game, f'data get entity @e[type=minecraft:villager,tag={tag},limit=1] '
'Offers.Recipes[0]', 'following entity data')
special = re.search(r'specialPrice"?\s*:\s*(-?\d+)', offer)
case = {'price': 20 + (int(special[1]) if special else 0),
'server': {item: server_count(game, item) for item in ('wheat', 'emerald')},
'hero': 'hero_of_the_village' in effect_ids(game)}
seen[f'{game.name}-{label}'] = case
want = {'price': price, 'server': {'wheat': 20 - price, 'emerald': 1},
'hero': amplifier is not None}
assert case == want, f'{game.name}-{label}: expected {want}, got {case}'
host('command', command=f'kill @e[tag={tag}]')
game.wait(5)
finally:
for game in (host, guest):
back_to_game(game)
game('command', command='effect clear @s')
game('command', command='clear @s')
host('command', command=f'kill @e[tag={tag}]')
return seen
def check_wind_burst_player(host, guest, out):
"""A mace smash with Wind Burst on another player launches the attacker up (`MaceItem.hurtEnemy`, the
enchantment's `post_attack` explosion on the attacker), the host smashing a guest and a guest the host (#3830)."""
result = {}
spots = apart(host, guest)
x, y, z = spots['host']
for label, attacker, victim in (('host-hits-guest', host, guest), ('guest-hits-host', guest, host)):
attacker('command', command='gamemode survival')
victim('command', command='gamemode survival')
attacker('command', command='clear @s')
attacker('command', command='effect give @s minecraft:resistance 60 4 true')
victim('command', command='effect clear @s')
victim('command', command='effect give @s minecraft:instant_health 1 10 true')
victim('command', command='effect give @s minecraft:absorption 60 19 true')
attacker('command', command='give @s minecraft:mace[minecraft:enchantments={"minecraft:wind_burst":1}]')
victim('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
attacker('command', command=f'tp @s {x + 0.5} {y + 9} {z + 0.5} 0 90')
attacker.raw('key', '1', 'tap')
attacker.wait(1)
replies = attacker.script(['mouse left down'] + ['state', 'mouse left click', 'wait 1'] * 50 + ['mouse left up'], every=True)
ys = [r['player']['position'][1] for r in replies if r.get('player')]
hit = next((i for i in range(1, len(ys)) if ys[i] > ys[i - 1] + 0.05), None)
result[label] = {'start': round(ys[0], 1), 'min': round(min(ys), 2), 'max_after_hit': None if hit is None else round(max(ys[hit:]), 2)}
assert hit is not None and max(ys[hit:]) > min(ys) + 5, f'{label}: the smash did not launch the attacker: {ys}'
attacker.raw('mouse', 'left', 'up')
result[label]['victim'] = [victim('state')['player']['health'], victim('state')['player']['absorption']]
attacker.wait(40)
return result
CHECKS += ['wind_burst_player']
CHECK_FNS['wind_burst_player'] = check_wind_burst_player
def check_boat_input_latch(host, guest, out):
"""AbstractBoat.tick steers with the input LocalPlayer.rideTick latched on the previous tick, so the
paddle packet describes the motion the boat just made. A boat on land that starts being paddled moves
one tick after the key goes down, not in the same tick (GrimAC's Simulation flagged the early move)."""
result = {}
spots = apart(host, guest)
for label, player in (('host', host), ('guest', guest)):
x, y, z = spots[label]
player('command', command='gamemode survival')
player('command', command=f'summon minecraft:oak_boat {x + 0.5} {y} {z + 3.5}')
player.wait(20)
try:
boat = board(player, 'oak_boat', (x + 0.5, y, z + 3.5))
player.wait(10)
def at():
return next(o for o in player('status')['objects'] if o['id'] == boat['id'])['position']
steps = []
for n in range(6):
player.wait(1 + n % 3) # vary the tick of the frame the key goes down in
# One ctl process: W goes down and the ticks that follow are counted exactly.
replies = player.script(['key w down', 'wait 0', 'status', 'wait 1', 'status', 'wait 1', 'status', 'key w up'],
every=True)
marks = [r for r in replies if 'objects' in r]
ticks = [r['ticks'] for r in replies if 'ticks' in r]
spots_now = [next(o for o in r['objects'] if o['id'] == boat['id'])['position'] for r in marks]
distances = [math.dist((a[0], a[2]), (spots_now[0][0], spots_now[0][2])) for a in spots_now]
steps.append({'ticks': ticks, 'moved': [round(d, 4) for d in distances]})
player.wait(40)
# W is read in the first tick after it goes down and steers the boat from the second on: a
# boat at rest on land moves 0.04, then 0.064 (land friction 0.6, +0.04 each tick). A boat
# steering in the tick W is read in would be a tick ahead.
def travelled(n):
v, total = 0.0, 0.0
for _ in range(max(0, n - 1)):
v = v * 0.6 + 0.04
total += v
return total
want = [round(travelled(t - ticks[0]), 4) for t in (ticks[0], ticks[2], ticks[4])]
assert all(abs(d - w) < 0.002 for d, w in zip(distances, want)), f'{label}: {steps} want {want}'
result[label] = steps
finally:
player.raw('key', 'w', 'up')
player.raw('key', 'shift', 'tap')
player.wait(5)
return result
CHECKS += ['boat_input_latch']
CHECK_FNS['boat_input_latch'] = check_boat_input_latch
def check_hook_smooth(host, guest, out):
"""FishingHook.tick runs on a client between the tracker's updates (every 5 ticks): a cast hook moves
every tick of its flight, not in jumps with two still ticks between them (#3781)."""
result = {}
apart(host, guest)
hx, hy, hz = (int(v // 1) for v in pos_of(host))
host('command', command='weather clear')
for game, dz in ((host, -5), (guest, 5)):
x, z = hx, hz + dz
host('command', command=f'fill {x - 1} {hy - 1} {z - 1} {x + 14} {hy - 1} {z + 1} minecraft:stone')
host('command', command=f'fill {x - 1} {hy} {z - 1} {x + 14} {hy + 4} {z + 1} minecraft:air')
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='item replace entity @s weapon.mainhand with minecraft:fishing_rod')
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} -90 0')
game.wait(10)
game.raw('look-at', f'{x + 8.5:.2f}', f'{hy + 1:.2f}', f'{z + 0.5:.2f}')
replies = game.script(['use-item'] + ['status', 'wait 1'] * 30, every=True)
seq = []
for r in replies:
hooks = [o for o in r.get('projectiles', []) if o.get('kind') == 'minecraft:fishing_bobber' and o.get('owner') == 0]
if hooks:
seq.append(hooks[0]['position'])
steps = [round(math.dist(a, b), 3) for a, b in zip(seq, seq[1:])]
result[game.name] = steps
game.raw('use-item')
game.wait(5)
# In flight every tick moves it (a hook eased between updates stood still two ticks of five).
assert all(d > 0.05 for d in steps[:6]), f'{game.name}: the hook moved in jumps: {steps}'
return result
CHECKS += ['hook_smooth']
CHECK_FNS['hook_smooth'] = check_hook_smooth
def check_merchant_close(host, guest, out):
"""AbstractVillager.stillValid: a trade screen closes when its villager is out of reach or dead, and
the payment slots go back to the player as on a normal close; in range it stays open."""
x, y, z = (int(v // 1) for v in pos_of(host))
tag = 'close_trade'
seen = {}
# (label, mode, tp x offset from the player's start, dies?, screen still open?)
cases = [('survival-near', 'survival', 0, False, True), ('survival-8', 'survival', -6.0, False, False),
('creative-8', 'creative', -6.0, False, True), ('creative-far', 'creative', -15.0, False, False),
('killed', 'survival', 0, True, False)]
try:
host('command', command=f'fill {x - 20} {y - 1} {z - 2} {x + 8} {y - 1} {z + 2} minecraft:stone')
host('command', command=f'fill {x - 20} {y} {z - 2} {x + 8} {y + 3} {z + 2} minecraft:air')
for game, other in ((host, guest), (guest, host)):
back_to_game(game)
back_to_game(other)
other('command', command=f'tp @s {x + 6.5} {y} {z + 1.5}')
for label, mode, dx, dies, stays in cases:
game('command', command=f'gamemode {mode}')
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
game('command', command='clear @s')
game('command', command='give @s minecraft:wheat 20')
host('command', command=f'summon minecraft:villager {x + 2.5} {y} {z + 0.5} {{Tags:["{tag}"],'
'NoAI:1b,PersistenceRequired:1b,Xp:1,VillagerData:{type:"minecraft:plains",'
'profession:"minecraft:farmer",level:1},Offers:{Recipes:[{buy:{id:"minecraft:wheat",count:20},'
'sell:{id:"minecraft:emerald",count:1},maxUses:16,uses:0,xp:0,priceMultiplier:0f,demand:0}]}}')
game.wait(20)
villager = next(e['id'] for e in game.raw('entities', 8, 'minecraft:villager')['entities']
if math.dist(e['pos'], [x + 2.5, y, z + 0.5]) < 0.5)
game.raw('interact-entity', villager)
game.raw('wait-for', 'screen', 'station', '--timeout', 10)
assert game('screen')['station'] == 'Merchant', f'{game.name}-{label}: no merchant screen'
# Selecting the offer moves the wheat into the payment slot.
game.raw('click-gui', -1, 28)
game.wait(10)
paid = station_slots(game).get(600)
assert paid == ('minecraft:wheat', 20), f'{game.name}-{label}: payment slot {paid}'
if dies:
host('command', command=f'kill @e[tag={tag}]')
else:
game('command', command=f'tp @s {x + 0.5 + dx} {y} {z + 0.5}')
game.wait(20)
screen = game('state').get('screen')
open_now = screen not in (None, 'playing')
case = {'open': open_now}
if not stays:
game.wait(10)
case['wheat'] = server_count(game, 'wheat')
seen[f'{game.name}-{label}'] = case
want = {'open': stays} if stays else {'open': False, 'wheat': 20}
assert case == want, f'{game.name}-{label}: expected {want}, got {case} (screen {screen})'
back_to_game(game)
host('command', command=f'kill @e[tag={tag}]')
game.wait(5)
finally:
for game in (host, guest):
back_to_game(game)
game('command', command='gamemode survival')
game('command', command='clear @s')
host('command', command=f'kill @e[tag={tag}]')
return seen
CHECKS += ['merchant-close']
CHECK_FNS.update({'merchant-close': check_merchant_close})
def check_merchant_reselect(host, guest, out):
"""AbstractButton.keyPressed: the offer button clicked last keeps focus through slot clicks, and Space
(key repeats too) or Enter presses it again, so the emptied payment slot refills after every trade."""
x, y, z = (int(v // 1) for v in pos_of(host))
tag = 'reselect_trade'
seen = {}
try:
host('command', command=f'fill {x - 4} {y - 1} {z - 2} {x + 4} {y - 1} {z + 2} minecraft:stone')
host('command', command=f'fill {x - 4} {y} {z - 2} {x + 4} {y + 3} {z + 2} minecraft:air')
for game, other in ((host, guest), (guest, host)):
back_to_game(game)
back_to_game(other)
other('command', command=f'tp @s {x - 3.5} {y} {z + 0.5}')
game('command', command='gamemode survival')
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
game('command', command='clear @s')
host('command', command=f'summon minecraft:villager {x + 2.5} {y} {z + 0.5} {{Tags:["{tag}"],'
'NoAI:1b,PersistenceRequired:1b,Xp:1,VillagerData:{type:"minecraft:plains",'
'profession:"minecraft:farmer",level:1},Offers:{Recipes:[{buy:{id:"minecraft:wheat",count:20},'
'sell:{id:"minecraft:emerald",count:1},maxUses:16,uses:0,xp:0,priceMultiplier:0f,demand:0}]}}')
game.wait(20)
villager = next(e['id'] for e in game.raw('entities', 8, 'minecraft:villager')['entities']
if math.dist(e['pos'], [x + 2.5, y, z + 0.5]) < 0.5)
game.raw('interact-entity', villager)
game.raw('wait-for', 'screen', 'station', '--timeout', 10)
assert game('screen')['station'] == 'Merchant', f'{game.name}: no merchant screen'
steps = []
# tryMoveItems moves all the wheat it finds, so each round gives one trade's worth.
for press in ('click', 'space repeat', 'enter tap'):
game('command', command='give @s minecraft:wheat 20')
game.wait(5)
if press == 'click':
game.raw('click-gui', -1, 28)
else:
game.raw('key', *press.split())
game.wait(10)
paid = station_slots(game).get(600)
steps.append({'press': press, 'paid': paid})
assert paid == ('minecraft:wheat', 20), f'{game.name}-{press}: payment slot {paid}'
game.raw('click-slot', 650, 'shift')
game.wait(10)
left = station_slots(game).get(600)
assert left is None, f'{game.name}-{press}: payment left after the trade: {left}'
game.shot(out, 'merchant-reselect')
game.raw('key', 'esc', 'tap')
game.wait(10)
case = {'steps': steps, 'server': {item: server_count(game, item) for item in ('wheat', 'emerald')}}
seen[game.name] = case
assert case['server'] == {'wheat': 0, 'emerald': 3}, f'{game.name}: expected 3 trades, got {case}'
host('command', command=f'kill @e[tag={tag}]')
game.wait(5)
finally:
for game in (host, guest):
back_to_game(game)
game('command', command='clear @s')
host('command', command=f'kill @e[tag={tag}]')
return seen
CHECKS += ['merchant-reselect']
CHECK_FNS['merchant-reselect'] = check_merchant_reselect
def check_riptide_rain(host, guest, out):
"""TridentItem.use/releaseUsing: a Riptide trident launches its user in rain, for the host and for a guest
(a server's client answers `isInRain` from its own chunks and weather), with an elytra worn or not (#3458)."""
result = {}
spots = apart(host, guest)
x, y, z = spots['host']
host('command', command=f'fill {x} {y + 4} {z} {x} {y + 120} {z} minecraft:air')
host('command', command='weather rain')
for label, player in (('host', host), ('guest', guest)):
player('command', command='gamemode survival')
player('command', command='clear @s')
player('command', command='give @s minecraft:trident[minecraft:enchantments={"minecraft:riptide":3}]')
try:
for case, elytra in (('plain', False), ('elytra', True)):
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
player('command', command='item replace entity @s armor.chest with '
+ ('minecraft:elytra' if elytra else 'minecraft:air'))
player.raw('key', '1', 'tap')
player.wait(25)
start = pos_of(player)
replies = player.script(['mouse right down', 'wait 25', 'mouse right up', 'wait 12', 'state'], every=True)
moved = round(math.dist(start, replies[-1]['player']['position']), 2)
result[f'{label}-{case}'] = moved
assert moved > 4, f'{label}-{case}: the Riptide trident in rain moved the player {moved} blocks: {result}'
finally:
player('command', command='clear @s')
player('command', command='item replace entity @s armor.chest with minecraft:air')
player.raw('key', 'w', 'up')
host('command', command='weather clear')
return result
CHECKS += ['riptide_rain']
CHECK_FNS['riptide_rain'] = check_riptide_rain
def check_f3_density(host, guest, out):
"""NoiseBasedChunkGenerator.addDebugScreenInfo: DebugEntryChunkGeneration adds the `Density` line to F3 where
the game runs the level's generator (the host's own server), and not on a LAN guest."""
seen = {}
for label, player in (('host', host), ('guest', guest)):
player('command', command='gamemode creative')
player('command', command='tp @s 40.5 90 -30.5')
player.wait(80)
player.raw('key', 'f3', 'tap')
player.wait(10)
seen[label] = player('status')['debug']['overlay']
player.shot(out, f'{label}-f3')
player.raw('key', 'f3', 'tap')
player.wait(5)
assert seen == {'host': True, 'guest': True}, f'F3 did not open: {seen}'
return seen
CHECKS += ['f3_density']
CHECK_FNS['f3_density'] = check_f3_density
def check_explorer_map(host, guest, out):
"""TreasureMapForEmeralds / ExplorationMapFunction with skip_existing_chunks (a trade's default):
findNearestMapStructure with createReference adds a reference to the start it finds (StructureStart.addReference,
at most one), so the next map from the same place points at another structure. Three cartographers' offers
are made, one after the other: each adds as many new starts as the first did, none is pointed at twice."""
x, y, z = (int(v // 1) for v in pos_of(host))
file = host.work / 'saves' / 'Lan' / 'data' / 'horizon_structure_references_minecraft_overworld.json'
host('command', command=f'fill {x - 4} {y - 1} {z - 2} {x + 4} {y - 1} {z + 2} minecraft:stone')
host('command', command=f'fill {x - 4} {y} {z - 2} {x + 4} {y + 3} {z + 2} minecraft:air')
guest('command', command=f'tp @s {x - 3.5} {y} {z + 0.5}')
host('command', command='gamemode survival')
host('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
sizes = []
for n in range(3):
host('command', command=f'summon minecraft:villager {x + 2.5} {y} {z + 0.5} {{Tags:["cartographer{n}"],'
'NoAI:1b,PersistenceRequired:1b,Xp:1,VillagerData:{type:"minecraft:plains",'
'profession:"minecraft:cartographer",level:5}}')
host.wait(20)
villager = next(e['id'] for e in host.raw('entities', 8, 'minecraft:villager')['entities']
if math.dist(e['pos'], [x + 2.5, y, z + 0.5]) < 0.5)
host.raw('interact-entity', villager)
host.raw('wait-for', 'screen', 'station', '--timeout', 10)
host.wait(20)
if n == 0:
host.shot(out, 'explorer-map-trades')
host.raw('key', 'esc', 'tap')
host.wait(10)
host('command', command=f'kill @e[tag=cartographer{n}]')
sizes.append(len(json.loads(file.read_text())) if file.exists() else 0)
assert sizes[0] > 0, f'the first cartographer pointed no map at a structure: {sizes}'
assert sizes == [sizes[0] * k for k in (1, 2, 3)], f'a map pointed at a structure twice: {sizes}'
return {'starts_after_each_cartographer': sizes, 'pointed_at': json.loads(file.read_text())}
CHECKS += ['explorer_map']
CHECK_FNS['explorer_map'] = check_explorer_map
def check_sulfur_cube_power(host, guest, out):
"""SulfurCube.primeWhenOnPoweredPosition: an explosive cube standing in or beside redstone power lights
its fuse (getBestOwnOrNeighbourSignal), then explodes when the fuse is out."""
x, y, z = (int(v // 1) for v in pos_of(host))
tag = 'power_cube'
result = {}
host('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 3} {y + 3} {z + 3} minecraft:stone')
host('command', command=f'fill {x - 2} {y} {z - 2} {x + 2} {y + 2} {z + 2} minecraft:air')
host('command', command=f'fill {x - 2} {y + 3} {z - 2} {x + 2} {y + 3} {z + 2} minecraft:glowstone')
guest('command', command=f'tp @s {x + 0.5} {y} {z + 1.5} 180 25')
host('command', command=f'tp @s {x + 40} {y} {z + 40}')
guest.raw('key', 'f1', 'tap')
host('command', command='gamerule minecraft:mob_griefing false')
host('command', command=f'summon minecraft:sulfur_cube {x + 0.5} {y} {z - 1.5} '
f'{{Tags:["{tag}"],Size:1,PersistenceRequired:1b,equipment:{{body:{{id:"minecraft:tnt",count:1}}}}}}')
host.wait(40)
calm = guest('entities', radius=12, type='sulfur_cube')['entities']
result['calm'] = calm
assert len(calm) == 1, f'the cube is missing: {result}'
guest.shot(out, 'sulfur-cube-calm')
host('command', command=f'setblock {x} {y - 1} {z - 1} minecraft:redstone_block')
host('command', command=f'setblock {x} {y - 1} {z - 2} minecraft:redstone_block')
host('command', command=f'setblock {x + 1} {y - 1} {z - 1} minecraft:redstone_block')
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 2} {y - 1} {z + 2} minecraft:redstone_block')
host.wait(8)
guest.shot(out, 'sulfur-cube-primed')
data = host('command', command=f'data get entity @e[tag={tag},limit=1]')['chat'][0]
result['fuse'] = data.split('fuse: ')[1].split(',')[0]
assert int(result['fuse']) > 0 and 'Invulnerable: 1b' in data, f'the powered cube is not primed: {result}'
host.wait(100)
guest.shot(out, 'sulfur-cube-flash')
host.wait(40)
gone = guest('entities', radius=12, type='sulfur_cube')['entities']
result['after'] = gone
assert not gone, f'the powered cube did not explode: {result}'
return result
CHECKS += ['sulfur_cube_power']
CHECK_FNS['sulfur_cube_power'] = check_sulfur_cube_power
def check_parrot_follows_mob(host, guest, out):
"""FollowMobGoal(1.0, 3.0, 7.0), priority 3 of a parrot: it flies after another kind of mob within 7 blocks
and stops about 3 blocks from it, instead of only wandering."""
x, y, z = (int(v // 1) for v in pos_of(host))
tag = 'parrot_follow'
host('command', command=f'fill {x - 12} {y - 1} {z - 12} {x + 12} {y - 1} {z + 12} minecraft:stone')
host('command', command=f'fill {x - 12} {y} {z - 12} {x + 12} {y + 8} {z + 12} minecraft:air')
guest('command', command=f'tp @s {x + 9.5} {y} {z + 9.5} 135 25')
host('command', command=f'tp @s {x - 9.5} {y} {z - 9.5}')
host('command', command=f'summon minecraft:cow {x + 0.5} {y} {z + 0.5} {{Tags:["{tag}"],NoAI:1b,PersistenceRequired:1b}}')
host('command', command=f'summon minecraft:parrot {x + 6.5} {y + 2} {z + 0.5} {{Tags:["{tag}"],PersistenceRequired:1b,Variant:0}}')
host.wait(60)
distances = []
for _ in range(12):
host.wait(20)
found = guest('entities', radius=24)['entities']
cow = next(e for e in found if e['type'] == 'minecraft:cow')
parrot = next(e for e in found if e['type'] == 'minecraft:parrot')
distances.append(round(math.dist(cow['pos'], parrot['pos']), 2))
if len(distances) == 6:
guest.shot(out, 'parrot-follows-cow')
distances.sort()
median = distances[len(distances) // 2]
assert median <= 3.5, f'the parrot did not stay by the cow: {distances}'
return {'distances_sorted': distances, 'median': median}
CHECKS += ['parrot_follows_mob']
CHECK_FNS['parrot_follows_mob'] = check_parrot_follows_mob
CHECKS += ['hero-trading']
CHECK_FNS.update({'hero-trading': check_hero_trading})
def check_cure_discount(host, guest, out):
"""#3306: ZombieVillager.finishConversion credits the player who fed the golden apple
(conversionStarter, level.getPlayerByUUID): the ZOMBIE_VILLAGER_CURED gossip (+20 major and +25
minor positive, reputation 125) lowers that player's price by floor(125 * 0.05) = 6, for the
host on its server thread and for a guest alike."""
x, y, z = (int(v // 1) for v in pos_of(host))
tag = 'cure_trade'
at = (x + 2.5, y, z + 0.5)
seen = {}
try:
host('command', command=f'fill {x - 4} {y - 1} {z - 2} {x + 4} {y - 1} {z + 2} minecraft:stone')
host('command', command=f'fill {x - 4} {y} {z - 2} {x + 4} {y + 3} {z + 2} minecraft:air')
host('command', command='time set midnight')
for game, other in ((host, guest), (guest, host)):
back_to_game(game)
back_to_game(other)
other('command', command=f'tp @s {x - 3.5} {y} {z + 0.5}')
game('command', command='gamemode survival')
game('command', command='effect clear @s')
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} -90 0')
game('command', command='clear @s')
game('command', command='give @s minecraft:golden_apple')
game.wait(10)
game.raw('select', 'minecraft:golden_apple')
host('command', command=f'summon minecraft:zombie_villager {at[0]} {at[1]} {at[2]} '
f'{{Tags:["{tag}"],NoAI:1b,PersistenceRequired:1b,Xp:1,VillagerData:{{type:"minecraft:plains",'
'profession:"minecraft:farmer",level:1},Offers:{Recipes:[{buy:{id:"minecraft:wheat",count:20},'
'sell:{id:"minecraft:emerald",count:1},maxUses:16,uses:0,xp:0,priceMultiplier:0.05f,demand:0}]}}')
host('command', command=f'effect give @e[tag={tag}] minecraft:weakness 60 0')
game.wait(20)
zombie = next(e['id'] for e in game.raw('entities', 8, 'minecraft:zombie_villager')['entities']
if math.dist(e['pos'], at) < 0.5)
# ZombieVillager.mobInteract: the apple starts the cure; then it is cut short.
game.raw('interact-entity', zombie)
game.wait(10)
# Horizon counts the cure down in the AI step, so NoAI goes for the last tick.
host('command', command=f'data merge entity @e[tag={tag},limit=1] {{ConversionTime:1,NoAI:0b}}')
villager = None
for _ in range(30):
game.wait(5)
villager = next((e['id'] for e in game.raw('entities', 8, 'minecraft:villager')['entities']
if math.dist(e['pos'], at) < 2), None)
if villager is not None:
break
assert villager is not None, f'{game.name}: the zombie villager was not cured'
near = f'x={at[0]},y={at[1]},z={at[2]},distance=..3,sort=nearest,limit=1'
host('command', command=f'data merge entity @e[type=minecraft:villager,{near}] '
f'{{NoAI:1b,Tags:["{tag}"]}}')
game('command', command='clear @s')
game('command', command='give @s minecraft:wheat 20')
game.wait(10)
game.raw('interact-entity', villager)
game.raw('wait-for', 'screen', 'station', '--timeout', 10)
assert game('screen')['station'] == 'Merchant', f'{game.name}: no merchant screen'
game.raw('click-gui', -1, 28)
game.wait(10)
assert station_slots(game).get(650) == ('minecraft:emerald', 1), f'{game.name}: no result'
game.shot(out, f'cure-discount-{game.name}')
game.raw('click-slot', 650, 'shift')
game.wait(10)
game.raw('key', 'esc', 'tap')
game.wait(10)
case = {item: server_count(game, item) for item in ('wheat', 'emerald')}
seen[game.name] = case
want = {'wheat': 20 - 14, 'emerald': 1}
assert case == want, f'{game.name}: expected {want}, got {case}'
host('command', command=f'kill @e[tag={tag}]')
game.wait(5)
finally:
for game in (host, guest):
back_to_game(game)
game('command', command='clear @s')
host('command', command=f'kill @e[tag={tag}]')
host('command', command='time set day')
return seen
CHECKS += ['cure-discount']
CHECK_FNS.update({'cure-discount': check_cure_discount})
def check_spectator_travel(host, guest, out):
"""Player.tick's spectator noPhysics survives a replacement body after dimension travel (#1299)."""
players = (host, guest)
seen = {}
for game in players:
home = pos_of(game)
game('command', command='gamemode spectator')
game.wait(10)
try:
for dimension in (NETHER, OVERWORLD):
game('command', command=f'execute in {dimension} run tp @s .5 102 .5 0 0')
there = wait_dimension(game, dimension, 600)
assert there.get('dimension') == dimension, f'{game.name} stayed in {there.get("dimension")}'
# A replacement body arrives before its chunks; fill needs the whole corridor loaded.
for _ in range(30):
if all(game.raw('block', *pos).get('loaded') for pos in ((-2, 101, -1), (2, 105, 16))):
break
game.wait(20)
game('command', command='fill -2 101 -1 2 105 16 minecraft:air')
game('command', command='fill -2 101 3 2 105 3 minecraft:stone')
game.wait(10)
wall = game.raw('block', 0, 102, 3)
assert wall.get('loaded') and wall.get('block') == 'minecraft:stone', f'wall: {wall}'
label = f'spectator-{dimension.removeprefix("minecraft:")}'
game.shot(out, f'{label}-before')
game.raw('key', 'w', 'down')
game.wait(20)
game.raw('key', 'w', 'up')
game.wait(10)
state = game('state')
client = state['player']
server = server_player(host, game)
seen[f'{game.name}-{dimension}'] = {'client': client['position'], 'server': server['position']}
game.shot(out, f'{label}-after')
assert state.get('gamemode') == 'spectator' and client['flying'], f'{game.name} lost spectator flight'
assert client['position'][2] > 4 and server['position'][2] > 4, \
f'{game.name} collides after travel to {dimension}: {seen}'
game('command', command='fill -2 101 3 2 105 3 minecraft:air')
finally:
game.raw('key', 'w', 'up')
game('command', command=f'execute in {OVERWORLD} run tp @s {home[0]:.2f} {home[1]:.2f} {home[2]:.2f}')
wait_dimension(game, OVERWORLD, 600)
game('command', command='gamemode survival')
game.wait(20)
return seen
CHECKS += ['spectator-travel']
CHECK_FNS.update({'spectator-travel': check_spectator_travel})
def check_villagers(host, guest, out):
"""Villager.DATA_VILLAGER_DATA and ZombieVillager's reach the guest: summoned villagers show
their type, profession and level, and an unemployed villager that takes a composter placed
afterwards turns into a farmer on the guest too (VillagerProfessionLayer draws from that data)."""
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
x, y, z = gx, gy, gz + 5
tag = 'vs_villager'
summoned = [('villager', -2, 'desert', 'librarian', 3), ('villager', 0, 'snow', 'weaponsmith', 5),
('zombie_villager', 2, 'taiga', 'cleric', 2), ('villager', 4, 'plains', 'none', 1)]
def villagers(game):
return {e['id']: e['villager'] for e in game.raw('entities', 16)['entities'] if 'villager' in e}
seen = {}
try:
host('command', command='time set 2000')
host('command', command=f'fill {x - 4} {y - 1} {z - 3} {x + 6} {y - 1} {z + 4} minecraft:stone')
host('command', command=f'fill {x - 4} {y} {z - 3} {x + 6} {y + 3} {z + 4} minecraft:air')
for kind, dx, t, p, level in summoned:
# The unemployed one keeps its AI to take the job.
ai = '' if p == 'none' else 'NoAI:1b,Invulnerable:1b,'
host('command', command=f'summon minecraft:{kind} {x + dx + 0.5} {y} {z + 0.5} {{Tags:["{tag}"],{ai}'
f'PersistenceRequired:1b,Rotation:[180f,0f],'
f'VillagerData:{{type:"minecraft:{t}",profession:"minecraft:{p}",level:{level}}}}}')
guest.wait(20)
seen['summoned'] = {'host': villagers(host), 'guest': villagers(guest)}
host('command', command=f'setblock {x + 5} {y} {z + 2} minecraft:composter')
for waited in range(10, 1210, 10):
guest.wait(10)
farmers = [i for i, v in villagers(host).items() if v['profession'] == 'farmer']
if farmers:
seen['ticks to the job'] = waited
break
guest.wait(10)
seen['job'] = {'host': villagers(host), 'guest': villagers(guest)}
guest('command', command=f'tp @s {x + 1.5} {y} {z - 2.5} 0 10')
guest.wait(10)
guest.shot(out, 'villagers')
finally:
host('command', command=f'kill @e[tag={tag}]')
host('command', command=f'fill {x - 4} {y} {z - 3} {x + 6} {y + 3} {z + 4} minecraft:air')
host('command', command='time set day')
host_view, guest_view = seen['summoned']['host'], seen['summoned']['guest']
for _, _, t, p, level in summoned:
want = {'type': t, 'profession': p, 'level': level}
ids = [i for i, v in host_view.items() if v == want]
assert ids, f'the host lacks {want}: {seen}'
assert guest_view.get(ids[0]) == want, f'the guest sees {guest_view.get(ids[0])} for {want}: {seen}'
farmers = [i for i, v in seen['job']['host'].items() if v['profession'] == 'farmer']
assert farmers, f'no villager took the job: {seen}'
assert seen['job']['guest'].get(farmers[0]) == seen['job']['host'][farmers[0]], \
f'the guest missed the new job: {seen}'
return seen
CHECKS += ['villagers']
CHECK_FNS.update(villagers=check_villagers)
def check_stopsound(host, guest, out):
"""StopSoundCommand sends each target ClientboundStopSoundPacket: a player's own /stopsound by
source and by event reaches its own client's sound engine, and the host's /stopsound of the
guest reaches the guest's (sounds stopped, counted in the status as `heard` is, audio or not)."""
event = 'minecraft:music_disc.cat'
def stopped(game):
return game('status')['stopped_sounds']
seen = {}
for game in (host, guest):
before = stopped(game)
game('command', command='stopsound @s record')
game('command', command=f'stopsound @s * {event}')
game.wait(10)
after = stopped(game)
seen[game.name] = {k: after.get(k, 0) - before.get(k, 0) for k in after}
assert seen[game.name].get('record *') == 1 and seen[game.name].get(f'* {event}') == 1, \
f'{game.name}: its own /stopsound did not reach its client: {seen}'
before = {g.name: stopped(g) for g in (host, guest)}
host('command', command=f'stopsound {GUEST_NAME}')
guest.wait(10)
for game in (host, guest):
after = stopped(game)
seen[f'{game.name}-by-host'] = after.get('* *', 0) - before[game.name].get('* *', 0)
assert seen['guest-by-host'] == 1 and seen['host-by-host'] == 0, \
f"the host's /stopsound of the guest stopped the wrong client: {seen}"
return seen
CHECKS += ['stopsound']
CHECK_FNS.update(stopsound=check_stopsound)
def check_riding_teleport(host, guest, out):
"""#1473: a chat /tp dismounts each player and leaves the horse behind in both modes;
teleporting another player exercises the guest-target command path too."""
spots = apart(host, guest)
result = {}
for label, player, source in (('host', host, None), ('guest', guest, None),
('guest-targeted', guest, host)):
x, y, z = spots['host' if player is host else 'guest']
horse_at = (x + 0.5, y, z + 2.5)
destination = (x + 3.5, y, z + 5.5)
tag = 'tp_horse'
try:
player('command', command='clear @s')
player('command', command='gamemode survival')
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
player('command', command='item replace entity @s weapon.mainhand with minecraft:saddle')
player('command', command=f'summon minecraft:horse {horse_at[0]} {y} {horse_at[2]} '
f'{{Tame:1b,NoAI:1b,NoGravity:1b,Tags:["{tag}"]}}')
player.wait(5)
horses = [m for m in player.raw('mobs', 16)['near']
if m['kind'] == 'minecraft:horse' and m['health'] > 0]
horse = next((m for m in horses if math.dist(m['pos'], horse_at) < 0.1), None)
assert horse, f'{label}: no live horse at {horse_at}: {horses}'
player.raw('look-at', horse_at[0], y + 0.8, horse_at[2])
player.raw('mouse', 'right', 'click')
player.wait(5)
player('command', command='item replace entity @s weapon.mainhand with minecraft:air')
player.wait(2)
player.raw('mouse', 'right', 'click')
player.wait(5)
assert player('status').get('riding') == horse['id'], f'{label}: not riding the horse'
player.shot(out, f'riding-tp-{label}-before')
coordinates = ' '.join(map(str, destination))
if source is None:
player.raw('key', 't', 'tap')
player.raw('type', f'/tp @s {coordinates}')
player.raw('key', 'enter', 'tap')
else:
source('command', command=f'tp {GUEST_NAME} {coordinates}')
player.wait(60)
player.shot(out, f'riding-tp-{label}-after')
seen = {'riding': player('status').get('riding'), 'client': pos_of(player),
'server': server_player(host, player)['position'],
'horse': mob_seen(player, horse['id'])['pos'],
'other_horse': mob_seen(guest if player is host else host, horse['id'])['pos']}
assert seen['riding'] is None, f'{label}: still mounted after /tp: {seen}'
assert all(math.dist(seen[side], destination) < 0.01 for side in ('client', 'server')), \
f'{label}: /tp did not keep its destination after 60 ticks: {seen}'
assert all(math.dist(seen[side], horse_at) < 0.01 for side in ('horse', 'other_horse')), \
f'{label}: /tp moved the horse: {seen}'
result[label] = seen
finally:
back_to_game(player)
player('command', command=f'kill @e[tag={tag}]')
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
# The next case uses this spot again: let the previous horse's death animation finish.
player.wait(25)
return result
CHECKS += ['riding_teleport']
CHECK_FNS.update(riding_teleport=check_riding_teleport)
def check_portal_rider(host, guest, out):
"""#1486: a survival guest rides a horse into a Nether portal. The horse goes
through at once (NetherPortalBlock.getPortalTransitionTime is 0 for an entity that isn't a
player; a passenger has no portal process of its own, Entity.canUsePortal) and carries its rider
(Entity.teleportCrossDimension): the guest leaves well before a player's own 80 ticks and
arrives in the Nether still on the same horse."""
x, y, z = apart(host, guest)['guest']
horse_at = (x + 1.0, y, z + 2.5)
# The portal's plane crosses the horse's box (z + 1.8 to z + 3.2), the rider's too.
pz = z + 2
tag = 'portal_rider'
home = 'execute in minecraft:overworld run'
try:
guest('command', command='clear @s')
guest('command', command='gamemode survival')
guest('command', command='gamerule players_nether_portal_default_delay 80')
guest('command', command='item replace entity @s weapon.mainhand with minecraft:saddle')
guest('command', command=f'summon minecraft:horse {horse_at[0]} {y} {horse_at[2]} '
f'{{Tame:1b,NoAI:1b,Tags:["{tag}"]}}')
guest.wait(5)
horses = [m for m in guest.raw('mobs', 16)['near'] if m['kind'] == 'minecraft:horse' and m['health'] > 0]
horse = next((m for m in horses if math.dist(m['pos'], horse_at) < 0.1), None)
assert horse, f'no live horse at {horse_at}: {horses}'
guest.raw('look-at', horse_at[0], y + 0.8, horse_at[2])
guest.raw('mouse', 'right', 'click')
guest.wait(5)
guest('command', command='item replace entity @s weapon.mainhand with minecraft:air')
guest.wait(2)
guest.raw('mouse', 'right', 'click')
guest.wait(5)
assert guest('status').get('riding') == horse['id'], 'the guest is not riding the horse'
# The frame around the horse and its rider, then the portal inside it.
for corner in (f'{x - 1} {y - 1} {pz} {x + 2} {y - 1} {pz}', f'{x - 1} {y + 3} {pz} {x + 2} {y + 3} {pz}',
f'{x - 1} {y} {pz} {x - 1} {y + 2} {pz}', f'{x + 2} {y} {pz} {x + 2} {y + 2} {pz}'):
guest('command', command=f'fill {corner} minecraft:obsidian')
guest('command', command=f'fill {x} {y} {pz} {x + 1} {y + 2} {pz} minecraft:nether_portal[axis=x]')
guest.shot(out, 'portal-rider-in')
left = None
for waited in range(0, 120, 5):
if guest('state').get('dimension') == NETHER:
left = waited
break
guest.wait(5)
assert left is not None, 'the guest on its horse did not go through the portal in 120 ticks'
assert left < 40, f'the guest left after {left} ticks: its own delay (80), not the horse\'s (0)'
# The server holds the guest until the chunks around the exit are lit, then places it there.
there = wait_dimension(guest, NETHER, 600)
seen = None
for _ in range(30):
guest.wait(20)
seen = {'riding': guest('status').get('riding'), 'horse': mob_seen(guest, horse['id']),
'at': pos_of(guest)}
if seen['horse'] and math.dist(seen['horse']['pos'], seen['at']) < 3:
break
guest.wait(40)
seen = {'riding': guest('status').get('riding'), 'horse': mob_seen(guest, horse['id']), 'at': pos_of(guest)}
guest.shot(out, 'portal-rider-nether')
assert there.get('dimension') == NETHER, f'the guest is in {there.get("dimension")}'
assert seen['horse'], f'the horse {horse["id"]} did not come to the Nether with its rider: {seen}'
assert seen['riding'] == horse['id'], f'the guest arrived on foot: {seen}'
assert math.dist(seen['horse']['pos'], seen['at']) < 3, f'the horse is not under its rider: {seen}'
host_dim = host('state').get('dimension')
assert host_dim == OVERWORLD, f'the host went to {host_dim} with the guest'
return {'left_after_ticks': left, 'riding': seen['riding'], 'horse': seen['horse']['pos'], 'guest': seen['at']}
finally:
back_to_game(guest)
guest('command', command=f'kill @e[tag={tag}]')
guest('command', command=f'{home} fill {x - 1} {y - 1} {pz} {x + 2} {y + 3} {pz} minecraft:air')
guest('command', command=f'{home} fill {x - 1} {y - 1} {pz} {x + 2} {y - 1} {pz} minecraft:stone')
guest('command', command=f'{home} tp @s {x + 0.5} {y} {z + 0.5} 0 0')
wait_dimension(guest, OVERWORLD, 600)
guest.wait(20)
CHECKS += ['portal_rider']
CHECK_FNS.update(portal_rider=check_portal_rider)
def check_cart_portal_rider(host, guest, out):
"""#6021: a player rides a minecart along rails into a Nether portal. The cart goes through at once
(Entity.handlePortal: a vehicle's transition time is 0; the passenger has no portal process of its
own) and takes its rider (Entity.teleportCrossDimension), so the player arrives in the Nether still
on the same cart, the cart under it and out of the Overworld. The host (the window of the
single-player server) and the guest each ride."""
spots = apart(host, guest)
tag = 'cart_portal_rider'
home = 'execute in minecraft:overworld run'
result = {}
for label, player in (('host', host), ('guest', guest)):
x, y, z = spots[label]
# Rails run east from the player's spot into a portal in the plane x = rx + 8 (axis z).
rx, pz = x + 1, z + 1
px = rx + 8
player('command', command='clear @s')
player('command', command='gamemode survival')
player('command', command=f'fill {rx - 1} {y - 1} {pz - 2} {px + 2} {y - 1} {pz + 3} minecraft:stone')
player('command', command=f'fill {rx - 1} {y} {pz - 2} {px + 2} {y + 4} {pz + 3} minecraft:air')
player('command', command=f'fill {rx} {y} {pz} {px - 1} {y} {pz} minecraft:powered_rail[powered=false,shape=east_west]')
player('command', command=f'setblock {rx - 1} {y} {pz} minecraft:stone')
player('command', command=f'fill {px} {y - 1} {pz - 1} {px} {y + 3} {pz + 2} minecraft:obsidian')
player('command', command=f'fill {px} {y} {pz} {px} {y + 2} {pz + 1} minecraft:nether_portal[axis=z]')
player('command', command=f'summon minecraft:minecart {rx + 0.5} {y} {pz + 0.5} {{Tags:["{tag}"]}}')
player('command', command=f'tp @s {rx + 0.5} {y} {pz - 0.5} 0 0')
player.wait(30)
try:
cart = board(player, 'minecart', (rx + 0.5, y, pz + 0.5))
# The rails come alive: a cart at rest on powered rails is pushed off the stone behind it.
player('command', command=f'setblock {rx + 1} {y} {pz + 1} minecraft:redstone_block')
left = None
for waited in range(0, 200, 5):
if player('state').get('dimension') == NETHER:
left = waited
break
player.wait(5)
carts = [o for o in player('status')['objects'] if o['kind'].endswith('minecart')]
assert left is not None, (f'the {label} on its minecart did not go through the portal in 200 ticks: '
f'riding {player("status").get("riding")}, at {pos_of(player)}, carts {carts}')
wait_dimension(player, NETHER, 600)
seen = None
for _ in range(30):
player.wait(20)
seen = {'riding': player('status').get('riding'), 'at': pos_of(player)}
if seen['riding'] == cart['id']:
break
assert seen['riding'] == cart['id'], f'the {label} arrived on foot, not on the cart: {seen}'
near = [o for o in player('status')['objects'] if o['id'] == cart['id']]
assert near, f'the cart {cart["id"]} did not come to the Nether with its rider'
assert math.dist(near[0]['position'], seen['at']) < 3, f'the cart is not under its rider: {near[0]} {seen}'
result[label] = {'left_after_ticks': left, 'riding': seen['riding'], 'cart': near[0]['position'],
'player': seen['at']}
finally:
back_to_game(player)
player('command', command=f'kill @e[tag={tag}]')
player('command', command=f'{home} fill {rx - 1} {y - 1} {pz - 2} {px + 2} {y + 4} {pz + 3} minecraft:air')
player('command', command=f'{home} fill {rx - 1} {y - 1} {pz - 2} {px + 2} {y - 1} {pz + 3} minecraft:stone')
player('command', command=f'{home} tp @s {x + 0.5} {y} {z + 0.5} 0 0')
wait_dimension(player, OVERWORLD, 600)
player.wait(20)
return result
CHECKS += ['cart_portal_rider']
CHECK_FNS.update(cart_portal_rider=check_cart_portal_rider)
def check_recipe_removal(host, guest, out):
"""ServerRecipeBook.removeRecipes sends ClientboundRecipeBookRemovePacket to the player:
the open book updates immediately, reopening keeps it empty, and later grants still arrive."""
seen = {}
for game in (host, guest):
back_to_game(game)
game('command', command='clear @s')
game('command', command='give @s minecraft:oak_log 4')
game('command', command='recipe give @s minecraft:oak_planks')
game('command', command='recipe give @s minecraft:stick')
game.wait(40)
game.raw('key', 'e', 'tap')
game.wait(5)
try:
book = game('screen').get('recipe_book')
assert book, f'{game.name}: the inventory has no recipe book'
if len(book['targets']) == 1:
game.raw('click-at', *centre(book['targets'][0]))
game.wait(5)
book = game('screen')['recipe_book']
known = book['known']
assert known > 1, f'{game.name}: recipes were not granted: {book}'
game.shot(out, 'recipe-removal-before')
game('command', command='recipe take @s *')
game.wait(20)
empty = game('screen')['recipe_book']['known']
game.shot(out, 'recipe-removal-empty')
assert empty == 0, f'{game.name}: forgotten recipes remain in the open book: {empty}'
game.raw('key', 'esc', 'tap')
game.wait(5)
game.raw('key', 'e', 'tap')
game.wait(5)
reopened = game('screen')['recipe_book']['known']
assert reopened == 0, f'{game.name}: reopening restored {reopened} forgotten recipes'
game('command', command='recipe give @s minecraft:oak_planks')
game('command', command='recipe give @s minecraft:stick')
game.wait(20)
given = game('screen')['recipe_book']['known']
assert given == 2, f'{game.name}: two later grants did not appear: {given}'
game('command', command='recipe take @s minecraft:oak_planks')
game.wait(20)
one = game('screen')['recipe_book']['known']
assert one == 1, f'{game.name}: taking one recipe left {one}, expected 1'
game('command', command='recipe take @s minecraft:stick')
game.wait(20)
assert game('screen')['recipe_book']['known'] == 0, f'{game.name}: the last recipe remains'
game('command', command='recipe give @s minecraft:oak_planks')
game.wait(20)
granted = game('screen')['recipe_book']['known']
assert granted == 1, f'{game.name}: a later grant did not appear: {granted}'
game.shot(out, 'recipe-removal-granted')
seen[game.name] = {'known': known, 'empty': empty, 'reopened': reopened,
'given': given, 'one': one, 'granted': granted}
finally:
back_to_game(game)
return seen
CHECKS += ['recipe_removal']
CHECK_FNS.update(recipe_removal=check_recipe_removal)
def check_recipe_reselect(host, guest, out):
"""RecipeBookComponent.keyPressed: Enter or Space (key repeats too) places `lastRecipe`, the recipe the
last book click placed, again, with the key's Shift; slot clicks keep it, so the grid refills after
every craft taken from the result slot."""
seen = {}
for game in (host, guest):
back_to_game(game)
game('command', command='gamemode survival')
game('command', command='clear @s')
# Picking up string unlocks more recipes: take them, so wool's is the book's only button.
game('command', command='give @s minecraft:string 12')
game.wait(20)
game('command', command='recipe take @s *')
game('command', command='recipe give @s minecraft:white_wool_from_string')
game.wait(20)
game.raw('key', 'e', 'tap')
game.wait(5)
try:
book = game('screen').get('recipe_book')
assert book, f'{game.name}: the inventory has no recipe book'
if len(book['targets']) == 1:
game.raw('click-at', *centre(book['targets'][0]))
game.wait(5)
book = game('screen')['recipe_book']
buttons = [r for r in book['targets'] if r[2:] == [25.0, 25.0]]
assert len(buttons) == 1, f'{game.name}: expected the wool button alone: {book}'
steps = []
# 12 string: Shift places 3 per grid slot, a plain press 1.
for press, shift, per_slot in (('click', True, 3), ('space repeat', True, 3), ('enter tap', False, 1)):
if press != 'click':
game('command', command='give @s minecraft:string 12')
game.wait(10)
if shift:
game.raw('key', 'shift', 'down')
if press == 'click':
game.raw('click-at', *centre(buttons[0]))
else:
game.raw('key', *press.split())
if shift:
game.raw('key', 'shift', 'up')
game.wait(10)
grid = {s['slot']: (s['stack']['item'], s['stack']['count'])
for s in game.raw('menu')['slots'] if 100 <= s['slot'] < 109}
steps.append({'press': press, 'grid': grid})
counts = sorted(c for item, c in grid.values() if item == 'minecraft:string')
assert counts == [per_slot] * 4, f'{game.name}-{press}: grid {grid}'
game.raw('click-slot', 200, 'shift')
game.wait(10)
game.shot(out, 'recipe-reselect')
game.raw('key', 'esc', 'tap')
game.wait(10)
case = {'steps': steps, 'server': {item: server_count(game, item) for item in ('string', 'white_wool')}}
seen[game.name] = case
# 36 string in, 3 + 3 + 1 wool out at 4 string each.
assert case['server'] == {'string': 8, 'white_wool': 7}, f'{game.name}: {case}'
finally:
back_to_game(game)
game('command', command='clear @s')
game('command', command='recipe give @s *')
return seen
CHECKS += ['recipe-reselect']
CHECK_FNS['recipe-reselect'] = check_recipe_reselect
def save_and_reopen(host, guest=None, world='Lan', guest_name=GUEST_NAME):
"""Save and Quit on `host`, reopen `world`, and reconnect `guest` when present."""
if guest is not None:
back_to_game(guest)
guest.raw('disconnect')
back_to_game(host)
host.raw('pause')
host.raw('click-button', 'Save and Quit to Title')
host.raw('wait-for', 'screen', 'title', '--timeout', 120)
host.raw('open', world)
host('wait_ready', seconds=400)
if guest is not None:
port = host.raw('lan', 'open', 0, 'commands')['port']
guest.raw('connect', f'127.0.0.1:{port}', guest_name)
guest.raw('wait-for', 'loaded', '--timeout', 300)
guest.wait(20)
def check_recipe_settings(host, guest, out):
"""RecipeBookComponent sends both settings to the server; each player's
own book keeps them when the authoritative world saves and reopens (#2290)."""
def inventory(game):
back_to_game(game)
game.raw('key', 'e', 'tap')
game.wait(5)
return game('screen')['recipe_book']
def flags(book):
return {'open': book['open'], 'filtering': book['filtering']}
def set_flags(game, opened, filtering):
book = inventory(game)
if not book['open']:
game.raw('click-at', *centre(book['targets'][0]))
game.wait(5)
book = game('screen')['recipe_book']
if book['filtering'] != filtering:
target = next(t for t in book['targets'] if t[2:] == [26.0, 16.0])
game.raw('click-at', *centre(target))
game.wait(5)
if not opened:
book = game('screen')['recipe_book']
game.raw('click-at', *centre(book['targets'][0]))
game.wait(5)
actual = flags(game('screen')['recipe_book'])
assert actual == {'open': opened, 'filtering': filtering}, f'{game.name}: {actual}'
game.shot(out, 'recipe-settings-before')
back_to_game(game)
return actual
# Deliberately different settings ensure neither player's book overwrites the other's.
before = {host.name: set_flags(host, True, True),
guest.name: set_flags(guest, False, True)}
host.wait(20)
save_and_reopen(host, guest)
after = {}
for game in (host, guest):
after[game.name] = flags(inventory(game))
game.shot(out, 'recipe-settings-reopened')
back_to_game(game)
assert after == before, f'saved recipe settings reset: before={before}, after={after}'
return {'before': before, 'reopened': after}
CHECKS += ['recipe_settings']
CHECK_FNS.update(recipe_settings=check_recipe_settings)
def check_reopen_player(host, guest, out):
"""Save and Quit, then reopen: the owner comes back where they stood with what
they held (the world's own player file, `players/data/<singleplayer uuid>.dat`),
and a guest likewise from their own file (#5065, #5075)."""
places = {host.name: (40.5, 120.0, -30.5), guest.name: (-25.5, 110.0, 45.5)}
items = {host.name: ('minecraft:diamond', 7), guest.name: ('minecraft:emerald', 5)}
def held(game):
item = items[game.name][0]
return game('inventory')['counts'].get(item, 0)
for game in (host, guest):
back_to_game(game)
x, y, z = places[game.name]
item, count = items[game.name]
# A glass block to stand on, so nobody is still falling when the world saves.
host('command', command=f'setblock {math.floor(x)} {int(y) - 1} {math.floor(z)} minecraft:glass')
game('command', command=f'tp @s {x} {y} {z}')
game('command', command=f'give @s {item} {count}')
host.wait(40)
before = {game.name: {'position': [round(v, 1) for v in pos_of(game)], 'held': held(game)}
for game in (host, guest)}
for game in (host, guest):
want = {'position': list(places[game.name]), 'held': items[game.name][1]}
assert before[game.name] == want, f'{game.name} not set up: {before[game.name]}'
save_and_reopen(host, guest)
after = {}
for game in (host, guest):
after[game.name] = {'position': [round(v, 1) for v in pos_of(game)], 'held': held(game)}
game.shot(out, 'reopen-player')
assert after == before, f'players reset on reopen: before={before}, after={after}'
return {'reopened': after}
CHECKS += ['reopen_player']
CHECK_FNS.update(reopen_player=check_reopen_player)
def check_leash_reload(host, guest, out):
"""A mob tied to a player stays tied across Save and Quit / reopen and can be untied by
right-clicking it afterward (#6876)."""
tag = 'leash_reload_cow'
spots = apart(host, guest)
x, y, z = spots['host']
at = (x + 0.5, y, z + 2.5)
def cow(game):
found = [m for m in game.raw('mobs', 32).get('near', []) if m['kind'] == 'minecraft:cow'
and abs(m['pos'][0] - at[0]) < 1.5 and abs(m['pos'][2] - at[2]) < 1.5]
assert len(found) == 1, f'{game.name} sees {len(found)} cows at {at}: {found}'
return found[0]
def nbt_ints(reply):
values = re.findall(r'-?\d+', reply.partition('[I;')[2].partition(']')[0])
assert len(values) == 4, f'not a UUID int array: {reply}'
return ','.join(values)
def player_uuid():
return nbt_ints(server_reply(host, 'data get entity @s UUID', '[I;'))
def cow_uuid():
return nbt_ints(server_reply(host, f'data get entity @e[type=minecraft:cow,tag={tag},limit=1] UUID', '[I;'))
def cow_leash_uuid():
return nbt_ints(server_reply(host, f'data get entity @e[type=minecraft:cow,tag={tag},limit=1] leash', '[I;'))
back_to_game(host)
back_to_game(guest)
host('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 1} {y - 1} {z + 4} minecraft:glass')
host('command', command=f'fill {x - 1} {y} {z - 1} {x + 1} {y + 3} {z + 4} minecraft:air')
host('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
host('command', command='gamemode survival')
host('command', command='clear @s')
host('command', command=f'summon minecraft:cow {at[0]} {y} {at[2]} '
f'{{NoAI:1b,Tags:["{tag}"],UUID:[I;{offline_uuid_ints(tag)}]}}')
host('command', command='item replace entity @s weapon.mainhand with minecraft:lead')
host.wait(30)
try:
owner_uuid = offline_uuid_ints(host.player)
before_player_uuid = player_uuid()
before_cow_uuid = cow_uuid()
assert before_player_uuid == owner_uuid, f'unexpected host UUID: {before_player_uuid} != {owner_uuid}'
host.raw('look-at', f'{at[0]:.2f}', f'{y + 0.7:.2f}', f'{at[2]:.2f}')
host.raw('mouse', 'right', 'click')
host.wait(20)
tied_before = {
'own': cow(host)['leash'],
'other': cow(guest)['leash'],
'nbt_holder': cow_leash_uuid(),
}
assert tied_before['own'] == 'Player' and tied_before['other'], f'cow not tied before save: {tied_before}'
host.shot(out, 'leash-reload-before')
save_and_reopen(host, guest)
back_to_game(host)
back_to_game(guest)
host.wait(20)
after_player_uuid = player_uuid()
after_cow_uuid = cow_uuid()
assert after_player_uuid == before_player_uuid, (
f'player UUID changed across reopen: {before_player_uuid} -> {after_player_uuid}'
)
assert after_cow_uuid == before_cow_uuid, (
f'cow persistent UUID changed across reopen: {before_cow_uuid} -> {after_cow_uuid}'
)
reopened_cow = cow(host)
assert reopened_cow['health'] > 0, f'cow not alive after reopen: {reopened_cow}'
tied_reopened = {
'own': reopened_cow['leash'],
'other': cow(guest)['leash'],
'nbt_holder': cow_leash_uuid(),
}
assert tied_reopened['own'] == 'Player' and tied_reopened['other'], (
f'cow lost player leash after reopen: {tied_reopened}'
)
host.shot(out, 'leash-reload-reopened')
# A closer player with an empty hand must not become the holder or untie it.
guest('command', command='clear @s')
guest('command', command=f'tp @s {at[0] + 1.0} {y} {at[2]}')
guest.raw('look-at', f'{at[0]:.2f}', f'{y + 0.7:.2f}', f'{at[2]:.2f}')
guest.raw('mouse', 'right', 'click')
host.wait(20)
bystander = {'own': cow(host)['leash'], 'other': cow(guest)['leash']}
# Right-clicking with an empty hand after reopening must untie the cow (#6876).
hand = host('state').get('held') or {}
assert hand.get('item') in (None, 'minecraft:air'), f'hand not empty after reopen: {hand}'
host.raw('look-at', f'{at[0]:.2f}', f'{y + 0.7:.2f}', f'{at[2]:.2f}')
host.raw('mouse', 'right', 'click')
host.wait(20)
untied_cow = cow(host)
assert untied_cow['health'] > 0, f'cow died when untied: {untied_cow}'
assert cow_uuid() == before_cow_uuid, 'cow persistent UUID changed after untying'
untied = {'own': untied_cow['leash'], 'other': cow(guest)['leash']}
# Keep every stage even when the regression fails, and attempt the actual
# post-reopen interaction before checking the serialized holder UUID.
evidence = {'player_uuid': before_player_uuid, 'cow_uuid': before_cow_uuid,
'before': tied_before, 'reopened': tied_reopened,
'bystander': bystander, 'untied': untied}
(out / 'leash-reload.json').write_text(json.dumps(evidence, indent=2) + '\n')
assert untied == {'own': None, 'other': None}, f'cow still leashed after right-click on reopen: {untied}'
assert bystander['own'] == 'Player' and bystander['other'].startswith('Mob('), (
f'closer bystander took or removed the leash: {bystander}'
)
assert tied_before['nbt_holder'] == owner_uuid, f'wrong leash holder UUID before save: {tied_before}'
assert tied_reopened['nbt_holder'] == owner_uuid, (
f'cow leash holder UUID changed after reopen: {tied_reopened}'
)
host.shot(out, 'leash-reload-untied')
finally:
host('command', command=f'kill @e[tag={tag}]')
host('command', command=f'kill @e[type=minecraft:item,x={at[0]},y={y},z={at[2]},distance=..6]')
host('command', command='clear @s')
return {
'player_uuid': before_player_uuid,
'cow_uuid': before_cow_uuid,
'before': tied_before,
'reopened': tied_reopened,
'bystander': bystander,
'untied': untied,
}
CHECKS += ['leash_reload']
CHECK_FNS.update(leash_reload=check_leash_reload)
def check_recipe_targets(host, guest, out):
"""RecipeCommand gives and takes the recipes of each target's own ServerRecipeBook: a
`/recipe give|take <other player>` changes that player's book and reaches their client,
not the book of the player who typed it: the guest gives the host a recipe too."""
host_name = host.raw('lan', 'status').get('host', 'Player')
books = {}
for game in (host, guest):
back_to_game(game)
game('command', command='clear @s')
game('command', command='recipe take @s *')
host.wait(20)
for game in (host, guest):
game.raw('key', 'e', 'tap')
game.wait(5)
book = game('screen').get('recipe_book')
assert book, f'{game.name}: the inventory has no recipe book'
if len(book['targets']) == 1:
game.raw('click-at', *centre(book['targets'][0]))
game.wait(5)
def known():
return {game.name: game('screen')['recipe_book']['known'] for game in (host, guest)}
try:
books['empty'] = known()
assert books['empty'] == {host.name: 0, guest.name: 0}, books
host('command', command=f'recipe give {GUEST_NAME} minecraft:oak_planks')
host('command', command=f'recipe give {GUEST_NAME} minecraft:stick')
host.wait(20)
books['host gives guest'] = known()
guest('command', command=f'recipe give {host_name} minecraft:stick')
guest.wait(20)
books['guest gives host'] = known()
host('command', command=f'recipe take {GUEST_NAME} minecraft:stick')
host.wait(20)
books['host takes from guest'] = known()
host.shot(out, 'recipe-targets-host')
guest.shot(out, 'recipe-targets-guest')
expected = {'empty': (0, 0), 'host gives guest': (0, 2), 'guest gives host': (1, 2),
'host takes from guest': (1, 1)}
for step, (in_host, in_guest) in expected.items():
assert books[step] == {host.name: in_host, guest.name: in_guest}, (step, books)
finally:
back_to_game(host)
back_to_game(guest)
return books
CHECKS += ['recipe_targets']
CHECK_FNS.update(recipe_targets=check_recipe_targets)
def check_shulker_bullets(host, guest, out):
"""ShulkerAttackGoal's bullets reach ClientPacketListener.handleAddEntity, then the existing
projectile renderer and interpolation, with distinct ids from the server's entity counter."""
x, y, z = (int(v // 1) for v in pos_of(host))
tag = 'lan_shulker_bullets'
selector = f'@e[type=minecraft:shulker,tag={tag},limit=1]'
sound = 'minecraft:entity.shulker.shoot'
paths = {g.name: {} for g in (host, guest)}
before = {g.name: heard(g, sound) for g in (host, guest)}
shots = dict.fromkeys(before, 0)
first_shot = stopped = None
photographed = False
def bullets(status):
return [p for p in status['projectiles'] if p['kind'] == 'ShulkerBullet']
try:
host('command', command='difficulty normal')
host('command', command='gamemode creative')
guest('command', command='gamemode survival')
guest('command', command='effect give @s minecraft:resistance 120 4 true')
host('command', command=f'fill {x - 10} {y - 1} {z - 10} {x + 10} {y - 1} {z + 18} minecraft:stone')
host('command', command=f'fill {x - 10} {y} {z - 10} {x + 10} {y + 12} {z + 18} minecraft:air')
# A west wall supports the shulker above the floor, leaving room for a short flight.
host('command', command=f'fill {x - 1} {y} {z + 7} {x - 1} {y + 4} {z + 7} minecraft:stone')
host('command', command=f'tp @s {x + 4.5} {y} {z - 0.5}')
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
host('command', command=f'summon minecraft:shulker {x + 0.5} {y + 3} {z + 7.5} '
f'{{NoAI:1b,PersistenceRequired:1b,Tags:["{tag}"]}}')
for game in (host, guest):
game.raw('look-at', x + 0.5, y + 3.5, z + 7.5)
guest.wait(20)
host('command', command=f'data merge entity {selector} {{NoAI:0b}}')
for waited in range(2, 402, 2):
guest.wait(2)
for game in (host, guest):
status = game('status')
live = bullets(status)
ids = [p['id'] for p in live]
assert all(ids) and len(set(ids)) == len(ids), f'{game.name}: invalid bullet ids {ids}'
for p in live:
paths[game.name].setdefault(p['id'], []).append(p['position'])
shots[game.name] = status['heard'].get(sound, 0) - before[game.name]
if shots['host'] and first_shot is None:
first_shot = waited
if first_shot is not None and waited - first_shot >= 8 and not photographed:
host('command', command='tick freeze')
try:
for game in (host, guest):
game.raw('look-at', x + 0.5, y + 3.5, z + 7.5)
game.raw('key', 'f1', 'tap')
try:
game.shot(out, 'shulker-bullets')
finally:
game.raw('key', 'f1', 'tap')
finally:
host('command', command='tick unfreeze')
photographed = True
if shots['host'] >= 2 and stopped is None:
host('command', command=f'data merge entity {selector} {{NoAI:1b}}')
stopped = waited
if stopped is not None and waited - stopped >= 20:
break
# Keep the survival target in range after a hit applies levitation.
if waited % 20 == 0:
guest('command', command='effect clear @s minecraft:levitation')
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
lab.write_json(out / 'shulker-bullets.json', {'shots': shots, 'paths': paths})
assert shots['host'] >= 2, f'the shulker did not attack twice: {shots}'
for name, by_id in paths.items():
assert len(by_id) == shots[name], f'{name}: {shots[name]} shots but bullet ids {list(by_id)}'
for id, positions in by_id.items():
assert any(math.dist(positions[0], p) > 0.5 for p in positions), \
f'{name}: bullet {id} did not move: {positions}'
assert paths['host'].keys() == paths['guest'].keys(), f'different bullets: {paths}'
for id, positions in paths['guest'].items():
# The clients interpolate three ticks behind, and reads are not simultaneous.
assert all(min(math.dist(p, q) for q in paths['host'][id]) < 1.5 for p in positions), \
f'bullet {id} diverged from the host: {paths}'
# Put blocks in the flight volume to exercise server removal without depending on homing.
for game in (host, guest):
game('command', command='gamemode creative')
game('command', command=f'tp @s {x + 0.5} {y + 16} {z + 0.5}')
host('command', command=f'fill {x - 10} {y} {z - 10} {x + 10} {y + 12} {z + 18} minecraft:stone')
for _ in range(20):
guest.wait(2)
remaining = {g.name: bullets(g('status')) for g in (host, guest)}
if not any(remaining.values()):
break
assert not any(remaining.values()), f'bullets survived block hits: {remaining}'
finally:
host('command', command='tick unfreeze')
host('command', command=f'kill @e[tag={tag}]')
host('command', command=f'fill {x - 10} {y} {z - 10} {x + 10} {y + 12} {z + 18} minecraft:air')
for game in (host, guest):
game('command', command='effect clear @s')
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
game('command', command='gamemode survival')
face_each_other(host, guest)
return {'shots': shots, 'ids': sorted(paths['host']), 'removed': True}
CHECKS += ['shulker_bullets']
CHECK_FNS.update(shulker_bullets=check_shulker_bullets)
def check_farmland(host, guest, out):
"""FarmlandBlock.fallOn's base-block change breaks the unsupported crop (#1492):
the host and guest each land on mature wheat, get its drops, and both clients see dirt/air."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
y = max(hy + 12, 100)
starts = {g.name: pos_of(g) for g in (host, guest)}
seen = {}
host('command', command='gamerule random_tick_speed 0')
try:
for game, other, dz in ((host, guest, -3), (guest, host, 3)):
x, z = hx + 2, hz + dz
soil, crop, marker = (x, y, z), (x, y + 1, z), (x + 4, y + 1, z)
for g in (game, other):
g('command', command='gamemode creative')
host('command', command=f'fill {x - 4} {y} {z - 2} {x + 4} {y} {z + 2} minecraft:stone')
host('command', command=f'fill {x - 4} {y + 1} {z - 2} {x + 4} {y + 7} {z + 2} minecraft:air')
host('command', command=f'setblock {x} {y} {z} minecraft:farmland[moisture=7]')
host('command', command=f'setblock {x} {y + 1} {z} minecraft:wheat[age=7]')
other('command', command=f'tp @s {x + 3.5} {y + 1} {z + 0.5}')
game('command', command=f'tp @s {x + 2.5} {y + 1} {z + 0.5}')
game('command', command='clear @s')
game('command', command='gamemode survival')
game.wait(20)
before = {g.name: [g.raw('block', *p).get('block') for p in (soil, crop)]
for g in (host, guest)}
assert all(v == ['minecraft:farmland', 'minecraft:wheat'] for v in before.values()), \
f'{game.name}: planted wheat missing before landing: {before}'
other.raw('look-at', x + 0.5, y + 1, z + 0.5)
other.shot(out, f'farmland-{game.name}-before')
# More than 1.5 blocks of fall distance guarantees the trampling random check.
game('command', command=f'tp @s {x + 0.5} {y + 6} {z + 0.5}')
game.wait(80)
after = {g.name: [g.raw('block', *p).get('block') for p in (soil, crop)]
for g in (host, guest)}
# Test the authoritative blocks too, rather than only each client's view.
game('command', command=f'execute if block {x} {y} {z} minecraft:dirt '
f'if block {x} {y + 1} {z} minecraft:air run '
f'setblock {marker[0]} {marker[1]} {marker[2]} minecraft:gold_block')
game.wait(10)
counts = {item: server_count(game, item) for item in ('wheat', 'wheat_seeds')}
client_counts = game('inventory')['counts']
seen[game.name] = {'blocks': after, 'server': game.raw('block', *marker).get('block'),
'drops': counts,
'client_drops': {item: client_counts.get('minecraft:' + item, 0)
for item in counts}}
other.shot(out, f'farmland-{game.name}-after')
finally:
host('command', command='gamerule random_tick_speed 3')
host('command', command='kill @e[type=minecraft:item]')
for g in (host, guest):
x0, y0, z0 = starts[g.name]
g('command', command=f'tp @s {x0} {y0} {z0}')
g('command', command='gamemode survival')
for name, s in seen.items():
assert all(v == ['minecraft:dirt', 'minecraft:air'] for v in s['blocks'].values()), \
f'{name}: unsupported wheat after landing: {seen}'
assert s['server'] == 'minecraft:gold_block', f'{name}: server kept the unsupported wheat: {seen}'
assert s['drops']['wheat'] == 1 and 1 <= s['drops']['wheat_seeds'] <= 4, \
f'{name}: mature wheat drops missing: {seen}'
assert s['client_drops'] == s['drops'], f'{name}: crop drops did not synchronize: {seen}'
return seen
CHECKS += ['farmland']
CHECK_FNS.update(farmland=check_farmland)
def check_large_chest(host, guest, out):
"""Every player slot can be picked up or shift-clicked into a double chest (#1505, #1589).
Reopening checks the authoritative contents, with a single chest as the control."""
seen = {}
for game in (host, guest):
game('command', command='gamemode survival')
x, y, z = (int(v // 1) for v in pos_of(game))
chest, other = (x, y, z + 2), (x + 1, y, z + 2)
try:
for double in (False, True):
size = 54 if double else 27
# Double chest indices 54..89 include the old cutoff at 63/64 and every hotbar slot.
sources = list(range(9, 36)) + list(range(9)) if double else [19, 0, 8]
for shift in (False, True):
back_to_game(game)
for at in (chest, other):
game('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
game('command', command='kill @e[type=minecraft:item,distance=..8]')
game('command', command='clear @s')
block = 'minecraft:chest[facing=north,type=left]' if double else 'minecraft:chest'
game('command', command=f'setblock {x} {y} {z + 2} {block}')
if double:
game('command', command=f'setblock {x + 1} {y} {z + 2} minecraft:chest[facing=north,type=right]')
for slot in sources:
name = f'hotbar.{slot}' if slot < 9 else f'inventory.{slot - 9}'
game('command', command=f'item replace entity @s {name} with minecraft:diamond')
game.wait(10)
game.raw('interact', *chest)
game.raw('wait-for', 'screen', 'container', '--timeout', 10)
game.wait(10)
assert container_items(game)[1] == size, f'{game.name}: expected {size} chest slots'
label = f'{"double" if double else "single"}-chest-{"shift" if shift else "pickup"}'
for target, slot in enumerate(sources):
if shift:
game.raw('key', 'shift', 'down')
try:
mouse(game, slot)
finally:
if shift:
game.raw('key', 'shift', 'up')
if not shift:
held = game('inventory').get('carried') or {}
if held.get('item') != 'minecraft:diamond' or held.get('count') != 1:
game.shot(out, f'{label}-slot-{slot}-failed')
raise AssertionError(f'{game.name} {label}: slot {slot} picked up {held}')
mouse(game, 300 + target)
game.raw('key', 'esc', 'tap')
game.wait(10)
game.raw('interact', *chest)
game.raw('wait-for', 'screen', 'container', '--timeout', 10)
game.wait(10)
contents = [s for s in game('screen')['slots'] if 300 <= s['slot'] < 300 + size and s.get('item')]
inv = game('inventory')
stored = sum(s['count'] for s in contents if s['item'] == 'minecraft:diamond')
assert stored == len(sources) and not inv['counts'].get('minecraft:diamond') and not inv.get('carried'), \
f'{game.name} {label}: chest {contents}, inventory {inv}'
game.shot(out, label)
seen[f'{game.name}-{label}'] = stored
game.raw('key', 'esc', 'tap')
finally:
back_to_game(game)
for at in (chest, other):
game('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
game('command', command='kill @e[type=minecraft:item,distance=..8]')
game('command', command='clear @s')
return seen
CHECKS += ['large_chest']
CHECK_FNS.update(large_chest=check_large_chest)
def check_falling(host, guest, out):
"""ItemEntity, FallingBlockEntity and PrimedTnt tick on a client too (gravity, collisions, drag),
and their position packets snap them (Entity's NO_OP InterpolationHandler). The server sends an
item's and a falling block's position every 20 ticks and TNT's every 10, so between two updates
the guest's own tick is what moves them: sampled every 2 ticks over the first 16, all three are
lower nearly every time. The TNT's fuse (sent once, at spawn) counts down on the guest too."""
x, y, z = (int(v // 1) for v in pos_of(guest))
top = y + 30
tag = 'falling_e2e'
host('command', command=f'fill {x + 2} {y} {z - 1} {x + 4} {top + 2} {z + 1} minecraft:air')
host('command', command=f'summon minecraft:item {x + 3.5} {top} {z + 0.5} '
f'{{Tags:["{tag}"],Item:{{id:"minecraft:diamond",count:1}},PickupDelay:32767}}')
host('command', command=f'summon minecraft:falling_block {x + 2.5} {top} {z - 0.5} '
f'{{Tags:["{tag}"],BlockState:{{Name:"minecraft:sand"}},DropItem:0b}}')
# Horizon's selectors don't see primed TNT yet: its fuse ends it in the air, 19 blocks down.
host('command', command=f'summon minecraft:tnt {x + 4.5} {top} {z + 0.5} {{fuse:34s}}')
heights = {'item': [], 'falling block': [], 'primed tnt': []}
fuses = []
try:
for _ in range(8):
items = [e['pos'][1] for e in guest.raw('entities', 48, 'minecraft:item')['entities']
if abs(e['pos'][0] - x - 3.5) < 0.5 and abs(e['pos'][2] - z - 0.5) < 0.5]
blocks = [o['position'][1] for o in guest('status')['projectiles']
if o.get('kind') == 'minecraft:falling_block'
and abs(o['position'][0] - x - 2.5) < 0.5 and abs(o['position'][2] - z + 0.5) < 0.5]
tnts = [o for o in guest('status')['projectiles'] if o.get('kind') == 'minecraft:tnt'
and abs(o['position'][0] - x - 4.5) < 0.5 and abs(o['position'][2] - z - 0.5) < 0.5]
heights['item'].append(items[0] if items else None)
heights['falling block'].append(blocks[0] if blocks else None)
heights['primed tnt'].append(tnts[0]['position'][1] if tnts else None)
fuses.append(tnts[0]['fuse'] if tnts else None)
guest.wait(2)
guest.shot(out, 'falling')
finally:
host('command', command=f'kill @e[tag={tag}]')
host('command', command=f'fill {x + 2} {y} {z - 1} {x + 4} {top + 2} {z + 1} minecraft:air')
for name, ys in heights.items():
seen = [h for h in ys if h is not None]
assert len(seen) >= 6, f'the guest saw the {name} {len(seen)} times of 8: {heights}'
drops = sum(b < a for a, b in zip(seen, seen[1:]))
# A tick or two can pass between two samples with no move; never most of them.
assert drops >= len(seen) - 2, f'the {name} fell in {drops} of {len(seen) - 1} samples: {heights}'
# The server sends the TNT's fuse with each move, so each sample says how many ticks it has
# fallen from rest: snapped to the server's place and ticking on, the guest has it exactly
# there (it used to trail two ticks behind, interpolating towards each update).
fell = [(h, 34 - f) for h, f in zip(heights['primed tnt'], fuses) if f is not None]
assert fell and fell[-1][1] > fell[0][1], f'the TNT\'s fuse on the guest: {fuses}'
off = max(abs(h - tnt_fall(top, n)) for h, n in fell)
assert off < 0.01, f'the guest\'s TNT is {off:.3f} off its fall (height, ticks): {fell}'
return {**heights, 'fuse': fuses}
def tnt_fall(top, ticks):
"""PrimedTnt.tick in open air from rest: its height after `ticks` ticks."""
y, v = top, 0.0
for _ in range(ticks):
v -= 0.04
y += v
v *= 0.98
return y
CHECKS += ['falling']
CHECK_FNS.update(falling=check_falling)
def check_crystals(host, guest, out):
"""#1532: a real left click detonates a placed End crystal in survival and creative,
on the host and the guest; reach, block occlusion and passing right clicks still apply."""
spots = apart(host, guest)
seen = {}
def crystals(game, at):
return [p for p in game('status')['projectiles'] if p['kind'] == 'minecraft:end_crystal'
and math.dist(p['position'], at) < 0.01]
for label, player, other in (('host', host, guest), ('guest', guest, host)):
x, y, z = spots[label]
y += 32 # Above the terrain and its entities: EndCrystalItem requires an empty box.
at = [x + 2.5, y, z + 0.5]
other('command', command='gamemode creative')
for mode in ('creative', 'survival'):
case = f'{label}-{mode}'
try:
host('command', command=f'fill {x - 5} {y - 1} {z - 3} {x + 5} {y - 1} {z + 3} minecraft:obsidian')
host('command', command=f'fill {x - 5} {y} {z - 3} {x + 5} {y + 3} {z + 3} minecraft:air')
player('command', command=f'gamemode {mode}')
player('command', command='clear @s')
player('command', command='effect clear @s')
player('command', command='effect give @s minecraft:instant_health 1 5 true')
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
player('command', command='give @s minecraft:end_crystal 1')
player.raw('select', 1)
player.wait(10)
player.raw('look-at', at[0], y - 0.01, at[2])
player.raw('mouse', 'right', 'click')
player.wait(10)
placed = crystals(player, at)
assert len(placed) == 1, f'{case}: placing the crystal: {placed}'
crystal_id = placed[0]['id']
tracked = crystals(other, at)
assert crystal_id != 0 and [p['id'] for p in tracked] == [crystal_id], f'{case}: crystal not tracked on both sides'
# The nearest face is six blocks away, beyond both modes' attack reach.
player('command', command=f'tp @s {x - 4.5} {y} {z + 0.5}')
player.wait(5)
player.raw('look-at', at[0], y + 1, at[2])
player.raw('mouse', 'left', 'click')
player.wait(10)
assert crystals(player, at), f'{case}: attacked a crystal out of reach'
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
host('command', command=f'fill {x + 1} {y} {z} {x + 1} {y + 2} {z} minecraft:obsidian')
player.wait(5)
player.raw('look-at', at[0], y + 1, at[2])
player.raw('mouse', 'left', 'click')
player.wait(10)
assert crystals(player, at), f'{case}: attacked a crystal through a block'
host('command', command=f'fill {x + 1} {y} {z} {x + 1} {y + 2} {z} minecraft:air')
player('command', command='item replace entity @s weapon.mainhand with minecraft:shield')
player.wait(10)
player.raw('look-at', at[0], y + 1, at[2])
player.raw('mouse', 'right', 'down')
player.wait(10)
assert player('state').get('using') and server_using(host, player), f'{case}: crystal consumed right click'
player.raw('mouse', 'right', 'up')
player('command', command='clear @s')
player.wait(5)
player.shot(out, f'crystals-{mode}-before')
player.raw('mouse', 'left', 'click')
player.wait(40)
remaining = {g.name: len(crystals(g, at)) for g in (player, other)}
health = server_player(host, player)['health']
screen = player('state').get('screen')
seen[case] = {'id': crystal_id, 'remaining': remaining, 'health': health, 'screen': screen}
player.shot(out, f'crystals-{mode}-after')
assert not any(remaining.values()), f'{case}: left click left the crystal intact: {seen[case]}'
assert health == (20 if mode == 'creative' else 0), f'{case}: explosion damage: {seen[case]}'
assert screen == ('playing' if mode == 'creative' else 'dead'), f'{case}: explosion death: {seen[case]}'
finally:
if player('state').get('screen') == 'dead':
respawn_when_ready(player)
player.wait(30)
else:
player.raw('mouse', 'right', 'up')
player('command', command='gamemode creative')
return seen
CHECKS += ['crystals']
CHECK_FNS.update(crystals=check_crystals)
def check_crystal_blast(host, guest, out):
"""#1533: TNT removes a crystal without a blast; a punched crystal blasts once.
Both clients must see the surviving distant and invulnerable crystals."""
spots = apart(host, guest)
seen = {}
for label, player, other in (('host', host, guest), ('guest', guest, host)):
x, y, z = spots[label]
y += 32
player('command', command='gamemode creative')
for offset, cause in enumerate(('tnt', 'melee')):
# Leave survivors in separate arenas; the scratch world is discarded.
z = spots[label][2] + (0 if label == 'host' else 80) + offset * 40
case = f'{label}-{cause}'
host('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 25} {y - 1} {z + 5} minecraft:bedrock')
host('command', command=f'fill {x - 3} {y} {z - 3} {x + 25} {y + 4} {z + 5} minecraft:air')
positions = [[x + dx, y, z + dz] for dx, dz in ((2.5, 0.5), (12.5, 0.5), (22.5, 0.5), (4.5, 3.5))]
def remaining(game):
crystals = [p for p in game('status')['projectiles'] if p['kind'] == 'minecraft:end_crystal']
return [i for i, at in enumerate(positions)
if any(math.dist(p['position'], at) < 0.01 for p in crystals)]
for i, at in enumerate(positions):
host('command', command=f'summon minecraft:end_crystal {at[0]} {at[1]} {at[2]} {{Invulnerable:{1 if i == 3 else 0}b}}')
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
other('command', command='gamemode creative')
other('command', command=f'tp @s {x + 0.5} {y} {z + 4.5}')
player('command', command='clear @s')
player.wait(20)
for game in (host, guest):
assert remaining(game) == [0, 1, 2, 3], f'{case}: crystals not tracked on {game.name}'
if cause == 'tnt':
player('command', command=f'summon minecraft:tnt {x + 0.5} {y} {z + 0.5} {{fuse:10s}}')
else:
player.raw('look-at', positions[0][0], y + 1, positions[0][2])
player.raw('mouse', 'left', 'click')
player.wait(80)
result = {game.name: remaining(game) for game in (host, guest)}
seen[case] = result
# TNT reaches the first crystal only. A direct crystal blast reaches
# the second too, but that crystal must not detonate the third.
expected = [1, 2, 3] if cause == 'tnt' else [2, 3]
assert all(ids == expected for ids in result.values()), f'{case}: expected {expected}, got {result}'
return seen
CHECKS += ['crystal_blast']
CHECK_FNS.update(crystal_blast=check_crystal_blast)
def check_boat_dismount_pose(host, guest, out):
"""Removing a boat detaches its mobs on the server and every observing client."""
x, y, z = map(math.floor, pos_of(host))
bx, bz = x + 3.5, z + 3.5
tag = 'boat_dismount_pose'
host('command', command='gamemode creative')
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 8} {y - 1} {z + 8} minecraft:stone')
host('command', command=f'fill {x - 2} {y} {z - 2} {x + 8} {y + 4} {z + 8} minecraft:air')
host('command', command=f'summon minecraft:oak_boat {bx} {y} {bz} {{Tags:["{tag}"]}}')
for _ in range(2):
host('command', command=f'summon minecraft:husk {bx} {y} {bz} {{NoAI:1b,Tags:["{tag}"]}}')
host.wait(5)
try:
host.wait(20)
boat = next(o for o in host('status')['objects']
if o['kind'].endswith('oak_boat') and math.dist(o['position'], [bx, y, bz]) < 2)
riders = [m['id'] for m in host.raw('mobs', 64)['near']
if m['kind'] == 'minecraft:husk' and m['mount'] == boat['id']]
assert len(riders) == 2, f'the boat did not collect both husks: {riders}'
for game in (host, guest):
game.wait(10)
for rider in riders:
m = mob_seen(game, rider)
assert m and m['mount'] == boat['id'] and m['passenger'], f'{game.name} did not see seated husk {rider}: {m}'
game.raw('look-at', bx, y + 1, bz)
game.shot(out, 'boat-passengers-seated')
host('command', command=f'tp @s {bx - 3} {y} {bz} 0 0')
host.wait(5)
# Aim at the near rim, below the riders, so their boxes cannot
# intercept the creative player's one hit that destroys the boat.
host.raw('look-at', bx - 0.55, y + 0.1, bz)
host.raw('mouse', 'left', 'click')
seen = {}
for game in (host, guest):
game.wait(20)
assert all(o['id'] != boat['id'] for o in game('status')['objects']), f'{game.name} still sees the boat'
seen[game.name] = [mob_seen(game, rider) for rider in riders]
game.raw('look-at', bx, y + 1, bz)
game.shot(out, 'boat-passengers-dismounted')
for m in seen[game.name]:
assert m and m['mount'] is None and not m['passenger'], f'{game.name} still draws a removed boat\'s passenger: {m}'
return {name: [{k: m[k] for k in ('id', 'mount', 'passenger')} for m in mobs] for name, mobs in seen.items()}
finally:
host('command', command=f'kill @e[tag={tag}]')
CHECKS += ['boat-dismount-pose']
CHECK_FNS['boat-dismount-pose'] = check_boat_dismount_pose
def check_sprint_fov(host, guest, out):
"""Releasing Ctrl must preserve a sprint until W is released, then reset FOV.
PlayerInput reports keys; PlayerCommand starts/stops the sprint. Confusing
them clears the client's sprint flag while leaving its speed modifier on.
"""
seen = {}
for game in (host, guest):
game('command', command='gamemode creative')
game('command', command='effect clear @s')
game.raw('options-set', 'fovEffectScale', '1.0')
x, y, z = map(math.floor, pos_of(game))
game('command', command=f'fill {x - 2} {y} {z - 2} {x + 2} {y + 3} {z + 20} minecraft:air')
game('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 2} {y - 1} {z + 20} minecraft:stone')
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
game.wait(20)
try:
game.raw('key', 'w', 'down')
game.raw('key', 'ctrl', 'down')
game.wait(10)
started = game('state')['player']
game.raw('key', 'ctrl', 'up')
game.wait(10)
released_ctrl = game('state')['player']
game.raw('key', 'w', 'up')
game.wait(20)
stopped = game('state')['player']
finally:
game.raw('key', 'ctrl', 'up')
game.raw('key', 'w', 'up')
seen[game.name] = states = {
label: {k: p[k] for k in ('sprinting', 'fov_modifier')}
for label, p in [('started', started), ('released_ctrl', released_ctrl), ('stopped', stopped)]
}
for label in ('started', 'released_ctrl'):
assert states[label]['sprinting'], f'{game.name} {label}: {states}'
assert abs(states[label]['fov_modifier'] - 1.15) < 0.002, f'{game.name} {label}: {states}'
assert not stopped['sprinting'], f'{game.name}: {states}'
assert abs(stopped['fov_modifier'] - 1.0) < 0.002, f'{game.name}: {states}'
return seen
CHECKS += ['sprint-fov']
CHECK_FNS['sprint-fov'] = check_sprint_fov
def check_critical_hits(host, guest, out):
"""#1898: a real falling melee hit emits crits on the attacker and observer.
Grounded hits and refused falling hits emit none, against mobs and players.
"""
x, y, z = map(math.floor, pos_of(host))
target = [x + 2.5, y, z + 0.5]
tag = 'critical_hit_particles'
seen = {}
host('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 6} {y - 1} {z + 6} minecraft:stone')
host('command', command=f'fill {x - 3} {y} {z - 3} {x + 6} {y + 5} {z + 6} minecraft:air')
host('command', command='time set midnight')
host('command', command='gamerule natural_health_regeneration false')
try:
for label, player, other in (('host', host, guest), ('guest', guest, host)):
for kind in ('zombie', 'player'):
for hit in ('grounded', 'critical', 'refused'):
case = f'{label}-{kind}-{hit}'
for game in (player, other):
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='effect clear @s')
game('command', command='effect give @s minecraft:instant_health 1 5 true')
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
other_at = target if kind == 'player' else [x + 0.5, y, z + 4.5]
other('command', command=f'tp @s {other_at[0]} {other_at[1]} {other_at[2]}')
host('command', command=f'gamerule pvp {str(hit != "refused").lower()}')
mob_id = None
if kind == 'zombie':
host('command', command=f'summon minecraft:zombie {target[0]} {y} {target[2]} '
f'{{NoAI:1b,PersistenceRequired:1b,Invulnerable:{int(hit == "refused")}b,Tags:["{tag}"]}}')
player.wait(20) # full attack charge and paired entities
if kind == 'zombie':
mob = next(m for m in player.raw('mobs', 16)['near']
if m['kind'] == 'minecraft:zombie' and math.dist(m['pos'], target) < 0.1)
mob_id = mob['id']
assert mob_seen(other, mob_id), f'{case}: observer has not paired the zombie'
health_before = mob_seen(player, mob_id)['health'] if mob_id is not None else hearts(other)[1]
sound = 'minecraft:entity.player.attack.crit'
sounds_before = {side: heard(g, sound) for side, g in (('own', player), ('other', other))}
def strike():
if hit != 'grounded':
player.raw('key', 'space', 'down')
player.wait(1)
player.raw('key', 'space', 'up')
body = None
for _ in range(15):
body = player('state')['player']
# Let the server record the fall before the attack packet.
if not body['on_ground'] and body['velocity'][1] < -0.2:
break
player.wait(1)
assert body and not body['on_ground'] and body['velocity'][1] < -0.2, \
f'{case}: jump never reached its falling phase: {body}'
player.raw('look-at', target[0], y + 1, target[2])
player.raw('mouse', 'left', 'click')
particles = sparked(player, other, ['crit'], strike)
player.shot(out, f'critical-hits-{kind}-{hit}-{label}')
player.wait(10) # finish the three-tick emitter and health replication
health_after = mob_seen(player, mob_id)['health'] if mob_id is not None else hearts(other)[1]
sounds = {side: heard(g, sound) - sounds_before[side]
for side, g in (('own', player), ('other', other))}
seen[case] = {'particles': particles, 'crit_sounds': sounds,
'health': [health_before, health_after]}
host('command', command=f'kill @e[tag={tag}]')
assert (health_after == health_before) if hit == 'refused' else (health_after < health_before), \
f'{case}: unexpected damage: {seen[case]}'
assert all(n == int(hit == 'critical') for n in sounds.values()), \
f'{case}: the server did not recognize the expected crit: {seen[case]}'
wrong = {case: result for case, result in seen.items()
if any((n <= 0 if case.endswith('-critical') else n != 0)
for n in result['particles'].values())}
assert not wrong, f'critical-hit particles: {wrong}'
return seen
finally:
host('command', command=f'kill @e[tag={tag}]')
host('command', command='gamerule pvp true')
host('command', command='gamerule natural_health_regeneration true')
host('command', command='time set day')
for game in (host, guest):
game.raw('key', 'space', 'up')
CHECKS += ['critical-hits']
CHECK_FNS['critical-hits'] = check_critical_hits
def check_sculk_container(host, guest, out):
"""A real guest's container interaction emits a server game event. Both
clients see the sensor and the non-summoning shrieker activate and reset.
The component oracle separately controls warning levels and seeded mobs.
"""
x, y, z = (int(v // 1) for v in pos_of(guest))
chest, sensor, shrieker = (x + 2, y, z), (x + 3, y, z), (x + 4, y, z)
guest('command', command='gamemode survival')
for at, block in ((chest, 'chest'), (shrieker, 'sculk_shrieker'), (sensor, 'sculk_sensor')):
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:{block}')
guest.wait(60)
seen = {}
# Keep the sensor's 30-tick active window observable while both clients
# acknowledge the container and the control socket inspects them.
host('command', command='tick rate 4')
try:
guest.raw('interact', *chest)
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
for game in (host, guest):
active = wait_state(game, sensor, 'sculk_sensor_phase', 'active', 40)
shrieking = wait_state(game, shrieker, 'shrieking', 'true', 40)
seen[game.name] = (active, shrieking)
assert all(s == ('active', 'true') for s in seen.values()), seen
guest.raw('key', 'esc', 'tap')
for game in (host, guest):
assert wait_state(game, sensor, 'sculk_sensor_phase', 'inactive', 160) == 'inactive', seen
assert wait_state(game, shrieker, 'shrieking', 'false', 160) == 'false', seen
finally:
host('command', command='tick rate 20')
if guest('state').get('screen') == 'container':
guest.raw('key', 'esc', 'tap')
for at in (sensor, shrieker, chest):
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
return seen
CHECKS += ['sculk_container']
CHECK_FNS['sculk_container'] = check_sculk_container
def check_projectile_land(host, guest, out):
"""Projectile.onHit raises PROJECTILE_LAND for every projectile, so a sculk sensor next to the
landing hears a thrown trident, a splash potion and a fishing hook. The sensor
stands 10 blocks from the thrower, past its 8-block range, so the throw's own PROJECTILE_SHOOT
cannot power it; only the landing, 1-5 blocks away, can."""
x, y, z = (int(v // 1) for v in pos_of(host))
sensor = (x + 10, y, z)
host('command', command=f'fill {x - 1} {y - 1} {z - 2} {x + 14} {y - 1} {z + 2} minecraft:stone')
host('command', command=f'fill {x - 1} {y} {z - 2} {x + 14} {y + 5} {z + 2} minecraft:air')
host('command', command=f'fill {x + 11} {y} {z - 2} {x + 11} {y + 4} {z + 2} minecraft:stone')
host('command', command=f'setblock {sensor[0]} {sensor[1]} {sensor[2]} minecraft:sculk_sensor')
host('command', command='difficulty peaceful')
host.wait(60)
# label: (item, gamemode, how it is thrown)
cases = {
'trident': ('minecraft:trident', 'survival', 'hold'),
'potion': ('minecraft:splash_potion[potion_contents={potion:"minecraft:water"}]', 'survival', 'use'),
'hook': ('minecraft:fishing_rod', 'survival', 'use'),
}
seen = {}
try:
for label, (item, mode, how) in cases.items():
assert wait_state(host, sensor, 'sculk_sensor_phase', 'inactive', 200) == 'inactive', seen
host('command', command=f'gamemode {mode}')
host('command', command='clear @s')
# Wandering mobs step, flap and get hurt next to the sensor and power it themselves.
host('command', command='kill @e[type=!minecraft:player,distance=..40]')
host('command', command=f'item replace entity @s weapon.mainhand with {item}')
host('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} -90 0')
host.wait(10)
if label == 'hook':
host.raw('look-at', f'{x + 4.5:.2f}', f'{y:.2f}', f'{z + 0.5:.2f}')
elif label == 'potion':
host.raw('look-at', f'{x + 11.0:.2f}', f'{y + 2.5:.2f}', f'{z + 0.5:.2f}')
else:
host.raw('look-at', f'{x + 11.0:.2f}', f'{y + 1.6:.2f}', f'{z + 0.5:.2f}')
host.wait(20)
assert state_of(host, sensor, 'sculk_sensor_phase') == 'inactive', f'{label}: the setup powered the sensor: {seen}'
if how == 'hold':
host.raw('use-item', '--hold', 1.2)
else:
host.raw('use-item')
seen[label] = wait_state(host, sensor, 'sculk_sensor_phase', 'active', 80)
if label == 'hook':
host.raw('use-item')
finally:
host('command', command='clear @s')
host('command', command='kill @e[type=minecraft:trident]')
host('command', command='gamemode creative')
host('command', command=f'fill {x + 11} {y} {z - 2} {x + 11} {y + 4} {z + 2} minecraft:air')
host('command', command=f'setblock {sensor[0]} {sensor[1]} {sensor[2]} minecraft:air')
assert all(v == 'active' for v in seen.values()), f'a landing the sensor did not hear: {seen}'
return seen
CHECKS += ['projectile_land']
CHECK_FNS['projectile_land'] = check_projectile_land
def check_replant(host, guest, out):
"""Instant breaks leave no server dig that can destroy a replacement (#1880).
ServerPlayerGameMode.handleBlockBreakAction's insta-mine branch finishes on
START; MultiPlayerGameMode sends no STOP or ABORT when attack is released.
Exercise actual flower placement and seed planting after a short click.
"""
spots = apart(host, guest)
seen = {}
host('command', command='gamerule random_tick_speed 0')
try:
for label, player in (('host', host), ('guest', guest)):
x, y, z = spots[label]
cell = (x, y, z + 2)
def observe(block):
# The integrated host is itself a client: wait for both views.
for _ in range(40):
blocks = [g.raw('block', *cell).get('block') for g in (host, guest)]
if blocks == [f'minecraft:{block}'] * 2:
return blocks
host.wait(2)
raise AssertionError(f'{label} expected {block} on both clients: {blocks}')
for mode, block, item, soil in (
('survival', 'dandelion', 'dandelion', 'dirt'),
('survival', 'wheat', 'wheat_seeds', 'farmland[moisture=7]'),
('survival', 'carrots', 'carrot', 'farmland[moisture=7]'),
('creative', 'dandelion', 'dandelion', 'dirt'),
):
player('command', command=f'gamemode {mode}')
player('command', command='clear @s')
player('command', command=f'item replace entity @s weapon.mainhand with minecraft:{item} 8')
player('command', command=f'setblock {x} {y - 1} {z + 2} minecraft:{soil}')
player('command', command=f'setblock {x} {y} {z + 2} minecraft:{block}')
player.wait(5)
observe(block)
# Flowers' narrow outlines have a position-dependent offset.
for dx in (0.0, -0.2, 0.2):
player.raw('look-at', x + 0.5 + dx, y + (0.3 if block == 'dandelion' else 0.1), z + 2.5)
target = player('looking_at').get('block') or {}
if target.get('pos') == list(cell):
break
assert target.get('pos') == list(cell), f'{label} does not aim at {block}: {target}'
player.raw('mouse', 'left', 'click')
player.wait(5)
observe('air')
# Keep aiming at the supporting soil and use the held flower/seeds.
player.raw('mouse', 'right', 'click')
player.wait(3)
observe(block)
player.wait(20) # past the stale dig's five-tick destroy delay
key = f'{label} {mode} {block}'
seen[key] = [g.raw('block', *cell).get('block') for g in (host, guest)]
player.shot(out, f'replant-{mode}-{block}')
assert seen[key] == [f'minecraft:{block}'] * 2, f'{key}: {seen}'
player('command', command=f'setblock {x} {y} {z + 2} minecraft:air')
finally:
host('command', command='gamerule random_tick_speed 3')
return seen
CHECKS += ['replant']
CHECK_FNS['replant'] = check_replant
def check_guardian_beams(host, guest, out):
"""Guardian's synced target drives the same beam on each client (#1491).
Guardian.defineSynchedData/onSyncedDataUpdated/aiStep and
GuardianRenderer.extractRenderState: charge, hit, then target loss.
"""
x, y, z = map(math.floor, pos_of(host))
host('command', command='difficulty normal')
host('command', command='gamerule natural_health_regeneration false')
host('command', command=f'fill {x - 10} {y - 2} {z - 10} {x + 10} {y + 4} {z + 10} minecraft:water')
seen = {}
try:
for kind in ('guardian', 'elder_guardian'):
for target, observer in ((host, guest), (guest, host)):
for game in (host, guest):
game('command', command='gamemode creative')
game('command', command='effect give @s minecraft:water_breathing infinite')
target('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
observer('command', command=f'tp @s {x + 4.5} {y} {z + 0.5} 0 0')
target('command', command='effect give @s minecraft:instant_health 1 10')
target('command', command='gamemode survival')
host('command', command=f'summon minecraft:{kind} {x + 0.5} {y} {z + 6.5}')
def mob(game):
return next(m for m in game.raw('mobs', 32)['near'] if m['kind'] == f'minecraft:{kind}')
def charging():
for _ in range(60):
target.wait(2)
states = [mob(g) for g in (target, observer)]
if all(m['guardian_beam'] is not None for m in states):
return states
raise Failed(f'{kind} never charged on both clients')
states = charging()
assert states[0]['target'] == 'Player', states
# The host reads the level that ticks the guardian (the server's), where a
# targeted player is `Player`.
if observer is host:
assert states[1]['target'] == 'Player', states
else:
assert states[1]['target'].startswith('Mob('), states
first = states[0]['guardian_beam'][0]
target.wait(4)
assert mob(target)['guardian_beam'][0] > first, 'client charge clock did not advance'
for game in (target, observer):
game.raw('look-at', x + 0.5, y + 0.5, z + 6.5)
game.shot(out, f'{kind}-{target.name}-charge')
# The beam must end after the first hit (before another charge).
before = target('status')['player']['health']
for _ in range(70):
target.wait(2)
if all(mob(g)['guardian_beam'] is None for g in (target, observer)):
break
else:
raise Failed(f'{kind} beam did not end after its attack')
after = target('status')['player']['health']
assert after < before, f'{kind} beam ended without damage: {before} -> {after}'
charging()
target('command', command='gamemode creative')
target.wait(10)
assert all(mob(g)['guardian_beam'] is None for g in (target, observer)), 'beam survived target loss'
seen[f'{kind}-{target.name}'] = {
'charge': [{'target': m['target'], 'beam': m['guardian_beam']} for m in states],
'health': [before, after]}
host('command', command=f'kill @e[type=minecraft:{kind}]')
host.wait(10)
finally:
for kind in ('guardian', 'elder_guardian'):
host('command', command=f'kill @e[type=minecraft:{kind}]')
for game in (host, guest):
game('command', command='gamemode creative')
game('command', command='effect clear @s')
return seen
CHECKS += ['guardian-beams']
CHECK_FNS['guardian-beams'] = check_guardian_beams
def check_fangs(host, guest, out):
"""EvokerFangs: authoritative spawn, event 4, client animation and server removal (#1482)."""
x, y, z = (int(v // 1) for v in pos_of(host))
tag = 'lan_evoker_fangs'
selector = f'@e[type=minecraft:evoker,tag={tag},limit=1]'
paths = {g.name: {} for g in (host, guest)}
warmup = dict.fromkeys(paths, False)
drawn = dict.fromkeys(paths, False)
stopped = False
photographed = False
def fangs(status):
return [p for p in status['projectiles'] if p['kind'] == 'minecraft:evoker_fangs']
try:
host('command', command='difficulty normal')
host('command', command='gamemode creative')
guest('command', command='gamemode survival')
guest('command', command='effect give @s minecraft:resistance 120 4 true')
host('command', command=f'fill {x - 10} {y - 1} {z - 10} {x + 10} {y - 1} {z + 18} minecraft:stone')
host('command', command=f'fill {x - 10} {y} {z - 10} {x + 10} {y + 8} {z + 18} minecraft:air')
host('command', command=f'tp @s {x + 4.5} {y} {z - 2.5}')
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
guest.wait(10)
host('command', command=f'summon minecraft:evoker {x + 0.5} {y} {z + 9.5} '
f'{{PersistenceRequired:1b,Tags:["{tag}"]}}')
for game in (host, guest):
game.raw('look-at', x + 0.5, y + 0.5, z + 5.5)
for _ in range(200):
guest.wait(2)
statuses = {g.name: g('status') for g in (host, guest)}
for name, status in statuses.items():
live = fangs(status)
ids = [f['id'] for f in live]
assert all(ids) and len(ids) == len(set(ids)), f'{name}: invalid fang ids {ids}'
warmup[name] |= any(not f['started'] for f in live)
drawn[name] |= status['fangs_vertices'] > 0
for f in live:
paths[name].setdefault(f['id'], []).append(f)
if paths['host'] and not stopped:
host('command', command=f'data merge entity {selector} {{NoAI:1b}}')
stopped = True
if (all(drawn.values()) and not photographed
and any(f['started'] and 8 <= f['life'] <= 16
for f in fangs(statuses['guest']))):
host('command', command='tick freeze')
try:
for game in (host, guest):
game.raw('key', 'f1', 'tap')
try:
game.shot(out, 'evoker-fangs')
finally:
game.raw('key', 'f1', 'tap')
finally:
host('command', command='tick unfreeze')
photographed = True
if stopped and not any(fangs(s) for s in statuses.values()):
break
lab.write_json(out / 'evoker-fangs.json', {'paths': paths, 'warmup': warmup, 'drawn': drawn})
assert all(warmup.values()), f'fang warmup not received: {warmup}'
assert all(drawn.values()), f'fang renderer produced no geometry: {drawn}'
assert paths['host'].keys() == paths['guest'].keys(), 'clients received different fang ids'
for id, samples in paths['host'].items():
other = paths['guest'][id]
assert all(f['position'] == samples[0]['position'] for f in samples + other), \
f'fang {id} is not at its authoritative position'
for name, frames in (('host', samples), ('guest', other)):
life = {f['life'] for f in frames if f['started']}
assert len(life) > 1, f'{name}: fang {id} did not animate: {frames}'
assert not any(fangs(g('status')) for g in (host, guest)), 'fangs survived server removal'
finally:
host('command', command='tick unfreeze')
host('command', command=f'kill @e[tag={tag}]')
# The evoker can summon vexes before fangs; killing it leaves those summons alive.
host('command', command=f'kill @e[type=minecraft:vex,x={x},y={y},z={z},distance=..32]')
guest('command', command='effect clear @s')
face_each_other(host, guest)
return {'fangs': len(paths['host']), 'warmup': warmup, 'drawn': drawn, 'removed': True}
CHECKS += ['fangs']
CHECK_FNS.update(fangs=check_fangs)
def check_sonic(host, guest, out):
"""A real warden's ray reaches connected clients once, with normal damage.
The creative observer sees the same ray nearby and none outside sendParticles' range.
"""
x, y, z = (int(v // 1) for v in pos_of(host))
result = {}
guest('command', command='gamemode creative')
host('command', command='difficulty normal')
host('command', command='gamerule natural_health_regeneration false')
try:
host('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 85} {y - 1} {z + 14} minecraft:stone')
host('command', command=f'fill {x - 3} {y} {z - 3} {x + 15} {y + 4} {z + 14} minecraft:air')
host('command', command=f'fill {x - 2} {y} {z + 4} {x + 2} {y + 3} {z + 4} minecraft:stone')
for near in (True, False):
guest('command', command=f'tp @s {x + (3 if near else 80) + 0.5} {y} {z + 0.5}')
host('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
host('command', command='gamemode survival')
host('command', command='effect give @s minecraft:instant_health 1 5')
host.wait(4)
host('command', command=f'summon minecraft:warden {x + 0.5} {y} {z + 9.5} {{NoAI:1b,Tags:["sonic_test"]}}')
host('command', command='attribute @e[tag=sonic_test,limit=1] minecraft:knockback_resistance base set 1')
before = {side: made(g, 'sonic_boom') for side, g in (('host', host), ('guest', guest))}
health = host('status')['player']['health']
host('command', command='data merge entity @e[tag=sonic_test,limit=1] {NoAI:0b}')
host('command', command='attribute @e[tag=sonic_test,limit=1] minecraft:movement_speed base set 0')
host('command', command='damage @e[tag=sonic_test,limit=1] 1 minecraft:player_attack by @s')
for _ in range(400):
host.wait(2)
if host('status')['player']['health'] < health:
break
host.wait(2) # let the observer process the same packet batch
seen = {side: made(g, 'sonic_boom') - before[side] for side, g in (('host', host), ('guest', guest))}
after = host('status')['player']['health']
result['near' if near else 'far'] = {'particles': seen, 'health': [health, after]}
host.shot(out, f'sonic-{"near" if near else "far"}')
host('command', command='kill @e[tag=sonic_test]')
assert after == health - 10, result
assert seen['host'] == 15, result
assert seen['guest'] == (15 if near else 0), result
finally:
host('command', command='kill @e[tag=sonic_test]')
host('command', command='gamerule natural_health_regeneration true')
return result
CHECKS += ['sonic']
CHECK_FNS['sonic'] = check_sonic
def check_creative_tools(host, guest, out):
"""#2035: switching Survival -> Creative cannot mine with a restricted tool.
Verify both the player's prediction and the host's authoritative block;
an ordinary tool must still break the same block.
"""
seen = {}
for label, player in (('host', host), ('guest', guest)):
x, y, z = (int(v // 1) for v in pos_of(player))
cell = (x, y + 1, z + 2)
host('command', command=f'fill {x - 1} {y} {z + 1} {x + 1} {y + 3} {z + 3} minecraft:air')
player('command', command='gamemode survival')
player('command', command='item replace entity @s weapon.mainhand with minecraft:netherite_sword')
player('command', command='gamemode creative')
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:stone')
player.wait(10)
player.raw('look-at', x + 0.5, y + 1.5, z + 2.5)
player.raw('mouse', 'left', 'down')
try:
player.wait(15)
finally:
player.raw('mouse', 'left', 'up')
seen[label] = {'client': player.raw('block', *cell).get('block'),
'server': host.raw('block', *cell).get('block')}
player.shot(out, 'creative-sword')
assert seen[label]['client'] == seen[label]['server'] == 'minecraft:stone', seen
player('command', command='item replace entity @s weapon.mainhand with minecraft:diamond_pickaxe')
player.wait(6)
player.raw('mouse', 'left', 'click')
player.wait(10)
seen[label]['ordinary_tool'] = host.raw('block', *cell).get('block')
assert seen[label]['ordinary_tool'] == 'minecraft:air', seen
return seen
CHECKS += ['creative_tools']
CHECK_FNS['creative_tools'] = check_creative_tools
def check_creative_throw(host, guest, out):
"""#3684: Q over a hotbar slot on a creative item tab drops the item instead of deleting it.
CreativeModeInventoryScreen.slotClicked on an item tab: with an empty hand the menu's THROW
sends the stack (handleCreativeModeItemDrop), Control+Q the whole stack; while carrying a stack
the screen itself drops one (Control: all) from the slot and the carried stack stays; over a
display slot it drops one, or a full stack with Control, of the shown item. On the Survival
Inventory tab the screen drops from the slot itself, one or the whole stack, carrying or not."""
seen = {}
def dropped(player, item):
return sum((e.get('item') or {}).get('count', 0) for e in player.raw('entities', 8)['entities']
if e['type'] == 'minecraft:item' and (e.get('item') or {}).get('item') == item)
def clear():
player('command', command='kill @e[type=minecraft:item,distance=..16]')
player.wait(2)
def slots(player, *ids):
inventory = player('inventory')
every = {s['slot']: (s.get('item'), s.get('count')) for s in inventory['hotbar'] + inventory['main']}
return {i: every[i] for i in ids}
def throw(player, slot, ctrl=False):
# The pointer over the slot, then Q (Control+Q) as AbstractContainerScreen.keyPressed reads it.
player.raw('scroll-at', *(f'{v:.1f}' for v in slot_point(player, slot)), 0)
if ctrl:
player.raw('key', 'ctrl', 'down')
player.raw('key', 'q', 'tap')
if ctrl:
player.raw('key', 'ctrl', 'up')
player.wait(5)
def tab(player, x, y):
# A tab button, from the screen's origin (slot 0 sits at 9, 112 on every tab).
sx, sy, _, _ = next(w for w in player.raw('ui')['widgets'] if w.get('id') == 'slot-0')['bounds']
player.raw('click-at', f'{sx - 9 + x:.1f}', f'{sy - 112 + y:.1f}')
player.wait(5)
for label, player in (('host', host), ('guest', guest)):
player('command', command='gamemode creative')
player('command', command='clear @s')
clear()
for slot, item in (('hotbar.0', 'stone 10'), ('hotbar.1', 'dirt 5'), ('hotbar.2', 'gravel 6'),
('inventory.0', 'cobblestone 10'), ('inventory.1', 'sand 5')):
player('command', command=f'item replace entity @s {slot} with minecraft:{item}')
player.wait(10)
player.raw('key', 'e', 'tap')
player.raw('wait-for', 'screen', 'creative', '--timeout', 10)
player.wait(5)
widgets = {w['id']: w for w in player.raw('ui')['widgets']}
if 'slot-900' not in widgets:
raise Failed(f'{label}: the creative screen did not open on an item tab')
shown = (widgets['slot-900'].get('stack') or {}).get('item')
s = seen[label] = {}
try:
# Empty hand: Q drops one stone, Control+Q all the gravel.
throw(player, 0)
s['one'] = (slots(player, 0)[0], dropped(player, 'minecraft:stone'))
# Each step's drops go before the next, so none is picked up again (40 ticks' delay).
clear()
throw(player, 2, ctrl=True)
s['all'] = (slots(player, 2)[2], dropped(player, 'minecraft:gravel'))
clear()
# Carrying a picked display stack: Q over the dirt drops one dirt, the hand keeps its stack.
player.raw('click', 'slot-900')
player.wait(2)
carried = player('inventory').get('carried')
throw(player, 1)
s['carrying'] = (slots(player, 1)[1], dropped(player, 'minecraft:dirt'), player('inventory').get('carried'))
clear()
# Control+Q, still carrying: the rest of the dirt.
throw(player, 1, ctrl=True)
s['whole'] = (slots(player, 1)[1], dropped(player, 'minecraft:dirt'), player('inventory').get('carried'))
clear()
# The carried stack goes to the empty hotbar slot 3.
player.raw('click', 'slot-3')
player.wait(2)
# A display slot: one of the shown item, a full stack with Control; the hotbar stays.
throw(player, 900)
s['display'] = dropped(player, shown)
clear()
throw(player, 900, ctrl=True)
s['display_all'] = dropped(player, shown)
s['hotbar'] = slots(player, 0, 1, 2, 3)
clear()
player.shot(out, f'creative-throw-{label}')
# The Survival Inventory tab (the bottom row's last).
tab(player, 169 + 13, 132 + 14)
if 'slot-9' not in {w['id'] for w in player.raw('ui')['widgets']}:
raise Failed(f'{label}: the Survival Inventory tab did not open')
throw(player, 9)
s['inventory_one'] = (slots(player, 9)[9], dropped(player, 'minecraft:cobblestone'))
clear()
throw(player, 9, ctrl=True)
s['inventory_all'] = (slots(player, 9)[9], dropped(player, 'minecraft:cobblestone'))
clear()
# Carrying the sand: Q over the stone drops one stone, the hand keeps the sand.
player.raw('click', 'slot-10')
player.wait(2)
throw(player, 0)
s['inventory_carrying'] = (slots(player, 0)[0], dropped(player, 'minecraft:stone'),
player('inventory').get('carried'))
player.raw('click', 'slot-10')
player.wait(2)
clear()
player.shot(out, f'creative-throw-inventory-{label}')
# Back to the first tab, where the screen opens next time.
tab(player, 13, -28 + 14)
finally:
player.raw('key', 'esc', 'tap')
player.wait(5)
assert s['one'] == (('minecraft:stone', 9), 1), seen
assert s['all'] == ((None, None), 6), seen
assert carried and s['carrying'] == (('minecraft:dirt', 4), 1, carried), seen
assert s['whole'] == ((None, None), 4, carried), seen
assert s['display'] == 1 and s['display_all'] == 64, seen
assert s['hotbar'] == {0: ('minecraft:stone', 9), 1: (None, None), 2: (None, None),
3: (carried['item'], carried['count'])}, seen
assert s['inventory_one'] == (('minecraft:cobblestone', 9), 1), seen
assert s['inventory_all'] == ((None, None), 9), seen
assert s['inventory_carrying'] == (('minecraft:stone', 8), 1, {'count': 5, 'item': 'minecraft:sand'}), seen
clear()
return seen
CHECKS += ['creative_throw']
CHECK_FNS['creative_throw'] = check_creative_throw
def check_cushions(host, guest, out):
"""#1935: place a colored cushion, consume it, track it, sit, dismount and break it.
#3117: placing swings the arm, and a seated player sits on another cushion at once."""
seen = {}
for label, player, color in (('host', host, 'red'), ('guest', guest, 'blue')):
x, y, z = (int(v // 1) for v in pos_of(player))
at = (x + 0.5, y, z + 2.5)
other = (x + 1.5, y, z + 2.5)
host('command', command=f'fill {x - 1} {y} {z + 1} {x + 1} {y + 3} {z + 3} minecraft:air')
host('command', command=f'setblock {x} {y - 1} {z + 2} minecraft:stone')
host('command', command=f'setblock {x + 1} {y - 1} {z + 2} minecraft:stone')
player('command', command='gamemode survival')
player('command', command=f'item replace entity @s weapon.mainhand with minecraft:{color}_cushion')
player.wait(10)
player.raw('look-at', *at)
swings = player('state')['swings']
player.raw('mouse', 'right', 'click')
player.wait(15)
def cushions(game, at=at):
return [o for o in game('status')['objects'] if o['kind'] == 'minecraft:cushion'
and abs(o['position'][0] - at[0]) < .1 and abs(o['position'][2] - at[2]) < .1]
server, client = cushions(host), cushions(player)
seen[label] = {'server': server, 'client': client,
'remaining': player('inventory')['counts'].get(f'minecraft:{color}_cushion', 0),
'place_swings': player('state')['swings'] - swings}
assert len(server) == len(client) == 1, seen
assert server[0]['color'] == client[0]['color'] == color, seen
assert seen[label]['remaining'] == 0, seen
# CushionItem.useOn returns SUCCESS: the hand swings.
assert seen[label]['place_swings'] == 1, seen
player.shot(out, 'cushion-placed')
player('command', command=f'item replace entity @s weapon.mainhand with minecraft:{color}_cushion')
player.wait(10)
player.raw('look-at', *other)
player.raw('mouse', 'right', 'click')
player.wait(15)
second = cushions(host, other)
assert len(second) == 1, (seen, second)
player.raw('look-at', at[0], at[1] + .125, at[2])
player.raw('mouse', 'right', 'click')
player.wait(10)
seen[label]['riding'] = player('status').get('riding')
assert seen[label]['riding'] == server[0]['id'], seen
# Entity.startRiding: a passenger first gets off its vehicle, so it moves to the other cushion.
player.raw('look-at', other[0], other[1] + .125, other[2])
player.raw('mouse', 'right', 'click')
player.wait(10)
# A client rides what the server's SetPassengers says.
seen[label]['switched'] = player('status').get('riding')
player.shot(out, 'cushion-switched')
assert seen[label]['switched'] == second[0]['id'], seen
player.raw('key', 'shift', 'down')
player.wait(5)
player.raw('key', 'shift', 'up')
player.wait(5)
assert player('status').get('riding') is None, seen
player('command', command='gamemode creative')
player.raw('look-at', at[0], at[1] + .125, at[2])
before = {g.name: g('status').get('particles', {}).get('block', 0) for g in (host, guest)}
player.raw('mouse', 'left', 'click')
player.wait(10)
assert not cushions(host) and not cushions(player), seen
seen[label]['break_particles'] = {
g.name: g('status').get('particles', {}).get('block', 0) - before[g.name]
for g in (host, guest)
}
assert all(n == 10 for n in seen[label]['break_particles'].values()), seen
player.raw('look-at', other[0], other[1] + .125, other[2])
player.raw('mouse', 'left', 'click')
player.wait(10)
assert not cushions(host, other) and not cushions(player, other), seen
return seen
CHECKS += ['cushions']
CHECK_FNS['cushions'] = check_cushions
def check_forced_levels(host, guest, out):
"""#1525: forced block/entity ticks outlive the last player and the 300-tick idle timeout.
Also keep a resident, unforced furnace outside the forced ticket's neighbour ring: scheduling
a playerless integrated-server level must not enable the fixtures' tick-everywhere fallback.
"""
start = pos_of(guest)
results = {}
guest('command', command='gamemode creative')
try:
for dimension in (NETHER, END):
into_level(guest, dimension, 8, 100, 8)
host('command', command='tick freeze')
furnace = ('minecraft:furnace{Items:[{Slot:0b,id:"minecraft:raw_iron",count:1},'
'{Slot:1b,id:"minecraft:coal",count:1}],cooking_total_time:200,'
'cooking_time_spent:0}')
for x in (8, 56):
guest('command', command=f'setblock {x} 100 8 {furnace}')
server_reply(guest, f'execute if data block {x} 100 8 '
'Items[{Slot:0b,id:"minecraft:raw_iron"}] '
'run say forced-input-ready', 'forced-input-ready')
guest('command', command='forceload add 8 8')
guest('command', command='summon minecraft:armor_stand 8.5 104 8.5 {Tags:["forced_tick"]}')
guest('command', command=f'execute in {OVERWORLD} run tp @s {start[0]} {start[1]} {start[2]}')
wait_dimension(guest, OVERWORLD, 400)
host('command', command='tick step 450')
host.wait(500)
prefix = f'execute in {dimension} '
results[dimension] = server_reply(
host, prefix + 'if data block 8 100 8 Items[{Slot:2b,id:"minecraft:iron_ingot"}] '
'if data block 56 100 8 {cooking_time_spent:0} '
'if data block 56 100 8 Items[{Slot:0b,id:"minecraft:raw_iron"}] '
'if data entity @e[tag=forced_tick,limit=1] {Pos:[8.5d,101.0d,8.5d]} run say forced-level-pass',
'forced-level-pass')
assert dims(host, guest) == (OVERWORLD, OVERWORLD), dims(host, guest)
host('command', command=prefix + 'run forceload remove 8 8')
host('command', command=prefix + 'run kill @e[tag=forced_tick]')
host('command', command='tick unfreeze')
finally:
host('command', command='tick unfreeze')
for dimension in (NETHER, END):
host('command', command=f'execute in {dimension} run forceload remove 8 8')
guest('command', command=f'execute in {OVERWORLD} run tp @s {start[0]} {start[1]} {start[2]}')
guest('command', command='gamemode survival')
return results
CHECKS += ['forced-levels']
CHECK_FNS['forced-levels'] = check_forced_levels
def check_dig_stop(host, guest, out):
"""#1340 and #1908: a block the client breaks at once leaves the server no dig to keep.
MultiPlayerGameMode sends no STOP or ABORT for a creative break or an instant
survival break (its isDestroying stays false), so the server must forget the dig on
its own, as ServerPlayerGameMode.handleBlockBreakAction's creative and insta-mine
branches do. Otherwise the server keeps mining the old spot: the block put back
there is destroyed again (#1340) and the mining sound keeps playing at it (#1908).
"""
result = {}
spots = apart(host, guest)
for label, player in (('host', host), ('guest', guest)):
x, y, z = spots[label]
cell = (x, y + 1, z + 2)
seen = result[label] = {}
for mode, block in (('creative', 'minecraft:stone'), ('survival', 'minecraft:dead_bush')):
player('command', command=f'gamemode {mode}')
player('command', command='clear @s')
host('command', command=f'fill {x - 1} {y} {z + 1} {x + 1} {y + 3} {z + 4} minecraft:air')
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} {block}')
player.wait(10)
before = {g.name: heard(g, 'minecraft:block.stone.hit') for g in (host, guest)}
player.raw('look-at', f'{cell[0] + 0.5:.2f}', f'{cell[1] + 0.4:.2f}', f'{cell[2] + 0.5:.2f}')
# One left-click, then the attack released.
player.raw('mouse', 'left', 'click')
player.wait(40)
seen[f'{mode} broken'] = host.raw('block', *cell).get('block')
seen[f'{mode} mining'] = player('state').get('mining')
# The block put back must stay: the server has no dig left to destroy it.
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:stone')
player.wait(100)
seen[f'{mode} replacement'] = [g.raw('block', *cell).get('block') for g in (host, player)]
seen[f'{mode} hits'] = {g.name: heard(g, 'minecraft:block.stone.hit') - before[g.name]
for g in (host, guest)}
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:air')
assert seen['creative broken'] == 'minecraft:air', f'{label}: the creative break failed: {seen}'
assert seen['survival broken'] == 'minecraft:air', f'{label}: the instant break failed: {seen}'
assert seen['creative mining'] is None, f'{label}: the client still digs: {seen}'
assert seen['survival mining'] is None, f'{label}: the client still digs: {seen}'
for mode in ('creative', 'survival'):
assert seen[f'{mode} replacement'] == ['minecraft:stone'] * 2, \
f'{label} {mode}: the server kept digging: {seen}'
assert all(hits == 0 for hits in seen[f'{mode} hits'].values()), \
f'{label} {mode}: the mining sound kept playing: {seen}'
return result
CHECKS += ['dig_stop']
CHECK_FNS['dig_stop'] = check_dig_stop
def check_team_push(host, guest, out):
"""Another player's LivingEntity.pushEntities on the guest's screen: EntitySelector.pushableBy reads the
pusher's team, which for a player goes by its profile name (Player.getScoreboardName), not its UUID. The
host 0.3 east of the guest pushes it west, unless the host's team has collisionRule never."""
for g in (host, guest):
g('command', command='gamemode survival')
host_at, guest_at = pos_of(host), pos_of(guest)
x, y, z = guest_at
x, z = x // 1 + 0.5, z // 1 + 0.5
seen = {}
try:
for case in ('no team', 'never'):
if case == 'never':
host('command', command='team add parity_ghosts')
host('command', command='team modify parity_ghosts collisionRule never')
host('command', command=f'team join parity_ghosts {host.player}')
guest('command', command=f'tp @s {x} {y:.4f} {z}')
host('command', command=f'tp @s {x + 0.3} {y:.4f} {z}')
guest.wait(40)
nx, _, nz = pos_of(guest)
guest.shot(out, 'team-push-' + case.replace(' ', '-'))
seen[case] = {'dx': round(nx - x, 3), 'dz': round(nz - z, 3)}
# Apart again before the next case.
host('command', command=f'tp @s {x + 3} {y:.4f} {z}')
guest.wait(10)
finally:
host('command', command='team remove parity_ghosts')
# Back where they stood: later checks place the guest next to the host (face_each_other), and the
# host's last spot here put the guest inside the hill east of the spawn (pvp then hit the wall).
for g, (px, py, pz) in ((host, host_at), (guest, guest_at)):
g('command', command=f'tp @s {px:.4f} {py:.4f} {pz:.4f}')
guest.wait(10)
assert seen['no team']['dx'] < -0.05, f'the host did not push the guest west: {seen}'
assert abs(seen['never']['dx']) < 0.01 and abs(seen['never']['dz']) < 0.01, \
f'a host on a collisionRule never team still pushed the guest: {seen}'
return seen
CHECKS += ['team-push']
CHECK_FNS['team-push'] = check_team_push
def check_dig_sounds(host, guest, out):
"""#2277: the mining sound plays once per cadence, for everyone.
Vanilla plays the block's hit sound from the server's destroy progress
level events (2019 and 2020), so the digger and the players around hear
the same hits. Horizon's digger heard every hit twice: once from its own
client and once from the server, which is what the report called a sound
played too often.
"""
result = {}
spots = apart(host, guest)
for label, player in (('host', host), ('guest', guest)):
other = guest if label == 'host' else host
x, y, z = spots[label]
cell = (x, y + 1, z + 2)
player('command', command='gamemode survival')
player('command', command='clear @s')
host('command', command=f'fill {x - 1} {y} {z + 1} {x + 1} {y + 3} {z + 4} minecraft:air')
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:stone')
player.wait(10)
before = {g.name: heard(g, 'minecraft:block.stone.hit') for g in (host, guest)}
player.raw('look-at', f'{cell[0] + 0.5:.2f}', f'{cell[1] + 0.5:.2f}', f'{cell[2] + 0.5:.2f}')
player.raw('mouse', 'left', 'down')
player.wait(40)
player.raw('mouse', 'left', 'up')
player.wait(5)
hits = {g.name: heard(g, 'minecraft:block.stone.hit') - before[g.name] for g in (host, guest)}
result[label] = hits
assert host.raw('block', *cell).get('block') == 'minecraft:stone', f'{label}: the stone broke: {result}'
assert hits[other.name] >= 3, f'{label}: the player nearby heard no mining sound: {result}'
assert hits[player.name] <= hits[other.name] + 3, \
f'{label}: the digger heard the mining sound twice: {result}'
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:air')
return result
CHECKS += ['dig_sounds']
CHECK_FNS['dig_sounds'] = check_dig_sounds
def check_dig_clock(host, guest, out):
"""#4124: attack held on a block only the top of a jump can reach keeps one dig clock.
A diamond pickaxe on stone is 30 ticks of digging (Player.getDestroySpeed: correct tool,
speed 8 over hardness 1.5 and 30), five times slower airborne. The stone hangs seven blocks
above the guest's feet, so the standing eye, 1.62 up, is 5.38 from the block's lowest face,
past the 4.5 pick, while at the apex the eye is 2.87 up and 4.13 away, inside it: the client
digs the stone for the few ticks around each apex only, and its pick misses on the way down.
Vanilla's stopDestroyBlock returns at once for a break that is not running, so after the first
fall it is a no-op: the client's progress and dig identity survive, no START is re-sent, and
the server keeps the clock of the first START (ServerPlayerGameMode.destroyPos). The STOP that
arrives when the accumulated progress passes 1.0 is therefore measured from that first START.
The face met is the underside: the eye is below the block at the apex too.
"""
x, y, z = (int(v // 1) for v in pos_of(guest))
cell = (x, y + 7, z)
# The eye's distance to the stone's lowest face: past the 4.5 pick standing, inside it at apex.
gap = lambda lift: round(cell[1] - (y + 1.62 + lift), 2)
seen = {'cell': list(cell), 'gap': {'standing': gap(0.0), 'apex': gap(1.2522)}}
assert gap(0.0) > 4.5 > gap(1.2522), f'the stone is not out of reach standing and in it at the apex: {seen}'
mode = guest('state').get('gamemode', 'survival')
try:
guest('command', command='gamemode survival')
guest('command', command='clear @s')
guest('command', command='item replace entity @s weapon.mainhand with minecraft:diamond_pickaxe')
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 2} {y + 8} {z + 2} minecraft:air')
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 2} {y - 1} {z + 2} minecraft:stone')
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:stone')
# Straight up: the ray meets the stone's lowest face at every eye height.
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 -90')
guest.wait(20)
standing = guest('state')
if 'looking_at' not in standing or standing.get('player') is None:
return 'skipped (the ctl state has no crosshair target or eyes)'
seen['standing'] = {'aim': standing['looking_at']['kind'],
'eye': [round(v, 2) for v in standing['player']['eye']],
'pitch': round(standing['player']['pitch'], 1)}
assert standing['looking_at']['kind'] != 'block' \
or standing['looking_at']['block']['pos'] != list(cell), f'the stone is in reach standing: {seen}'
assert host.raw('block', *cell).get('block') == 'minecraft:stone', f'the stone did not stay: {seen}'
# Hold attack and keep jumping: each fall stops the client's dig (its progress is zeroed)
# and airborne digs five times slower, so only the server's clock can finish the stone.
before = standing['ticks']
nearest, stretch, polls, broke = None, 0, 0, False
guest.raw('mouse', 'left', 'down')
guest.raw('key', 'space', 'down')
deadline = time.time() + 15
while time.time() < deadline:
polls += 1
now = guest('state')
target = now['looking_at'].get('block')
if target and target['pos'] == list(cell):
nearest = target['distance'] if nearest is None else min(nearest, target['distance'])
if (now.get('mining') or {}).get('pos') == list(cell):
stretch = max(stretch, now['mining']['ticks'])
if polls % 3 == 0 and host.raw('block', *cell).get('block') == 'minecraft:air':
broke = True
break
guest.wait(2)
seen.update(ticks=guest('state')['ticks'] - before, polls=polls,
nearest=None if nearest is None else round(nearest, 2), longest_dig=stretch,
guest_view=guest.raw('block', *cell).get('block'))
guest.shot(out, 'dig_clock')
assert nearest is not None and nearest < 4.5, f'the guest never had the stone at an apex: {seen}'
assert broke and seen['guest_view'] == 'minecraft:air', \
f'attack held through 15 s of jumps did not break the stone: {seen}'
# One in-reach stretch is about five ticks; more than that survived a fall, which is the
# accumulation vanilla's stopDestroyBlock leaves behind and the fix restores.
assert stretch > 8, f'the dig restarted on every jump ({stretch} ticks): {seen}'
return seen
finally:
guest.raw('release')
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:air')
guest('command', command=f'gamemode {mode}')
CHECKS += ['dig_clock']
CHECK_FNS['dig_clock'] = check_dig_clock
def check_popped_break_effects(host, guest, out):
"""Block.playerWillDestroy only skips the mined block's own level event: a block that pops off
when its support is mined (a torch on the side, sugar cane above) plays its break event (sound and
particles) for the miner too, once, as it does for the players around (#3747)."""
result = {}
spots = apart(host, guest)
cases = [('torch', 'minecraft:dirt', 'minecraft:torch', (0, 1, 0), 'minecraft:block.wood.break'),
('cane', 'minecraft:sand', 'minecraft:sugar_cane', (0, 1, 0), 'minecraft:block.grass.break')]
for label, player in (('host', host), ('guest', guest)):
other = guest if label == 'host' else host
x, y, z = spots[label]
player('command', command='gamemode survival')
player('command', command='clear @s')
for name, support, popped, offset, sound in cases:
cell = (x, y + 1, z + 3)
host('command', command=f'fill {x - 1} {y} {z + 1} {x + 1} {y + 3} {z + 5} minecraft:air')
host('command', command=f'setblock {cell[0]} {cell[1] - 1} {cell[2]} minecraft:sand')
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} {support}')
at = (cell[0] + offset[0], cell[1] + offset[1], cell[2] + offset[2])
if name == 'cane':
host('command', command=f'fill {x - 2} {cell[1] - 1} {cell[2] + 1} {x - 2} {cell[1] - 1} {cell[2] + 1} minecraft:water')
host('command', command=f'fill {cell[0] - 1} {cell[1]} {cell[2]} {cell[0] - 1} {cell[1]} {cell[2]} minecraft:water')
host('command', command=f'setblock {cell[0]} {cell[1] + 1} {cell[2]} minecraft:sugar_cane')
else:
host('command', command=f'setblock {at[0]} {at[1]} {at[2]} {popped}')
host.wait(10)
player.until(lambda: player.raw('block', *at)['block'], lambda b: b.startswith(popped.split('[')[0]))
before = {g.name: heard(g, sound) for g in (host, guest)}
player.raw('look-at', f'{cell[0] + 0.5:.2f}', f'{cell[1] + 0.5:.2f}', f'{cell[2] + 0.5:.2f}')
player.raw('mouse', 'left', 'down')
player.until(lambda: host.raw('block', *cell)['block'], lambda b: b == 'minecraft:air', ticks=120)
player.raw('mouse', 'left', 'up')
player.wait(10)
seen = {g.name: heard(g, sound) - before[g.name] for g in (host, guest)}
result[f'{label}-{name}'] = seen
assert host.raw('block', *at)['block'] == 'minecraft:air', f'{label}-{name}: the popped block stayed: {result}'
assert seen[player.name] == 1, f'{label}-{name}: the miner heard {seen[player.name]} break sounds: {result}'
assert seen[other.name] == 1, f'{label}-{name}: the other player heard {seen[other.name]}: {result}'
return result
CHECKS += ['popped_break_effects']
CHECK_FNS['popped_break_effects'] = check_popped_break_effects
def mooshroom_types(game, centre):
"""The colour (0 red, 1 brown) this side draws for each mooshroom around `centre`, by column."""
return {round(m['pos'][0] - centre[0]): m['variant'] for m in game.raw('mobs', 32).get('near', [])
if m['kind'] == 'minecraft:mooshroom' and abs(m['pos'][2] - centre[2] - 6) < 2}
def check_mooshroom(host, guest, out):
"""MushroomCow.DATA_TYPE, entry 18 after AgeableMob's DATA_BABY_ID and AGE_LOCKED, reaches both
clients (#3236): a mooshroom summoned brown is drawn brown, and a red one a bolt strikes
(MushroomCow.thunderHit) turns brown on the guest's screen too."""
x, y, z = (int(v // 1) for v in pos_of(host))
centre = (x + 0.5, y, z + 0.5)
sides = {'host': host, 'guest': guest}
seen = {}
# 12 blocks apart, so one bolt (3 blocks of reach) strikes only the red one, on stone with
# nothing around (no fire).
host('command', command=f'fill {x - 8} {y - 1} {z + 3} {x + 8} {y - 1} {z + 9} minecraft:stone')
host('command', command=f'fill {x - 8} {y} {z + 3} {x + 8} {y + 4} {z + 9} minecraft:air')
try:
host('command', command=f'summon minecraft:mooshroom {x - 5.5} {y} {z + 6.5} {{NoAI:1b,Type:"brown"}}')
host('command', command=f'summon minecraft:mooshroom {x + 6.5} {y} {z + 6.5} {{NoAI:1b,Type:"red"}}')
host.wait(20)
for g in sides.values():
g.raw('look-at', centre[0], y + 1, z + 6.5)
seen['spawned'] = {name: mooshroom_types(g, centre) for name, g in sides.items()}
host('command', command=f'summon minecraft:lightning_bolt {x + 6.5} {y} {z + 6.5}')
guest.until(lambda: mooshroom_types(guest, centre), lambda t: t.get(6) == 1, ticks=40)
host.wait(5)
seen['struck'] = {name: mooshroom_types(g, centre) for name, g in sides.items()}
seen['server'] = host('command', command=f'data get entity @e[type=minecraft:mooshroom,x={x + 6.5},'
f'y={y},z={z + 6.5},distance=..1,limit=1] Type')['chat'][-1]
guest.shot(out, 'mooshroom')
finally:
host('command', command=f'kill @e[type=minecraft:mooshroom,x={x - 8},y={y - 1},z={z + 3},dx=16,dy=6,dz=6]')
host('command', command=f'fill {x - 8} {y} {z + 3} {x + 8} {y + 4} {z + 9} minecraft:air')
for name in sides:
assert seen['spawned'][name] == {-6: 1, 6: 0}, f'{name}: brown and red at the summon: {seen}'
assert seen['struck'][name] == {-6: 1, 6: 1}, f'{name}: the struck one turned brown: {seen}'
assert '"brown"' in seen['server'], f'the server has the struck one brown: {seen}'
return seen
CHECKS += ['mooshroom']
CHECK_FNS['mooshroom'] = check_mooshroom
def check_shulker_menu(host, guest, out):
"""A shulker box opens a ShulkerBoxMenu (ShulkerBoxBlockEntity.createMenu), whose ShulkerBoxSlots
refuse shulker boxes. The server opened it as a 9x3 chest menu, which took them."""
x, y, z = (int(v // 1) for v in pos_of(guest))
box = (x, y, z + 2)
try:
guest('command', command='gamemode survival')
guest('command', command='clear @s')
guest('command', command='give @s minecraft:cobblestone 1')
guest('command', command='give @s minecraft:red_shulker_box 1')
guest('command', command=f'setblock {box[0]} {box[1]} {box[2]} minecraft:shulker_box')
guest.wait(10)
guest.raw('interact', *box)
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
guest.wait(10)
# The cobblestone goes in; the red shulker box stays on the cursor.
mouse(guest, hotbar_slot(guest, 'minecraft:cobblestone'))
mouse(guest, 301)
mouse(guest, hotbar_slot(guest, 'minecraft:red_shulker_box'))
mouse(guest, 300)
guest.wait(10)
guest.shot(out, 'shulker-menu')
guest.raw('key', 'esc', 'tap')
guest.wait(10)
inside, size = open_container(guest, box, out, 'shulker-menu-after')
counts = guest('inventory')['counts']
stored = host('command', command=f'data get block {box[0]} {box[1]} {box[2]} Items')
finally:
guest('command', command=f'setblock {box[0]} {box[1]} {box[2]} minecraft:air')
guest('command', command='clear @s')
assert size == 27 and inside == {1: 'minecraft:cobblestone'}, f'the shulker box holds {inside} ({size})'
assert 'red_shulker_box' not in json.dumps(stored), f'the server stored {stored}'
assert counts.get('minecraft:red_shulker_box') == 1, f'inventory after: {counts}'
return {'inside': inside, 'inventory': counts}
CHECKS += ['shulker_menu']
CHECK_FNS['shulker_menu'] = check_shulker_menu
def check_keys_released(host, guest, out):
"""Gui.setScreen -> KeyMapping.releaseAll: the screen shown after leaving a server releases every held
key, so W and Space held through the disconnect don't walk and jump on in the next world (#3246)."""
port = host.raw('lan', 'status')['port']
guest.raw('key', 'w', 'down')
guest.raw('key', 'space', 'down')
held = sorted(guest('status')['keys'])
guest.raw('disconnect')
left = sorted(guest('status')['keys'])
guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME)
guest.raw('wait-for', 'loaded', '--timeout', 300)
guest.wait(20)
rejoined = sorted(guest('status')['keys'])
start = pos_of(guest)
guest.wait(20)
end = pos_of(guest)
walked = round(math.hypot(end[0] - start[0], end[2] - start[2]), 3)
seen = {'held': held, 'left': left, 'rejoined': rejoined, 'walked': walked}
assert held == ['KeyW', 'Space'], f'the keys were not pressed: {seen}'
assert left == [] and rejoined == [], f'keys stayed down after leaving: {seen}'
assert walked < 0.1, f'the guest walked on its own after rejoining: {seen}'
return seen
CHECKS += ['keys_released']
CHECK_FNS['keys_released'] = check_keys_released
def offline_uuid_ints(name):
"""UUIDUtil.createOfflinePlayerUUID as the contents of an int-array UUID tag."""
digest = bytearray(hashlib.md5(f'OfflinePlayer:{name}'.encode()).digest())
digest[6] = digest[6] & 0x0f | 0x30
digest[8] = digest[8] & 0x3f | 0x80
return ','.join(str(int.from_bytes(digest[i:i + 4], 'big', signed=True)) for i in range(0, 16, 4))
def check_pet_death(host, guest, out):
"""TamableAnimal.die (#3514): with showDeathMessages, a tame pet's owner, and nobody else, hears how it
died (CombatTracker.getDeathMessage): the guest's wolf slain by a zombie, the host's cat killed."""
hx, hy, hz = pos_of(host)
tag = 'pet_death'
try:
host('command', command=f'summon minecraft:zombie {hx + 1.5:.2f} {hy:.2f} {hz - 2:.2f} '
f'{{NoAI:1b,Silent:1b,Tags:["{tag}"]}}')
host('command', command=f'summon minecraft:wolf {hx - 1.5:.2f} {hy:.2f} {hz - 2:.2f} '
f'{{CustomName:"Rex",NoAI:1b,Tags:["{tag}","rex"],'
f'Owner:[I;{offline_uuid_ints(GUEST_NAME)}]}}')
host('command', command=f'summon minecraft:cat {hx:.2f} {hy:.2f} {hz - 3:.2f} '
f'{{CustomName:"Tom",NoAI:1b,Tags:["{tag}","tom"],'
f'Owner:[I;{offline_uuid_ints(host.player)}]}}')
host.wait(5)
host('command', command=f'damage @e[tag=rex,limit=1] 100 minecraft:mob_attack '
f'by @e[type=minecraft:zombie,tag={tag},limit=1]')
host('command', command='kill @e[tag=tom]')
host.wait(20)
guest_chat = guest.raw('messages', 10)['chat']
host_chat = host.raw('messages', 10)['chat']
guest.shot(out, 'pet-death')
finally:
host('command', command=f'kill @e[tag={tag}]')
seen = {'guest': guest_chat, 'host': host_chat}
assert 'Rex was slain by Zombie' in guest_chat, f"the wolf's owner was not told: {seen}"
assert 'Rex was slain by Zombie' not in host_chat, f"someone else's wolf was told to the host: {seen}"
assert 'Tom was killed' in host_chat, f"the cat's owner was not told: {seen}"
assert 'Tom was killed' not in guest_chat, f"someone else's cat was told to the guest: {seen}"
return seen
CHECKS += ['pet_death']
CHECK_FNS['pet_death'] = check_pet_death
def check_data_uuid(host, guest, out):
"""EntityDataAccessor: `/data get entity @s UUID` prints the player who runs it, their own
offline UUID, for the guest and for the host (a server player too), not the world owner's."""
seen = {}
for game, name in ((guest, GUEST_NAME), (host, host.player)):
reply = server_reply(game, 'data get entity @s UUID', '[I;')
seen[name] = ','.join(re.findall(r'-?\d+', reply.partition('[I;')[2].partition(']')[0]))
for name, ints in seen.items():
assert ints == offline_uuid_ints(name), f"{name}'s UUID is not their own: {seen}"
return seen
CHECKS += ['data_uuid']
CHECK_FNS['data_uuid'] = check_data_uuid
def check_arrow_pickup(host, guest, out):
"""ClientPacketListener.handleTakeItemEntity (#4486): an arrow a player picks up
(AbstractArrow.playerTouch, once in the ground) flies into them on every client, the
collector's and the other player's, as an ItemPickupParticle, like an item does."""
face_each_other(host, guest)
seen = {}
try:
# The guest stands 3 blocks east: each arrow lands on the far side from the other player.
for game, dx in ((host, -0.9), (guest, 0.9)):
game('command', command='gamemode survival')
game('command', command='clear @s')
before = {g.name: g('status')['pickups'].get('arrow', 0) for g in (host, guest)}
game('command', command=f'summon minecraft:arrow ~{dx} ~2 ~ {{pickup:1b,Motion:[0.0,-1.0,0.0]}}')
game.wait(40)
game.shot(out, 'arrow-pickup')
seen[game.name] = {'flown': {g.name: g('status')['pickups'].get('arrow', 0) - before[g.name]
for g in (host, guest)},
'arrows': game('inventory')['counts'].get('minecraft:arrow', 0)}
finally:
for game in (host, guest):
game('command', command='clear @s')
host('command', command='kill @e[type=minecraft:arrow]')
for name, case in seen.items():
assert case['arrows'] == 1, f'{name} did not pick the arrow up: {seen}'
assert all(n == 1 for n in case['flown'].values()), f"{name}'s arrow did not fly to them: {seen}"
return seen
CHECKS += ['arrow_pickup']
CHECK_FNS['arrow_pickup'] = check_arrow_pickup
def check_trident_pickup(host, guest, out):
"""#5577: a thrown trident a player picks up (AbstractArrow.playerTouch, then Player.take) flies
into them on every client as an ItemPickupParticle, like an arrow, instead of vanishing. A
server's client keeps tridents apart from the other projectiles."""
face_each_other(host, guest)
seen = {}
try:
for game in (host, guest):
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='item replace entity @s weapon.mainhand with minecraft:trident')
# Straight down: it sticks at the player's feet, inside their pickup box.
game('command', command='tp @s ~ ~ ~ ~ 90')
game.wait(5)
before = {g.name: g('status')['pickups'].get('trident', 0) for g in (host, guest)}
game.raw('use-item', '--hold', 1.2)
game.wait(40)
game.shot(out, 'trident-pickup')
seen[game.name] = {'flown': {g.name: g('status')['pickups'].get('trident', 0) - before[g.name]
for g in (host, guest)},
'tridents': game('inventory')['counts'].get('minecraft:trident', 0)}
finally:
for game in (host, guest):
game.raw('mouse', 'right', 'up')
game('command', command='clear @s')
host('command', command='kill @e[type=minecraft:trident]')
face_each_other(host, guest)
for name, case in seen.items():
assert case['tridents'] == 1, f'{name} did not pick the trident up: {seen}'
assert all(n == 1 for n in case['flown'].values()), f"{name}'s trident did not fly to them: {seen}"
return seen
CHECKS += ['trident_pickup']
CHECK_FNS['trident_pickup'] = check_trident_pickup
# Mounts for mount_other_level: (kind, summon NBT, blocks in front of the player). A camel is
# always tame (Camel.isTamed); the equines need Tame:1b, and a nautilus an Owner, the player's
# offline UUID (TamableAnimal.readAdditionalSaveData tames it). A donkey, mule and llama get a
# chest for their extra slots; a camel is 1.7 wide and long, so it stands a little further off.
OTHER_LEVEL_MOUNTS = [
('horse', '{Tame:1b,NoAI:1b}', 2.5),
('camel', '{NoAI:1b}', 3.0),
('llama', '{Tame:1b,NoAI:1b,ChestedHorse:1b,Strength:3}', 2.5),
('trader_llama', '{Tame:1b,NoAI:1b,ChestedHorse:1b,Strength:3}', 2.5),
('nautilus', '{Owner:[I;OWNER],NoAI:1b}', 2.5),
('donkey', '{Tame:1b,NoAI:1b,ChestedHorse:1b}', 2.5),
('mule', '{Tame:1b,NoAI:1b,ChestedHorse:1b}', 2.5),
]
HEIGHTS.update({'nautilus': 0.95, 'mule': 1.6, 'trader_llama': 1.87})
# The second level the server ticks while the players stand in the Overworld: a chunk force-loaded
# there.
OTHER_LEVELS = ['minecraft:the_nether', 'minecraft:the_end']
def check_mount_other_level(host, guest, out):
"""#5331: ServerPlayer.tick checks the open menu's stillValid in the player's own level. With a
chunk force-loaded in the Nether, or in the End, the server ticks a second level that has none
of the Overworld's mobs; the inventory of a tamed horse, camel, llama, trader llama, nautilus,
donkey and mule, opened with shift + right click or with the inventory key while riding, and
again by shift + right click after getting off, must stay open through those ticks instead of
closing at once. The other way round too (in_nether): the player and the mount
in the Nether while the other player keeps the Overworld ticking. Each case is reported on its
own (LAN_E2E_MOUNTS=camel,llama picks mounts, LAN_E2E_LEVELS=the_end,in_nether cases).
By default only the horse and the camel, with the Nether force-loaded, plus in_nether: the
whole matrix takes about 10 minutes, past what the nightly LAN suite has left of its timeout.
LAN_E2E_MOUNTS=all and LAN_E2E_LEVELS=all run all of it."""
spots = apart(host, guest)
result = {}
failures = []
def env_list(name, default):
given = [k for k in os.environ.get(name, '').split(',') if k]
return [] if given == ['all'] else given or default
# An empty list means every one.
only = env_list('LAN_E2E_MOUNTS', ['horse', 'camel'])
picked = env_list('LAN_E2E_LEVELS', ['the_nether', 'in_nether'])
mounts = [m for m in OTHER_LEVEL_MOUNTS if not only or m[0] in only]
def try_mount(where, label, player, spot, kind, nbt, ahead, into):
"""Summon `kind` in front of `spot` and open its inventory the three ways, each reported in
`into[kind]` and, when it fails, in `failures`."""
x, y, z = spot
who = f'{where} {label} {kind}'
opened = into[kind] = {}
try:
player('command', command='gamemode survival')
player('command', command='clear @s')
owner = offline_uuid_ints(host.player if label == 'host' else GUEST_NAME)
player('command', command=f'summon minecraft:{kind} {x + 0.5} {y} {z + ahead} '
f'{nbt.replace("OWNER", owner)}')
player.wait(5)
mob = next((m for m in player.raw('mobs', 16).get('near', [])
if m['kind'] == f'minecraft:{kind}' and m['health'] > 0), None)
assert mob, f'the {label} sees no {kind}'
p = mob['pos']
aim = (f'{p[0]:.2f}', f'{p[1] + HEIGHTS[kind] / 2:.2f}', f'{p[2]:.2f}')
def shift_click():
player.raw('look-at', *aim)
player.raw('key', 'shift', 'down')
player.wait(3)
player.raw('mouse', 'right', 'click')
player.wait(2)
player.raw('key', 'shift', 'up')
def stays_open(how, what):
# The screen opens and is still open 20 ticks later; each way is reported on its
# own, so one that fails doesn't hide the others.
if mount_screen(player) is None:
opened[how] = False
failures.append(f'{who}: {what} opened no {kind} inventory')
return
player.wait(20)
opened[how] = mount_screen(player, ticks=2) is not None
if not opened[how]:
failures.append(f'{who}: the {kind} inventory ({what}) closed by itself')
back_to_game(player)
player.wait(5)
shift_click()
stays_open('use', 'shift + right click')
player.raw('look-at', *aim)
player.raw('mouse', 'right', 'click')
player.wait(8)
riding = player('status').get('riding')
if riding == mob['id']:
player.raw('key', 'e', 'tap')
stays_open('key', 'the inventory key')
hold(player, 'shift', 10)
player.wait(10)
if player('status').get('riding') is not None:
failures.append(f'{who}: did not get off the {kind}')
else:
failures.append(f'{who}: right-clicked the {kind}: riding {riding}')
# Off the mount and open again.
shift_click()
stays_open('reopen', 'shift + right click after getting off')
except AssertionError as error:
failures.append(f'{who}: {error}')
finally:
back_to_game(player)
player('command', command=f'kill @e[type=minecraft:{kind},x={x},y={y},z={z},distance=..16]')
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
# A killed mob lies there for 20 ticks (LivingEntity.tickDeath): the next mount's
# clicks, at the same spot, must not land on it.
for _ in range(15):
player.wait(2)
if not any(m['kind'] == f'minecraft:{kind}' for m in player.raw('mobs', 16).get('near', [])):
break
for level in OTHER_LEVELS:
if picked and level.removeprefix('minecraft:') not in picked:
continue
result[level] = {}
host('command', command=f'execute in {level} run forceload add 0 0')
try:
host.wait(10)
for label, player in (('host', host), ('guest', guest)):
result[level][label] = {}
for kind, nbt, ahead in mounts:
try_mount(level, label, player, spots[label], kind, nbt, ahead, result[level][label])
finally:
host('command', command=f'execute in {level} run forceload remove 0 0')
# The player and the mount in the Nether, the other player keeping the Overworld ticking: the
# Overworld's pass must leave the menu alone. One mount is enough: every mount's menu takes the
# same check.
if picked and 'in_nether' not in picked:
pass
else:
result['in_nether'] = {}
for label, player, spot in (('host', host, (0, 100, 0)), ('guest', guest, (12, 100, 0))):
x, y, z = spot
box = f'{x - 3} {y - 1} {z - 3} {x + 4} {y + 3} {z + 6}'
result['in_nether'][label] = {}
try:
# The Nether's mobs, fire and the fall into whatever is below until the platform
# stands can't get in the way.
player('command', command='gamemode creative')
player('command', command='effect give @s minecraft:resistance 120 4 true')
player('command', command='effect give @s minecraft:fire_resistance 120 0 true')
player('command', command=f'execute in minecraft:the_nether run tp @s {x + 0.5} {y} {z + 0.5} 0 0')
state = wait_dimension(player, 'minecraft:the_nether', 600)
if state.get('dimension') != 'minecraft:the_nether':
failures.append(f'in_nether {label}: still in {state.get("dimension")}')
continue
player('command', command=f'fill {box} minecraft:air')
player('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 4} {y - 1} {z + 6} minecraft:obsidian')
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
player.wait(20)
at = pos_of(player)
if abs(at[1] - y) > 0.1:
failures.append(f'in_nether {label}: not on the platform at {y}: {at}')
continue
for kind, nbt, ahead in mounts[:1]:
try_mount('in_nether', label, player, spot, kind, nbt, ahead, result['in_nether'][label])
finally:
back_to_game(player)
host('command', command=f'execute in minecraft:the_nether run fill {box} minecraft:air')
player('command', command='effect clear @s')
bx, by, bz = spots[label]
player('command', command=f'execute in minecraft:overworld run tp @s {bx + 0.5} {by} {bz + 0.5} 0 0')
wait_dimension(player, 'minecraft:overworld', 600)
player.wait(10)
assert not failures, f'{failures}: {result}'
return result
CHECKS += ['mount_other_level']
CHECK_FNS['mount_other_level'] = check_mount_other_level
def check_cushion_break_seated(host, guest, out):
"""#4571: breaking the cushion one sits on (Entity.setRemoved) puts the rider on the floor it
sat on (LivingEntity.dismountVehicle from a removed vehicle), not a block below it."""
seen = {}
for label, player in (('host', host), ('guest', guest)):
x, y, z = (int(v // 1) for v in pos_of(player))
at = (x + 0.5, y, z + 2.5)
host('command', command=f'fill {x - 1} {y} {z + 1} {x + 1} {y + 3} {z + 3} minecraft:air')
host('command', command=f'setblock {x} {y - 1} {z + 2} minecraft:stone')
player('command', command='gamemode creative')
player('command', command='item replace entity @s weapon.mainhand with minecraft:green_cushion')
player.wait(10)
player.raw('look-at', *at)
player.raw('mouse', 'right', 'click')
player.wait(15)
cushion = [o for o in host('status')['objects'] if o['kind'] == 'minecraft:cushion'
and abs(o['position'][0] - at[0]) < .1 and abs(o['position'][2] - at[2]) < .1]
assert len(cushion) == 1, (label, cushion)
player('command', command='item replace entity @s weapon.mainhand with minecraft:air')
player.wait(5)
player.raw('look-at', at[0], at[1] + .125, at[2])
player.raw('mouse', 'right', 'click')
player.wait(10)
assert player('status').get('riding') == cushion[0]['id'], label
player.raw('look-at', at[0], at[1] + .125, at[2])
player.raw('mouse', 'left', 'click')
player.wait(20)
seen[label] = {'riding': player('status').get('riding'), 'position': pos_of(player)}
player.shot(out, 'cushion-broken-seated')
assert seen[label]['riding'] is None, seen
assert abs(seen[label]['position'][1] - y) < 1e-3, seen
assert abs(seen[label]['position'][0] - at[0]) < 1e-3, seen
return seen
CHECKS += ['cushion_break_seated']
CHECK_FNS['cushion_break_seated'] = check_cushion_break_seated
def check_hoglin_wall(host, guest, out):
host('command', command='gamemode creative')
x, y, z = (int(v // 1) for v in pos_of(host))
gx, gz = x + 6, z
box = f'{gx - 2} {y} {gz - 2} {gx + 2} {y + 3} {gz + 2}'
health = lambda: server_reply(host, 'data get entity @e[type=minecraft:hoglin,limit=1] Health', 'entity data')
try:
host('command', command='kill @e[type=minecraft:hoglin]')
host('command', command=f'fill {box} minecraft:stone')
host('command', command=f'summon minecraft:hoglin {gx + 0.5} {y + 1} {gz + 0.5} {{NoAI:1b}}')
before = health()
host.wait(40)
after = health()
finally:
host('command', command='kill @e[type=minecraft:hoglin]')
host('command', command=f'fill {box} minecraft:air')
host('command', command='gamemode survival')
assert before != after, f'a hoglin inside a wall took no damage: {before} -> {after}'
return {'before': before, 'after': after}
CHECKS += ['hoglin-wall']
CHECK_FNS['hoglin-wall'] = check_hoglin_wall
def check_attack_over_slab(host, guest, out):
"""#5772: a mob whose centre a block hides from the eyes, but whose head shows over it, takes the
hit the crosshair aims at its head. The attack packet names only the entity, so the server aims
the swapped-in player itself; it aimed at the centre and dropped the hit (#5903). A bottom slab at
eye height between the player and a zombie hides the zombie's centre, not its head."""
x, y, z = map(math.floor, pos_of(host))
tag = 'attack_over_slab'
target = [x + 2.5, y, z + 0.5]
seen = {}
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 5} {y - 1} {z + 3} minecraft:stone')
host('command', command=f'fill {x - 2} {y} {z - 2} {x + 5} {y + 4} {z + 3} minecraft:air')
host('command', command=f'setblock {x + 1} {y + 1} {z} minecraft:stone_slab[type=bottom]')
try:
for label, player, other in (('host', host, guest), ('guest', guest, host)):
player('command', command='gamemode survival')
player('command', command='clear @s')
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
other('command', command=f'tp @s {x + 0.5} {y} {z + 3.5}')
host('command', command=f'summon minecraft:zombie {target[0]} {y} {target[2]} '
f'{{NoAI:1b,PersistenceRequired:1b,Tags:["{tag}"]}}')
player.wait(20) # full attack charge and the zombie paired
mob = next(m for m in player.raw('mobs', 16)['near']
if m['kind'] == 'minecraft:zombie' and math.dist(m['pos'], target) < 0.1)
before = mob_seen(player, mob['id'])['health']
# The head, over the slab: the crosshair hits the zombie, not the slab.
player.raw('look-at', target[0], y + 1.8, target[2])
player.wait(2)
player.raw('mouse', 'left', 'click')
player.wait(10)
after = mob_seen(player, mob['id'])['health']
player.shot(out, f'attack-over-slab-{label}')
seen[label] = {'health': [before, after]}
host('command', command=f'kill @e[tag={tag}]')
player.wait(5)
assert after < before, f'{label}: the hit over the slab did no damage: {seen}'
return seen
finally:
host('command', command=f'kill @e[tag={tag}]')
host('command', command=f'setblock {x + 1} {y + 1} {z} minecraft:air')
CHECKS += ['attack_over_slab']
CHECK_FNS['attack_over_slab'] = check_attack_over_slab
def check_wind_bystanders(host, guest, out):
"""A wind charge bursting on the floor pushes the other entities it reaches (ServerExplosion.hurtEntities
-> Entity.pushFromExplosion): an experience orb, a falling block, a boat and a minecart, but never a dropped
item (ItemEntity.ignoreExplosion unless the burst affects block-like entities, which a wind charge's never
does). The scene was run on the real 26.3 server (`tick freeze`, `tick step 3` after the burst): the orb
moved (+1.45, +0.19, 0), the falling block (-1.23, +0.82, 0), the boat (0, +0.30, +0.76), the minecart
(0, +0.14, -0.39), the item not at all."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
x, y, z = hx + 6, hy, hz
for g in (host, guest):
g('command', command='gamemode spectator') # orbs home in on survival players
steps = [f'fill {x - 8} {y - 1} {z - 8} {x + 8} {y - 1} {z + 8} minecraft:bedrock',
f'fill {x - 8} {y} {z - 8} {x + 8} {y + 5} {z + 8} minecraft:air',
'kill @e[type=!player]', 'tick freeze',
f'summon minecraft:experience_orb {x + 1.4} {y} {z + 0.5} {{Value:1s}}',
f'summon minecraft:falling_block {x - 0.4} {y + 0.02} {z + 0.5} {{BlockState:{{Name:"minecraft:sand"}},Time:1,NoGravity:1b}}',
f'summon minecraft:oak_boat {x + 0.5} {y} {z + 1.5}',
f'summon minecraft:minecart {x + 0.5} {y} {z - 0.5}',
f'summon minecraft:item {x + 1.1} {y} {z - 0.1} {{Item:{{id:"minecraft:stone",count:1}}}}',
'tick step 20']
def scene():
near = lambda p: abs(p[0] - x) < 4 and abs(p[2] - z) < 4
found = {}
for e in host.raw('entities', 8, 'minecraft:experience_orb')['entities']:
found['orb'] = e['pos']
found['orb_velocity'] = e.get('velocity')
for e in host.raw('entities', 8, 'minecraft:item')['entities']:
if near(e['pos']):
found['item'] = e['pos']
for p in host.raw('simulation')['projectiles']:
kind = {'minecraft:falling_block': 'fall', 'minecraft:boat': 'boat', 'minecraft:minecart': 'cart'}.get(p['kind'])
if kind and near(p['position']):
found[kind] = p['position']
return found
try:
host('command', command='tick unfreeze')
for c in steps:
host('command', command=c)
host.wait(10)
before = scene()
host('command', command=f'summon minecraft:wind_charge {x + 0.5} {y + 0.6} {z + 0.5} {{Motion:[0.0,-1.0,0.0]}}')
ticks = []
for _ in range(4):
host('command', command='tick step 1')
host.wait(4)
ticks.append(scene())
after = ticks[2]
finally:
host('command', command='tick unfreeze')
host('command', command='kill @e[type=!player]')
for g in (host, guest):
g('command', command='gamemode creative')
moved = {k: [round(a - b, 3) for a, b in zip(after[k], before[k])] for k in ('orb', 'fall', 'boat', 'cart', 'item') if k in before and k in after}
assert set(moved) == {'orb', 'fall', 'boat', 'cart', 'item'}, f'missing entities: {before} {after}'
assert moved['item'] == [0, 0, 0], f'a dropped item was pushed: {moved}'
# An orb's position follows its tracker, which a frozen tick does not refresh: its velocity shows the push.
orb = after.get('orb_velocity') or [0, 0, 0]
assert orb[0] > 0.3 and orb[1] > 0.02, f'the orb was not pushed away: {orb}'
for kind, axis, sign in (('fall', 0, -1), ('boat', 2, 1), ('cart', 2, -1)):
assert moved[kind][axis] * sign > 0.2 and moved[kind][1] > 0.05, f'{kind} was not pushed away: {moved}'
return {'before': before, 'moved': moved, 'orb_velocity': orb, 'ticks': ticks}
CHECKS += ['wind-bystanders']
CHECK_FNS['wind-bystanders'] = check_wind_bystanders
def check_lightning_box(host, guest, out):
"""A lightning bolt tests the box of every entity against its strike box (3 around, 9 above),
not their feet, and destroys boats, minecarts and experience orbs (LightningBolt.tick ->
Entity.thunderHit -> VehicleEntity.hurtServer 5 x 10 > 40; ExperienceOrb health 5). Real
vanilla 26.3 (same scene): boat, cart, orb and a boat whose feet stand 3.5 out are gone, a cow
whose feet stand 3.2 out drops from 10 to 5, a boat 6 out is untouched."""
x, y, z = (int(v // 1) for v in pos_of(host))
ox, oy, oz = x + 12, y, z
tag = 'dg_bolt'
spots = {'boat': ('minecraft:oak_boat', 1.5, 0), 'cart': ('minecraft:minecart', -1.5, 0),
'orb': ('minecraft:experience_orb', 0, -1.5), 'edgeboat': ('minecraft:oak_boat', 0, 3.5),
'edgecow': ('minecraft:cow', 3.2, 0), 'farboat': ('minecraft:oak_boat', 6.0, 0)}
markers = {name: (ox - 8 + i, oy, oz + 12) for i, name in enumerate(spots)}
def alive(name):
if name == 'orb': # @e does not list experience orbs; the state counts them by kind
return remote(host).get('entities', {}).get('minecraft:experience_orb', 0) > 0
# Marker blocks: a command the game runs, a block the control reads back.
mx, my, mz = markers[name]
host('command', command=f'setblock {mx} {my} {mz} minecraft:air')
host('command', command=f'execute if entity @e[tag={tag}_{name}] run setblock {mx} {my} {mz} minecraft:gold_block')
host.wait(2)
return host.raw('block', mx, my, mz).get('block') == 'minecraft:gold_block'
seen = {}
try:
host('command', command='gamemode creative')
host('command', command='difficulty normal')
host('command', command=f'fill {ox - 9} {oy - 1} {oz - 9} {ox + 9} {oy - 1} {oz + 14} minecraft:stone')
host('command', command=f'fill {ox - 9} {oy} {oz - 9} {ox + 9} {oy + 6} {oz + 14} minecraft:air')
host('command', command=f'kill @e[tag={tag}]')
for name, (kind, dx, dz) in spots.items():
extra = ',NoAI:1b,PersistenceRequired:1b' if kind == 'minecraft:cow' else ''
host('command', command=f'summon {kind} {ox + 0.5 + dx} {oy} {oz + 0.5 + dz} '
f'{{Tags:["{tag}","{tag}_{name}"]{extra}}}')
host.wait(10)
seen['before'] = {name: alive(name) for name in spots}
assert all(seen['before'].values()), f'the command cannot see every entity before the bolt: {seen}'
def cow():
near = [m for m in host.raw('entities', 20, 'cow')['entities']
if math.dist(m['pos'], (ox + 3.7, oy, oz + 0.5)) < 1.0]
return near[0]['health'] if near else 0.0
seen['cow_before'] = cow()
host('command', command=f'summon minecraft:lightning_bolt {ox + 0.5} {oy} {oz + 0.5}')
seen['cow_series'] = []
for _ in range(8):
host.wait(1)
seen['cow_series'].append(cow())
host.wait(2)
seen['after'] = {name: alive(name) for name in spots}
seen['cow_after'] = min(seen['cow_series'])
host.shot(out, 'lightning-box')
finally:
host('command', command=f'kill @e[tag={tag}]')
# Vanilla: everything but the far boat is gone; the cow is hurt.
# (The cow burns on after the strike, so only its health at the strike is compared.)
gone = {k: v for k, v in seen['after'].items() if k != 'edgecow'}
assert gone == {'boat': False, 'cart': False, 'orb': False, 'edgeboat': False, 'farboat': True}, \
f'who survived the bolt: {seen}'
assert seen['cow_series'][0] == 5.0, f'the cow with its feet 3.2 out was not struck for 5: {seen}'
return seen
CHECKS += ['lightning-box']
CHECK_FNS['lightning-box'] = check_lightning_box
def check_boat_submerged(host, guest, out):
"""AbstractBoat.tick ejects players after 60 consecutive underwater ticks on the server."""
hx, _, hz = pos_of(host)
x, y, z = int(hx // 16) * 16 + 8, 100, int(hz // 16) * 16 + 8
tag = 'submerged_boat'
seen = {}
try:
for player in (host, guest):
for kind in ('oak_boat', 'bamboo_raft'):
label = f'{player.name}-{kind}'
host('command', command=f'kill @e[tag={tag}]')
host('command', command=f'fill {x-4} {y} {z-4} {x+4} {y+6} {z+4} minecraft:air')
host('command', command=f'fill {x-4} {y-1} {z-4} {x+4} {y+5} {z+4} minecraft:stone hollow')
host('command', command=f'fill {x-4} {y+5} {z-4} {x+4} {y+5} {z+4} minecraft:air')
host('command', command=f'fill {x-3} {y} {z-3} {x+3} {y} {z+3} minecraft:water')
player('command', command='gamemode survival')
player('command', command=f'tp @s {x+0.5} {y+1} {z-0.5}')
player('command', command=f'summon minecraft:{kind} {x+0.5} {y+1} {z+2.5} {{Tags:["{tag}"]}}')
player.wait(30)
boat = board(player, kind, (x+0.5, y+1, z+2.5))
player.wait(80)
assert player('status').get('riding') == boat['id'], f'{label}: ejected at the surface'
water = f'fill {x-3} {y} {z-3} {x+3} {y+4} {z+3}'
# A short immersion followed by a dry hull must reset the 60-tick countdown.
host('command', command=water + ' minecraft:water')
player.wait(25)
host('command', command=water + ' minecraft:air')
player.wait(20)
assert player('status').get('riding') == boat['id'], f'{label}: ejected after a short immersion'
host('command', command=water + ' minecraft:water')
player.wait(40)
early = player('status').get('riding')
assert early == boat['id'], f'{label}: the immersion counter did not reset (riding {early}, boat {boat["id"]})'
player.shot(out, 'boat-submerged-' + label + '-before')
player.wait(40)
after = player('status').get('riding')
player.shot(out, 'boat-submerged-' + label + '-after')
seen[label] = dict(boat=boat['id'], early=early, after=after)
lab.write_json(out / 'boat-submerged.json', seen)
assert after is None, f'{label}: still riding a submerged boat: {seen[label]}'
return seen
finally:
host('command', command=f'kill @e[tag={tag}]')
host('command', command=f'fill {x-3} {y} {z-3} {x+3} {y+4} {z+3} minecraft:air')
CHECKS += ['boat_submerged']
CHECK_FNS['boat_submerged'] = check_boat_submerged
def check_shulker_lift(host, guest, out):
"""ShulkerBoxBlockEntity.tick moves the local player on each client, even with its menu open."""
hx, _, hz = pos_of(host)
x, y, z = int(hx // 16) * 16 + 8, 100, int(hz // 16) * 16 + 8
box = (x, y, z)
modes = {player: player('state')['gamemode'] for player in (host, guest)}
host('command', command=f'fill {x-4} {y-1} {z-4} {x+4} {y+5} {z+4} minecraft:air')
host('command', command=f'fill {x-4} {y-1} {z-4} {x+4} {y-1} {z+4} minecraft:stone')
host('command', command=f'setblock {x} {y} {z} minecraft:shulker_box[facing=up]')
seen = {}
try:
for player in (host, guest):
other = guest if player is host else host
other('command', command=f'tp @s {x+3.5} {y} {z+0.5}')
for mode in ('survival', 'creative'):
player('command', command=f'gamemode {mode}')
player('command', command=f'tp @s {x+0.5} {y+1} {z+0.5}')
player.wait(15)
before = pos_of(player)
assert abs(before[1] - (y+1)) < 0.01, f'not standing on the box: {before}'
label = f'shulker-lift-{player.name}-{mode}'
player.shot(out, label + '-before')
player.raw('interact', *box)
player.raw('wait-for', 'screen', 'container', '--timeout', 10)
player.wait(20)
opened = pos_of(player)
player.shot(out, label + '-open')
# Let the player settle on the open lid: it must remain a supporting shape.
player.wait(20)
settled = pos_of(player)
player.raw('key', 'esc', 'tap')
player.wait(30)
closed = pos_of(player)
seen[label] = dict(before=before, opened=opened, settled=settled, closed=closed)
lab.write_json(out / 'shulker-lift.json', seen)
assert abs(opened[1] - (y+1.5)) < 0.02, f'opening lid did not lift {label}: {seen[label]}'
assert abs(settled[1] - (y+1.5)) < 0.02, f'open lid did not support {label}: {seen[label]}'
assert abs(closed[1] - (y+1)) < 0.02, f'closing lid did not lower {label}: {seen[label]}'
return seen
finally:
for player in (host, guest):
back_to_game(player)
player('command', command=f'gamemode {modes[player]}')
host('command', command=f'setblock {x} {y} {z} minecraft:air')
CHECKS += ['shulker_lift']
CHECK_FNS['shulker_lift'] = check_shulker_lift
def check_blast_bystanders(host, guest, out):
"""Primed TNT pushes a falling block and a boat, and breaks a boat in reach
(ServerExplosion.hurtEntities -> VehicleEntity.hurtServer, Entity.pushFromExplosion). Run on the real
26.3 server (`tick freeze`, `tick step 8` after the fuse): the falling block moved (+1.795, +1.235, 0),
the far boat (+0.071, 0, 0, tracker-lagged here); the near boat broke and dropped one item."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
x, y, z = hx + 6, hy, hz
for g in (host, guest):
g('command', command='gamemode spectator')
tx = x - 4.5
steps = [f'fill {x - 8} {y - 1} {z - 8} {x + 8} {y - 1} {z + 8} minecraft:bedrock',
f'fill {x - 8} {y} {z - 8} {x + 8} {y + 5} {z + 8} minecraft:air',
'kill @e[type=!player]', 'tick freeze',
f'summon minecraft:falling_block {x - 2.5} {y + 1.02} {z + 0.5} {{BlockState:{{Name:"minecraft:sand"}},Time:1,NoGravity:1b}}',
f'summon minecraft:oak_boat {tx} {y} {z + 3.5}',
f'summon minecraft:oak_boat {x + 3.0} {y} {z + 0.5}']
def scene():
found = {}
status = host('status')
# `simulation` has the server's own positions; `status` follows the trackers, which lag.
for p in host.raw('simulation')['projectiles'] + status['projectiles'] + status['objects']:
k = p['kind']
kind = {'minecraft:falling_block': 'fall', }.get(k, 'boat' if k.endswith('_boat') else None)
if kind and abs(p['position'][0] - x) < 8 and abs(p['position'][2] - z) < 8:
found.setdefault(kind, []).append(p['position'])
return found
try:
host('command', command='tick unfreeze')
for c in steps:
host('command', command=c)
host.wait(10)
before = scene()
host('command', command=f'summon minecraft:tnt {tx} {y + 1} {z + 0.5} {{fuse:5s}}')
host('command', command='tick step 8')
host.wait(10)
after = scene()
finally:
host('command', command='tick unfreeze')
host('command', command='kill @e[type=!player]')
for g in (host, guest):
g('command', command='gamemode creative')
assert len(before.get('boat', [])) == 2 and len(after.get('boat', [])) == 1, f'the near boat was not broken: {before} {after}'
moved = {k: [round(a - b, 3) for a, b in zip(after[k][0], before[k][0])] for k in ('fall',) if k in before and k in after}
assert set(moved) == {'fall'}, f'missing entities: {before} {after}'
far = [round(a - b, 3) for a, b in zip(after['boat'][0], next(p for p in before['boat'] if p[0] > x))]
for kind, want in (('fall', [1.795, 1.235, 0.0]),):
assert all(abs(a - b) < 0.05 for a, b in zip(moved[kind], want)), f'{kind} moved {moved[kind]}, vanilla {want}'
# A boat's position here follows its tracker, so only the direction is asserted (sbs.py blast_bystanders
# matches the vanilla boat to 1e-9: Pos x 3.0712677).
assert far[0] > 0.01 and abs(far[2]) < 0.02, f'the far boat was not pushed east: {far}'
return {'moved': moved, 'far_boat': far}
CHECKS += ['blast-bystanders']
CHECK_FNS['blast-bystanders'] = check_blast_bystanders
def check_wind_remaining(host, guest, out):
"""A wind burst pushes a firework rocket and a thrown trident (ServerExplosion.hurtEntities ->
Entity.pushFromExplosion; neither overrides ignoreExplosion). Real 26.3 server, wind charge into the floor,
tick freeze, tick step 1 after the burst: the rocket's Motion x -0.3174, -0.3650, -0.4197 and y 0.5971, 0.6371,
0.6771 on the next three ticks (it starts at (0, 0.08, 0), 0.8 west of the burst, 1.0 above the floor); a
trident 0.8 east gets (+0.446, +0.490) on the first tick when NoGravity holds. A summoned trident here
ignores NoGravity and falls first, so only its direction is asserted."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
x, y, z = hx + 6, hy, hz
host('command', command='gamemode spectator')
def read():
found = {}
for p in host.raw('simulation')['projectiles']:
if p['kind'] in ('minecraft:trident', 'minecraft:firework_rocket') and abs(p['position'][0] - x) < 4:
found[p['kind'].split(':')[1]] = p['velocity']
return found
detail = {}
try:
host('command', command='tick unfreeze')
for c in (f'fill {x - 8} {y - 1} {z - 8} {x + 8} {y - 1} {z + 8} minecraft:bedrock',
f'fill {x - 8} {y} {z - 8} {x + 8} {y + 5} {z + 8} minecraft:air', 'kill @e[type=!player]', 'tick freeze',
f'summon minecraft:trident {x + 1.3} {y + 1.0} {z + 0.5} {{NoGravity:1b}}',
f'summon minecraft:firework_rocket {x - 0.3} {y + 1.0} {z + 0.5} {{LifeTime:200}}', 'tick step 2'):
host('command', command=c)
host.wait(8)
detail['before'] = read()
host('command', command=f'summon minecraft:wind_charge {x + 0.5} {y + 0.6} {z + 0.5} {{Motion:[0.0,-1.0,0.0]}}')
detail['ticks'] = []
for _ in range(3):
host('command', command='tick step 1')
host.wait(4)
detail['ticks'].append(read())
finally:
host('command', command='tick unfreeze')
host('command', command='kill @e[type=!player]')
host('command', command='gamemode creative')
first = detail['ticks'][0]
assert 'firework_rocket' in first and 'trident' in first, f'no rocket listed: {detail}'
rocket = first['firework_rocket']
assert abs(rocket[0] + 0.3174) < 0.01 and abs(rocket[1] - 0.5971) < 0.01, f'the rocket was not pushed like vanilla: {detail}'
trident = first['trident']
assert trident[0] > 0.2 and trident[1] > 0.1, f'the trident was not pushed away: {detail}'
return detail
CHECKS += ['wind-remaining']
CHECK_FNS['wind-remaining'] = check_wind_remaining
def check_selector_tags(host, guest, out):
"""`@e[tag=]` and `/tag` find summoned experience orbs, arrows, thrown items, fireballs and wind
charges, and `/data get entity` reads a wind charge (vanilla 26.3, same scene: every kind is found
by its summoned Tags; sbs.py selkinds)."""
x, y, z = (int(v // 1) for v in pos_of(host))
kinds = ['experience_orb', 'arrow', 'snowball', 'egg', 'ender_pearl', 'fireball', 'small_fireball', 'wind_charge']
row = (x, y + 8, z + 4)
seen = {}
def marker(i, selector):
spot = (row[0] + i, row[1], row[2])
host('command', command=f'setblock {spot[0]} {spot[1]} {spot[2]} minecraft:air')
host('command', command=f'execute if {selector} run setblock {spot[0]} {spot[1]} {spot[2]} minecraft:gold_block')
host.wait(2)
return host.raw('block', *spot).get('block') == 'minecraft:gold_block'
try:
host('command', command='gamemode creative')
host('command', command='tick freeze')
for i, k in enumerate(kinds):
host('command', command=f'summon minecraft:{k} {x + 0.5 + i} {y + 20} {z + 0.5} '
f'{{NoGravity:1b,Tags:["sel_{k}"],Motion:[0.0d,0.0d,0.0d]}}')
host('command', command='summon minecraft:snowball ~ ~20 ~ {NoGravity:1b,Motion:[0.0d,0.0d,0.0d]}')
host('command', command='tag @e[type=minecraft:snowball,tag=!sel_snowball] add sel_late')
host.wait(4)
for i, k in enumerate(kinds):
seen[k] = marker(i, f'entity @e[tag=sel_{k}]')
seen['late'] = marker(len(kinds), 'entity @e[tag=sel_late]')
seen['data'] = marker(len(kinds) + 1, 'data entity @e[type=minecraft:wind_charge,limit=1] Pos')
finally:
host('command', command='tick unfreeze')
host('command', command='kill @e[type=!player]')
assert all(seen.values()), f'the selector cannot see these: {[k for k, v in seen.items() if not v]}'
return seen
CHECKS += ['selector-tags']
CHECK_FNS['selector-tags'] = check_selector_tags
def check_mining_drop_latency(host, guest, out):
"""An instant survival break sends its drop in the tick that handles START (#4572).
Queue the click behind the server barrier, then release it at one TPS. A
snapshot taken before tick_guests delays the drop until the next second.
Both the integrated owner and a LAN guest must see the first snapshot.
"""
hx, _, hz = pos_of(host)
x, y, z = int(hx), 100, int(hz)
seen = {}
try:
host('command', command=f'fill {x-5} {y-1} {z-5} {x+5} {y-1} {z+5} minecraft:stone')
host('command', command=f'fill {x-5} {y} {z-5} {x+5} {y+4} {z+5} minecraft:air')
for player in (host, guest):
player('command', command='clear @s')
player('command', command='gamemode survival')
for player, other in ((host, guest), (guest, host)):
host('command', command='tick rate 20')
host('command', command='kill @e[type=minecraft:item]')
player('command', command=f'tp @s {x+0.5} {y} {z-3.5}')
other('command', command=f'tp @s {x+3.5} {y} {z+0.5}')
host('command', command=f'setblock {x} {y} {z} minecraft:torch')
player.wait(20)
assert all(g.raw('block', x, y, z).get('block') == 'minecraft:torch'
for g in (host, guest)), 'torch did not reach both clients'
player.raw('look-at', x+0.5, y+0.3, z+0.5)
host('command', command='tick rate 1')
host.raw('server-hold', 'on')
try:
player.raw('mouse', 'left', 'click')
# The client's input tick also slows to one TPS. Wait for its
# prediction while transport runs and the server is still held.
deadline = time.monotonic() + 3
while player.raw('block', x, y, z).get('block') != 'minecraft:air':
assert time.monotonic() < deadline, \
f'{player.name}: client did not predict the instant break'
time.sleep(0.02)
finally:
host.raw('server-hold', 'off')
started = time.monotonic()
arrivals = {}
while time.monotonic() - started < 0.75 and len(arrivals) < 2:
for viewer in (host, guest):
if viewer.name in arrivals:
continue
entities = viewer.raw('entities', 24, 'minecraft:item')['entities']
if any(abs(e['pos'][0] - x - 0.5) < 2
and abs(e['pos'][2] - z - 0.5) < 2 for e in entities):
arrivals[viewer.name] = round(time.monotonic() - started, 3)
time.sleep(0.01)
seen[player.name] = arrivals
lab.write_json(out / 'mining-drop-latency.json', seen)
assert len(arrivals) == 2, \
f'{player.name}: drop waited for a second server tick: {seen}'
finally:
host.raw('server-hold', 'off')
host('command', command='tick rate 20')
host('command', command='kill @e[type=minecraft:item]')
for player in (host, guest):
player.raw('release')
return seen
CHECKS += ['mining-drop-latency']
CHECK_FNS['mining-drop-latency'] = check_mining_drop_latency
def check_scale_body(host, guest, out):
"""#3684: a player's `minecraft:scale` is the same entity's attribute through a change of level and
a death. ServerPlayer.teleport keeps the player; ServerPlayer.restoreFrom copies the base values to
the body after a death (assignBaseValues), then sets its health to the max they give. The eye the
client sees stays at 0.162 (1.62 * 0.1), not back at 1.62, and a max health base of 40 respawns the
player with 40."""
seen = {}
def scale_of(player):
# The eye height the player's client has: the server's scale reaches it as an attribute update.
state = player('state')['player']
return round(state['eye'][1] - state['position'][1], 3)
for label, player in (('host', host), ('guest', guest)):
s = seen[label] = {}
player('command', command='gamemode creative')
player('command', command='attribute @s minecraft:scale base set 0.1')
player('command', command='attribute @s minecraft:max_health base set 40')
player.wait(20)
s['set'] = scale_of(player)
x, z = (int(v // 1) for v in pos_of(player)[::2])
into_level(player, NETHER, x, 100, z)
s['nether'] = scale_of(player)
player('command', command=f'execute in {OVERWORLD} run tp @s {x + 0.5} 200 {z + 0.5}')
wait_dimension(player, OVERWORLD, 400)
player.wait(40)
s['overworld'] = scale_of(player)
player('command', command='kill @s')
player.wait(30)
player.raw('respawn')
player.wait(60)
back_to_game(player)
s['respawned'] = scale_of(player)
s['health'] = player('state')['player']['health']
player('command', command='attribute @s minecraft:scale base set 1')
player('command', command='attribute @s minecraft:max_health base set 20')
player.wait(20)
s['reset'] = scale_of(player)
for label, s in seen.items():
for step in ('set', 'nether', 'overworld', 'respawned'):
assert s[step] == 0.162, f'{label} after {step}: {s}'
assert s['reset'] == 1.62, f'{label} reset: {s}'
assert s['health'] == 40, f'{label} respawned health: {s}'
return seen
CHECKS += ['scale-body']
CHECK_FNS['scale-body'] = check_scale_body
def check_datapack_recipe(host, guest, out):
"""A crafting recipe from the world's data pack shows its result in the crafting table of the
host's window and of a guest (#6733). Vanilla's CraftingMenu.slotChangedCraftingGrid sets the
result on the server and ClientboundContainerSetSlotPacket sends slot 0: the window shows the
server's, not a result of its own recipes. The world reopens to load the pack."""
if not SERVER_THREAD:
return {'skipped': "the reopen is the server thread's"}
pack = host.work / 'saves' / 'Lan' / 'datapacks' / 'repro'
(pack / 'data/repro/recipe').mkdir(parents=True, exist_ok=True)
(pack / 'pack.mcmeta').write_text('{"pack":{"description":"repro","min_format":[121,0],"max_format":[121,0]}}')
(pack / 'data/repro/recipe/dirt_to_diamond.json').write_text(
'{"type":"minecraft:crafting_shapeless","ingredients":["minecraft:dirt"],"result":{"id":"minecraft:diamond"}}')
seen = {}
try:
host.raw('disconnect')
for attempt in range(60):
time.sleep(1)
try:
host.raw('open', 'Lan')
break
except Failed:
if attempt == 59:
raise
host('wait_ready', seconds=400)
port = host.raw('lan', 'open', 0, 'commands')['port']
back_to_game(guest)
guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME)
guest.raw('wait-for', 'loaded', '--timeout', 300)
guest.wait(20)
for game in (host, guest):
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='give @s minecraft:dirt 4')
x, y, z = (int(v // 1) for v in pos_of(game))
table = (x, y, z + 2)
game('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:crafting_table')
game.wait(10)
grab(game)
game.raw('interact', *table)
game.raw('wait-for', 'screen', 'inventory', '--timeout', 10)
game.wait(5)
slot = next(s['slot'] for s in game('inventory')['hotbar'] if s and s.get('item') == 'minecraft:dirt')
game.raw('click-slot', slot)
game.raw('click-slot', 100, 'right')
game.raw('click-slot', slot)
game.wait(10)
case = {'result': result_of(game)}
game.shot(out, f'datapack-recipe-{game.name}')
game.raw('click-slot', 200)
game.wait(10)
case['carried'] = held(game)
case['emptied'] = result_of(game)
game.raw('key', 'esc', 'tap')
game.wait(5)
game('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:air')
seen[game.name] = case
finally:
back_to_game(host)
back_to_game(guest)
shutil.rmtree(pack, ignore_errors=True)
for name, case in seen.items():
assert case['result'] == ('minecraft:diamond', 1), f'{name}: the pack recipe shows {case}'
assert case['carried'] == ('minecraft:diamond', 1), f'{name}: taking the result gave {case}'
assert case['emptied'] == (None, None), f'{name}: the result stays after the grid is used up: {case}'
return seen
CHECKS += ['datapack-recipe']
CHECK_FNS['datapack-recipe'] = check_datapack_recipe
CHECKS += ['raid-captain-fields']
CHECK_FNS['raid-captain-fields'] = check_raid_captain_fields
def check_damage_attacker(host, guest, out):
"""Connected causing players survive /damage and /execute on attacker.
Scoreboard writes observe whether a relation actually ran; selector feedback
wording and the warden's sonic behavior are outside this check.
"""
objective = 'h059_attacker'
positions = [(g, pos_of(g)) for g in (host, guest)]
seen = {}
for g in (host, guest):
g('command', command='gamemode creative')
host('command', command='forceload add 0 0')
for g in (host, guest):
g('command', command='tp @s 8.5 230 8.5')
guest.wait(20)
host('command', command='tick freeze')
host('command', command=f'scoreboard objectives add {objective} dummy')
def game_time():
line = server_reply(host, 'time query gametime', 'time is')
return int(re.search(r'time is (\d+)', line).group(1))
def relations(observer, target, serial):
result = {}
conditions = {'any': '', 'guest': f'[name={GUEST_NAME}]',
'host': f'[name={host.player}]', 'cow': '[tag=h059_cause]'}
for name, condition in conditions.items():
holder = f'#h059_{serial}_{observer.name}_{name}'
observer('command', command=f'scoreboard players set {holder} {objective} 0')
observer('command', command=f'execute as {target} on attacker if entity @s{condition} '
f'run scoreboard players set {holder} {objective} 1')
line = server_reply(observer, f'scoreboard players get {holder} {objective}', holder, objective, ' has ')
result[name] = int(re.search(r' has (-?\d+) ', line).group(1))
return result
try:
frozen = game_time()
host('command', command='summon cow 8 200 8 {NoAI:1b,NoGravity:1b,Tags:["h059_cause"]}')
cow = '@e[tag=h059_cause,limit=1]'
cases = [('none', '', 'none'), ('cow', f' by {cow}', 'cow'),
('host', f' by {host.player}', 'host'), ('guest', f' by {GUEST_NAME}', 'guest'),
('indirect_guest', f' by {cow} from {GUEST_NAME}', 'guest')]
serial = 0
for issuer in (host, guest):
for kind, health in (('pig', 9000), ('warden', 499000)):
for cause, suffix, expected in cases:
serial += 1
tag = f'h059_subject_{serial}'
target = f'@e[tag={tag},limit=1]'
host('command', command=f'summon {kind} 4 200 4 '
f'{{NoAI:1b,NoGravity:1b,Tags:["h059_subject","{tag}"]}}')
server_reply(issuer, f'damage {target} 1 minecraft:player_attack{suffix}', 'Applied')
line = server_reply(host, f'data get entity {target} Health 1000', str(health))
assert re.search(rf'\b{health}\b', line), line
want = {name: int(name == expected or name == 'any' and expected != 'none')
for name in ('any', 'guest', 'host', 'cow')}
row = {g.name: relations(g, target, serial) for g in (host, guest)}
seen[f'{issuer.name}/{kind}/{cause}'] = row
assert all(result == want for result in row.values()), (issuer.name, kind, cause, want, row)
if issuer is host and kind == 'pig' and cause == 'host':
# Refused equal damage keeps the old attacker; an accepted
# stronger hit replaces it with the other connected player.
for amount, remaining, credit in ((1, 9000, 'host'), (2, 8000, 'guest')):
host('command', command=f'damage {target} {amount} minecraft:player_attack by {GUEST_NAME}')
server_reply(host, f'data get entity {target} Health 1000', str(remaining))
changed = {g.name: relations(g, target, f'{serial}_hit{amount}') for g in (host, guest)}
expected_credit = {name: int(name in ('any', credit)) for name in want}
seen[f'host/pig/replacement{amount}'] = changed
assert all(value == expected_credit for value in changed.values()), changed
host('command', command=f'kill {target}')
assert game_time() == frozen, 'damage and attacker reads must not advance world ticks'
finally:
host('command', command='kill @e[tag=h059_subject]')
host('command', command='kill @e[tag=h059_cause]')
host('command', command=f'scoreboard objectives remove {objective}')
host('command', command='forceload remove 0 0')
for g, at in positions:
g('command', command='tp @s ' + ' '.join(str(v) for v in at))
g('command', command='gamemode survival')
host('command', command='tick unfreeze')
return seen
CHECKS += ['damage-attacker']
CHECK_FNS['damage-attacker'] = check_damage_attacker
def check_spectator_container(host, guest, out):
"""A spectator inspects a container without its lid, its sound or a lid left open (#8517).
`ChestBlockEntity.startOpen` returns at once for a spectator, and
`stopOpen` skips it the same way, so `ContainerOpenersCounter` never counts
one: the menu opens (a spectator does look inside), while the lid, its
block event and the open and close sounds stay away and nothing is left
open when the menu closes.
"""
opened_sound, closed_sound = 'minecraft:block.chest.open', 'minecraft:block.chest.close'
x, y, z = (int(v // 1) for v in pos_of(guest))
chest = (x, y, z + 2)
guest('command', command=f'setblock {chest[0]} {chest[1]} {chest[2]} minecraft:chest')
guest('command', command='gamemode spectator')
guest.wait(10)
before = {g.name: (heard(g, opened_sound), heard(g, closed_sound)) for g in (host, guest)}
try:
guest.raw('interact', *chest)
screen = guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
guest.wait(20)
opened = {'guest': lid(guest, chest), 'host': lid(host, chest)}
guest.shot(out, 'spectator-container-open')
guest.raw('key', 'esc', 'tap')
guest.wait(20)
closed = {'guest': lid(guest, chest), 'host': lid(host, chest)}
finally:
guest('command', command='gamemode survival')
guest('command', command=f'setblock {chest[0]} {chest[1]} {chest[2]} minecraft:air')
after = {g.name: (heard(g, opened_sound), heard(g, closed_sound)) for g in (host, guest)}
sounds = {name: (after[name][0] - before[name][0], after[name][1] - before[name][1]) for name in after}
assert screen, 'the spectator did not get the container screen'
assert all(v < 0.1 for v in opened.values()), f'the lid while a spectator looked inside: {opened}'
assert all(v < 0.1 for v in closed.values()), f'the lid after the spectator closed it: {closed}'
assert not any(any(heard_at) for heard_at in sounds.values()), f'the sounds a spectator caused: {sounds}'
return {'opened': opened, 'closed': closed, 'sounds': sounds}
CHECKS += ['spectator-container']
CHECK_FNS['spectator-container'] = check_spectator_container
def check_trident_stack_durability(host, guest, out):
"""#8911: stack max_damage gates trident use/release and its capped wear."""
player = host
objective = 'trident_clock'
old_pos = pos_of(player)
rows, failures, component_failures = {}, [], []
def reply(text):
# The host command control request returns this command's authoritative replies.
return '\n'.join(player('command', command=text)['chat'])
def ticks(n):
def clock():
line = reply(f'scoreboard players get @s {objective}')
match = re.search(r'has (-?\d+) ', line)
assert match, f'prerequisite: no player clock: {line}'
return int(match.group(1))
start = clock()
deadline = time.monotonic() + 30
while clock() - start < n:
assert time.monotonic() < deadline, 'prerequisite: player clock stalled'
player.wait(1)
try:
for command in ['tick freeze', 'weather clear', 'difficulty normal',
'gamerule spawn_mobs false', 'forceload add 0 0 15 15',
'fill 0 199 0 8 204 8 stone strict',
f'scoreboard objectives add {objective} minecraft.custom:minecraft.play_time']:
player('command', command=command)
cases = [('small', 10, 9, True, False, True, False),
('large', 500, 249, True, False, True, True),
('default-near', None, 249, True, False, True, False),
('default', None, 0, True, False, True, True),
('dry', 500, 249, False, False, True, False),
('creative-near', 10, 9, True, True, True, False),
('throw-near', 10, 9, True, False, False, False),
('throw', 500, 249, True, False, False, True)]
for label, maximum, damage, wet, creative, riptide, admitted in cases:
player.raw('release')
player('command', command='kill @e[tag=trident_durability]')
player('command', command='kill @e[type=trident]')
# Frozen death animations still occupy the spin's collision route.
start = int(re.search(r'\d+', reply('time query gametime')).group())
player('command', command='tick step 25')
deadline = time.monotonic() + 30
while int(re.search(r'\d+', reply('time query gametime')).group()) < start + 25:
assert time.monotonic() < deadline, 'prerequisite: cleanup ticks stalled'
player.wait(1)
# A one-block column keeps the charge and first spin contact in the same loaded cell.
player('command', command='fill 3 200 3 5 204 5 stone strict')
player('command', command='fill 4 200 4 4 203 4 '+('water' if wet else 'air')+' strict')
player('command', command='gamemode '+('creative' if creative else 'survival'))
player('command', command='clear @s')
player('command', command='tp @s 4.5 200 4.5 0 0')
components = [f'damage={damage}']
if maximum is not None:
components.append(f'max_damage={maximum}')
if riptide:
components.append('enchantments={"minecraft:riptide":1}')
item = 'minecraft:trident[' + ','.join(components) + ']'
player('command', command='item replace entity @s weapon.mainhand with '+item)
player.raw('key', '1', 'tap')
ticks(24)
predicate = item.replace('damage=0,', '').replace('[damage=0]', '')
assert 'Test passed' in reply(f'execute if items entity @s weapon.mainhand {predicate}'), label
mode = 'creative' if creative else 'survival'
assert 'Test passed' in reply(f'execute if entity @s[gamemode={mode}]'), label
assert 'Test passed' in reply('execute positioned 4.5 200 4.5 if entity @s[distance=..0.75]'), label
assert 'Test passed' in reply('execute if block 4 200 4 '+('water' if wet else 'air')), label
client_components = player('state')['held'].get('components', '')
if maximum is not None and f'"minecraft:max_damage":{maximum}' not in client_components.replace(' ', ''):
component_failures.append(label)
player.raw('mouse', 'right', 'down')
ticks(20)
using = player('state').get('using')
# Charge is observed, not inferred from a wall-clock sleep.
charge = using is not None and using['ticks'] >= 10
if riptide:
player('command', command='summon zombie 4.5 200 4.5 '
f'{{NoAI:1b,NoGravity:1b,Silent:1b,Tags:["trident_durability","trident_{label}"]}}')
assert 'Test passed' in reply(f'execute if entity @e[tag=trident_{label}]'), label
player.raw('mouse', 'right', 'up')
ticks(10)
hand = reply('data get entity @s SelectedItem')
row = {'charge': charge, 'using': using, 'hand': hand, 'client_components': client_components}
if riptide:
health = reply(f'data get entity @e[tag=trident_{label},limit=1] Health 1000')
row['health'] = health
hit = 'is 12128' in health
healthy = 'is 20000' in health
checks = [charge == admitted, hit if admitted else healthy,
(f'"minecraft:damage": {damage + (2 if admitted else 0)}' in hand
if damage or admitted else '"minecraft:damage"' not in hand)]
else:
thrown = reply('execute if entity @e[type=trident]')
row['thrown'] = thrown
checks = [charge == admitted, ('Test passed' in thrown) == admitted]
rows[label] = row
failures += [f'{label}/{i}' for i, passed in enumerate(checks) if not passed]
detail = {'cases': rows, 'failures': failures, 'component_failures': component_failures}
(out / 'trident-stack-durability.json').write_text(json.dumps(detail, indent=2)+'\n')
assert not failures and not component_failures, detail
return detail
finally:
player.raw('release')
for command in ['clear @s', 'kill @e[tag=trident_durability]', 'kill @e[type=trident]',
f'scoreboard objectives remove {objective}', 'forceload remove 0 0',
'tick unfreeze', 'gamemode survival', 'tp @s '+' '.join(map(str, old_pos))]:
player('command', command=command)
CHECKS += ['trident-durability']
CHECK_FNS['trident-durability'] = check_trident_stack_durability
def check_pig_saddle_shears(host, guest, out):
"""#9124: a real click shears an unoccupied pig before the mounting route.
Fresh Java 26.3: remove/drop/wear for unoccupied shears, no change or
mounting when occupied, and ordinary mounting with an empty hand.
"""
player = guest or host
name = getattr(player, 'player', GUEST_NAME)
old_host, old_player = pos_of(host), pos_of(player)
rows = []
objective = 'pig_shear_test'
def reply(text):
return '\n'.join(host('command', command=text)['chat'])
def number(text, success=False):
mode = 'success' if success else 'result'
reply(f'scoreboard players set probe {objective} 0')
reply(f'execute store {mode} score probe {objective} run {text}')
line = reply(f'scoreboard players get probe {objective}')
found = re.search(r'has (-?\d+) ', line)
assert found, f'prerequisite: missing score reply: {line}'
return int(found.group(1))
def step(n):
start = int(re.search(r'is (-?\d+)', reply('time query gametime')).group(1))
reply(f'tick step {n}')
deadline = time.monotonic() + 30
while True:
now = int(re.search(r'is (-?\d+)', reply('time query gametime')).group(1))
if now >= start + n:
assert now == start + n, 'prerequisite: world advanced extra ticks'
return
assert time.monotonic() < deadline, 'prerequisite: world ticks stalled'
player.wait(1)
try:
for text in ['tick freeze', 'gamerule spawn_mobs false', 'difficulty normal',
'forceload add 0 0 15 15', 'fill 0 98 0 15 99 15 stone',
'fill 0 100 0 15 106 15 air', f'scoreboard objectives add {objective} dummy']:
reply(text)
if guest:
reply(f'gamemode spectator {host.player}')
reply(f'tp {host.player} 60.5 100 70.5')
for i, label in enumerate(('unoccupied', 'occupied', 'mount-control'), 1):
occupied, empty = label == 'occupied', label == 'mount-control'
player.raw('release')
reply(f'ride {name} dismount')
reply('kill @e[tag=pig_shear_subject]')
reply('kill @e[tag=pig_shear_rider]')
step(25)
reply('kill @e[type=item]')
reply(f'gamemode survival {name}')
reply(f'clear {name}')
reply(f'tp {name} 4.5 100 4.5 0 0')
if not empty:
reply(f'item replace entity {name} weapon.mainhand with shears')
uid = f'00000007-0000-0007-0000-00070000000{i}'
child = ',Passengers:[{id:"minecraft:chicken",Pos:[6.5d,100d,4.5d],NoAI:1b,Tags:["pig_shear_rider"]}]' if occupied else ''
reply(f'summon pig 6.5 100 4.5 {{UUID:[I;7,7,7,{i}],NoAI:1b,PersistenceRequired:1b,Tags:["pig_shear_subject"],'
'Health:20f,attributes:[{id:"minecraft:max_health",base:20d}],'
'equipment:{saddle:{id:"minecraft:saddle",count:1}}' + child + '}')
step(1) # publish the loaded pig to the real client before the click
player.wait(2)
saddle = 'execute if entity @e[tag=pig_shear_subject,nbt={equipment:{saddle:{id:"minecraft:saddle"}}}]'
passenger = f'execute as @e[tag=pig_shear_rider,type=chicken] on vehicle if entity @s[nbt={{UUID:[I;7,7,7,{i}]}}]'
assert number(f'execute if entity @e[tag=pig_shear_subject,type=pig,nbt={{UUID:[I;7,7,7,{i}],NoAI:1b,Pos:[6.5d,100d,4.5d]}}]', True) == 1
assert number(f'data get entity {uid} Health') == 20
assert number(saddle, True) == 1 and number(passenger, True) == int(occupied)
held = reply(f'data get entity {name} SelectedItem')
assert ('minecraft:shears' in held) == (not empty)
if not empty:
assert number(f'data get entity {name} SelectedItem.count') == 1
assert number(f'data get entity {name} SelectedItem.components."minecraft:damage"') == 0
assert number(f'execute if entity @a[name={name},gamemode=survival,nbt={{Pos:[4.5d,100d,4.5d]}}]', True) == 1
assert number('execute if entity @e[type=item,nbt={Item:{id:"minecraft:saddle"}}]') == 0
player.raw('look-at', 6.5, 100.4, 4.5)
player.wait(2)
picked = player('state')['looking_at']
assert picked['kind'] == 'entity' and picked['entity']['type'] == 'minecraft:pig', picked
player.raw('interact-entity', picked['entity']['id'])
command_says(player, 'time query gametime') # same-connection interaction barrier
step(1)
player.wait(2)
rows.append({'case': label, 'saddle': number(saddle, True),
'drops': number('execute if entity @e[type=item,nbt={Item:{id:"minecraft:saddle",count:1}}]'),
'wear': number(f'data get entity {name} SelectedItem.components."minecraft:damage"'),
'riding': player('status').get('riding') is not None,
'passenger': number(passenger, True), 'health': number(f'data get entity {uid} Health'),
'live': number(f'execute if entity {uid}', True)})
(out / 'pig-saddle-shears.json').write_text(json.dumps(rows, indent=2))
expected = [dict(case='unoccupied', saddle=0, drops=1, wear=1, riding=False, passenger=0, health=20, live=1),
dict(case='occupied', saddle=1, drops=0, wear=0, riding=False, passenger=1, health=20, live=1),
dict(case='mount-control', saddle=1, drops=0, wear=0, riding=True, passenger=0, health=20, live=1)]
assert rows == expected, rows
return rows
finally:
reply(f'ride {name} dismount')
reply('kill @e[tag=pig_shear_subject]')
reply('kill @e[tag=pig_shear_rider]')
reply('kill @e[type=item]')
reply(f'scoreboard objectives remove {objective}')
reply('forceload remove 0 0')
reply('tick unfreeze')
for actor, position in ((host.player, old_host), (name, old_player)):
reply(f'gamemode survival {actor}')
reply(f'tp {actor} '+' '.join(map(str, position)))
CHECKS += ['pig-saddle-shears']
CHECK_FNS['pig-saddle-shears'] = check_pig_saddle_shears
def prepare_recipes_hmod(*games):
"""Install the same original content package in both scratch game folders."""
fixture = ROOT / 'tools/fixtures/recipes_hmod'
package = io.BytesIO()
with zipfile.ZipFile(package, 'w', zipfile.ZIP_DEFLATED) as archive:
for path in sorted(fixture.rglob('*')):
if path.is_file():
archive.write(path, path.relative_to(fixture).as_posix())
for game in games:
mods = game.work / 'horizon-mods'
mods.mkdir()
(mods / 'recipe_pilot.hmod').write_bytes(package.getvalue())
(game.work / 'horizon-mods.txt').write_text('mod.recipe_pilot:true\nmod.recipes:true\n')
(game.work / 'options.txt').write_text('lang:en_us\n')
settings = mods / '.horizon-mod-data/recipes'
settings.mkdir(parents=True)
(settings / 'give').write_text('false')
def check_recipes_hmod(host, guest, out):
"""Recipes' real panel -> normal server menu -> custom output -> save/rejoin (#9647).
Opt in with --check recipes-hmod and --bin-dir pointing at a game compiled with the official
native Recipes mod. The fixture is ours; no Java mod, copied assets or synthetic game replies.
"""
item = 'minecraft:echo_shard'
recipe = 'recipe_pilot:flint_to_echo_shard'
seen = {}
def widget(game, predicate):
matches = [w for w in game.raw('ui')['widgets'] if predicate(w)]
assert len(matches) == 1, f'{game.name}: expected one Recipes widget, got {matches}'
return matches[0]
def search(game, text):
ui = game.raw('ui')
fields = [w for w in ui['widgets'] if w['kind'] == 'field']
if not fields:
game.shot(out, 'recipes-hmod-no-search')
(out / f'{game.name}-recipes-hmod-ui.json').write_text(json.dumps(ui, indent=2))
assert fields, f'{game.name}: no Recipes search field (mod absent or empty-search recovery failed)'
# The native panel is to the right of the vanilla recipe book's search field.
field = max(fields, key=lambda w: w['bounds'][0])
game.raw('click', field['id'])
# `key` drives key bindings; `type` drives this focused edit box, including backspace.
game.script([r"type '\b'"] * 50 + [f'type {text}'])
game.wait(5)
def page(game):
result = widget(game, lambda w: w.get('label') == 'Echo Shard'
and w['kind'] == 'button')
game.raw('click', result['id'])
game.wait(5)
return widget(game, lambda w: (w.get('label') or '').startswith('+ '))
def contents(game):
return {'flint': server_count(game, 'flint'), 'echo_shard': server_count(game, item)}
for game, supplied in ((host, 4), (guest, 5)):
game('command', command='clear @s')
server_reply(game, 'recipe take @s *', 'recipe')
x, y, z = (math.floor(v) for v in pos_of(game))
table = (x, y, z - 2)
game('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:crafting_table')
game.wait(10)
game.raw('interact', *table)
game.raw('wait-for', 'screen', 'inventory', '--timeout', 10)
assert game('screen').get('craft_size') == 3
# The output exists in vanilla; its flint recipe is original .hmod content.
search(game, 'recipe_pilot:no_such_item')
assert not any(w.get('label') == 'Echo Shard' for w in game.raw('ui')['widgets'])
search(game, item)
game.shot(out, 'recipes-hmod-search')
unknown = page(game)
assert unknown['enabled'] is False, f'{game.name}: unknown recipe can be placed: {unknown}'
game.shot(out, 'recipes-hmod-unknown')
assert not any(i for i, _ in grid_of(game, 100, 109).values())
assert not result_of(game)[0]
server_reply(game, f'recipe give @s {recipe}', 'recipe')
game.wait(10)
back = widget(game, lambda w: w.get('label') == 'Back')
game.raw('click', back['id'])
game.wait(5)
known = page(game)
assert known['enabled'] is True, f'{game.name}: learned custom recipe unavailable: {known}'
game.raw('click', known['id'])
game.wait(10)
missing = contents(game)
assert missing == {'flint': 0, 'echo_shard': 0}, missing
assert not any(i for i, _ in grid_of(game, 100, 109).values())
assert not result_of(game)[0], f'{game.name}: missing ingredients fabricated a result'
game.shot(out, 'recipes-hmod-missing')
# Supply the input through the server, then use only the panel's normal + action.
game('command', command=f'give @s minecraft:flint {supplied}')
game.wait(10)
known = widget(game, lambda w: (w.get('label') or '').startswith('+ '))
game.raw('click', known['id'])
game.until(lambda: result_of(game), lambda r: bool(r[0]))
grid = [s for s in grid_of(game, 100, 109).values() if s[0]]
result = result_of(game)
assert grid == [('minecraft:flint', 1)], f'{game.name}: filled grid {grid}'
# Independent expected yield: changing the fixture to yield one must fail here.
assert result == (item, 2), f'{game.name}: custom result {result}'
game.shot(out, 'recipes-hmod-filled')
game.raw('click-slot', 200, 'shift')
game.wait(10)
assert not result_of(game)[0], f'{game.name}: consumed input left another result'
back_to_game(game)
crafted = contents(game)
assert crafted == {'flint': supplied - 1, 'echo_shard': 2}, crafted
seen[game.name] = {'missing': missing, 'grid': grid, 'result': result, 'crafted': crafted}
game('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:air')
save_and_reopen(host, guest)
for game in (host, guest):
persisted = contents(game)
assert persisted == seen[game.name]['crafted'], f'{game.name}: after rejoin {persisted}'
seen[game.name]['rejoined'] = persisted
game.shot(out, 'recipes-hmod-rejoined')
(out / 'recipes-hmod.json').write_text(json.dumps(seen, indent=2) + '\n')
return seen
CHECKS += ['recipes-hmod']
CHECK_FNS['recipes-hmod'] = check_recipes_hmod
if __name__ == '__main__':
main()