Files
RustNotMC/tools/lan_e2e.py
T
SashegDev 874b6f20c2 Клиент: начальный импорт кодовой базы
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
2026-10-29 22:04:28 +03:00

7857 lines
412 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 uses its integrated server thread by default; --in-frame compares the fallback, and
--server-thread remains accepted. 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);
- 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;
- 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);
- 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);
- 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;
- 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: (--server-thread) 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: (--server-thread) 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: (--server-thread) 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_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.
- 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.
- 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.
- 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, and a spawner's
burst when it spawns (#1395, #1401, #1402);
- 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.
- 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).
- 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).
- minecarts: the host and the guest each get into a minecart on rails, sneak out beside it, and break
it (#1222).
- 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.
- 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.
- 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: (--server-thread) 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).
- 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).
- 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).
- 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).
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 --server-thread --check portals # the host's world on its server thread
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 json
import math
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import zlib
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'
# Match normal single player; --in-frame is the comparison path.
SERVER_THREAD = True
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'spawned', 'sign', 'damage', 'mobs', 'hazards', 'pvp',
'arrows', 'kills', 'leave', 'hurt', 'looks', 'leads', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'styles',
'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing',
'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push',
'piston-push', 'piston-slime', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'thrown', 'sparks', 'hud', 'sidebar', 'border', 'particles',
'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects',
'portals', 'distance', 'growth', 'doors', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'hands',
'lightning', 'riding', 'occupied_horse', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption',
'breath', 'lava', 'levels', 'drops', 'underwater', 'fireworks']
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)
os.symlink(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')
if SERVER_THREAD:
# 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}'
else:
seen = lan_guest(host).get('model_armor')
assert seen == [None, 'minecraft:diamond_chestplate', 'minecraft:diamond_leggings', None], \
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_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` (on the server thread 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)."""
if not SERVER_THREAD:
return 'skipped (needs --server-thread)'
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):
if not SERVER_THREAD:
return 'skipped (needs --server-thread)'
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}'
# 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):
"""(--server-thread) 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."""
if not SERVER_THREAD:
return 'skipped (needs --server-thread)'
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:
sx, sy, sz = (int(v // 1) for v in pos_of(host))
guest('command', command=f'execute in {OVERWORLD} run spawnpoint @s {sx} {sy} {sz}')
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],exact_teleport: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}')
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_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'
return {'before': before, 'after': after, 'water': water}
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_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 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)] if SERVER_THREAD else [])
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]')
# 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 pig\'s 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 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_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']
guest('command', command=f'clear @s minecraft:{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 in-frame host and a remote user (both remote with --server-thread).
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."""
points = [f'{v:.1f}' for s in slots for v in slot_point(game, s)]
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 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
# in-frame host (or --server-thread 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) if SERVER_THREAD else (guest,))}
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_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_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): in frame its model, on the server thread its
# client's (SLEEPING_POS), and a client pairing with the sleeper later too.
host.wait(5)
lying = {'host': lan_guest(host).get('model_lying') if not SERVER_THREAD else 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_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']
# 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)
detail = {'hungry': hungry, 'fed': fed, 'bread': left, 'using': using, 'after': guest('state').get('using')}
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}'
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):
# On its server thread the host's window is a client of its server too.
if game is host and not SERVER_THREAD:
return lan_guest(host).get('model_swinging')
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):
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 2')
host.wait(5)
face_each_other(host, guest)
def using(game):
# On its server thread the host's window is a client of its server too.
if game is host and not SERVER_THREAD:
return lan_guest(host).get('model_using') or 0
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')
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.wait(10)
seen[name] = {'during': samples, 'after': using(watcher)}
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}'
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}}}',
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 check_fireworks(host, guest, out):
"""Event 17 must arrive before removal, with the item's colored, trailing explosion (#1478).
FireworkRocketEntity.explode / handleEntityEvent; run with and without --server-thread.
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_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
finally:
player('command', command=f'fill {at[0]} {at[1] - 1} {at[2]} {at[0]} {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}'
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 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')
# Without the server thread a guest's command reaches no host player (as `@a` doesn't).
if not SERVER_THREAD:
result['guest all'].pop('other dust')
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(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)] if SERVER_THREAD else [])
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 guest's in the host's `lan status`, on the server
thread the host's too; the host's own world otherwise."""
if game is host and not SERVER_THREAD:
return own(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):
"""(--server-thread) 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)."""
if not SERVER_THREAD:
return 'skipped (needs --server-thread)'
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.tick takes 1 off its damage every tick."""
here = [o for o in player('status')['objects'] if o['id'] == vehicle_id]
if not here:
return
vp = here[0]['position']
# Keep the original ten hits/two-tick spacing in one connection. Starting
# several ctl processes per hit lets the vehicle's damage decay between hits.
player.script([f'look-at {vp[0]:.2f} {vp[1] + 0.3:.2f} {vp[2]:.2f}',
*[line for _ in range(10) for line in ('mouse left click', 'wait 2')]])
assert not any(o['id'] == vehicle_id for o in player('status')['objects']), \
f'ten hits 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_minecarts(host, guest, out):
"""#1222: 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."""
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}
finally:
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}}')
player('command', command=f'summon minecraft:armor_stand {x - 1.5} {y} {z + 1.5}')
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}'
result[label] = {'frame': 'diamond, turned', 'stand': 'helmet on and off'}
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
# On its server thread the host's own game is a client too, sent the same light updates.
if not SERVER_THREAD:
assert host.raw('light', near, y, gz, near, y, gz)['sky'] != '.', 'the host lacks the chunk past the guest'
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 if SERVER_THREAD else 'host') 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_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']
if not SERVER_THREAD:
host.raw('hurtlog')
# A mob's blow (no attacker: no difficulty scaling); armour counts against it.
guest('command', command='damage @s 10 minecraft:mob_attack')
if SERVER_THREAD:
# 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)]
continue
guest.wait(10)
log = [h for p in host.raw('hurtlog')['players'] if p['name'] == GUEST_NAME for h in p['hurts']]
taken['worn' if worn else 'bare'] = [(h['amount'], h['armor'], round(h['taken'], 2)) for h in log]
finally:
guest('command', command='clear @s')
guest('command', command='effect give @s minecraft:instant_health 1 5')
if SERVER_THREAD:
# 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
assert taken['bare'] == [(10.0, 0.0, 10.0)], f'10 damage on no armour: {taken}'
assert taken['worn'] == [(10.0, 15.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, 'crafting': check_crafting,
'spawned': check_spawned,
'sign': check_sign, 'damage': check_damage, 'mobs': check_mobs,
'hazards': check_hazards, 'pvp': check_pvp, 'respawn': check_respawn,
'containers': check_containers, '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, 'sleep': check_sleep, 'push': check_push, 'piston-push': check_piston_push,
'piston-slime': check_piston_slime,
'eat': check_eat, 'eating': check_eating, 'xp': check_xp,
'objects': check_objects, 'projectiles': check_projectiles, 'blast': check_blast,
'light': check_light, 'plates': check_plates, '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, 'worn': check_worn, 'kills': check_kills, 'leave': check_leave,
'arrows': check_arrows, 'predict': check_predict, 'ladder': check_ladder,
'boats': check_boats, '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,
'thrown': check_thrown,
'sparks': check_sparks,
'hud': check_hud,
'sidebar': check_sidebar,
'border': check_border,
'particles': check_particles, 'fireworks': check_fireworks}
def run(ctl, out, resources, checks):
host, guest = Game('host', ctl, resources), Game('guest', ctl, resources)
# Either way explicitly: the game's default is the server thread.
host.d.env['HORIZON_SERVER_THREAD'] = '1' if SERVER_THREAD else '0'
# 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:
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')
mode = parser.add_mutually_exclusive_group()
mode.add_argument('--server-thread', dest='server_thread', action='store_true', default=True,
help='host on its server thread (default; retained for existing callers)')
mode.add_argument('--in-frame', dest='server_thread', action='store_false',
help='host the world in the window, for comparison')
args = parser.parse_args()
global SERVER_THREAD
SERVER_THREAD = args.server_thread
if args.bin_dir:
bins = Path(args.bin_dir).resolve()
else:
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client',
'--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)
print(f'LAN host mode: {"server-thread" if SERVER_THREAD else "in-frame"}')
results = run(bins / 'horizon-ctl', out, resources, args.check or CHECKS)
(out / 'results.json').write_text(json.dumps({'mode': 'server-thread' if SERVER_THREAD else 'in-frame',
'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 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."""
if game is host and not SERVER_THREAD:
return host('state').get('using')
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'])
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}'
# The in-frame host draws only its own hook.
if other is guest or SERVER_THREAD:
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_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 in-frame host and network guest (both are clients with --server-thread).
"""
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_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
CHECKS += ['hero-trading']
CHECK_FNS.update({'hero-trading': check_hero_trading})
def check_spectator_travel(host, guest, out):
"""Player.tick's spectator noPhysics survives a replacement body after dimension travel (#1299)."""
players = (host, guest) if SERVER_THREAD else (host,)
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_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_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)
guest.raw('disconnect')
host.raw('pause')
host.raw('click-button', 'Save and Quit to Title')
host.raw('wait-for', 'screen', 'title', '--timeout', 120)
host.raw('open', 'Lan')
host('wait_ready', seconds=400)
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)
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_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. An in-frame host isn't one of the players a guest's
command can name (`command_players` lists the guests), so the guest gives the host a recipe
only when the host is a client of its server thread."""
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()
if SERVER_THREAD:
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')
by_guest = int(SERVER_THREAD)
expected = {'empty': (0, 0), 'host gives guest': (0, 2), 'guest gives host': (by_guest, 2),
'host takes from guest': (by_guest, 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_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.
Run with both --server-thread and the in-frame host.
"""
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 (its own in-frame, the
# server's on the server thread), 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.
Run with and without --server-thread to cover the window's local renderer too.
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_cushions(host, guest, out):
"""#1935: place a colored cushion, consume it, track it, sit, dismount and break it."""
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)
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 survival')
player('command', command=f'item replace entity @s weapon.mainhand with minecraft:{color}_cushion')
player.wait(10)
player.raw('look-at', *at)
player.raw('mouse', 'right', 'click')
player.wait(15)
def cushions(game):
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)}
assert len(server) == len(client) == 1, seen
assert server[0]['color'] == client[0]['color'] == color, seen
assert seen[label]['remaining'] == 0, seen
player.shot(out, 'cushion-placed')
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
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
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.
"""
if not SERVER_THREAD:
return 'skipped (needs --server-thread)'
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
if __name__ == '__main__':
main()