mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
13233 lines
698 KiB
Python
Executable File
13233 lines
698 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""LAN multiplayer end to end: a host opens its world to LAN and a guest joins, both real games.
|
|
|
|
The host's world runs on its integrated server thread. Two Horizon clients run under horizon-ctl in scratch folders. The host
|
|
creates a survival world and opens it to LAN with commands allowed; the guest connects to it by
|
|
address. The script then checks, from both sides, what a guest's game used to get wrong (#366):
|
|
|
|
- tab: the guest's player list holds the host and the guest, under their own names;
|
|
- self: the guest's own model exists (third person, inventory preview);
|
|
- armour: each side draws the other player in that player's own armour;
|
|
- nametags: each side draws the other player's name above them;
|
|
- chest: a chest the guest opens opens on the guest's screen, its lid opens on both sides and
|
|
closes on both when the guest closes it; the host keeps playing with its mouse look (#687);
|
|
- menu-transfer: a chest the guest has open closes when the server replaces the guest's player (a
|
|
dimension change, a death and respawn); the inventory opens as its own menu, items kept;
|
|
- crafting: the guest crafts planks in the inventory's 2x2 grid and opens a crafting table, which
|
|
leaves the host's screen and mouse look alone;
|
|
- datapack-recipe: (--server-thread) a crafting recipe from the world's data pack shows its result in the
|
|
table of the host's window and of a guest, and taking it crafts (#6733);
|
|
- recipes-hmod: opt-in, requires native Recipes: search original .hmod content, fill the server's
|
|
crafting grid, consume inputs and preserve both players' output after save/rejoin (#9647);
|
|
- spawned: the host and a guest new to the world join on different spots (PlayerSpawnFinder), and the
|
|
guest opens a crafting table, a chest, a furnace and a stonecutter where it joined;
|
|
- sign: a sign the guest places doesn't open a sign editor on the host;
|
|
- damage: a guest in survival takes fall damage;
|
|
- mobs: with the host in creative, a zombie and a skeleton go for the guest and hurt them;
|
|
- hazards: a guest in survival gets hungry sprint-jumping and is hurt by lava, a block at the eyes,
|
|
drowning and the void (then respawns);
|
|
- pvp: the guest and the host hit each other with a sword; with the pvp game rule off, or on one
|
|
team without friendly fire, nothing;
|
|
- kills: a pig the guest kills counts in the guest's killed statistic, not the host's (#484);
|
|
- leave: a guest who leaves has leave_game in their saved statistics, and again after the next leave;
|
|
|
|
- arrows: the guest and the host shoot each other with a bow; with the pvp game rule off, nothing (#484);
|
|
- flame-arrows: an arrow the host or the guest shoots from a Flame bow burns in flight on both sides
|
|
(Entity.DATA_SHARED_FLAGS_ID's fire flag), one from a plain bow doesn't (#7356);
|
|
- looks: mobs look on both sides as the server has them (their synched data): a calf is a
|
|
baby, a red sheep is sheared, a size 4 slime, a tamed wolf sits with its blue collar, a
|
|
cow on fire burns and goes out; then the wither's health drops when it is hurt, the
|
|
sheep is dyed blue with its wool back and the calf grows up (#1367, #1368, #1370,
|
|
#1371, #1385, #1390);
|
|
- leads: the host and the guest each tie a cow to a lead and untie it: the rope shows on both
|
|
sides, held by the player who tied it, and goes on both (#1360);
|
|
- respawn: a player killed is drawn dead by the other side, and alive again once it respawns (the
|
|
respawned player is a new entity to the others); the host dies, and on the server thread
|
|
the guest too;
|
|
- hurt: a pig the guest hits, and one the host hits, flashes red on the other side too (the
|
|
server's damage event) (#848), and so does a hurt host or guest (#2827);
|
|
- containers: the guest's ender chest holds the guest's items, not the host's, and a double chest
|
|
opens as 9x6 with the right half first;
|
|
- names: a renamed shulker box, copper chest and chest the guest places open on the guest's screen
|
|
with their custom names as the title (BaseContainerBlockEntity.getDisplayName, #3245);
|
|
- machines: a hopper, a dispenser and a dropper open on the guest's screen with their 5 and 9
|
|
slots, and keep what the guest shift-clicks into them (#848);
|
|
- brewing: a brewing stand opens on the guest's screen; blaze powder, nether wart and a bottle the
|
|
guest shift-clicks go to the fuel, ingredient and bottle slots, and the stand's fuel shows
|
|
(#848);
|
|
- loom: a loom opens on the guest's screen; the banner and dye the guest shift-clicks go to their
|
|
slots, a pattern button the guest clicks makes the server's result, and the guest takes the
|
|
patterned banner (#868);
|
|
- stonecutter: a stonecutter opens on the guest's screen with the recipes of the stone the guest puts
|
|
in; the guest picks the slab and shift-clicks the result, and the server has cut every
|
|
stone into slabs (#868);
|
|
- grindstone: a grindstone opens on the guest's screen; the enchanted sword the guest shift-clicks in
|
|
shows the server's result, and the guest takes it with only its curse left and the
|
|
experience orbs the server awards (#868);
|
|
- anvil: each player repairs and renames a sword (ServerboundRenameItemPacket), takes it for
|
|
the server's level cost, and the server has the repaired, renamed sword (#868);
|
|
both players hear exactly one anvil sound for each result (#1907);
|
|
- enchanting: an enchanting table opens on the guest's screen with the server's offers for the
|
|
sword the guest puts in; the guest clicks the first offer and the server enchants the
|
|
sword for a level and a lapis, and counts it in the guest's enchant_item stat (#868);
|
|
- smithing: a smithing table opens on the guest's screen; the guest upgrades a diamond sword to
|
|
netherite and the server has it (#868);
|
|
- lectern: a lectern's written book opens on the guest's screen; the guest turns its pages
|
|
(ContainerButtonClick) and the server's lectern follows, then the guest takes the book
|
|
and the server has it in the guest's inventory (#868);
|
|
- cartography: a cartography table opens on the guest's screen; the guest puts in a map the host
|
|
made and paper, takes the zoomed-out map, and the server has a new map for it (#868);
|
|
- feedback: the guest's command feedback names the guest and shows the numbers;
|
|
- chat: a line the host sends reaches the guest's event log (`wait-for chat`), and on the
|
|
server thread the host window's too;
|
|
- styles: a styled tellraw reaches the host and the guest (each its own) with its colour, bold, click and hover
|
|
events; each player's /give tells them of the Diamonds! advancement before the give's
|
|
own message, and never echoes their command back to them as `[Name: ...]`;
|
|
- effects: a beacon's power reaches every player in range, the host and the guest;
|
|
- distance: the server follows the host's render distance, past vanilla's 32 too
|
|
(IntegratedServer.tickServer), and the guest's chunks follow the server's (#804)
|
|
- portals: the server holds a level per dimension: the guest goes to the Nether
|
|
through a portal while the host stays, then the host goes to the End while the guest is in
|
|
the Nether, and both come back.
|
|
- levels: the host and the guest each in a level of their own: `execute in`
|
|
teleports into another level, and `execute in` and `at` change and test blocks there;
|
|
mobs fall in two levels at once, with ids unique across them; the guest rejoins in the
|
|
Nether, dies there and respawns in the Overworld, then in the Nether at a `spawnpoint`
|
|
and at a respawn anchor set there; the host takes a Nether portal alone;
|
|
the guest takes an End portal, an End gateway and the exit portal alone; and the host
|
|
saves, quits and reopens with the guest in the Nether.
|
|
- commands: the guest's chat completes commands and their arguments over the server's command
|
|
tree (Tab), and runs what it completed;
|
|
- recipes: the guest's recipe book lists the planks recipe the logs unlock, and clicking it
|
|
places a log in the inventory's crafting grid (the server places it).
|
|
- recipe_removal: the host and guest each forget all and individual recipes while the book
|
|
is open; it stays empty when reopened, and a later grant appears (#1495).
|
|
- recipe-reselect: the host and the guest each craft wool three times from one recipe-book click: Space
|
|
(a key repeat, Shift held: as many as fit) and Enter (one) place the recipe clicked last again.
|
|
- recipe_targets: `/recipe give|take <other player>` changes that player's book, the host's
|
|
for the guest and (server thread) the guest's for the host; their open book shows it.
|
|
- feedback: the guest's command feedback names the guest and shows the numbers, and the host (an
|
|
operator) is told of it as `[Guest: ...]`.
|
|
- clicks: with real mouse presses (not click-slot, which skips the screen's press and drag
|
|
handling), the guest puts a stack into a chest and a furnace, drags planks across
|
|
its crafting grid and gathers a stack with a double click; the server has each.
|
|
- shift-double-click: a 32 diamond stack on the cursor, Shift held and two left clicks under 250 ms
|
|
apart on a chest's 8 diamonds: what 26.3 moves, same pair 400 ms apart as control.
|
|
- recipe-shift: in a crafting table, 20 diamonds and craftable-only book: Shift-click on the
|
|
diamond block recipe fills the grid with the max sets, then a Shift-click on the
|
|
result crafts them (#4166).
|
|
- shift-drag: a 20 diamond stack dragged over three slots of a crafting table spreads 6 each with
|
|
the left button and 1 each with the right, with Shift held over the drag or not; the
|
|
server hands the rest back when the table closes (#4166).
|
|
- modifier-release: a Shift tap or the control socket's release command leaves the next real
|
|
inventory click as a normal pickup, rather than a stuck Shift transfer (#4166).
|
|
- wheel: with the Mouse Tweaks extra on, real wheel events and a right-drag in a furnace, a
|
|
crafting table, a hopper and a dropper: fuel and smeltables go to their slots, nothing
|
|
into a result slot, and down over a result takes it. The server has each.
|
|
- sleep: at night the guest lies down in its own bed (not the host); alone it stays night, it
|
|
gets up with Escape, and once the host sleeps too the night ends and both wake.
|
|
- bed-occupied: the bed the host sleeps in is occupied and the guest cannot lie down in it; it is
|
|
free again after Escape, death, the bed broken and the sleeper leaving the game.
|
|
- sleep-reload: a villager asleep in its bed is still asleep there, its bed
|
|
occupied, after the host saves, quits and reopens; the guest's bed is saved free.
|
|
- predict: with the host frozen, the oak door the guest opens opens on the guest's screen at once (its
|
|
prediction) and creaks there at once, stays open once the server answers, and each side hears
|
|
it once; an iron door stays shut (#869).
|
|
- push: a cow standing in the guest pushes it just clear of the cow (the guest's own client
|
|
pushes it, as in vanilla), not ever faster across the ground.
|
|
- eat: holding right click with bread, the guest's own screen plays the eating (the item in use
|
|
counts up) and the server eats one loaf and feeds the guest.
|
|
- eat-on-block: sweet berries clicked on a block they can't be planted on: the guest's own screen
|
|
plays the eating and the server eats one, a click released early eats nothing, and on
|
|
dirt they are planted and not eaten (BlockItem.useOn).
|
|
- cauldron: a water bucket (creative), a lava bucket and an empty bucket (survival) used on a
|
|
cauldron's side fill or empty the cauldron only: nothing is poured beside it, not even
|
|
for the moment before the server answers (#5294).
|
|
- consume-release: the host and the guest each cancel eating a golden apple and drinking milk from
|
|
either hand: use ends on client and server, the item and effects stay, and holding
|
|
use to completion still consumes the item (#1293).
|
|
- eating: each side sees the other player eat: the item in use (LivingEntity's flags) on the
|
|
player it draws, and no longer once the bread is eaten (#803);
|
|
- eat_full: holding right click with melon slices until the food bar is full: the guest's own
|
|
client eats each slice when the server says it is done (entity event 9,
|
|
USE_ITEM_COMPLETE), so no other bite starts; held on chorus fruit, both fruits are
|
|
eaten across its cooldown (#2471).
|
|
- xp: experience orbs 4 blocks away fly to the guest, show on the guest's screen and give the
|
|
guest their points (not the host).
|
|
- objects: boats, minecarts, armor stands, item frames and paintings the host summons reach the
|
|
guest as the host has them (kind, place, rotation, what they hold or wear, the variant).
|
|
- projectiles: primed TNT (one falling), an end crystal and an arrow that lands reach the guest where
|
|
the host has them; a falling block lands on both sides.
|
|
- shulker_bullets: every shot a shulker fires has its own id and flies on both sides, following
|
|
the same trajectory; the bullets disappear on both sides when they hit blocks (#1494).
|
|
- crystals: the host and the guest punch placed End crystals in survival and creative; the blast
|
|
kills the survival player, and attacks respect reach and blocks in front (#1532).
|
|
- thrown: a splash potion and an eye of ender the host and the guest each throw fly on both
|
|
sides (#1363, #1391);
|
|
- sparks: level events' particles on both sides: the host's and the guest's bone meal on wheat
|
|
(growth sparks), honeycomb on copper and an axe taking the wax off, a water bucket
|
|
poured beside lava (its smoke, and its hiss heard once on each side), and a spawner's
|
|
burst when it spawns (#1395, #1401, #1402, #7537);
|
|
- fireball: a fire charge a dispenser shoots onto stone lights a fire both sides see, and the fire
|
|
goes out on its own within a minute (it ticks: FireBlock.tick on a floor that doesn't burn)
|
|
|
|
|
|
- blast: primed TNT going off beside a guest in survival hurts the guest and pushes it away
|
|
(ServerExplosion.hurtEntities takes every player); facing it behind a raised shield, the
|
|
guest takes nothing.
|
|
- blast-projectiles: primed TNT going off pushes a flying arrow and a hanging snowball away (ServerExplosion.hurtEntities
|
|
takes every entity, Entity.pushFromExplosion), while an arrow lying in the floor stays put
|
|
(AbstractArrow.push does nothing in the ground).
|
|
|
|
|
|
- worn: iron armour the guest puts on in its inventory protects it: a 10-damage blow takes 6
|
|
(armour 15, LivingEntity.getDamageAfterArmorAbsorb), and the full 10 without it.
|
|
- plates: a stone pressure plate the guest steps on is pressed on both sides, and comes up once
|
|
the guest steps off (#848).
|
|
- plate-projectiles: a guest's thrown trident, a firework rocket and a guest's fishing bobber press a wooden
|
|
pressure plate; a trident leaves a stone plate up, which senses only players and mobs (#5896).
|
|
- swings: each side sees the other's arm swing while it mines a block, and the guest sees its own
|
|
swing when its hoe tills dirt (the server's use) (#848).
|
|
- cracks: each side sees the other player's crack on the block they mine grow, and go once it breaks
|
|
(#1096).
|
|
- time: the guest's clock is the host's: it stops with `/time pause`, and catches up with the
|
|
server's game time after the guest's game stalls for a few seconds (#848).
|
|
- growth: far from the host, the chunks around the guest tick (#890): water placed there flows
|
|
and wheat on farmland grows (random ticks), however the host runs its world.
|
|
- farmland: the host and guest each trample planted farmland: the wheat breaks and drops its items,
|
|
and both clients see dirt/air (#1492).
|
|
- ladder: the guest climbs down ten blocks of ladder and lands unhurt: the server resets its fall
|
|
distance on a ladder every tick, as LivingEntity.handleOnClimbable does (#1255).
|
|
- boats: the host (single player, on its server thread or not) and the guest each right-click a
|
|
boat on water and ride it, paddle it forward (the other side sees it move), sneak out,
|
|
and break it with their fists (#1222).
|
|
- boat_shoved: the host and the guest each sit in a boat a piston shoves and /tp moves; the server
|
|
takes the new spot as the boat's last good position, so the rider's next moves pass: at most a few
|
|
"moved wrongly" lines, not an endless loop of them.
|
|
- minecarts: the host and the guest each get into a minecart on rails, sneak out beside it, and break
|
|
it (#1222).
|
|
- cart_mob_rider: a minecart with a pig in it runs about 3.3 blocks farther than an empty one in 40 ticks
|
|
(AbstractMinecart.getSlowdownFactor 0.997 when isVehicle, not only for a player).
|
|
- frames: the host and the guest each put a diamond in an item frame and turn it, and put a
|
|
helmet on an armor stand and take it back (#1222).
|
|
- hands: the host and the guest each, at a cow that cares for none of it: draw a bow and shoot, and
|
|
let go of a golden apple at once without eating it (the click goes on to the item);
|
|
milk the cow with a bucket in the off hand and feed it wheat from the off hand; and
|
|
punch the diamond out of an item frame, which then shows empty on both sides (#1350,
|
|
#1355, #1362).
|
|
- mounts: the host and the guest each open a tamed horse's, donkey's and llama's inventory
|
|
(shift + right click, then the inventory key while riding), put on a saddle, armour
|
|
or a carpet and fill the chest by shift-clicking.
|
|
- mount_other_level: with a Nether chunk force-loaded (a second level ticking), the host's and the
|
|
guest's horse, camel, llama, trader llama, nautilus, donkey and mule inventories stay
|
|
open, opened by shift + right click, by the inventory key while riding and again after
|
|
getting off; also with an End chunk force-loaded, and with the player and its horse in
|
|
the Nether while the other player keeps the Overworld ticking (#5331).
|
|
- riding: the host and the guest each saddle and ride a horse (walk, jump), a pig and a strider
|
|
(with their sticks), a camel and a donkey, sit on a llama and fly a happy ghast, and
|
|
get off by sneaking; the other side sees the animal move.
|
|
- cart_portal_rider: the host and the guest each ride a minecart along rails into a Nether portal: the
|
|
cart carries the player, who arrives in the Nether still riding the same cart (#6021).
|
|
- portal_rider: a survival guest rides a horse into a Nether portal: the horse goes
|
|
through at once and carries it, so it arrives in the Nether still riding the same horse.
|
|
- riding_teleport: the host and the guest each /tp through chat while riding a horse; after 60
|
|
ticks each stays dismounted at the destination and both sides see the horse left
|
|
behind. The host also teleports a mounted guest by name.
|
|
- lightning: a bolt summoned beside the host, then the guest: both sides get the bolt and flash the
|
|
sky (on its server thread the host's window is a client of it too).
|
|
- drops: an item lies where the host, then the guest, places a block: it comes out of the block
|
|
on the server, and both sides see it outside (#1252).
|
|
- underwater: the host and the guest each dig dirt by hand standing under water: five times slower
|
|
than in the air on their own client as on the server, and it breaks (#1254).
|
|
- worldspawn: the host moves the world spawn (`setworldspawn`): the host and the guest, with no bed,
|
|
respawn there (#1310).
|
|
- shield: a vindicator's axe disables the host's, then the guest's, raised shield: the server
|
|
counts its 5 s cooldown down once a tick (#1316).
|
|
- beds: the host and the guest each set their respawn point at a bed and die elsewhere: they
|
|
stand up beside the bed, or, when the bed is gone or boxed in by stone, come back at
|
|
the world spawn (#1308).
|
|
- deathmining: the host and the guest each die while breaking obsidian and let go on the death
|
|
screen: once respawned, the obsidian stays (#1315).
|
|
- contacts: the host and the guest each stand on magma (and sneak on it), stand on a lit campfire,
|
|
walk against a cactus and through grown sweet berry bushes: the server hurts them, but
|
|
not the sneaking player or young bushes (#1305, #1311, #1312, #1313).
|
|
- absorption: the host and the guest each take Absorption II and a hit of 3: their own HUD has the
|
|
server's golden hearts, 8 then 5 (#1306).
|
|
- breath: the host and the guest each stay underwater for 15 s in a Respiration III helmet: their
|
|
own bubbles follow the server's air, which the enchantment makes last (#1304).
|
|
- lava: the host and the guest each stand in a Nether lava current with Fire
|
|
Resistance, which pushes them at the Nether's strength (#1309).
|
|
- flight: a player in creative double-taps jump and flies, stays up, and stops flying in survival:
|
|
the guest, and on the server thread the host's window too, take what may fly from the
|
|
server's abilities (ClientPacketListener.handlePlayerAbilities).
|
|
- light: the guest's light matches the host's: around a torch placed near it, under a roof and
|
|
once a block of the roof is removed under the sky, and in the last chunk it tracks next to
|
|
glowstone in a chunk it doesn't (only the server's light updates bring that one).
|
|
- totem: the host and the guest each take a lethal hit holding a totem of undying in the off
|
|
hand, then the main hand: the server uses it up and keeps them alive at 1 health with its
|
|
effects, and each side hears the totem once (entity event 35) (#1292).
|
|
- keepinv: the host and the guest each die holding diamonds and wearing a helmet: with keep_inventory
|
|
off both drop where they died and the player respawns without them; with it on nothing
|
|
drops and the player keeps them (#1284).
|
|
- signs: the host and the guest each place an oak sign: the editor opens on their own screen
|
|
(ClientboundOpenSignEditorPacket), the two lines they write reach the server
|
|
(ServerboundSignUpdatePacket) and both sides show them (#1380).
|
|
- books: the host and the guest each write in a book and quill on their own screen, sign it
|
|
(ServerboundEditBookPacket), read the written book the server gave them
|
|
(ClientboundOpenBookPacket), then drop it: the other side sees its title, author and
|
|
page (#1366).
|
|
- elytra: the host and the guest, high up with an Elytra on, jump in the air and glide
|
|
(ServerboundPlayerCommandPacket START_FALL_FLYING): both their client and the server
|
|
have them gliding, they go forward far more than down, and the glide ends on both once
|
|
they land (#1288).
|
|
- helduse: the host and the guest raise a shield, charge and fire a crossbow, throw a trident
|
|
and scope with a spyglass: each use starts on their own client and on the server, and
|
|
letting go ends it on both (RELEASE_USE_ITEM), so the shield lowers, the trident flies
|
|
and the loaded crossbow fires on the next use (#1300, #1281, #1282, #1301).
|
|
- wind: the host and the guest each throw a wind charge at their feet: the server's burst
|
|
launches them about six blocks up (the push in ClientboundExplodePacket) and they land
|
|
unhurt (#1386).
|
|
- fishing: the host and the guest each cast a fishing rod: the hook stays out on the server and
|
|
both sides draw it and it reels back in from the ground, and in open water a fish
|
|
bites and reeling in catches it (#1348).
|
|
- fishing-item: reeling in an item hooked by a fishing rod moves it smoothly on both clients
|
|
instead of leaving it still until a later position update.
|
|
- maps: the host and the guest each make a map and hold it: their client draws the terrain
|
|
around them and their marker, from the server's map data (#1382).
|
|
- body: the host and the guest each walk while drawing a bow (slowed), stand on powder snow
|
|
with leather boots (held up) and without (sinking), and sprint-swim looking down
|
|
(diving): their own client decides it (#1278, #1296, #1303).
|
|
- strikes: a bolt strikes the host and the guest standing in water and hurts them, and a cave
|
|
spider's bite poisons them (#1480, #1484).
|
|
- cursor: the host and the guest each close their inventory with a stack on the cursor: it
|
|
goes back into their inventory, none is thrown and the cursor is empty (#1283).
|
|
- knockback: the host hits the guest three times, then the guest the host, two seconds apart: each
|
|
hit slides the victim as far as vanilla's does, 1.989 blocks (a guest's server-side
|
|
velocity settles between hits; it used to add up, so each push went further).
|
|
- villagers: the guest sees each villager's and zombie villager's type, profession and level as the
|
|
host has them, and an unemployed villager that takes a composter becomes a farmer on
|
|
the guest too (the guest used to draw every villager as an unemployed plains one).
|
|
- stopsound: the host's and the guest's own /stopsound, by source and by event, reach their own
|
|
client's sounds, and the host's /stopsound of the guest only the guest's (StopSoundCommand
|
|
sends each target its packet).
|
|
- boat_input_latch: a boat on land paddled from rest moves one tick after W goes down, not in that tick:
|
|
it steers with the input latched on the previous tick, as the paddle packet it sends says (#3789).
|
|
- popped_break_effects: a torch on the side of a block and sugar cane above one play their break sound once
|
|
for the miner and for the player around when the support is mined (#3747).
|
|
- wind_burst_player: a mace smash with Wind Burst on another player launches the attacker up, for the host and for a guest (#3830).
|
|
- hook_smooth: a cast fishing hook moves every tick of its flight on the client, between the tracker's updates (#3781).
|
|
- merchant-close: the host and the guest each open a trade screen with the payment in its slot; the screen
|
|
closes and the payment returns when they walk out of reach (7 blocks in survival, 9 in
|
|
creative) or the villager dies, and stays open in range.
|
|
- f3_density: F3 on the host (single player on its server thread) shows the Density line of the noise settings'
|
|
debug functions (N, T, V, C, E, D, W, PV, PS) at its feet; a LAN guest, which has no world generator, has none.
|
|
- explorer_map: three shipwreck maps from a loot table point at three different buried treasures (an exploration
|
|
map skips the structures a map already pointed at), and the host and the guest each see their map.
|
|
- sulfur_cube_power: an explosive sulfur cube (TNT inside) stays calm on stone, lights its fuse when a redstone
|
|
block is put beside it, and blows up when the fuse runs out; the guest sees it flash.
|
|
- merchant-reselect: the host and the guest each trade three times from one offer click: Space (a key
|
|
repeat) and Enter press the focused offer button again and refill the payment slot.
|
|
- parrot_follows_mob: a wild parrot (FollowMobGoal) flies to a cow standing 6 blocks away and hovers within 3.5 blocks of it (a parrot that only wanders stays about 4.5 blocks off).
|
|
- riptide_rain: the host and the guest each launch with a Riptide trident in rain, with an elytra worn or not (#3458).
|
|
- hero-trading: the host and the guest each trade 20 wheat for an emerald, paying 14 with Hero I
|
|
and 12 with Hero III; clearing the effect restores the ordinary price (#1365).
|
|
- cure-discount: the host and the guest each cure a zombie villager with a golden apple and pay 14
|
|
wheat instead of 20 for its emerald (the ZOMBIE_VILLAGER_CURED gossip, #3306).
|
|
- falling: an item, a falling block and primed TNT the host summons high up fall smoothly on the
|
|
guest, a little further every tick between the server's updates (every 20 ticks, 10 for
|
|
TNT), as their own client tick moves them (they used to hang, then jump down once a
|
|
second, #1568), and the TNT's fuse burns down on the guest too.
|
|
- instant_digging: an instant survival break leaves no repeated stone hit sounds or stale server dig (#1908).
|
|
- replant: flowers and crops replanted after a short harvest click stay planted for both players;
|
|
the server forgets the completed instant break (#1880).
|
|
- dig_stop: a block the client breaks at once (a creative break, a dead bush) leaves the
|
|
server no dig to keep: the block put back stays and no mining sound plays on (#1340, #1908).
|
|
- dig_clock: a guest in survival holds attack on a stone seven blocks above its feet: the standing
|
|
eye is 5.38 from its underside and only near a jump's apex (4.13) inside the 4.5 pick,
|
|
and the dig resumed after each fall is measured from the START that named the block, so
|
|
it breaks about one dig in rather than one dig per landing (#4124).
|
|
|
|
Each check is reported on its own (a failing one doesn't stop the others) and the screenshots of
|
|
both sides go to --out (host-*.png, guest-*.png).
|
|
|
|
python3 tools/lan_e2e.py # build with the fast profile, run every check
|
|
python3 tools/lan_e2e.py --bin-dir target/fast # prebuilt binaries
|
|
python3 tools/lan_e2e.py --check chest --check tab
|
|
python3 tools/lan_e2e.py --check portals
|
|
|
|
Needs the prepared resources in .local/horizon-client. On Linux the games run on vscreen (the
|
|
desktop), or headless (offscreen, no pause) where there is none; on macOS both games render offscreen. Games started here have no audio (HORIZON_NO_AUDIO=1).
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import zlib
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from agent_control_e2e import Cli, Failed, game_env # noqa: E402
|
|
import lab # noqa: E402
|
|
from integration_env import game_environment, require_server_reports # noqa: E402
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
GUEST_NAME = 'Guest'
|
|
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'menu-transfer', 'crafting', 'spawned', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'deaths',
|
|
'flow-push', 'bubble-column', 'arrows', 'projectile-vehicles', 'flame-arrows', 'kills', 'looting', 'leave', 'hurt', 'looks', 'leads', 'entity-tags', 'respawn', 'containers', 'names', 'feedback', 'titles', 'chat', 'styles',
|
|
'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing',
|
|
'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'shift-double-click', 'recipe-shift', 'shift-drag', 'modifier-release', 'wheel', 'sleep', 'sleep-villager', 'bed-occupied', 'sleep-reload', 'predict', 'push',
|
|
'piston-push', 'piston-entities', 'piston-slime', 'piston-slide', 'eat', 'eat-on-block', 'cauldron', 'eating', 'xp', 'objects', 'projectiles', 'thrown', 'sparks', 'hud', 'sidebar', 'border', 'particles',
|
|
'worn', 'blast', 'blast-projectiles', 'barter', 'piglin_offspring', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'plate-projectiles', 'effects',
|
|
'portals', 'distance', 'growth', 'doors', 'flight', 'ladder', 'boats', 'boat_ice_reboard', 'boat_shoved', 'minecarts', 'frames', 'hands',
|
|
'lightning', 'riding', 'occupied_horse', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption',
|
|
'breath', 'lava', 'levels', 'drops', 'underwater', 'fireworks', 'rocket-walls', 'rocket-ceilings', 'tag-query']
|
|
|
|
|
|
class Game:
|
|
"""One client: its scratch folder, its socket and a CLI driver."""
|
|
|
|
def __init__(self, name, ctl, resources):
|
|
self.name = name
|
|
self.work = Path(tempfile.mkdtemp(prefix=f'hzlan-{name}-'))
|
|
(self.work / '.local/horizon-client').mkdir(parents=True)
|
|
lab.link_dir(resources, self.work / '.local/horizon-client' / lab.VERSION)
|
|
self.socket = self.work / 'c.sock'
|
|
env = game_env(os.environ.get('VSCREEN', 'lan-e2e'), home=self.work / 'home')
|
|
env = game_environment(env, self.work)
|
|
self.d = Cli(ctl, self.socket, self.work, env)
|
|
self.d.name = name
|
|
|
|
def __call__(self, tool, **args):
|
|
return self.d(tool, **args)
|
|
|
|
def raw(self, *words):
|
|
"""A game command horizon-ctl has no table entry for (lan, connect): the words as typed."""
|
|
result = subprocess.run(self.d.base + [str(w) for w in words], cwd=self.work, env=self.d.env,
|
|
capture_output=True, text=True, timeout=600)
|
|
lines = result.stdout.strip().splitlines()
|
|
reply = json.loads(lines[-1]) if lines else {'error': result.stderr.strip()}
|
|
print(f'[{self.name}] {list(words)} -> {json.dumps(reply)[:400]}')
|
|
if result.returncode or 'error' in reply:
|
|
raise Failed(f'{" ".join(map(str, words))}: {reply}')
|
|
return reply
|
|
|
|
def shot(self, out, label):
|
|
path = out / f'{self.name}-{label}.png'
|
|
self.d('screenshot', path=path)
|
|
return path
|
|
|
|
def wait(self, ticks):
|
|
self.d('wait', ticks=ticks)
|
|
|
|
def script(self, lines, every=False):
|
|
"""Keep tick-sensitive input in one ctl process, through the normal control commands. Returns the last
|
|
reply, or with every=True all of them in order."""
|
|
result = subprocess.run(self.d.base + ['script'], input='\n'.join(lines) + '\n',
|
|
cwd=self.work, env=self.d.env, capture_output=True, text=True,
|
|
timeout=600)
|
|
replies = [json.loads(line) for line in result.stdout.splitlines() if line.strip()]
|
|
ticks = [r['ticks'] for r in replies if 'ticks' in r]
|
|
print(f'[{self.name}] script {lines} -> {replies[-1] if replies else result.stderr[:400]}; ticks={ticks}')
|
|
assert result.returncode == 0 and replies and not any('error' in r for r in replies), result.stdout
|
|
return replies if every else replies[-1]
|
|
|
|
def until(self, observe, predicate, ticks=100):
|
|
"""Wait for an observable answer, within the existing tick budget."""
|
|
last = observe()
|
|
for _ in range(ticks):
|
|
if predicate(last):
|
|
return last
|
|
self.wait(1)
|
|
last = observe()
|
|
assert predicate(last), f'{self.name}: state did not arrive within {ticks} ticks: {last}'
|
|
return last
|
|
|
|
def close(self):
|
|
try:
|
|
self.d('quit')
|
|
except Exception:
|
|
pass
|
|
time.sleep(2)
|
|
log = self.socket.with_suffix('.log')
|
|
return log.read_text(errors='replace') if log.exists() else ''
|
|
|
|
|
|
def remote(guest):
|
|
"""The guest's view of the server (`status`'s server part)."""
|
|
return guest('status')['server']
|
|
|
|
|
|
def dead(guest):
|
|
"""Whether the guest is dead: its death screen (the last health it was sent can stay above 0)."""
|
|
return guest('state').get('screen') == 'dead' or remote(guest)['health'] <= 0
|
|
|
|
|
|
def back_to_game(game):
|
|
"""Close whatever screen a failed check left open, without opening the pause menu."""
|
|
try:
|
|
if game('state').get('screen') not in (None, 'playing'):
|
|
game.raw('key', 'esc', 'tap')
|
|
game.wait(2)
|
|
except Failed:
|
|
pass
|
|
|
|
|
|
def pos_of(game):
|
|
return game('state')['player']['position']
|
|
|
|
|
|
def face_each_other(host, guest):
|
|
"""Put the guest 3 blocks east of the host, both looking at the other's head."""
|
|
hx, hy, hz = pos_of(host)
|
|
guest('command', command=f'tp @s {hx + 3:.2f} {hy:.2f} {hz:.2f}')
|
|
guest.wait(30)
|
|
host.raw('look-at', f'{hx + 3:.2f}', f'{hy + 1.6:.2f}', f'{hz:.2f}')
|
|
guest.raw('look-at', f'{hx:.2f}', f'{hy + 1.6:.2f}', f'{hz:.2f}')
|
|
host.wait(10)
|
|
|
|
|
|
def check_tab(host, guest, out):
|
|
players = remote(guest)['players']
|
|
names = sorted(p['name'] if isinstance(p, dict) else str(p) for p in players)
|
|
assert names == sorted([host.player, GUEST_NAME]), f'guest tab list: {players}, host {host.player}'
|
|
guest.raw('key', 'tab', 'down')
|
|
guest.wait(5)
|
|
guest.shot(out, 'tab')
|
|
guest.raw('key', 'tab', 'up')
|
|
return names
|
|
|
|
|
|
def check_self(host, guest, out):
|
|
guest.raw('key', 'f5', 'tap')
|
|
guest.wait(10)
|
|
guest.shot(out, 'self-third-person')
|
|
status = guest('state')
|
|
guest.raw('key', 'f5', 'tap')
|
|
guest.raw('key', 'f5', 'tap')
|
|
guest.raw('key', 'e', 'tap')
|
|
guest.raw('wait-for', 'screen', 'inventory')
|
|
guest.shot(out, 'self-inventory')
|
|
model = remote(guest).get('player_model')
|
|
guest.raw('key', 'esc', 'tap')
|
|
assert model, f'the guest has no player model: {status.get("camera")}'
|
|
return 'model drawn'
|
|
|
|
|
|
def check_armour(host, guest, out):
|
|
host('command', command='item replace entity @s armor.head with minecraft:golden_helmet')
|
|
host('command', command='item replace entity @s armor.chest with minecraft:golden_chestplate')
|
|
guest('command', command='item replace entity @s armor.chest with minecraft:diamond_chestplate')
|
|
guest('command', command='item replace entity @s armor.legs with minecraft:diamond_leggings')
|
|
face_each_other(host, guest)
|
|
host.shot(out, 'armour')
|
|
guest.shot(out, 'armour')
|
|
# The host's window is a client of its server thread: what it draws is what it was sent.
|
|
seen = [e for e in remote(host).get('player_equipment', [])]
|
|
assert all(any(item in json.dumps(e) for e in seen) for item in
|
|
('diamond_chestplate', 'diamond_leggings')), f'the host draws the guest in {seen}'
|
|
host_gear = [e for e in remote(guest).get('player_equipment', [])]
|
|
assert any('golden_chestplate' in json.dumps(e) for e in host_gear), f'the guest sees the host in {host_gear}'
|
|
return 'each in their own'
|
|
|
|
|
|
def check_nametags(host, guest, out):
|
|
face_each_other(host, guest)
|
|
h = host('status').get('name_tags') or []
|
|
g = guest('status').get('name_tags') or []
|
|
host.shot(out, 'nametags')
|
|
guest.shot(out, 'nametags')
|
|
assert GUEST_NAME in h, f'host draws name tags {h}'
|
|
assert host.player in g, f'guest draws name tags {g}'
|
|
return {'host': h, 'guest': g}
|
|
|
|
|
|
def grab(game):
|
|
"""Mouse look on, as a player's grabbed pointer: an agent's mouse-move stands in for it."""
|
|
game.raw('mouse-move', 1, 0)
|
|
game.raw('mouse-move', -1, 0)
|
|
|
|
|
|
def host_unaffected(host, what):
|
|
"""What a guest opens is the guest's: the host keeps its world on screen and its mouse look (#687)."""
|
|
state = host('state')
|
|
assert state.get('screen') == 'playing', f'{what} opened the {state.get("screen")} screen on the host'
|
|
assert state.get('pointer_grabbed'), f"{what} released the host's pointer"
|
|
|
|
|
|
def lid(game, pos):
|
|
return game.raw('block', *pos).get('lid', 0.0)
|
|
|
|
|
|
def check_chest(host, guest, out):
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
chest = (x, y, z + 2)
|
|
guest('command', command=f'setblock {chest[0]} {chest[1]} {chest[2]} minecraft:chest')
|
|
guest.wait(10)
|
|
grab(host)
|
|
guest.raw('interact', *chest)
|
|
screen = guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
|
guest.wait(20)
|
|
host_unaffected(host, "the guest's chest")
|
|
opened = {'guest': lid(guest, chest), 'host': lid(host, chest)}
|
|
host.raw('look-at', chest[0] + 0.5, chest[1] + 0.5, chest[2] + 0.5)
|
|
host.wait(2)
|
|
host.shot(out, 'chest-open')
|
|
guest.shot(out, 'chest-open')
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(20)
|
|
closed = {'guest': lid(guest, chest), 'host': lid(host, chest)}
|
|
host.shot(out, 'chest-closed')
|
|
assert screen, 'no container screen on the guest'
|
|
assert opened['guest'] > 0.9 and opened['host'] > 0.9, f'lid while open: {opened}'
|
|
assert closed['guest'] < 0.1 and closed['host'] < 0.1, f'lid after closing: {closed}'
|
|
return {'open': opened, 'closed': closed}
|
|
|
|
|
|
def check_menu_transfer(host, guest, out):
|
|
"""A container the guest has open does not outlive the server replacing its player (a dimension
|
|
change, a death and respawn): the screen is closed and the inventory opens as its own menu again."""
|
|
guest('command', command='clear @s')
|
|
guest('command', command='give @s minecraft:diamond 3')
|
|
home = pos_of(guest)
|
|
x, y, z = (int(v // 1) for v in home)
|
|
chest = (x, y, z + 2)
|
|
guest('command', command=f'setblock {chest[0]} {chest[1]} {chest[2]} minecraft:chest')
|
|
guest.wait(10)
|
|
results = {}
|
|
|
|
def open_chest():
|
|
guest.raw('interact', *chest)
|
|
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
|
guest.wait(10)
|
|
|
|
def settled(label):
|
|
"""The menu is gone after the transfer; `e` opens the player's own 2x2 inventory with its items."""
|
|
screen = guest('state').get('screen')
|
|
guest.raw('key', 'e', 'tap')
|
|
guest.raw('wait-for', 'screen', 'inventory', '--timeout', 10)
|
|
inventory = guest('screen')
|
|
stale = [s for s in inventory.get('slots', []) if s.get('slot', 0) >= 300]
|
|
diamonds = guest('inventory')['counts'].get('minecraft:diamond', 0)
|
|
guest.shot(out, f'menu-transfer-{label}')
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(5)
|
|
results[label] = {'screen': screen, 'craft_size': inventory.get('craft_size'), 'stale': len(stale),
|
|
'diamonds': diamonds}
|
|
assert screen in (None, 'playing'), f'{label}: the screen after the transfer was {screen}'
|
|
assert inventory.get('craft_size') == 2 and not stale, f'{label}: the inventory menu was {inventory}'
|
|
assert diamonds == 3, f'{label}: {diamonds} diamonds after the transfer'
|
|
|
|
try:
|
|
open_chest()
|
|
into_level(guest, NETHER, x, 100, z)
|
|
settled('dimension')
|
|
guest('command', command=f'execute in {OVERWORLD} run tp @s {home[0]:.2f} {home[1]:.2f} {home[2]:.2f}')
|
|
wait_dimension(guest, OVERWORLD, 400)
|
|
guest.wait(20)
|
|
open_chest()
|
|
guest('command', command='kill @s')
|
|
guest.wait(20)
|
|
guest.raw('respawn')
|
|
wait_dimension(guest, OVERWORLD, 400)
|
|
guest.wait(40)
|
|
settled('respawn')
|
|
finally:
|
|
back_to_game(guest)
|
|
guest('command', command=f'execute in {OVERWORLD} run setblock {chest[0]} {chest[1]} {chest[2]} minecraft:air')
|
|
return results
|
|
|
|
|
|
def check_crafting(host, guest, out):
|
|
guest('command', command='clear @s')
|
|
guest('command', command='give @s minecraft:oak_log 1')
|
|
guest.wait(10)
|
|
guest.raw('key', 'e', 'tap')
|
|
guest.raw('wait-for', 'screen', 'inventory')
|
|
inv = guest('inventory')
|
|
slot = next(s['slot'] for s in inv['hotbar'] if s and s.get('item') == 'minecraft:oak_log')
|
|
# Horizon's slot ids (click-slot): the hotbar is 0-8, the grid 100-108, the result 200.
|
|
guest.raw('click-slot', slot)
|
|
guest.raw('click-slot', 100)
|
|
guest.wait(5)
|
|
result = [s for s in guest('screen').get('slots', []) if s.get('slot') == 200]
|
|
guest.shot(out, 'crafting-grid')
|
|
guest.raw('click-slot', 200, 'shift')
|
|
guest.wait(5)
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(5)
|
|
planks = guest('inventory')['counts'].get('minecraft:oak_planks', 0)
|
|
assert planks == 4, f'crafted {planks} planks (result slot: {result})'
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
table = (x, y, z - 2)
|
|
guest('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:crafting_table')
|
|
guest.wait(10)
|
|
grab(host)
|
|
guest.raw('interact', *table)
|
|
guest.raw('wait-for', 'screen', 'inventory', '--timeout', 10)
|
|
host_unaffected(host, "the guest's crafting table")
|
|
size = guest('screen').get('craft_size')
|
|
assert size == 3, f'the crafting table opened a {size}x{size} grid'
|
|
# Two planks one above the other in the 3x3 grid: four sticks.
|
|
slot = next(s['slot'] for s in guest('inventory')['hotbar'] if s and s.get('item') == 'minecraft:oak_planks')
|
|
guest.raw('click-slot', slot)
|
|
guest.raw('click-slot', 100, 'right')
|
|
guest.raw('click-slot', 103, 'right')
|
|
guest.raw('click-slot', slot)
|
|
guest.wait(5)
|
|
guest.shot(out, 'crafting-table')
|
|
guest.raw('click-slot', 200, 'shift')
|
|
guest.wait(5)
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(5)
|
|
counts = guest('inventory')['counts']
|
|
assert counts.get('minecraft:stick') == 4 and counts.get('minecraft:oak_planks') == 2, f'after the table: {counts}'
|
|
return {'planks': planks, 'table': counts}
|
|
|
|
|
|
def check_spawned(host, guest, out):
|
|
"""Players new to a world don't share one spot: ServerPlayer.adjustSpawnLocation spreads them over the
|
|
respawn radius (PlayerSpawnFinder). Two players standing in each other target each other, so a guest
|
|
left in the host could open nothing it clicked. At its own spot, the guest opens a crafting table, a
|
|
chest, a furnace and a stonecutter."""
|
|
joined = {'host': [round(v, 2) for v in host.joined_at], 'guest': [round(v, 2) for v in guest.joined_at]}
|
|
apart = abs(joined['host'][0] - joined['guest'][0]) + abs(joined['host'][2] - joined['guest'][2]) >= 0.6
|
|
guest('command', command='gamemode survival')
|
|
for game in (host, guest):
|
|
x, y, z = game.joined_at
|
|
game('command', command=f'tp @s {x} {y} {z}')
|
|
guest.wait(20)
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
at = (x, y, z + 2)
|
|
grab(host)
|
|
opened = {}
|
|
for block in ('crafting_table', 'chest', 'furnace', 'stonecutter'):
|
|
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:{block}')
|
|
guest.wait(10)
|
|
guest.raw('interact', *at)
|
|
guest.wait(10)
|
|
opened[block] = guest('state').get('screen')
|
|
guest.shot(out, f'spawned-{block}')
|
|
back_to_game(guest)
|
|
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
|
|
host_unaffected(host, "the guest's stations")
|
|
face_each_other(host, guest)
|
|
assert apart, f'the host and the guest joined on one spot: {joined}'
|
|
want = {'crafting_table': 'inventory', 'chest': 'container', 'furnace': 'container', 'stonecutter': 'station'}
|
|
assert opened == want, f'where it joined, the guest opened {opened}'
|
|
return {'joined': joined, 'opened': opened}
|
|
|
|
|
|
def check_sign(host, guest, out):
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
spot = (x + 2, y, z)
|
|
guest('command', command=f'setblock {spot[0]} {spot[1] - 1} {spot[2]} minecraft:stone')
|
|
guest('command', command=f'setblock {spot[0]} {spot[1]} {spot[2]} minecraft:air')
|
|
guest('command', command='give @s minecraft:oak_sign 1')
|
|
guest.wait(10)
|
|
grab(host)
|
|
guest.raw('place', *spot, '--item', 'minecraft:oak_sign')
|
|
guest.wait(20)
|
|
placed = host.raw('block', *spot).get('block')
|
|
host.shot(out, 'sign')
|
|
assert placed and 'sign' in placed, f'the guest placed {placed}'
|
|
host_unaffected(host, "the guest's sign")
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.raw('wait-for', 'screen', 'playing')
|
|
return placed
|
|
|
|
|
|
def effect_ids(game):
|
|
return [e.get('id', '').removeprefix('minecraft:') for e in game('state')['player'].get('effects', [])]
|
|
|
|
|
|
def check_effects(host, guest, out):
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
bx, by, bz = x - 3, y, z - 3
|
|
host('command', command=f'fill {bx - 1} {by - 1} {bz - 1} {bx + 1} {by - 1} {bz + 1} minecraft:iron_block')
|
|
host('command', command=f'setblock {bx} {by} {bz} minecraft:beacon{{primary_effect:"minecraft:speed"}}')
|
|
# BeaconBlockEntity.tick applies its powers every 80 ticks, once the beam has reached the sky.
|
|
seen = {}
|
|
for _ in range(15):
|
|
host.wait(20)
|
|
seen = {'host': effect_ids(host), 'guest': effect_ids(guest)}
|
|
if all('speed' in v for v in seen.values()):
|
|
break
|
|
host.shot(out, 'effects')
|
|
guest.shot(out, 'effects')
|
|
assert 'speed' in seen['host'] and 'speed' in seen['guest'], f'beacon effects: {seen}'
|
|
return seen
|
|
|
|
|
|
def wait_dimension(game, dimension, ticks):
|
|
"""Wait up to `ticks` for the game to be in `dimension` with its world loaded around it."""
|
|
for _ in range(ticks // 20):
|
|
state = game('state')
|
|
if state.get('dimension') == dimension and state.get('player'):
|
|
return state
|
|
game.wait(20)
|
|
return game('state')
|
|
|
|
|
|
def nether_portal(game, x, y, z):
|
|
"""A lit portal along x, two blocks wide and three tall, its inside starting at x y z."""
|
|
game('command', command=f'fill {x - 1} {y - 1} {z} {x + 2} {y + 3} {z} minecraft:obsidian')
|
|
game('command', command=f'fill {x} {y} {z} {x + 1} {y + 2} {z} minecraft:nether_portal[axis=x]')
|
|
|
|
|
|
def lan_guest(host):
|
|
"""The guest's entry in the host's `lan status` (the owner is listed too)."""
|
|
guests = host.raw('lan', 'status')['guests']
|
|
return next(g for g in guests if g['name'] == GUEST_NAME)
|
|
|
|
def check_distance(host, guest, out):
|
|
"""IntegratedServer.tickServer: the server takes the host's render distance as its view distance
|
|
(past 32, which Horizon's options allow) and sends it to every player (SetChunkCacheRadius)."""
|
|
seen = {}
|
|
try:
|
|
for distance in (48, 6):
|
|
host.raw('render-distance', distance)
|
|
# The pause screen doesn't hold the server here: it follows on its next tick.
|
|
host.wait(20)
|
|
guest.wait(20)
|
|
seen[distance] = (host('status').get('render_distance'), guest('status').get('render_distance'))
|
|
finally:
|
|
host.raw('render-distance', 12)
|
|
# A client streams out to min(its render distance, the server's view distance + 1).
|
|
assert seen[48][0] == 48, f'the host at 48 streams {seen[48][0]} chunks out (all: {seen})'
|
|
assert seen[6][0] == 6, f'the host at 6 streams {seen[6][0]} chunks out (all: {seen})'
|
|
assert seen[6][1] == 7, f'the guest streams {seen[6][1]} with the server at 6 (all: {seen})'
|
|
return seen
|
|
|
|
|
|
def check_portals(host, guest, out):
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
try:
|
|
return portals(host, guest, out, x, y, z, hx, hy, hz)
|
|
finally:
|
|
# The guest comes home into its portal's frame (an obsidian ceiling three blocks up) and the
|
|
# host onto its End portal at spawn: take both away so the next checks start in the open.
|
|
home = 'execute in minecraft:overworld run'
|
|
guest('command', command=f'{home} fill {x - 1} {y} {z + 3} {x + 2} {y + 3} {z + 3} minecraft:air')
|
|
host('command', command=f'{home} setblock {hx} {hy} {hz} minecraft:air')
|
|
guest('command', command=f'{home} tp @s {x + 0.5} {y} {z + 0.5}')
|
|
guest.wait(20)
|
|
|
|
|
|
def portals(host, guest, out, x, y, z, hx, hy, hz):
|
|
nether_portal(guest, x, y, z + 3)
|
|
guest.wait(5)
|
|
guest('command', command=f'tp @s {x + 0.5} {y} {z + 3.5}')
|
|
# players_nether_portal_default_delay: 80 ticks in survival.
|
|
there = wait_dimension(guest, 'minecraft:the_nether', 600)
|
|
# On a server the guest is held where it entered until the chunks around the destination are
|
|
# lit, then placed in its portal: wait for that before reading where it arrived.
|
|
for _ in range(30):
|
|
guest.wait(20)
|
|
arrived = [int(v // 1) for v in pos_of(guest)]
|
|
if guest.raw('block', *arrived).get('block') == 'minecraft:nether_portal':
|
|
break
|
|
guest.shot(out, 'portals-nether')
|
|
host_dim = host('state').get('dimension')
|
|
assert there.get('dimension') == 'minecraft:the_nether', f'the guest is in {there.get("dimension")}'
|
|
assert host_dim == 'minecraft:overworld', f'the host went to {host_dim} with the guest'
|
|
below = guest.raw('block', arrived[0], arrived[1] - 1, arrived[2])
|
|
assert below.get('loaded') and below.get('block') != 'minecraft:air', f'under the guest in the Nether: {below}'
|
|
# handleRespawn's LocalPlayer.setPortalCooldown: standing in the arrival portal keeps the cooldown
|
|
# going, so the overlay fades out (20 ticks from full) instead of building up again.
|
|
guest.wait(40)
|
|
effect = guest('state')['player'].get('portal_effect')
|
|
assert effect == 0, f'the portal overlay is at {effect} in the arrival portal'
|
|
# The host takes an End portal while the guest is in the Nether: three levels at once.
|
|
host('command', command=f'setblock {hx} {hy} {hz} minecraft:end_portal')
|
|
end = wait_dimension(host, 'minecraft:the_end', 600)
|
|
host.wait(40)
|
|
host.shot(out, 'portals-end')
|
|
guest_dim = guest('state').get('dimension')
|
|
assert end.get('dimension') == 'minecraft:the_end', f'the host is in {end.get("dimension")}'
|
|
assert guest_dim == 'minecraft:the_nether', f'the guest left the Nether for {guest_dim}'
|
|
# Both come back: the host through an exit portal, the guest out of its portal and in again.
|
|
ex, ey, ez = (int(v // 1) for v in pos_of(host))
|
|
host('command', command=f'setblock {ex + 2} {ey} {ez} minecraft:end_portal')
|
|
host('command', command=f'tp @s {ex + 2.5} {ey} {ez + 0.5}')
|
|
guest('command', command=f'tp @s {arrived[0] + 0.5} {arrived[1]} {arrived[2] - 2.5}')
|
|
guest.wait(30)
|
|
guest('command', command=f'tp @s {arrived[0] + 0.5} {arrived[1]} {arrived[2] + 0.5}')
|
|
# ServerPlayer.showEndCredits: the host's first exit from the End rolls the poem and credits;
|
|
# closing them (WinScreen.onClose sends PERFORM_RESPAWN) takes it home.
|
|
credits = False
|
|
for _ in range(60):
|
|
host.wait(5)
|
|
if host('screen').get('screen') == 'win':
|
|
credits = True
|
|
host.raw('key', 'esc', 'tap')
|
|
break
|
|
assert credits, 'the host left the End without the credits'
|
|
home = wait_dimension(host, 'minecraft:overworld', 600)
|
|
back = wait_dimension(guest, 'minecraft:overworld', 600)
|
|
guest.wait(40)
|
|
host.shot(out, 'portals-back')
|
|
guest.shot(out, 'portals-back')
|
|
assert home.get('dimension') == 'minecraft:overworld', f'the host stayed in {home.get("dimension")}'
|
|
assert back.get('dimension') == 'minecraft:overworld', f'the guest stayed in {back.get("dimension")}'
|
|
return {'guest_nether': arrived, 'host_end': [ex, ey, ez], 'credits': credits, 'back': pos_of(guest)}
|
|
|
|
|
|
NETHER, END, OVERWORLD = 'minecraft:the_nether', 'minecraft:the_end', 'minecraft:overworld'
|
|
|
|
|
|
def check_levels(host, guest, out):
|
|
"""Each player in its own level (MinecraftServer.levels), the host and the guest
|
|
apart: the steps run in order, each reported, and the check fails if any did."""
|
|
start = {g.name: pos_of(g) for g in (host, guest)}
|
|
steps, failed = {}, []
|
|
guest('command', command='gamemode creative')
|
|
try:
|
|
for name, step in LEVEL_STEPS:
|
|
try:
|
|
steps[name] = step(host, guest, out)
|
|
except (Failed, AssertionError, StopIteration, KeyError, TypeError, IndexError) as e:
|
|
steps[name], failed = repr(e), failed + [name]
|
|
for g in (host, guest):
|
|
back_to_game(g)
|
|
print(f'[lan] levels {name}: {"FAIL" if name in failed else "PASS"} {steps[name]}')
|
|
finally:
|
|
for g in (host, guest):
|
|
back_to_game(g)
|
|
if g('state').get('screen') == 'dead':
|
|
g.raw('respawn')
|
|
g.wait(20)
|
|
x, y, z = start[g.name]
|
|
g('command', command=f'execute in {OVERWORLD} run tp @s {x:.2f} {y:.2f} {z:.2f}')
|
|
guest('command', command='gamemode survival')
|
|
guest.wait(40)
|
|
assert not failed, f'failed: {failed}; {steps}'
|
|
return steps
|
|
|
|
|
|
def level_floor(game, x, y, z):
|
|
"""An obsidian floor under x y z with air above it, in the level `game` is in (fill needs the
|
|
chunks loaded: ServerLevel.hasChunksAt)."""
|
|
game('command', command=f'fill {x - 4} {y - 1} {z - 4} {x + 4} {y - 1} {z + 4} minecraft:obsidian')
|
|
game('command', command=f'fill {x - 4} {y} {z - 4} {x + 4} {y + 4} {z + 4} minecraft:air')
|
|
|
|
|
|
def into_level(game, dimension, x, y, z):
|
|
"""`execute in DIMENSION run tp` to x y z, onto a floor built once the level has loaded around it."""
|
|
game('command', command=f'execute in {dimension} run tp @s {x + 0.5} {y} {z + 0.5}')
|
|
state = wait_dimension(game, dimension, 400)
|
|
game.wait(20)
|
|
level_floor(game, x, y, z)
|
|
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
game.wait(20)
|
|
return state
|
|
|
|
|
|
def dims(host, guest):
|
|
return host('state').get('dimension'), guest('state').get('dimension')
|
|
|
|
|
|
def level_execute(host, guest, out):
|
|
"""`execute in the_nether run tp` takes the guest alone. From the Nether, the guest's commands
|
|
reach the host's level (CommandSourceStack.getLevel): `execute in overworld` and `execute at`
|
|
the host put a block beside the host, and `execute if block` reads it."""
|
|
x, z = (int(v // 1) for v in pos_of(guest)[::2])
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
there = into_level(guest, NETHER, x, 100, z)
|
|
below = guest.raw('block', x, 99, z)
|
|
gold = f'{hx} {hy} {hz + 2} minecraft:gold_block'
|
|
try:
|
|
guest('command', command=f'execute in {OVERWORLD} run setblock {gold}')
|
|
guest('command', command=f'execute at {host.player} run setblock ~ ~ ~-2 minecraft:diamond_block')
|
|
guest('command', command=f'execute in {OVERWORLD} if block {gold} in {NETHER} '
|
|
f'run setblock {x} 99 {z} minecraft:gold_block')
|
|
host.wait(10)
|
|
placed = [host.raw('block', hx, hy, hz + 2), host.raw('block', hx, hy, hz - 2)]
|
|
found = guest.raw('block', x, 99, z)
|
|
finally:
|
|
host('command', command=f'fill {hx} {hy} {hz - 2} {hx} {hy} {hz + 2} minecraft:air')
|
|
assert dims(host, guest) == (OVERWORLD, NETHER), f'host, guest in {dims(host, guest)}'
|
|
assert there.get('dimension') == NETHER and below.get('block') == 'minecraft:obsidian', f'{there} on {below}'
|
|
assert [p.get('block') for p in placed] == ['minecraft:gold_block', 'minecraft:diamond_block'], \
|
|
f'beside the host: {placed}'
|
|
assert found.get('block') == 'minecraft:gold_block', f'execute in overworld if block: {found}'
|
|
return {'guest': pos_of(guest)}
|
|
|
|
|
|
def level_ticking(host, guest, out):
|
|
"""Mobs and objects fall in both levels at once (each level with a player ticks); every entity
|
|
has its own id across the levels."""
|
|
gx, gy, gz = pos_of(guest)
|
|
hx, hy, hz = pos_of(host)
|
|
# Clear the whole walking area in each level: normal pig AI can leave a
|
|
# narrow landing strip during the fall/observation window.
|
|
for game, x, y, z in ((host, hx, hy, hz), (guest, gx, gy, gz)):
|
|
bx, by, bz = int(x // 1), int(y // 1), int(z // 1)
|
|
game('command', command=f'fill {bx - 12} {by - 1} {bz - 12} {bx + 12} {by - 1} {bz + 12} minecraft:stone')
|
|
game('command', command=f'fill {bx - 12} {by} {bz - 12} {bx + 12} {by + 6} {bz + 12} minecraft:air')
|
|
guest('command', command=f'summon minecraft:pig {gx + 2.5:.2f} {gy + 3:.2f} {gz:.2f}')
|
|
guest('command', command=f'summon minecraft:armor_stand {gx - 2.5:.2f} {gy + 3:.2f} {gz:.2f}')
|
|
host('command', command=f'summon minecraft:pig {hx + 2.5:.2f} {hy + 3:.2f} {hz - 3:.2f}')
|
|
host('command', command=f'summon minecraft:armor_stand {hx - 2.5:.2f} {hy + 3:.2f} {hz - 3:.2f}')
|
|
try:
|
|
guest.wait(60)
|
|
seen = {}
|
|
for name, g, floor in (('guest', guest, gy), ('host', host, hy)):
|
|
mobs = [m for m in g.raw('mobs', 8)['near'] if m['kind'] == 'minecraft:pig']
|
|
stands = [o for o in g('status')['objects'] if o['kind'] == 'minecraft:armor_stand']
|
|
seen[name] = {'pigs': [(m['id'], round(m['pos'][1], 2)) for m in mobs],
|
|
'pig_positions': [m['pos'] for m in mobs],
|
|
'stands': [(o['id'], round(o['position'][1], 2)) for o in stands], 'floor': floor}
|
|
finally:
|
|
guest('command', command='kill @e[type=!minecraft:player,distance=..8]')
|
|
host('command', command='kill @e[type=!minecraft:player,distance=..8]')
|
|
for name, s in seen.items():
|
|
heights = [y for _, y in s['pigs'] + s['stands']]
|
|
assert len(s['pigs']) == 1 and len(s['stands']) == 1, f'{name} sees {s}'
|
|
assert all(abs(y - s['floor']) < 0.1 for y in heights), f"{name}'s did not fall to the floor: {seen}"
|
|
ids = [i for s in seen.values() for i, _ in s['pigs'] + s['stands']]
|
|
assert len(set(ids)) == len(ids), f'ids shared across the levels: {seen}'
|
|
return seen
|
|
|
|
|
|
def level_rejoin(host, guest, out):
|
|
"""The guest leaves in the Nether and joins again there (its player data keeps its level)."""
|
|
before = pos_of(guest)
|
|
port = host.raw('lan', 'status')['port']
|
|
guest.raw('disconnect')
|
|
time.sleep(2)
|
|
guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME)
|
|
guest.raw('wait-for', 'loaded', '--timeout', 300)
|
|
guest.wait(20)
|
|
after = pos_of(guest)
|
|
assert dims(host, guest) == (OVERWORLD, NETHER), f'host, guest in {dims(host, guest)}'
|
|
assert all(abs(a - b) < 1 for a, b in zip(before, after)), f'left at {before}, back at {after}'
|
|
return {'at': after}
|
|
|
|
|
|
def level_death(host, guest, out):
|
|
"""The guest dies in the Nether and respawns in the Overworld; the host stays where it is."""
|
|
guest('command', command='kill @s')
|
|
guest.wait(20)
|
|
guest.raw('respawn')
|
|
back = wait_dimension(guest, OVERWORLD, 400)
|
|
guest.wait(20)
|
|
assert back.get('dimension') == OVERWORLD, f'the guest respawned in {back.get("dimension")}'
|
|
assert dims(host, guest) == (OVERWORLD, OVERWORLD), f'host, guest in {dims(host, guest)}'
|
|
return {'respawned': back['player']['position']}
|
|
|
|
|
|
def level_respawn_points(host, guest, out):
|
|
"""Respawn points outside the Overworld (ServerPlayer.findRespawnAndUseSpawnBlock): a point
|
|
`spawnpoint` sets in the Nether (forced), then a charged respawn anchor the guest uses there,
|
|
each bring the guest back in the Nether; the anchor loses a charge."""
|
|
home = pos_of(guest)
|
|
x, z = (int(v // 1) for v in home[::2])
|
|
into_level(guest, NETHER, x, 100, z)
|
|
results = {}
|
|
try:
|
|
guest('command', command=f'spawnpoint @s {x} 100 {z}')
|
|
results['spawnpoint'] = die_and_respawn(guest)
|
|
guest('command', command=f'setblock {x + 2} 100 {z} minecraft:respawn_anchor[charges=2]')
|
|
guest.wait(10)
|
|
guest.raw('interact', x + 2, 100, z)
|
|
guest.wait(10)
|
|
results['anchor'] = die_and_respawn(guest)
|
|
results['charges'] = guest.raw('block', x + 2, 100, z).get('state')
|
|
finally:
|
|
# Leave no Overworld spawnpoint behind: with the anchor removed below, the guest's next death
|
|
# falls back to the world spawn and clears its point, which check_world_spawn relies on.
|
|
guest('command', command=f'execute in {NETHER} run setblock {x + 2} 100 {z} minecraft:air')
|
|
guest('command', command=f'execute in {OVERWORLD} run tp @s {home[0]:.2f} {home[1]:.2f} {home[2]:.2f}')
|
|
wait_dimension(guest, OVERWORLD, 400)
|
|
point, anchor = results['spawnpoint'], results['anchor']
|
|
assert point[0] == NETHER and abs(point[1][0] - x - 0.5) < 0.6 and abs(point[1][2] - z - 0.5) < 0.6, \
|
|
f'the spawnpoint brought the guest to {point}'
|
|
assert anchor[0] == NETHER and abs(anchor[1][0] - x - 2.5) < 2 and abs(anchor[1][2] - z - 0.5) < 2, \
|
|
f'the anchor brought the guest to {anchor}'
|
|
assert 'charges=1' in (results['charges'] or ''), f'the anchor after the respawn: {results["charges"]}'
|
|
return results
|
|
|
|
|
|
def die_and_respawn(guest):
|
|
"""Kill the guest and respawn it: the dimension and position it comes back at, once settled."""
|
|
guest('command', command='kill @s')
|
|
guest.wait(20)
|
|
guest.raw('respawn')
|
|
guest.wait(80)
|
|
state = guest('state')
|
|
return state.get('dimension'), state['player']['position']
|
|
|
|
|
|
def level_host_nether(host, guest, out):
|
|
"""The host goes through a Nether portal while the guest stays in the Overworld."""
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
nether_portal(host, hx, hy, hz - 3)
|
|
try:
|
|
host('command', command=f'tp @s {hx + 0.5} {hy} {hz - 2.5}')
|
|
there = wait_dimension(host, NETHER, 600)
|
|
host.wait(20)
|
|
result = dims(host, guest)
|
|
assert there.get('dimension') == NETHER, f'the host is in {there.get("dimension")}'
|
|
assert result == (NETHER, OVERWORLD), f'host, guest in {result}'
|
|
return {'host': there['player']['position']}
|
|
finally:
|
|
host('command', command=f'execute in {OVERWORLD} run fill {hx - 1} {hy} {hz - 3} {hx + 2} {hy + 3} {hz - 3} '
|
|
'minecraft:air')
|
|
host('command', command=f'execute in {OVERWORLD} run tp @s {hx + 0.5} {hy} {hz + 0.5}')
|
|
wait_dimension(host, OVERWORLD, 400)
|
|
|
|
|
|
def level_guest_end(host, guest, out):
|
|
"""The guest steps into an End portal alone and lands on the obsidian platform, an End gateway
|
|
takes it across the End, and an exit portal takes it back to the Overworld."""
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
guest('command', command=f'setblock {x + 2} {y} {z} minecraft:end_portal')
|
|
try:
|
|
guest('command', command=f'tp @s {x + 2.5} {y} {z + 0.5}')
|
|
wait_dimension(guest, END, 600)
|
|
# The arrival waits for the platform's chunks; its portal cooldown (10 ticks) runs out after,
|
|
# and a player who stands in a portal while it runs stays (Entity.setAsInsidePortal).
|
|
guest.wait(60)
|
|
landed = pos_of(guest)
|
|
assert dims(host, guest) == (OVERWORLD, END), f'host, guest in {dims(host, guest)}'
|
|
assert [int(v // 1) for v in landed] == [100, 49, 0], f'the guest landed at {landed}'
|
|
finally:
|
|
guest('command', command=f'execute in {OVERWORLD} run setblock {x + 2} {y} {z} minecraft:air')
|
|
# A gateway far out with an exact exit at the End spawn, as end_gateway_return's
|
|
# (TheEndGatewayBlockEntity.getPortalPosition), takes the guest back there within the End.
|
|
guest('command', command='tp @s 700.5 80 0.5')
|
|
guest.wait(60)
|
|
level_floor(guest, 700, 80, 0)
|
|
guest('command', command='tp @s 700.5 80 0.5')
|
|
guest.wait(20)
|
|
guest('command', command='setblock 702 80 0 minecraft:end_gateway{exit_portal:[I;100,50,0],ExactTeleport:1b}')
|
|
guest('command', command='tp @s 702.5 80 0.5')
|
|
gateway = None
|
|
for _ in range(20):
|
|
guest.wait(10)
|
|
gateway = pos_of(guest)
|
|
if abs(gateway[0] - 702.5) > 50:
|
|
break
|
|
assert dims(host, guest) == (OVERWORLD, END), f'host, guest in {dims(host, guest)}'
|
|
assert abs(gateway[0] - 100.5) < 1 and abs(gateway[2] - 0.5) < 1 and 49 <= gateway[1] <= 50, \
|
|
f'the gateway left the guest at {gateway}'
|
|
guest.wait(40)
|
|
ex, ey, ez = (int(v // 1) for v in pos_of(guest))
|
|
guest('command', command=f'setblock {ex + 2} {ey} {ez} minecraft:end_portal')
|
|
guest('command', command=f'tp @s {ex + 2.5} {ey} {ez + 0.5}')
|
|
# ServerPlayer.showEndCredits: the guest's first exit from the End rolls the credits; closing
|
|
# them (WinScreen.onClose sends PERFORM_RESPAWN) takes it home.
|
|
credits = False
|
|
for _ in range(60):
|
|
guest.wait(5)
|
|
if guest('screen').get('screen') == 'win':
|
|
credits = True
|
|
guest.raw('key', 'esc', 'tap')
|
|
break
|
|
assert credits, 'the guest left the End without the credits'
|
|
home = wait_dimension(guest, OVERWORLD, 600)
|
|
guest.wait(20)
|
|
assert dims(host, guest) == (OVERWORLD, OVERWORLD), f'host, guest in {dims(host, guest)}'
|
|
return {'landed': landed, 'gateway': gateway, 'home': home['player']['position']}
|
|
|
|
|
|
def level_reload(host, guest, out):
|
|
"""The host saves and quits with the guest in the Nether, opens the world again and opens it to the
|
|
LAN; the guest joins again where it was, in the Nether."""
|
|
x, z = (int(v // 1) for v in pos_of(guest)[::2])
|
|
into_level(guest, NETHER, x, 100, z)
|
|
before = pos_of(guest)
|
|
host.raw('disconnect')
|
|
# The world saves on its server thread before the title screen shows.
|
|
for attempt in range(60):
|
|
time.sleep(1)
|
|
try:
|
|
host.raw('open', 'Lan')
|
|
break
|
|
except Failed:
|
|
if attempt == 59:
|
|
raise
|
|
host('wait_ready', seconds=400)
|
|
port = host.raw('lan', 'open', 0, 'commands')['port']
|
|
back_to_game(guest)
|
|
guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME)
|
|
guest.raw('wait-for', 'loaded', '--timeout', 300)
|
|
guest.wait(20)
|
|
after = pos_of(guest)
|
|
assert dims(host, guest) == (OVERWORLD, NETHER), f'host, guest in {dims(host, guest)}'
|
|
assert all(abs(a - b) < 1 for a, b in zip(before, after)), f'left at {before}, back at {after}'
|
|
return {'at': after}
|
|
|
|
|
|
LEVEL_STEPS = [('execute', level_execute), ('ticking', level_ticking), ('rejoin', level_rejoin),
|
|
('death', level_death), ('respawn_points', level_respawn_points),
|
|
('host_nether', level_host_nether),
|
|
('guest_end', level_guest_end), ('reload', level_reload)]
|
|
|
|
|
|
def check_player_armor_components(host, guest):
|
|
"""LivingEntity.doHurtEquipment on a fully ticking LAN player (#7480)."""
|
|
slots = [('head', 'helmet'), ('chest', 'chestplate'),
|
|
('legs', 'leggings'), ('feet', 'boots')]
|
|
cases, failures = {}, []
|
|
|
|
def server_ticks(ticks):
|
|
# Client frames can outrun the integrated server under load. Measure
|
|
# the authoritative clock so heal expiry and hurt cooldown really pass.
|
|
def clock():
|
|
reply = server_reply(guest, 'time query gametime', 'The game time is')
|
|
return int(re.search(r'The game time is (\d+)', reply).group(1))
|
|
start = clock()
|
|
deadline = time.monotonic() + 90
|
|
while clock() - start < ticks:
|
|
assert time.monotonic() < deadline, 'armor fixture server clock stalled'
|
|
guest.wait(5)
|
|
|
|
def health():
|
|
reply = server_reply(guest, 'data get entity @s Health', 'entity data')
|
|
return float(re.search(r'entity data: ([0-9.]+)f?\s*$', reply).group(1))
|
|
|
|
guest('command', command='gamemode survival')
|
|
server_reply(guest, 'gamerule natural_health_regeneration false', 'Game rule', 'false')
|
|
guest('command', command='fill ~-1 ~ ~-1 ~1 ~2 ~1 minecraft:air')
|
|
# This must be player-issued, and followed by real player ticks. The
|
|
# command-script oracle does not supply that tick/health setup.
|
|
guest('command', command='attribute @s minecraft:max_health base set 1000')
|
|
try:
|
|
for case, components, damage, present in [
|
|
('normal', '', 1, True),
|
|
('unbreakable', '[minecraft:unbreakable={}]', None, True),
|
|
('custom_limit', '[minecraft:max_damage=10,minecraft:damage=9]', None, False),
|
|
]:
|
|
for slot, item in slots:
|
|
guest('command', command=f'item replace entity @s armor.{slot} with '
|
|
f'minecraft:diamond_{item}{components}')
|
|
guest('command', command='effect give @s minecraft:instant_health 1 10')
|
|
server_ticks(25)
|
|
healed = health()
|
|
assert healed == 1000, f'invalid armor fixture health: {case}: {healed}'
|
|
server_reply(guest, 'effect clear @s minecraft:instant_health', 'effect')
|
|
server_ticks(25)
|
|
assert health() == 1000, f'armor fixture lost health: {case}'
|
|
host('command', command=f'damage {GUEST_NAME} 4 minecraft:cactus')
|
|
server_ticks(2)
|
|
observed = {}
|
|
for slot, item in slots:
|
|
reply = server_reply(guest, 'data get entity @s '
|
|
f'equipment.{slot}.components."minecraft:damage"')
|
|
value = re.search(r'entity data: (-?\d+)\s*$', reply)
|
|
read_damage = int(value.group(1)) if value else None
|
|
exists = server_reply(guest, f'execute if items entity @s armor.{slot} '
|
|
f'minecraft:diamond_{item}')
|
|
observed[slot] = {'damage': read_damage, 'present': 'Test passed' in exists}
|
|
after_health = health()
|
|
cases[case] = {'health': after_health, 'equipment': observed}
|
|
want = {slot: {'damage': damage, 'present': present} for slot, _ in slots}
|
|
if observed != want:
|
|
failures.append(case)
|
|
if case != 'custom_limit' and not math.isclose(after_health, 999.04, abs_tol=0.002):
|
|
failures.append(f'{case}: armor reduction')
|
|
finally:
|
|
for slot, _ in slots:
|
|
guest('command', command=f'item replace entity @s armor.{slot} with minecraft:air')
|
|
guest('command', command='attribute @s minecraft:max_health base set 20')
|
|
guest('command', command='effect give @s minecraft:instant_health 1 10')
|
|
server_ticks(25)
|
|
server_reply(guest, 'effect clear @s minecraft:instant_health', 'effect')
|
|
server_reply(guest, 'gamerule natural_health_regeneration true', 'Game rule', 'true')
|
|
# Same-hit reduction ordering is recorded independently: a component fix
|
|
# must not silently claim to fix the deferred armor-wear health adapter.
|
|
order = {'actual_health': cases['custom_limit']['health'], 'vanilla_health': 996.0}
|
|
order['matches'] = math.isclose(order['actual_health'], order['vanilla_health'], abs_tol=0.002)
|
|
assert not failures, {'armor_component_failures': failures, 'cases': cases,
|
|
'same_hit_armor_order': order}
|
|
return {'cases': cases, 'same_hit_armor_order': order}
|
|
|
|
|
|
def check_damage(host, guest, out):
|
|
guest('command', command='gamemode survival')
|
|
guest.wait(10)
|
|
before = remote(guest)['health']
|
|
x, y, z = pos_of(guest)
|
|
guest('command', command=f'tp @s {x:.2f} {y + 12:.2f} {z:.2f}')
|
|
guest.wait(50)
|
|
after = remote(guest)['health']
|
|
guest.shot(out, 'damage')
|
|
assert after < before, f'health {before} -> {after} after a 12-block fall'
|
|
# Into water: the fall does no damage.
|
|
x, y, z = pos_of(guest)
|
|
guest('command', command=f'setblock {int(x // 1)} {int(y // 1)} {int(z // 1)} minecraft:water')
|
|
guest('command', command=f'tp @s {x:.2f} {y + 12:.2f} {z:.2f}')
|
|
guest.wait(50)
|
|
water = remote(guest)['health']
|
|
assert water >= after, f'health {after} -> {water} after falling into water'
|
|
# The host's /damage on the guest by name hurts it (not "Target is invulnerable").
|
|
guest('command', command='effect give @s minecraft:instant_health 1 4')
|
|
guest.wait(10)
|
|
healed = remote(guest)['health']
|
|
said = host('command', command=f'damage {GUEST_NAME} 4 minecraft:generic')
|
|
guest.wait(20)
|
|
hit = remote(guest)['health']
|
|
applied = any('Applied' in line for line in said.get('chat', []))
|
|
assert applied and hit < healed, f'/damage {GUEST_NAME} 4: health {healed} -> {hit} ({said})'
|
|
armor = check_player_armor_components(host, guest)
|
|
return {'before': before, 'after': after, 'water': water, 'damage_command': [healed, hit],
|
|
'armor_components': armor}
|
|
|
|
|
|
def first_hurt(guest, before, ticks):
|
|
"""The guest's health once it drops below `before`, waiting at most `ticks`."""
|
|
health = remote(guest)['health']
|
|
for _ in range(ticks // 10):
|
|
if health < before:
|
|
break
|
|
guest.wait(10)
|
|
health = remote(guest)['health']
|
|
return health
|
|
|
|
|
|
def check_mobs(host, guest, out):
|
|
host('command', command='gamemode creative')
|
|
host('command', command='difficulty normal')
|
|
host('command', command='time set midnight')
|
|
guest('command', command='gamemode survival')
|
|
guest.wait(10)
|
|
hurt = {}
|
|
try:
|
|
for mob, distance in (('zombie', 3), ('skeleton', 7)):
|
|
x, y, z = pos_of(guest)
|
|
before = remote(guest)['health']
|
|
host('command', command=f'summon minecraft:{mob} {x:.2f} {y:.2f} {z + distance:.2f}')
|
|
low = first_hurt(guest, before, 300)
|
|
guest.shot(out, f'mobs-{mob}')
|
|
host.shot(out, f'mobs-{mob}')
|
|
host('command', command=f'kill @e[type=minecraft:{mob}]')
|
|
hurt[mob] = (before, low)
|
|
finally:
|
|
host('command', command='gamemode survival')
|
|
host('command', command='time set day')
|
|
for mob, (before, low) in hurt.items():
|
|
assert low < before, f'the {mob} never hurt the guest: health {before} -> {low} (all: {hurt})'
|
|
return hurt
|
|
|
|
|
|
def check_hazards(host, guest, out):
|
|
guest('command', command='gamemode survival')
|
|
# A fresh life: full health, so no regeneration eats food while the guest runs.
|
|
guest('command', command='kill @s')
|
|
guest.wait(20)
|
|
# ServerPlayer.doTick sends the dead player's health of 0 too.
|
|
died = {'screen': guest('state').get('screen'), 'health': remote(guest)['health']}
|
|
assert died == {'screen': 'dead', 'health': 0.0}, f'the guest was killed: {died}'
|
|
guest.raw('respawn')
|
|
guest.wait(40)
|
|
# Sprint-jumping down a stone lane: ServerPlayer.jumpFromGround and checkMovementStatistics.
|
|
guest('command', command='fill ~ ~-1 ~-1 ~60 ~-1 ~1 minecraft:stone')
|
|
guest('command', command='fill ~ ~ ~-1 ~60 ~3 ~1 minecraft:air')
|
|
guest.wait(10)
|
|
x, y, z = pos_of(guest)
|
|
guest.raw('look-at', f'{x + 60:.2f}', f'{y + 1.6:.2f}', f'{z:.2f}')
|
|
food = lan_guest(host)['food']
|
|
for k in ('w', 'ctrl', 'space'):
|
|
guest.raw('key', k, 'down')
|
|
guest.wait(160)
|
|
for k in ('space', 'ctrl', 'w'):
|
|
guest.raw('key', k, 'up')
|
|
guest.wait(10)
|
|
fed = lan_guest(host)['food']
|
|
hunger = {'before': food, 'after': fed}
|
|
hurt = {}
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
x, y, z = hx + 3, hy, hz
|
|
for name, fill, ticks in (('lava', 'minecraft:lava', 60), ('wall', 'minecraft:stone', 60),
|
|
('drowning', 'minecraft:water', 500), ('void', None, 60)):
|
|
if dead(guest):
|
|
guest.raw('respawn')
|
|
guest.wait(40)
|
|
# A stone box around the guest holds the lava and the water in.
|
|
guest('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 1} {y + 2} {z + 1} minecraft:stone')
|
|
guest('command', command=f'fill {x} {y} {z} {x} {y + 1} {z} minecraft:air')
|
|
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
guest.wait(100)
|
|
before = remote(guest)['health']
|
|
if fill:
|
|
guest('command', command=f'fill {x} {y} {z} {x} {y + 1} {z} {fill}')
|
|
else:
|
|
guest('command', command=f'tp @s {x + 0.5} -140 {z + 0.5}')
|
|
hurt[name] = (before, first_hurt(guest, before, ticks))
|
|
guest.shot(out, f'hazards-{name}')
|
|
# The host tidies up: the void can kill the guest before this, the server drops a dead
|
|
# guest's packets (lan_guest_packet), and the box left standing suffocated it in pvp.
|
|
# Water puts the lava's fire out.
|
|
host('command', command=f'fill {x} {y} {z} {x} {y + 1} {z} minecraft:water')
|
|
guest.wait(5)
|
|
host('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 1} {y + 2} {z + 1} minecraft:air')
|
|
host('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 1} {y - 1} {z + 1} minecraft:stone')
|
|
guest.wait(20)
|
|
if dead(guest):
|
|
guest.raw('respawn')
|
|
guest.wait(40)
|
|
# [food, saturation, exhaustion]: 8 s of sprint-jumping is about 2 of exhaustion; every 4
|
|
# of it takes a point of saturation, or of food once saturation is gone (FoodData.tick).
|
|
spent = 4 * (food[0] + food[1] - fed[0] - fed[1]) + fed[2] - food[2]
|
|
assert spent > 1.0, f'no hunger from moving: {hunger} ({spent:.2f} spent)'
|
|
for name, (before, after) in hurt.items():
|
|
assert after < before, f'{name} never hurt the guest: health {before} -> {after} (all: {hurt})'
|
|
return {'died': died, 'hunger': hunger, **hurt}
|
|
|
|
|
|
def check_pvp(host, guest, out):
|
|
if dead(guest):
|
|
guest.raw('respawn')
|
|
guest.wait(40)
|
|
for g in (host, guest):
|
|
g('command', command='gamemode survival')
|
|
g('command', command='item replace entity @s weapon.mainhand with minecraft:iron_sword')
|
|
face_each_other(host, guest)
|
|
hits = {}
|
|
try:
|
|
for name, rule, attacker in (('guest hits host', 'true', guest), ('host hits guest', 'true', host),
|
|
('pvp off', 'false', guest), ('guest hits teammate', 'true', guest),
|
|
('host hits teammate', 'true', host)):
|
|
host('command', command=f'gamerule pvp {rule}')
|
|
if name == 'guest hits teammate':
|
|
# Player.canHarmPlayer: the victim's team without friendly fire spares its allies.
|
|
host('command', command='team add parity_allies')
|
|
host('command', command='team modify parity_allies friendlyFire false')
|
|
host('command', command='team join parity_allies @a')
|
|
face_each_other(host, guest)
|
|
attacker.wait(30) # a full attack charge
|
|
victim = (lambda: host('state')['player']['health']) if attacker is guest else (lambda: remote(guest)['health'])
|
|
before = victim()
|
|
attacker.raw('attack')
|
|
attacker.wait(10)
|
|
hits[name] = (before, victim())
|
|
attacker.shot(out, 'pvp-' + name.replace(' ', '-'))
|
|
finally:
|
|
host('command', command='gamerule pvp true')
|
|
host('command', command='team remove parity_allies')
|
|
assert hits['guest hits host'][1] < hits['guest hits host'][0], f'the guest did not hurt the host: {hits}'
|
|
assert hits['host hits guest'][1] < hits['host hits guest'][0], f'the host did not hurt the guest: {hits}'
|
|
# Natural regeneration may heal the victim meanwhile.
|
|
assert hits['pvp off'][1] >= hits['pvp off'][0], f'pvp off still hurts: {hits}'
|
|
for name in ('guest hits teammate', 'host hits teammate'):
|
|
assert hits[name][1] == hits[name][0], f'friendly fire off still hurts ({name}): {hits}'
|
|
return hits
|
|
|
|
|
|
def leave_game_total(host):
|
|
"""The leave_game statistic summed over the stats files the host's world holds."""
|
|
total = 0
|
|
for path in (host.work / 'saves' / 'Lan' / 'players' / 'stats').glob('*.json'):
|
|
custom = json.loads(path.read_text()).get('stats', {}).get('minecraft:custom', {})
|
|
total += custom.get('minecraft:leave_game', 0)
|
|
return total
|
|
|
|
|
|
def check_leave(host, guest, out):
|
|
# PlayerList.remove awards Stats.LEAVE_GAME before it saves the player: a guest who leaves has it in
|
|
# their saved statistics, and it counts again the next time they leave.
|
|
port = host.raw('lan', 'status')['port']
|
|
before = leave_game_total(host)
|
|
counts = []
|
|
for _ in range(2):
|
|
guest.raw('disconnect')
|
|
for _ in range(20):
|
|
time.sleep(0.5)
|
|
if leave_game_total(host) > (counts[-1] if counts else before):
|
|
break
|
|
counts.append(leave_game_total(host))
|
|
guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME)
|
|
guest.raw('wait-for', 'loaded', '--timeout', 300)
|
|
guest.wait(20)
|
|
assert counts == [before + 1, before + 2], f'leave_game was {before}, then {counts} after two leaves'
|
|
return {'leave_game': counts}
|
|
|
|
|
|
def check_kills(host, guest, out):
|
|
# A mob the guest kills is the guest's kill (#484): LivingEntity.die credits lastHurtByPlayer, so the
|
|
# killed statistic (and its scoreboard criterion) counts for the guest, not the host.
|
|
host('command', command='scoreboard objectives add parity_kills minecraft.killed:minecraft.pig')
|
|
for g in (host, guest):
|
|
g('command', command='gamemode survival')
|
|
guest('command', command='item replace entity @s weapon.mainhand with minecraft:iron_sword')
|
|
face_each_other(host, guest)
|
|
hx, hy, hz = pos_of(host)
|
|
try:
|
|
# Between the two, in front of the guest, with one health left.
|
|
host('command', command=f'summon minecraft:pig {hx + 1.5:.2f} {hy:.2f} {hz:.2f} {{NoAI:1b,Health:1.0f}}')
|
|
guest.wait(30)
|
|
guest.raw('look-at', f'{hx + 1.5:.2f}', f'{hy + 0.5:.2f}', f'{hz:.2f}')
|
|
guest.wait(10)
|
|
guest.raw('attack')
|
|
guest.wait(20)
|
|
host('command', command=f'scoreboard players get {GUEST_NAME} parity_kills')
|
|
host('command', command=f'scoreboard players get {host.player} parity_kills')
|
|
host.wait(10)
|
|
chat = host.raw('messages', 10)['chat']
|
|
guest.shot(out, 'kills')
|
|
finally:
|
|
host('command', command='kill @e[type=minecraft:pig]')
|
|
host('command', command='scoreboard objectives remove parity_kills')
|
|
assert any(f'{GUEST_NAME} has 1 [parity_kills]' in line for line in chat), f"the guest's kill is not theirs: {chat}"
|
|
assert not any(f'{host.player} has 1 [parity_kills]' in line for line in chat), f"the host got the guest's kill: {chat}"
|
|
return chat
|
|
|
|
|
|
def check_looting(host, guest, out):
|
|
"""Looting on the guest's sword adds to the drops of a mob they kill (#4496). Deaths are handled
|
|
in the server's tick, after the guest's swing, so the killer's weapon has to be read from that
|
|
guest, not from the server's own player. Looting 255 adds round(255 x uniform(0, 1)) porkchops
|
|
to the pig's 1-3, so a pig dropping 3 or fewer is the bug (or a 1-in-100 roll: two tries)."""
|
|
for g in (host, guest):
|
|
g('command', command='gamemode survival')
|
|
guest('command', command='item replace entity @s weapon.mainhand with '
|
|
'minecraft:iron_sword[minecraft:enchantments={"minecraft:looting":255}]')
|
|
face_each_other(host, guest)
|
|
hx, hy, hz = pos_of(host)
|
|
counts = []
|
|
|
|
def porkchops():
|
|
# On the ground, or already picked up by either player (the pig dies at the guest's feet).
|
|
items = host.raw('entities', 8, 'minecraft:item')['entities']
|
|
ground = sum(e['item']['count'] for e in items if e['item']['item'] == 'minecraft:porkchop')
|
|
return ground + sum(g('inventory')['counts'].get('minecraft:porkchop', 0) for g in (host, guest))
|
|
|
|
try:
|
|
for _ in range(2):
|
|
for g in (host, guest):
|
|
g('command', command='clear @s minecraft:porkchop')
|
|
host('command', command=f'summon minecraft:pig {hx + 1.5:.2f} {hy:.2f} {hz:.2f} {{NoAI:1b,Health:1.0f}}')
|
|
guest.wait(30)
|
|
guest.raw('look-at', f'{hx + 1.5:.2f}', f'{hy + 0.5:.2f}', f'{hz:.2f}')
|
|
guest.wait(10)
|
|
guest.raw('attack')
|
|
guest.wait(30)
|
|
counts.append(porkchops())
|
|
host('command', command='kill @e[type=minecraft:item]')
|
|
if counts[-1] > 3:
|
|
break
|
|
guest.shot(out, 'looting')
|
|
finally:
|
|
host('command', command='kill @e[type=minecraft:pig]')
|
|
host('command', command='kill @e[type=minecraft:item]')
|
|
assert counts[-1] > 3, f"Looting 255 on the guest's sword added nothing: porkchops {counts}"
|
|
return counts
|
|
|
|
|
|
def check_arrows(host, guest, out):
|
|
# A player's arrow on another player (#484): AbstractArrow.onHitEntity, refused by
|
|
# ServerPlayer.canHarmPlayer with the pvp game rule off (the arrow turns back).
|
|
for g in (host, guest):
|
|
g('command', command='gamemode survival')
|
|
g('command', command='clear @s')
|
|
g('command', command='item replace entity @s weapon.mainhand with minecraft:bow')
|
|
g('command', command='give @s minecraft:arrow 16')
|
|
hits = {}
|
|
try:
|
|
for name, rule, shooter in (('guest shoots host', 'true', guest), ('host shoots guest', 'true', host),
|
|
('pvp off', 'false', guest)):
|
|
host('command', command=f'gamerule pvp {rule}')
|
|
for g in (host, guest):
|
|
g('command', command='effect give @s minecraft:instant_health 1 5')
|
|
face_each_other(host, guest)
|
|
shooter.wait(20)
|
|
victim = (lambda: host('state')['player']['health']) if shooter is guest else (lambda: remote(guest)['health'])
|
|
before = victim()
|
|
shooter.raw('use-item', '--hold', 1.3) # a full draw (BowItem: 20 ticks)
|
|
shooter.wait(20)
|
|
hits[name] = (before, victim())
|
|
shooter.shot(out, 'arrows-' + name.replace(' ', '-'))
|
|
finally:
|
|
host('command', command='gamerule pvp true')
|
|
for g in (host, guest):
|
|
g('command', command='clear @s')
|
|
assert hits['guest shoots host'][1] < hits['guest shoots host'][0], f"the guest's arrow did not hurt the host: {hits}"
|
|
assert hits['host shoots guest'][1] < hits['host shoots guest'][0], f"the host's arrow did not hurt the guest: {hits}"
|
|
assert hits['pvp off'][1] >= hits['pvp off'][0], f'pvp off, an arrow still hurts: {hits}'
|
|
return hits
|
|
|
|
|
|
def command_says(player, command):
|
|
"""What the server printed back to `command` (the lines that are its own reply)."""
|
|
since = player.raw('events')['last']
|
|
player('command', command=command)
|
|
for _ in range(40):
|
|
lines = [e.get('text', '') for e in player.raw('events', '--since', since)['events'] if own_reply(e)]
|
|
if lines:
|
|
return ' '.join(lines)
|
|
player.wait(2)
|
|
raise Failed(f'no answer to {command}')
|
|
|
|
|
|
def check_projectile_vehicles(host, guest, out):
|
|
"""Every projectile kind strikes a boat or a minecart (`VehicleEntity.isPickable`), and
|
|
`VehicleEntity.hurtServer` takes their damage (+10 per point, destroyed past 40): an arrow of speed 3
|
|
(damage 6) and a trident (8) destroy the boat or cart; a snowball, an egg and an ender pearl (0) stop at it
|
|
and leave it whole. Before, boats were no target at all and carts only for arrows: the rest flew on through."""
|
|
x, y, z = apart(host, guest)['host']
|
|
host('command', command='gamemode creative')
|
|
host('command', command=f'fill {x - 2} {y} {z - 4} {x + 3} {y + 3} {z + 7} minecraft:air')
|
|
host('command', command=f'fill {x - 2} {y - 1} {z - 4} {x + 3} {y - 1} {z + 7} minecraft:stone')
|
|
host('command', command=f'forceload add {x - 2} {z - 4} {x + 3} {z + 7}')
|
|
still = 'NoGravity:1b,Silent:1b'
|
|
result = {}
|
|
try:
|
|
for shot, speed, target, destroyed in (
|
|
('arrow', 3.0, 'oak_boat', True), ('arrow', 3.0, 'minecart', True),
|
|
('trident', 1.5, 'oak_boat', True), ('trident', 1.5, 'minecart', True),
|
|
('snowball', 1.5, 'oak_boat', False), ('snowball', 1.5, 'minecart', False),
|
|
('egg', 1.5, 'oak_boat', False), ('egg', 1.5, 'minecart', False),
|
|
('ender_pearl', 1.5, 'oak_boat', False), ('ender_pearl', 1.5, 'minecart', False),
|
|
('arrow', 0.5, 'oak_boat', False)):
|
|
name = f'{shot}-{speed}-{target}'
|
|
host('command', command=f'kill @e[type=!minecraft:player,x={x},y={y},z={z},distance=..30]')
|
|
host('command', command=f'summon minecraft:{target} {x + 2.5} {y + 0.1} {z + 3.5} {{{still},Tags:["hv_v"]}}')
|
|
host('command', command=f'summon minecraft:{shot} {x + 2.5} {y + 0.35} {z - 2.5} '
|
|
f'{{{still},Tags:["hv_s"],Motion:[0.0d,0.0d,{speed}d]}}')
|
|
host.wait(40)
|
|
vehicles = [o for o in host('status')['objects'] if o['kind'].endswith(target)
|
|
and abs(o['position'][2] - (z + 3.5)) < 4]
|
|
shot_left = command_says(host, f'execute if entity @e[tag=hv_s]')
|
|
result[name] = {'vehicle left': len(vehicles), 'projectile still flying': 'passed' in shot_left}
|
|
where = command_says(host, 'data get entity @e[tag=hv_s,limit=1] Pos') if shot_left and 'passed' in shot_left else ''
|
|
assert (not vehicles) == destroyed, (f'{name}: the {target} {"stays" if destroyed else "was destroyed"}: '
|
|
f'{result[name]}, projectile {where}')
|
|
if shot == 'trident':
|
|
# ThrownTrident.onHitEntity: REVERSE (-0.5) times 0.02, so it leaves at -1/100 of its speed
|
|
# (a trident that flew on, or bounced twice as hard, has a Motion far from that).
|
|
reply = command_says(host, 'data get entity @e[tag=hv_s,limit=1] Motion[2]')
|
|
back = float(reply.rsplit(':', 1)[1].strip().rstrip('d'))
|
|
result[name]['bounce'] = back
|
|
assert -0.02 < back < 0, f'{name}: the trident leaves the {target} at Motion z {back}, vanilla\'s is -0.015'
|
|
# An arrow or snowball is spent on the hit; a trident bounces off and stays.
|
|
assert shot == 'trident' or 'passed' not in shot_left, \
|
|
f'{name}: the {shot} flew through or past the {target}: {result[name]}'
|
|
finally:
|
|
host('command', command=f'kill @e[type=!minecraft:player,x={x},y={y},z={z},distance=..30]')
|
|
host('command', command=f'forceload remove {x - 2} {z - 4} {x + 3} {z + 7}')
|
|
return result
|
|
|
|
|
|
def check_flame_arrows(host, guest, out):
|
|
# BowItem.releaseUsing -> EnchantmentHelper.onProjectileSpawned: Flame sets the arrow on fire, and
|
|
# Entity.baseTick's setSharedFlagOnFire sends it, so every client draws the flames (#7356).
|
|
spots = apart(host, guest)
|
|
seen = {}
|
|
try:
|
|
for label, shooter, other in (('host', host, guest), ('guest', guest, host)):
|
|
x, y, z = spots[label]
|
|
shooter('command', command='gamemode survival')
|
|
for bow in ('flame', 'plain'):
|
|
item = 'minecraft:bow[minecraft:enchantments={"minecraft:flame":1}]' if bow == 'flame' else 'minecraft:bow'
|
|
shooter('command', command=f'item replace entity @s weapon.mainhand with {item}')
|
|
shooter('command', command='give @s minecraft:arrow 1')
|
|
shooter.wait(10)
|
|
# Straight up into open sky: it slows down over the shooter, where the other player looks.
|
|
shooter.raw('look-at', f'{x + 0.5:.2f}', f'{y + 60:.2f}', f'{z + 0.52:.2f}')
|
|
shooter.raw('use-item', '--hold', 1.3) # a full draw (BowItem: 20 ticks); returns at once
|
|
case = {'own': set(), 'other': set()}
|
|
shot = False
|
|
for _ in range(40):
|
|
shooter.wait(2)
|
|
for side, game in (('own', shooter), ('other', other)):
|
|
for p in game('status')['projectiles']:
|
|
if p['kind'] != 'Arrow' or abs(p['position'][0] - (x + 0.5)) > 4:
|
|
continue
|
|
case[side].add(bool(p.get('on_fire')))
|
|
# Near the top of its flight: the other player looks at it and shows it.
|
|
if side == 'other' and not shot and abs(p['velocity'][1]) < 0.6:
|
|
other.raw('look-at', *(f'{v:.2f}' for v in p['position']))
|
|
other.wait(1)
|
|
other.shot(out, f'flame-arrows-{label}-{bow}')
|
|
shot = True
|
|
if shot:
|
|
break
|
|
seen[f'{label} {bow}'] = {k: sorted(v) for k, v in case.items()}
|
|
shooter('command', command='kill @e[type=minecraft:arrow]')
|
|
shooter.wait(5)
|
|
finally:
|
|
for g in (host, guest):
|
|
g('command', command='clear @s')
|
|
for name, case in seen.items():
|
|
want = [True] if name.endswith('flame') else [False]
|
|
assert case['own'] == want and case['other'] == want, f'{name}: on_fire seen in flight {seen}'
|
|
return seen
|
|
|
|
|
|
def check_deaths(host, guest, out):
|
|
def other_player(watcher):
|
|
"""The other player's entity id as `watcher` draws it: aiming by an entity, not by
|
|
coordinates, holds when a just-respawned client's position lags the server's."""
|
|
for _ in range(20):
|
|
found = watcher.raw('entities', 6, 'player')['entities']
|
|
if found:
|
|
return found[0]['id']
|
|
watcher.wait(5)
|
|
raise AssertionError(f'{watcher.name} does not draw the other player')
|
|
|
|
def ready():
|
|
for g in (host, guest):
|
|
if g('state').get('screen') == 'dead':
|
|
g.raw('respawn')
|
|
g.wait(40)
|
|
back_to_game(g)
|
|
g('command', command='effect give @s minecraft:instant_health 1 5')
|
|
host('command', command='execute at @s run fill ~-5 200 ~-5 ~5 200 ~5 minecraft:stone')
|
|
host('command', command='tp @s ~ 201 ~')
|
|
host('command', command=f'tp {GUEST_NAME} {host.player}')
|
|
guest('command', command='tp @s ~3 ~ ~')
|
|
host.wait(20)
|
|
host.raw('look-at-entity', other_player(host))
|
|
guest.raw('look-at-entity', other_player(guest))
|
|
host.wait(5)
|
|
|
|
said = {}
|
|
try:
|
|
host('command', command='gamerule pvp true')
|
|
host('command', command='gamerule natural_health_regeneration false')
|
|
for g in (host, guest):
|
|
g('command', command='gamemode survival')
|
|
g('command', command='clear @s')
|
|
g('command', command='item replace entity @s armor.chest with minecraft:air')
|
|
host('command', command='item replace entity @s weapon.mainhand with minecraft:iron_sword')
|
|
ready()
|
|
host('command', command=f'damage {GUEST_NAME} 18 minecraft:generic')
|
|
host.wait(30)
|
|
host.raw('attack')
|
|
host.wait(20)
|
|
said['sword'] = host.raw('messages', 10)['chat']
|
|
host.shot(out, 'deaths-sword')
|
|
guest('command', command='item replace entity @s weapon.mainhand with minecraft:iron_sword')
|
|
ready()
|
|
host('command', command=f'damage {host.player} 18 minecraft:generic')
|
|
guest.wait(30)
|
|
guest.raw('attack')
|
|
guest.wait(20)
|
|
said['host_death'] = host.raw('messages', 10)['chat']
|
|
said['host_death_behind_guest'] = guest.raw('messages', 10)['chat']
|
|
guest.shot(out, 'deaths-host')
|
|
host('command', command='item replace entity @s weapon.mainhand with minecraft:bow')
|
|
host('command', command='give @s minecraft:arrow 16')
|
|
ready()
|
|
host('command', command=f'damage {GUEST_NAME} 18 minecraft:generic')
|
|
host.raw('use-item', '--hold', 1.3)
|
|
host.wait(20)
|
|
said['arrow'] = host.raw('messages', 10)['chat']
|
|
guest('command', command='item replace entity @s weapon.mainhand with minecraft:bow')
|
|
guest('command', command='give @s minecraft:arrow 16')
|
|
ready()
|
|
host('command', command=f'damage {host.player} 18 minecraft:generic')
|
|
guest.raw('use-item', '--hold', 1.3)
|
|
guest.wait(20)
|
|
said['arrow_behind_guest'] = guest.raw('messages', 10)['chat']
|
|
host('command', command='item replace entity @s weapon.mainhand with minecraft:air')
|
|
host('command', command='item replace entity @s armor.chest with '
|
|
'minecraft:diamond_chestplate[minecraft:enchantments={"minecraft:thorns":255}]')
|
|
ready()
|
|
hx, hy, hz = pos_of(host)
|
|
for _ in range(8):
|
|
if dead(guest):
|
|
break
|
|
guest('command', command='effect give @s minecraft:instant_health 1 5')
|
|
host('command', command=f'tp @s {hx:.2f} {hy:.2f} {hz:.2f}')
|
|
host('command', command=f'tp {GUEST_NAME} {host.player}')
|
|
guest('command', command='tp @s ~3 ~ ~')
|
|
host('command', command=f'damage {GUEST_NAME} 19 minecraft:generic')
|
|
guest.wait(30)
|
|
guest.raw('look-at-entity', other_player(guest))
|
|
guest.raw('attack')
|
|
guest.wait(10)
|
|
said['thorns'] = host.raw('messages', 10)['chat']
|
|
host.shot(out, 'deaths-thorns')
|
|
host('command', command='item replace entity @s weapon.mainhand with minecraft:flint_and_steel')
|
|
ready()
|
|
gx, gy, gz = pos_of(guest)
|
|
host('command', command=f'tp @s {gx - 4:.2f} {gy:.2f} {gz:.2f}')
|
|
bx, by, bz = int(gx) - 1, int(gy), int(gz)
|
|
host('command', command=f'setblock {bx} {by} {bz} minecraft:tnt')
|
|
host.raw('look-at', f'{bx + 0.5:.2f}', f'{by + 0.5:.2f}', f'{bz + 0.5:.2f}')
|
|
host.wait(2)
|
|
host.raw('use-item')
|
|
guest.wait(100)
|
|
said['tnt'] = host.raw('messages', 10)['chat']
|
|
host.shot(out, 'deaths-tnt')
|
|
ready()
|
|
hx, hy, hz = pos_of(host)
|
|
host('command', command='effect give @s minecraft:resistance 10 5')
|
|
host('command', command=f'summon minecraft:pig {hx + 3:.2f} {hy:.2f} {hz:.2f} {{NoAI:1b}}')
|
|
host('command', command=f'setblock {int(hx) + 2} {int(hy)} {int(hz)} minecraft:tnt')
|
|
host('command', command='item replace entity @s weapon.mainhand with minecraft:flint_and_steel')
|
|
host.raw('look-at', f'{int(hx) + 2.5:.2f}', f'{int(hy) + 0.5:.2f}', f'{int(hz) + 0.5:.2f}')
|
|
host.wait(2)
|
|
host.raw('use-item')
|
|
host.wait(100)
|
|
said['blast_pig'] = host.raw('entities', 6, 'pig')['entities']
|
|
host.shot(out, 'deaths-blast-pig')
|
|
finally:
|
|
host('command', command='gamerule natural_health_regeneration true')
|
|
for g in (host, guest):
|
|
g('command', command='item replace entity @s armor.chest with minecraft:air')
|
|
g('command', command='clear @s')
|
|
name = host.player
|
|
want = {'sword': f'{GUEST_NAME} was slain by {name}',
|
|
'host_death': f'{name} was slain by {GUEST_NAME}',
|
|
'host_death_behind_guest': f'{name} was slain by {GUEST_NAME}',
|
|
'arrow': f'{GUEST_NAME} was shot by {name}',
|
|
'arrow_behind_guest': f'{name} was shot by {GUEST_NAME}',
|
|
'thorns': f'{GUEST_NAME} was killed while trying to hurt {name}',
|
|
'tnt': f'{GUEST_NAME} was blown up by {name}'}
|
|
for case, line in want.items():
|
|
assert line in said[case], f'no "{line}" in the {case} chat: {said[case]}'
|
|
assert not said['blast_pig'], f'the blast left the pig standing: {said["blast_pig"]}'
|
|
for case, lines in said.items():
|
|
assert not any('%1$s' in line or '%2$s' in line for line in lines), \
|
|
f'a raw template in the {case} chat: {lines}'
|
|
return said
|
|
|
|
def dead_players(game, want, ticks=60):
|
|
"""The other players `game`'s client draws dead, once that is `want` (empty or not) or after `ticks`."""
|
|
for _ in range(ticks // 5):
|
|
dead = remote(game)['dead_players']
|
|
if bool(dead) == want:
|
|
return dead
|
|
game.wait(5)
|
|
return dead
|
|
|
|
|
|
def check_respawn(host, guest, out):
|
|
# ServerLevel.removePlayerImmediately and addRespawnedPlayer: a respawned player is removed
|
|
# from the other players and paired again as a new entity, so their clients drop the body.
|
|
# The host window only has a client's view of the guest on the server thread.
|
|
for g in (host, guest):
|
|
back_to_game(g)
|
|
results = {}
|
|
sides = [('host', host, guest), ('guest', guest, host)]
|
|
for name, dying, watching in sides:
|
|
face_each_other(host, guest)
|
|
dying('command', command='kill @s')
|
|
dying.wait(30)
|
|
dead = dead_players(watching, True)
|
|
watching.shot(out, f'respawn-{name}-dead')
|
|
dying.raw('respawn')
|
|
dying.wait(40)
|
|
back_to_game(dying)
|
|
# Back within tracking range of the other player, wherever the respawn point is.
|
|
x, y, z = pos_of(dying)
|
|
watching('command', command=f'tp @s {x + 3:.2f} {y:.2f} {z:.2f}')
|
|
watching.wait(40)
|
|
alive = dead_players(watching, False)
|
|
watching.shot(out, f'respawn-{name}-alive')
|
|
results[name] = {'dead': dead, 'after_respawn': alive}
|
|
assert dead, f'the {name} was not drawn dead: {results}'
|
|
assert not alive, f'the {name} still lies dead after respawning: {results}'
|
|
return results
|
|
|
|
|
|
def check_hurt(host, guest, out):
|
|
for g in (host, guest):
|
|
g('command', command='gamemode survival')
|
|
g('command', command='clear @s')
|
|
face_each_other(host, guest)
|
|
hx, hy, hz = pos_of(host)
|
|
# Between the two, within both players' reach; bare hands leave it alive.
|
|
host('command', command=f'summon minecraft:pig {hx + 1.5:.2f} {hy:.2f} {hz + 0.8:.2f} {{NoAI:1b}}')
|
|
host.wait(30)
|
|
|
|
def pig(game):
|
|
return game.raw('entities', 6, 'pig')['entities'][0]
|
|
seen = {}
|
|
for name, attacker, watcher in (('guest hits', guest, host), ('host hits', host, guest)):
|
|
attacker.wait(30) # a full attack charge
|
|
attacker.raw('attack', pig(attacker)['id'])
|
|
seen[name] = {'attacker': pig(attacker)['hurt_time'], 'watcher': pig(watcher)['hurt_time']}
|
|
attacker.shot(out, 'hurt-' + name.replace(' ', '-'))
|
|
watcher.shot(out, 'hurt-' + name.replace(' ', '-') + '-seen')
|
|
attacker.wait(20)
|
|
host('command', command='kill @e[type=minecraft:pig]')
|
|
# A hurt player flashes red for the other one too (#2827).
|
|
watched = (('host hurt', host, guest), ('guest hurt', guest, host))
|
|
for name, hurt, watcher in watched:
|
|
hurt.wait(30)
|
|
# Instant damage hurts on the next effect tick.
|
|
hurt('command', command='effect give @s minecraft:instant_damage 1 0')
|
|
for _ in range(10):
|
|
other = watcher.raw('entities', 6, 'player')['entities'][0]
|
|
if other['hurt_time'] > 0:
|
|
break
|
|
watcher.wait(1)
|
|
seen[name] = {'watcher': other['hurt_time']}
|
|
watcher.shot(out, 'hurt-' + name.replace(' ', '-') + '-seen')
|
|
# LivingEntity.hurtTime: 10 when hurt, one less each tick; the reads come a few ticks later.
|
|
for name, sides in seen.items():
|
|
assert all(t > 0 for t in sides.values()), f'{name}: the hurt flash {seen}'
|
|
return seen
|
|
|
|
|
|
def mob_looks(game, centre):
|
|
"""What `check_looks` compares of each mob around `centre`, by kind and column."""
|
|
found = {}
|
|
for m in game.raw('mobs', 32).get('near', []):
|
|
if abs(m['pos'][0] - centre[0]) > 12 or abs(m['pos'][2] - centre[2]) > 14:
|
|
continue
|
|
key = f"{m['kind'].removeprefix('minecraft:')}@{round(m['pos'][0] - centre[0])}"
|
|
found[key] = {'baby': m['baby'], 'color': m['color'], 'sheared': m['sheared'], 'size': m['size'],
|
|
'tame': m['tame'], 'sitting': m['sitting'], 'burning': m['burning'],
|
|
'health': round(m['health'])}
|
|
return found
|
|
|
|
|
|
def check_looks(host, guest, out):
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
centre = (x + 0.5, y, z + 0.5)
|
|
# Each mob in a column of its own (the key), standing on the stone floor.
|
|
looks = {
|
|
'cow@-5': ('{NoAI:1b,Fire:80s}', {'burning': True}),
|
|
'cow@-3': ('{NoAI:1b,Age:-24000}', {'baby': True}),
|
|
'cow@-1': ('{NoAI:1b}', {'baby': False}),
|
|
'sheep@1': ('{NoAI:1b,Color:14b,Sheared:1b}', {'color': 14, 'sheared': True}),
|
|
'slime@3': ('{NoAI:1b,Size:3}', {'size': 4}),
|
|
'wolf@5': ('{NoAI:1b,Owner:[I;1,2,3,4],Sitting:1b,CollarColor:11b}',
|
|
{'tame': True, 'sitting': True, 'color': 11}),
|
|
}
|
|
sides = {'host': host, 'guest': guest}
|
|
seen = {}
|
|
try:
|
|
for key, (tag, _) in looks.items():
|
|
kind, dx = key.split('@')
|
|
host('command', command=f'summon minecraft:{kind} {centre[0] + int(dx)} {y} {z + 4.5} {tag}')
|
|
host('command', command=f'summon minecraft:wither {centre[0]} {y + 3} {z + 10.5} {{NoAI:1b,Invul:0}}')
|
|
host.wait(30)
|
|
seen['spawned'] = {name: mob_looks(g, centre) for name, g in sides.items()}
|
|
# Changes after the spawn: the wither hurt, the sheep dyed and grown back, the calf grown up.
|
|
host('command', command='damage @e[type=minecraft:wither,limit=1] 150 minecraft:generic')
|
|
for kind, dx, tag in (('sheep', 1, '{Color:11b,Sheared:0b}'), ('cow', -3, '{Age:0}')):
|
|
host('command', command=f'data merge entity @e[type=minecraft:{kind},x={centre[0] + dx},y={y},'
|
|
f'z={z + 4.5},distance=..0.5,limit=1] {tag}')
|
|
guest.raw('look-at', centre[0], y + 1, z + 4.5)
|
|
host.raw('look-at', centre[0], y + 1, z + 4.5)
|
|
# The fire (80 ticks) goes out in the meantime.
|
|
host.wait(80)
|
|
for name, g in sides.items():
|
|
g.shot(out, 'looks')
|
|
seen['later'] = {name: mob_looks(g, centre) for name, g in sides.items()}
|
|
finally:
|
|
# A killed slime splits: shrink it first so no children are left hopping into the next check.
|
|
host('command', command=f'data merge entity @e[type=minecraft:slime,x={centre[0] + 3},y={y},z={z + 4.5},'
|
|
f'distance=..2,limit=1] {{Size:0}}')
|
|
host('command', command=f'kill @e[type=!minecraft:player,x={x - 12},y={y - 2},z={z - 2},dx=24,dy=12,dz=16]')
|
|
wrong = []
|
|
for name in sides:
|
|
spawned, later = seen['spawned'][name], seen['later'][name]
|
|
for key, (_, want) in looks.items():
|
|
got = spawned.get(key)
|
|
if got is None or any(got[k] != v for k, v in want.items()):
|
|
wrong.append(f'{name} {key}: {got}, wants {want}')
|
|
if later.get('cow@-5', {}).get('burning') is not False:
|
|
wrong.append(f"{name}: the cow's fire never went out: {later.get('cow@-5')}")
|
|
if [later.get('sheep@1', {}).get(k) for k in ('color', 'sheared')] != [11, False]:
|
|
wrong.append(f"{name}: the sheep dyed blue with its wool back: {later.get('sheep@1')}")
|
|
if later.get('cow@-3', {}).get('baby') is not False:
|
|
wrong.append(f"{name}: the calf grown up: {later.get('cow@-3')}")
|
|
withers = [v['health'] for k, v in spawned.items() if k.startswith('wither')]
|
|
hurt = [v['health'] for k, v in later.items() if k.startswith('wither')]
|
|
if withers != [300] or hurt != [150]:
|
|
wrong.append(f"{name}: the wither's health {withers} then {hurt}, wants [300] then [150]")
|
|
assert not wrong, '\n'.join(wrong)
|
|
return seen['later']['guest']
|
|
|
|
|
|
def tag_check_says(player, command):
|
|
"""What the server printed back to `command` (the lines that are its own reply)."""
|
|
since = player.raw('events')['last']
|
|
player('command', command=command)
|
|
for _ in range(40):
|
|
lines = [e.get('text', '') for e in player.raw('events', '--since', since)['events'] if own_reply(e)]
|
|
if lines:
|
|
return ' '.join(lines)
|
|
player.wait(2)
|
|
raise Failed(f'no answer to {command}')
|
|
|
|
|
|
def check_entity_tags(host, guest, out):
|
|
"""`/tag add` reaches a thrown trident, a firework rocket and a falling block, and `@e[tag=]` finds them:
|
|
before, `/tag` wrote nothing for these three and still reported success."""
|
|
x, y, z = apart(host, guest)['host']
|
|
host('command', command=f'fill {x - 2} {y} {z - 4} {x + 3} {y + 3} {z + 7} minecraft:air')
|
|
host('command', command=f'fill {x - 2} {y - 1} {z - 4} {x + 3} {y - 1} {z + 7} minecraft:stone')
|
|
host('command', command=f'forceload add {x - 2} {z - 4} {x + 3} {z + 7}')
|
|
kinds = (('trident', ''), ('firework_rocket', 'LifeTime:200,'),
|
|
('falling_block', 'BlockState:{Name:"minecraft:sand"},Time:1,'))
|
|
result = {}
|
|
try:
|
|
for n, (kind, extra) in enumerate(kinds):
|
|
at = f'{x + 0.5} {y + 2} {z + 0.5 + n}'
|
|
host('command', command=f'summon minecraft:{kind} {at} {{{extra}NoGravity:1b,Silent:1b}}')
|
|
host('command', command=f'tag @e[type=minecraft:{kind},x={x + 0.5},y={y + 2},z={z + 0.5 + n},distance=..1,limit=1] add hv_tag{n}')
|
|
host.wait(4)
|
|
says = tag_check_says(host, f'execute if entity @e[tag=hv_tag{n}]')
|
|
result[kind] = says
|
|
assert 'passed' in says, f'/tag add on a {kind} did not reach @e[tag=]: {says}'
|
|
finally:
|
|
host('command', command=f'kill @e[type=!minecraft:player,x={x},y={y},z={z},distance=..30]')
|
|
host('command', command=f'forceload remove {x - 2} {z - 4} {x + 3} {z + 7}')
|
|
return result
|
|
|
|
|
|
def check_leads(host, guest, out):
|
|
spots = apart(host, guest)
|
|
result = {}
|
|
for label, player, other in (('host', host, guest), ('guest', guest, host)):
|
|
x, y, z = spots[label]
|
|
at = (x + 0.5, y, z + 2.5)
|
|
|
|
def cow(game):
|
|
found = [m for m in game.raw('mobs', 32).get('near', []) if m['kind'] == 'minecraft:cow'
|
|
and abs(m['pos'][0] - at[0]) < 1 and abs(m['pos'][2] - at[2]) < 1]
|
|
assert found, f'{game.name} sees no cow at {at}'
|
|
return found[0]
|
|
player('command', command='gamemode survival')
|
|
player('command', command=f'summon minecraft:cow {at[0]} {y} {at[2]} {{NoAI:1b}}')
|
|
player('command', command='item replace entity @s weapon.mainhand with minecraft:lead')
|
|
player.wait(30)
|
|
try:
|
|
player.raw('look-at', f'{at[0]:.2f}', f'{y + 0.7:.2f}', f'{at[2]:.2f}')
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(20)
|
|
tied = {'own': cow(player)['leash'], 'other': cow(other)['leash']}
|
|
player.shot(out, 'leads-' + label)
|
|
other.shot(out, f'leads-{label}-seen')
|
|
# An empty hand unties it (the lead was used up).
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(20)
|
|
untied = {'own': cow(player)['leash'], 'other': cow(other)['leash']}
|
|
finally:
|
|
player('command', command=f'kill @e[type=!minecraft:player,x={at[0]},y={y},z={at[2]},distance=..6]')
|
|
player('command', command='clear @s')
|
|
result[label] = {'tied': tied, 'untied': untied}
|
|
# The other side draws it held by the player who tied it (another player to it).
|
|
assert tied['own'] == 'Player' and tied['other'], f"the {label}'s cow on its lead: {tied}"
|
|
assert untied == {'own': None, 'other': None}, f"the {label}'s cow untied: {untied}"
|
|
return result
|
|
|
|
|
|
def container_items(game):
|
|
"""The open container's stacks by slot (Horizon's container slots are 300 and up) and its size."""
|
|
slots = [s for s in game('screen').get('slots', []) if s.get('slot', 0) >= 300]
|
|
return {s['slot'] - 300: s['item'] for s in slots if s.get('item')}, len(slots)
|
|
|
|
|
|
def open_container(guest, pos, out, label, take=None):
|
|
"""Open the container at `pos` and read it; `take` shift-clicks that container slot out first."""
|
|
guest.raw('interact', *pos)
|
|
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
|
# The server's menu (an ender or double chest has no stand-in block entity: key i32::MIN),
|
|
# then its contents, can come a few ticks after the screen.
|
|
for _ in range(20):
|
|
guest.wait(2)
|
|
screen = guest('screen')
|
|
items = {s['slot']: s.get('item') for s in screen.get('slots', [])}
|
|
if screen.get('container') == [-2**31, 0, 0] and (take is None or items.get(300 + take)):
|
|
break
|
|
guest.wait(5)
|
|
if take is not None:
|
|
guest.raw('click-slot', 300 + take, 'shift')
|
|
guest.wait(5)
|
|
items = container_items(guest)
|
|
guest.shot(out, label)
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(5)
|
|
return items
|
|
|
|
|
|
def check_containers(host, guest, out):
|
|
host('command', command='item replace entity @s enderchest.0 with minecraft:diamond')
|
|
guest('command', command='item replace entity @s enderchest.4 with minecraft:emerald')
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
guest('command', command=f'setblock {x} {y} {z + 2} minecraft:ender_chest')
|
|
guest.wait(10)
|
|
ender, ender_size = open_container(guest, (x, y, z + 2), out, 'ender-chest')
|
|
# Facing north, a left half pairs with the block to its east (ChestBlock.getConnectedDirection).
|
|
guest('command', command=f'setblock {x} {y} {z - 2} minecraft:chest[facing=north,type=left]')
|
|
guest('command', command=f'setblock {x + 1} {y} {z - 2} minecraft:chest[facing=north,type=right]')
|
|
guest('command', command=f'item replace block {x + 1} {y} {z - 2} container.0 with minecraft:gold_ingot')
|
|
guest('command', command=f'item replace block {x} {y} {z - 2} container.0 with minecraft:iron_ingot')
|
|
guest.wait(10)
|
|
double, double_size = open_container(guest, (x, y, z - 2), out, 'double-chest')
|
|
# Taking from each writes back to the guest's ender inventory and to the right half.
|
|
guest('command', command='clear @s')
|
|
open_container(guest, (x, y, z + 2), out, 'ender-chest-take', take=4)
|
|
open_container(guest, (x, y, z - 2), out, 'double-chest-take', take=0)
|
|
taken = guest('inventory')['counts']
|
|
after = (open_container(guest, (x, y, z + 2), out, 'ender-chest-after')[0],
|
|
open_container(guest, (x, y, z - 2), out, 'double-chest-after')[0])
|
|
host_ender = host('command', command='data get entity @s EnderItems')
|
|
for at in ((x, y, z + 2), (x, y, z - 2), (x + 1, y, z - 2)):
|
|
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
|
|
assert ender == {4: 'minecraft:emerald'} and ender_size == 27, f'the guest\'s ender chest: {ender} ({ender_size})'
|
|
assert double_size == 54, f'the double chest opened with {double_size} slots'
|
|
assert double == {0: 'minecraft:gold_ingot', 27: 'minecraft:iron_ingot'}, f'the double chest: {double}'
|
|
assert 'emerald' not in json.dumps(host_ender), f'the guest\'s item went to the host: {host_ender}'
|
|
assert taken.get('minecraft:emerald') == 1 and taken.get('minecraft:gold_ingot') == 1, f'taken: {taken}'
|
|
assert after == ({}, {27: 'minecraft:iron_ingot'}), f'after taking: {after}'
|
|
return {'ender': ender, 'double': double, 'taken': taken}
|
|
|
|
|
|
def check_names(host, guest, out):
|
|
# BlockItem.updateBlockEntityComponents gives the item's custom_name to the block entity, and its
|
|
# menu opens with it as the title (ClientboundOpenScreenPacket), shulker boxes and copper chests too.
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
titles = {}
|
|
for i, item in enumerate(('red_shulker_box', 'copper_chest', 'chest')):
|
|
spot = (x - 2 + 2 * i, y, z + 2)
|
|
guest('command', command=f'setblock {spot[0]} {spot[1] - 1} {spot[2]} minecraft:stone')
|
|
guest('command', command=f'setblock {spot[0]} {spot[1]} {spot[2]} minecraft:air')
|
|
guest('command', command=f'give @s minecraft:{item}[minecraft:custom_name="Named {i}"]')
|
|
guest.wait(10)
|
|
guest.raw('place', *spot, '--item', f'minecraft:{item}')
|
|
guest.wait(10)
|
|
guest.raw('interact', *spot)
|
|
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
|
title = guest.until(lambda: guest('screen').get('title'), bool, ticks=40)
|
|
titles[item] = ''.join(span['text'] for span in title)
|
|
guest.shot(out, f'names-{item}')
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(5)
|
|
guest('command', command=f'setblock {spot[0]} {spot[1]} {spot[2]} minecraft:air')
|
|
want = {'red_shulker_box': 'Named 0', 'copper_chest': 'Named 1', 'chest': 'Named 2'}
|
|
assert titles == want, f'the titles of the renamed containers: {titles}'
|
|
return titles
|
|
|
|
|
|
def check_machines(host, guest, out):
|
|
# After `recipes`: the stacks given here unlock recipes, which would move the book's first one.
|
|
guest('command', command='gamemode survival')
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
at = (x, y, z + 2)
|
|
seen = {}
|
|
for block in ('hopper', 'dispenser', 'dropper'):
|
|
guest('command', command='clear @s')
|
|
guest('command', command='give @s minecraft:cobblestone 4')
|
|
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:{block}')
|
|
guest.wait(10)
|
|
guest.raw('interact', *at)
|
|
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
|
guest.wait(10)
|
|
size = container_items(guest)[1]
|
|
guest.shot(out, f'machines-{block}')
|
|
guest.raw('click-slot', hotbar_slot(guest, 'minecraft:cobblestone'), 'shift')
|
|
guest.wait(10)
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(10)
|
|
# Opened again, the menu holds what the server has.
|
|
seen[block] = (size, open_container(guest, at, out, f'machines-{block}-after')[0])
|
|
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
|
|
want = {'hopper': (5, {0: 'minecraft:cobblestone'}), 'dispenser': (9, {0: 'minecraft:cobblestone'}),
|
|
'dropper': (9, {0: 'minecraft:cobblestone'})}
|
|
assert seen == want, f'the machines on the guest: {seen}'
|
|
return seen
|
|
|
|
|
|
def check_brewing(host, guest, out):
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
loads = ('blaze_powder', 'nether_wart', 'glass_bottle')
|
|
for item in loads:
|
|
guest('command', command=f'give @s minecraft:{item}')
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
at = (x, y, z + 2)
|
|
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:brewing_stand')
|
|
guest.wait(10)
|
|
guest.raw('interact', *at)
|
|
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
|
guest.wait(10)
|
|
size = container_items(guest)[1]
|
|
# BrewingStandMenu.quickMoveStack: blaze powder to the fuel slot, an ingredient to its slot,
|
|
# a bottle to the bottle slots.
|
|
for item in loads:
|
|
guest.raw('click-slot', hotbar_slot(guest, f'minecraft:{item}'), 'shift')
|
|
guest.wait(5)
|
|
# BrewingStandBlockEntity.serverTick burns the blaze powder into 20 uses; the guest sees them
|
|
# through the menu's data slots.
|
|
guest.wait(10)
|
|
info = guest.raw('menu')['info']
|
|
guest.shot(out, 'brewing')
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(10)
|
|
items = open_container(guest, at, out, 'brewing-after')[0]
|
|
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
|
|
seen = {'size': size, 'items': items, 'fuel': info and info.get('fuel')}
|
|
want = {'size': 5, 'items': {0: 'minecraft:glass_bottle', 3: 'minecraft:nether_wart'}, 'fuel': 20}
|
|
assert seen == want, f'the brewing stand on the guest: {seen}'
|
|
return seen
|
|
|
|
|
|
def open_station(guest, block, out, label):
|
|
"""Place `block` two blocks south of the guest and open it: the station's position."""
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
at = (x, y, z + 2)
|
|
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:{block}')
|
|
guest.wait(10)
|
|
guest.raw('interact', *at)
|
|
guest.raw('wait-for', 'screen', 'station', '--timeout', 10)
|
|
guest.wait(10)
|
|
guest.shot(out, label)
|
|
return at
|
|
|
|
|
|
def station_slots(guest):
|
|
"""The open station's stacks: input slots 600 and up, the result at 650."""
|
|
menu = guest.raw('menu')
|
|
return {s['slot']: (s['stack']['item'], s['stack']['count']) for s in menu['slots'] if s['slot'] >= 600}
|
|
|
|
|
|
def close_station(guest, at):
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(10)
|
|
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
|
|
|
|
|
|
def check_loom(host, guest, out):
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
for item in ('white_banner', 'red_dye'):
|
|
guest('command', command=f'give @s minecraft:{item}')
|
|
at = open_station(guest, 'loom', out, 'loom')
|
|
# LoomMenu.quickMoveStack: a banner to the banner slot, a dye to the dye slot.
|
|
for item in ('white_banner', 'red_dye'):
|
|
guest.raw('click-slot', hotbar_slot(guest, f'minecraft:{item}'), 'shift')
|
|
guest.wait(5)
|
|
loaded = station_slots(guest)
|
|
# LoomScreen's first pattern button (clickMenuButton 0): the server makes the result.
|
|
guest.raw('click-gui', 67, 20)
|
|
guest.wait(10)
|
|
made = station_slots(guest).get(650)
|
|
guest.shot(out, 'loom-pattern')
|
|
guest.raw('click-slot', 650, 'shift')
|
|
guest.wait(10)
|
|
left = station_slots(guest)
|
|
close_station(guest, at)
|
|
# The server's copy of the banner the guest took has the red layer.
|
|
since = guest.raw('events')['last']
|
|
guest('command', command='data get entity @s Inventory[{id:"minecraft:white_banner"}].components')
|
|
layer = guest.raw('wait-for', 'chat', 'color: "red"', '--timeout', 10, '--since', since).get('ok')
|
|
counts = guest('inventory')['counts']
|
|
seen = {'loaded': loaded, 'made': made, 'left': left, 'layer': layer, 'counts': counts}
|
|
want = {'loaded': {600: ('minecraft:white_banner', 1), 601: ('minecraft:red_dye', 1)},
|
|
'made': ('minecraft:white_banner', 1), 'left': {}, 'layer': True,
|
|
'counts': {'minecraft:white_banner': 1}}
|
|
assert seen == want, f'the loom on the guest: {seen}'
|
|
return seen
|
|
|
|
|
|
def own_reply(event):
|
|
"""A chat event that answers the guest's own command. On the server thread the guest also gets
|
|
its own command's `[Guest: ...]` operator broadcast before #1373, a moment later, which an answer
|
|
to the next command must not be taken for."""
|
|
return event.get('event') == 'chat' and not event.get('text', '').startswith(f'[{GUEST_NAME}: ')
|
|
|
|
|
|
def server_count(guest, item):
|
|
"""How many of `item` the server says the guest has (`clear` with a count of 0 only counts)."""
|
|
since = guest.raw('events')['last']
|
|
item = item if ':' in item else f'minecraft:{item}'
|
|
guest('command', command=f'clear @s {item} 0')
|
|
for n in range(0, 65):
|
|
reply = guest.raw('events', '--since', since)
|
|
lines = [e.get('text', '') for e in reply['events'] if own_reply(e)]
|
|
for line in lines:
|
|
if 'matching item' in line or 'No items were found' in line:
|
|
return 0 if 'No items' in line else int(line.split('Found ')[1].split(' ')[0])
|
|
guest.wait(2)
|
|
raise Failed(f'no answer to clear for {item}')
|
|
|
|
|
|
def check_stonecutter(host, guest, out):
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
guest('command', command='give @s minecraft:stone 3')
|
|
at = open_station(guest, 'stonecutter', out, 'stonecutter')
|
|
# StonecutterMenu.quickMoveStack: stone has recipes, so it goes to the input.
|
|
guest.raw('click-slot', hotbar_slot(guest, 'minecraft:stone'), 'shift')
|
|
guest.wait(10)
|
|
recipes = guest.raw('menu')['info']['recipes']
|
|
index = recipes.index('minecraft:stone_slab_from_stone_stonecutting')
|
|
# StonecutterScreen's recipe grid: four columns of 16x18 cells from (52, 14).
|
|
guest.raw('click-gui', 52 + index % 4 * 16 + 8, 14 + index // 4 * 18 + 9)
|
|
guest.wait(10)
|
|
picked = (guest.raw('menu')['info']['selected'], station_slots(guest).get(650))
|
|
guest.shot(out, 'stonecutter-recipe')
|
|
# A shift-click on the result cuts while there is stone (doClick repeats quickMoveStack).
|
|
guest.raw('click-slot', 650, 'shift')
|
|
guest.wait(10)
|
|
left = station_slots(guest)
|
|
close_station(guest, at)
|
|
seen = {'picked': picked, 'left': left,
|
|
'server': {item: server_count(guest, item) for item in ('stone_slab', 'stone')}}
|
|
want = {'picked': (index, ('minecraft:stone_slab', 2)), 'left': {},
|
|
'server': {'stone_slab': 6, 'stone': 0}}
|
|
assert seen == want, f'the stonecutter on the guest: {seen}'
|
|
return seen
|
|
|
|
|
|
def server_reply(guest, command, *needles):
|
|
"""The first chat line holding every needle after the guest runs `command`."""
|
|
since = guest.raw('events')['last']
|
|
guest('command', command=command)
|
|
for _ in range(0, 65):
|
|
reply = guest.raw('events', '--since', since)
|
|
for e in reply['events']:
|
|
line = e.get('text', '')
|
|
if own_reply(e) and all(n in line for n in needles):
|
|
return line
|
|
guest.wait(2)
|
|
raise Failed(f'no answer to {command}')
|
|
|
|
|
|
def check_grindstone(host, guest, out):
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
guest('command', command='xp set @s 0 levels')
|
|
guest('command', command='xp set @s 0 points')
|
|
guest('command', command='give @s minecraft:iron_sword[minecraft:enchantments={"minecraft:sharpness":3,'
|
|
'"minecraft:vanishing_curse":1},minecraft:damage=100]')
|
|
at = open_station(guest, 'grindstone', out, 'grindstone')
|
|
# GrindstoneMenu.quickMoveStack: with both inputs empty, into the first.
|
|
guest.raw('click-slot', hotbar_slot(guest, 'minecraft:iron_sword'), 'shift')
|
|
guest.wait(10)
|
|
loaded = station_slots(guest)
|
|
guest.shot(out, 'grindstone-result')
|
|
guest.raw('click-slot', 650, 'shift')
|
|
# The orbs (sharpness III: 12 to 23 points) fly to the guest.
|
|
guest.wait(60)
|
|
left = station_slots(guest)
|
|
close_station(guest, at)
|
|
sword = server_reply(guest, 'data get entity @s Inventory[{id:"minecraft:iron_sword"}].components',
|
|
'minecraft:damage')
|
|
# 16 points make exactly two levels and leave 0 points: count the levels too.
|
|
xp = (server_reply(guest, 'xp query @s points', 'experience points')
|
|
+ server_reply(guest, 'xp query @s levels', 'experience levels'))
|
|
seen = {'loaded': loaded, 'left': left, 'curse kept': 'vanishing_curse' in sword,
|
|
'sharpness gone': 'sharpness' not in sword,
|
|
'damage': re.search(r'minecraft:damage"?: 100\b', sword) is not None,
|
|
'xp': any(int(n) > 0 for n in re.findall(r'has (\d+) experience', xp))}
|
|
want = {'loaded': {600: ('minecraft:iron_sword', 1), 650: ('minecraft:iron_sword', 1)}, 'left': {},
|
|
'curse kept': True, 'sharpness gone': True, 'damage': True, 'xp': True}
|
|
assert seen == want, f'the grindstone on the guest: {seen} ({sword!r}, {xp!r})'
|
|
return seen
|
|
|
|
|
|
def check_anvil(host, guest, out):
|
|
# AnvilMenu.onTake emits one level event to everyone, including the user.
|
|
# Exercise both the host and a remote user (both are clients of the server).
|
|
return {actor.name: check_anvil_user(actor, (host, guest), out) for actor in (guest, host)}
|
|
|
|
|
|
def check_anvil_user(guest, listeners, out):
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
guest('command', command='xp set @s 5 levels')
|
|
guest('command', command='give @s minecraft:iron_sword[minecraft:damage=200]')
|
|
guest('command', command='give @s minecraft:iron_ingot 3')
|
|
at = open_station(guest, 'anvil', out, 'anvil')
|
|
# ItemCombinerMenu.quickMoveStack: into the first input that takes it.
|
|
for item in ('iron_sword', 'iron_ingot'):
|
|
guest.raw('click-slot', hotbar_slot(guest, f'minecraft:{item}'), 'shift')
|
|
guest.wait(5)
|
|
# The box shows "Iron Sword": erase it and type the new name.
|
|
for _ in range(len('Iron Sword')):
|
|
guest.raw('type-back')
|
|
guest.raw('type', 'Blade')
|
|
guest.wait(10)
|
|
menu = guest.raw('menu')
|
|
offered = (menu['info']['anvil_cost'], station_slots(guest).get(650))
|
|
guest.shot(out, 'anvil-rename')
|
|
sounds = ('minecraft:block.anvil.use', 'minecraft:block.anvil.destroy')
|
|
before = {g.name: sum(heard(g, s) for s in sounds) for g in listeners}
|
|
guest.raw('click-slot', 650, 'shift')
|
|
guest.wait(20)
|
|
sound_counts = {g.name: sum(heard(g, s) for s in sounds) - before[g.name] for g in listeners}
|
|
assert sound_counts == {g.name: 1 for g in listeners}, f'anvil sounds: {sound_counts}'
|
|
left = station_slots(guest)
|
|
close_station(guest, at)
|
|
sword = server_reply(guest, 'data get entity @s Inventory[{id:"minecraft:iron_sword"}].components',
|
|
'minecraft:damage')
|
|
levels = server_reply(guest, 'xp query @s levels', 'experience levels')
|
|
seen = {'offered': offered, 'left': left, 'name': 'Blade' in sword,
|
|
'damage': re.search(r'minecraft:damage"?: 14\b', sword) is not None,
|
|
'levels': int(re.search(r'has (\d+) experience', levels).group(1)),
|
|
'ingots': server_count(guest, 'iron_ingot')}
|
|
# Three ingots repair 62 each (a quarter of 250), plus the name: four levels.
|
|
want = {'offered': (4, ('minecraft:iron_sword', 1)), 'left': {}, 'name': True, 'damage': True,
|
|
'levels': 1, 'ingots': 0}
|
|
assert seen == want, f'the anvil on the guest: {seen} ({sword!r})'
|
|
return {**seen, 'heard': sound_counts}
|
|
|
|
|
|
def check_smithing(host, guest, out):
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
for item in ('netherite_upgrade_smithing_template', 'diamond_sword', 'netherite_ingot'):
|
|
guest('command', command=f'give @s minecraft:{item}')
|
|
at = open_station(guest, 'smithing_table', out, 'smithing')
|
|
# SmithingMenu.canMoveIntoInputSlots: each to the slot whose recipe test takes it.
|
|
for item in ('netherite_upgrade_smithing_template', 'diamond_sword', 'netherite_ingot'):
|
|
guest.raw('click-slot', hotbar_slot(guest, f'minecraft:{item}'), 'shift')
|
|
guest.wait(5)
|
|
guest.wait(5)
|
|
loaded = station_slots(guest)
|
|
guest.shot(out, 'smithing-result')
|
|
guest.raw('click-slot', 650, 'shift')
|
|
guest.wait(10)
|
|
left = station_slots(guest)
|
|
close_station(guest, at)
|
|
seen = {'loaded': loaded, 'left': left,
|
|
'server': {item: server_count(guest, item) for item in ('netherite_sword', 'diamond_sword', 'netherite_ingot')}}
|
|
want = {'loaded': {600: ('minecraft:netherite_upgrade_smithing_template', 1), 601: ('minecraft:diamond_sword', 1),
|
|
602: ('minecraft:netherite_ingot', 1), 650: ('minecraft:netherite_sword', 1)},
|
|
'left': {}, 'server': {'netherite_sword': 1, 'diamond_sword': 0, 'netherite_ingot': 0}}
|
|
assert seen == want, f'the smithing table on the guest: {seen}'
|
|
return seen
|
|
|
|
|
|
def check_enchanting(host, guest, out):
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
guest('command', command='xp set @s 30 levels')
|
|
guest('command', command='give @s minecraft:iron_sword')
|
|
guest('command', command='give @s minecraft:lapis_lazuli 3')
|
|
# EnchantmentMenu awards the enchant_item stat; a stat objective shows the guest's count.
|
|
guest('command', command='scoreboard objectives add lanench minecraft.custom:minecraft.enchant_item')
|
|
at = open_station(guest, 'enchanting_table', out, 'enchanting')
|
|
# EnchantmentMenu.quickMoveStack: the sword to the item slot, the lapis to its own.
|
|
for item in ('iron_sword', 'lapis_lazuli'):
|
|
guest.raw('click-slot', hotbar_slot(guest, f'minecraft:{item}'), 'shift')
|
|
guest.wait(5)
|
|
guest.wait(10)
|
|
costs = guest.raw('menu')['info']['costs']
|
|
guest.shot(out, 'enchanting-offers')
|
|
# EnchantmentScreen's first offer button (clickMenuButton 0).
|
|
guest.raw('click-gui', 110, 23)
|
|
guest.wait(10)
|
|
left = station_slots(guest)
|
|
close_station(guest, at)
|
|
sword = server_reply(guest, 'data get entity @s Inventory[{id:"minecraft:iron_sword"}]', 'iron_sword')
|
|
levels = server_reply(guest, 'xp query @s levels', 'experience levels')
|
|
stat = server_reply(guest, 'scoreboard players get @s lanench', 'lanench')
|
|
guest('command', command='scoreboard objectives remove lanench')
|
|
seen = {'offered': costs[0] > 0, 'enchanted': 'minecraft:enchantments' in sword,
|
|
'left': left.get(601), 'levels': int(re.search(r'has (\d+) experience', levels).group(1)),
|
|
'lapis': server_count(guest, 'lapis_lazuli'), 'stat': ' has 1 ' in stat}
|
|
want = {'offered': True, 'enchanted': True, 'left': ('minecraft:lapis_lazuli', 2), 'levels': 29, 'lapis': 2,
|
|
'stat': True}
|
|
assert seen == want, f'the enchanting table on the guest: {seen} ({costs}, {sword!r})'
|
|
return seen
|
|
|
|
|
|
def check_beacon(host, guest, out):
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
guest('command', command='effect clear @s')
|
|
guest('command', command='give @s minecraft:iron_ingot')
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
at = (x, y, z + 2)
|
|
# A one-level pyramid under the beacon; its levels come with the first base check (80 ticks).
|
|
guest('command', command=f'fill {x - 1} {y - 1} {z + 1} {x + 1} {y - 1} {z + 3} minecraft:iron_block')
|
|
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:beacon')
|
|
guest.wait(100)
|
|
guest.raw('interact', *at)
|
|
guest.raw('wait-for', 'screen', 'station', '--timeout', 10)
|
|
guest.wait(10)
|
|
# BeaconMenu.quickMoveStack: the ingot to the payment slot.
|
|
guest.raw('click-slot', hotbar_slot(guest, 'minecraft:iron_ingot'), 'shift')
|
|
guest.wait(10)
|
|
paid = station_slots(guest).get(600)
|
|
# BeaconScreen (54 px wider, 27 to the left): Haste, the first level's second power, then Done,
|
|
# which sends ServerboundSetBeaconPacket and closes the menu.
|
|
guest.raw('click-gui', 77 - 27 + 11, 22 + 11)
|
|
guest.wait(5)
|
|
guest.shot(out, 'beacon')
|
|
guest.raw('click-gui', 164 - 27 + 11, 107 + 11)
|
|
guest.wait(10)
|
|
screen = guest('state').get('screen')
|
|
effects = []
|
|
# BeaconBlockEntity.applyEffects runs every 80 ticks.
|
|
for _ in range(12):
|
|
guest.wait(20)
|
|
effects = effect_ids(guest)
|
|
if 'haste' in effects:
|
|
break
|
|
seen = {'paid': paid, 'haste': 'haste' in effects, 'ingots': server_count(guest, 'iron_ingot'),
|
|
'screen': screen}
|
|
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
|
|
want = {'paid': ('minecraft:iron_ingot', 1), 'haste': True, 'ingots': 0, 'screen': 'playing'}
|
|
assert seen == want, f'the beacon on the guest: {seen} ({effects})'
|
|
return seen
|
|
|
|
|
|
def check_crafter(host, guest, out):
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
guest('command', command='give @s minecraft:oak_planks 4')
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
at = (x, y, z + 2)
|
|
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:crafter')
|
|
guest.wait(10)
|
|
guest.raw('interact', *at)
|
|
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
|
guest.wait(10)
|
|
# CrafterScreen.slotClicked: an empty-handed click disables the first slot
|
|
# (ServerboundContainerSlotStateChangedPacket); the planks then skip it.
|
|
guest.raw('click-slot', 300)
|
|
guest.wait(5)
|
|
guest.raw('click-slot', hotbar_slot(guest, 'minecraft:oak_planks'), 'shift')
|
|
guest.wait(10)
|
|
guest.shot(out, 'crafter')
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(10)
|
|
# Opening it again shows the server's crafter.
|
|
items = open_container(guest, at, out, 'crafter-after')
|
|
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
|
|
seen = {'items': items[0] if isinstance(items, tuple) else items, 'planks': server_count(guest, 'oak_planks')}
|
|
want = {'items': {1: 'minecraft:oak_planks'}, 'planks': 0}
|
|
assert seen == want, f'the crafter on the guest: {seen}'
|
|
return seen
|
|
|
|
|
|
def check_lectern(host, guest, out):
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
at = (x, y, z + 2)
|
|
book = ('{id:"minecraft:written_book",count:1,components:{"minecraft:written_book_content":'
|
|
'{title:"Lan",author:"Host",pages:["one","two","three"]}}}')
|
|
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:lectern[has_book=true]{{Book:{book}}}')
|
|
guest.wait(10)
|
|
guest.raw('interact', *at)
|
|
guest.raw('wait-for', 'screen', 'book', '--timeout', 10)
|
|
guest.wait(10)
|
|
opened = guest.raw('book')
|
|
guest.shot(out, 'lectern')
|
|
# LecternScreen's page arrows: ContainerButtonClick 2 (forward) and 1 (back); the server's
|
|
# LecternMenu.setData clamps to the book's last page and sends the page back (data slot 0).
|
|
for button in ('next', 'next', 'next', 'back'):
|
|
guest.raw('book', button)
|
|
guest.wait(5)
|
|
guest.wait(10)
|
|
turned = guest.raw('book')
|
|
page = server_reply(guest, f'data get block {at[0]} {at[1]} {at[2]} Page', 'block data')
|
|
guest.shot(out, 'lectern-page')
|
|
# Take Book: ContainerButtonClick 3; the server empties the lectern and closes the menu.
|
|
guest.raw('book', 'take')
|
|
guest.wait(20)
|
|
screen = guest('state').get('screen')
|
|
held = server_count(guest, 'written_book')
|
|
has_book = server_reply(guest, f'execute if block {at[0]} {at[1]} {at[2]} minecraft:lectern[has_book=false]',
|
|
'Test passed')
|
|
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
|
|
seen = {'opened': (opened.get('page'), opened.get('pages')), 'turned': turned.get('page'),
|
|
'server page': int(re.search(r'(-?\d+)\s*$', page).group(1)), 'screen': screen, 'held': held,
|
|
'emptied': 'Test passed' in has_book}
|
|
want = {'opened': (0, 3), 'turned': 1, 'server page': 1, 'screen': 'playing', 'held': 1, 'emptied': True}
|
|
assert seen == want, f'the lectern on the guest: {seen} ({page!r})'
|
|
return seen
|
|
|
|
|
|
def check_cartography(host, guest, out):
|
|
# The host makes a map (EmptyMapItem.use), the guest gets a copy of it.
|
|
host('command', command='clear @s')
|
|
host('command', command='item replace entity @s weapon.mainhand with minecraft:map')
|
|
host.wait(5)
|
|
host.raw('use-item')
|
|
host.wait(20)
|
|
made = server_reply(host, 'data get entity @s Inventory[{id:"minecraft:filled_map"}].components', 'map_id')
|
|
map_id = int(re.search(r'map_id"?: (\d+)', made).group(1))
|
|
host('command', command='clear @s')
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
guest('command', command=f'give @s minecraft:filled_map[minecraft:map_id={map_id}]')
|
|
guest('command', command='give @s minecraft:paper')
|
|
at = open_station(guest, 'cartography_table', out, 'cartography')
|
|
# CartographyTableMenu.quickMoveStack: a map with an id to the map slot, paper to the other.
|
|
for item in ('filled_map', 'paper'):
|
|
guest.raw('click-slot', hotbar_slot(guest, f'minecraft:{item}'), 'shift')
|
|
guest.wait(5)
|
|
guest.wait(10)
|
|
loaded = station_slots(guest)
|
|
guest.shot(out, 'cartography-result')
|
|
# The result's onTake uses the map and the paper; MapItem.onCraftedPostProcess gives the zoomed
|
|
# out map a new id on the server.
|
|
guest.raw('click-slot', 650, 'shift')
|
|
guest.wait(10)
|
|
left = station_slots(guest)
|
|
close_station(guest, at)
|
|
taken = server_reply(guest, 'data get entity @s Inventory[{id:"minecraft:filled_map"}].components', 'map_id')
|
|
new_id = int(re.search(r'map_id"?: (\d+)', taken).group(1))
|
|
seen = {'loaded': loaded, 'left': left, 'new map': new_id != map_id,
|
|
'server': {item: server_count(guest, item) for item in ('filled_map', 'paper')}}
|
|
want = {'loaded': {600: ('minecraft:filled_map', 1), 601: ('minecraft:paper', 1),
|
|
650: ('minecraft:filled_map', 1)},
|
|
'left': {}, 'new map': True, 'server': {'filled_map': 1, 'paper': 0}}
|
|
assert seen == want, f'the cartography table on the guest: {seen} ({made!r}, {taken!r})'
|
|
return seen
|
|
|
|
|
|
def slot_point(game, slot):
|
|
"""The GUI point in the middle of slot `slot` of the open screen (`ui`'s slot-N)."""
|
|
for w in game.raw('ui')['widgets']:
|
|
if w.get('id') == f'slot-{slot}':
|
|
x, y, width, height = w['bounds']
|
|
return x + width / 2, y + height / 2
|
|
raise Failed(f'no slot {slot} on the screen')
|
|
|
|
|
|
def mouse(game, *slots, right=False):
|
|
"""Press over the first slot, move over the others and release: one click, or a drag.
|
|
|
|
A drag crosses every slot: `mouseDragged` takes the slots the pointer is over as move events
|
|
land, so each leg gets a second point a couple of pixels into the same slot -- a single point
|
|
drag would skip the start slot no mouse held still on. One slot stays a plain click."""
|
|
if len(slots) <= 1:
|
|
points = [f'{v:.1f}' for s in slots for v in slot_point(game, s)]
|
|
else:
|
|
points = []
|
|
for s in slots:
|
|
x, y = slot_point(game, s)
|
|
points += [f'{x:.1f}', f'{y:.1f}', f'{x + 2:.1f}', f'{y + 1:.1f}']
|
|
game.raw('drag-at', *(['right'] if right else []), *points)
|
|
game.wait(4)
|
|
|
|
|
|
def hotbar_slot(game, item):
|
|
return next(s['slot'] for s in game('inventory')['hotbar'] if s and s.get('item') == item)
|
|
|
|
|
|
def check_clicks(host, guest, out):
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
guest('command', command='give @s minecraft:cobblestone 10')
|
|
guest('command', command='give @s minecraft:coal 3')
|
|
guest('command', command='give @s minecraft:raw_iron 2')
|
|
guest('command', command='give @s minecraft:oak_planks 2')
|
|
guest.wait(10)
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
chest, furnace = (x, y, z + 2), (x, y, z - 2)
|
|
guest('command', command=f'setblock {chest[0]} {chest[1]} {chest[2]} minecraft:chest')
|
|
guest('command', command=f'setblock {furnace[0]} {furnace[1]} {furnace[2]} minecraft:furnace')
|
|
guest.wait(10)
|
|
# Pick the cobblestone up and put it down in the chest's first slot.
|
|
guest.raw('interact', *chest)
|
|
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
|
guest.wait(10)
|
|
mouse(guest, hotbar_slot(guest, 'minecraft:cobblestone'))
|
|
mouse(guest, 300)
|
|
guest.wait(10)
|
|
guest.shot(out, 'clicks-chest')
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(10)
|
|
in_chest, _ = open_container(guest, chest, out, 'clicks-chest-after')
|
|
# Coal into the fuel slot, raw iron into the input.
|
|
guest.raw('interact', *furnace)
|
|
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
|
guest.wait(10)
|
|
mouse(guest, hotbar_slot(guest, 'minecraft:coal'))
|
|
mouse(guest, 301)
|
|
mouse(guest, hotbar_slot(guest, 'minecraft:raw_iron'))
|
|
mouse(guest, 300)
|
|
guest.wait(10)
|
|
guest.shot(out, 'clicks-furnace')
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(10)
|
|
# Opening the furnace again resends the whole menu: the server's own view.
|
|
in_furnace, _ = open_container(guest, furnace, out, 'clicks-furnace-after')
|
|
# Two planks dragged over the 2x2 grid's left column: one each, four sticks.
|
|
guest.raw('key', 'e', 'tap')
|
|
guest.raw('wait-for', 'screen', 'inventory')
|
|
guest.wait(5)
|
|
mouse(guest, hotbar_slot(guest, 'minecraft:oak_planks'))
|
|
mouse(guest, 100, 102)
|
|
guest.wait(10)
|
|
grid = {s['slot']: (s.get('item'), s.get('count')) for s in guest('screen').get('slots', [])
|
|
if s.get('slot') in (100, 102, 200) and s.get('item')}
|
|
guest.shot(out, 'clicks-drag')
|
|
guest.raw('click-slot', 200, 'shift')
|
|
guest.wait(10)
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(10)
|
|
# The chest's menu brings the whole inventory as the server has it.
|
|
open_container(guest, chest, out, 'clicks-inventory')
|
|
counts = guest('inventory')['counts']
|
|
for at in (chest, furnace):
|
|
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
|
|
assert in_chest == {0: 'minecraft:cobblestone'}, f'the chest holds {in_chest} after the guest put cobblestone in'
|
|
assert in_furnace == {0: 'minecraft:raw_iron', 1: 'minecraft:coal'}, f'the furnace holds {in_furnace}'
|
|
assert grid.get(100) == ('minecraft:oak_planks', 1) and grid.get(102) == ('minecraft:oak_planks', 1), \
|
|
f'the drag left the grid as {grid}'
|
|
assert counts.get('minecraft:stick') == 4 and 'minecraft:cobblestone' not in counts, f'inventory after: {counts}'
|
|
return {'chest': in_chest, 'furnace': in_furnace, 'grid': grid, 'inventory': counts}
|
|
|
|
|
|
def held(game):
|
|
"""The stack on the cursor of the open screen, as (item, count)."""
|
|
c = game('screen').get('carried') or {}
|
|
return (c.get('item'), c.get('count'))
|
|
|
|
|
|
def container_of(game, n):
|
|
"""The open container's (most) slots by container index, Horizon's slots 300 and up."""
|
|
return {s['slot'] - 300: (s.get('item'), s.get('count'))
|
|
for s in game('screen').get('slots', []) if 300 <= s.get('slot', 0) < 300 + n}
|
|
|
|
|
|
def grid_of(game, first, last):
|
|
"""The open screen's slots `first..last-1` (empties included) as (item, count)."""
|
|
return {s['slot']: (s.get('item'), s.get('count'))
|
|
for s in game('screen').get('slots', []) if first <= s.get('slot', 0) < last}
|
|
|
|
|
|
def result_of(game):
|
|
"""The crafting result of the open screen, as (item, count)."""
|
|
s = grid_of(game, 200, 201).get(200, (None, None))
|
|
return s
|
|
|
|
|
|
def check_modifier_release(host, guest, out):
|
|
"""Agent key taps and release must clear Shift before the next mouse pickup."""
|
|
guest('command', command='gamemode survival')
|
|
grab(host)
|
|
seen = {}
|
|
for case in ('tap', 'release'):
|
|
guest('command', command='clear @s')
|
|
guest('command', command='give @s minecraft:diamond 10')
|
|
guest.wait(5)
|
|
guest.raw('key', 'e', 'tap')
|
|
guest.raw('wait-for', 'screen', 'inventory', '--timeout', 10)
|
|
guest.wait(5)
|
|
try:
|
|
if case == 'tap':
|
|
guest.raw('key', 'shift', 'tap')
|
|
else:
|
|
guest.raw('key', 'shift', 'down')
|
|
guest.raw('release')
|
|
mouse(guest, hotbar_slot(guest, 'minecraft:diamond'))
|
|
seen[case] = held(guest)
|
|
guest.shot(out, f'modifier-release-{case}')
|
|
assert seen[case] == ('minecraft:diamond', 10), \
|
|
f'{case} left Shift held: the click carried {seen[case]}'
|
|
finally:
|
|
guest.raw('release')
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(5)
|
|
assert server_count(guest, 'diamond') == 10, 'modifier cleanup lost items'
|
|
host_unaffected(host, "the guest's modifier cleanup")
|
|
return seen
|
|
|
|
|
|
def check_shift_double_click(host, guest, out):
|
|
"""A stack on the cursor, Shift held and two left clicks under 250 ms apart on one chest stack:
|
|
what 26.3 moves (mouseReleased's double click, #4166). The same pair with the clicks too far
|
|
apart is the control: only the first release's Shift click happens."""
|
|
guest('command', command='gamemode survival')
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
chest = (x, y, z + 2)
|
|
guest('command', command=f'setblock {chest[0]} {chest[1]} {chest[2]} minecraft:chest')
|
|
guest.wait(10)
|
|
grab(host)
|
|
timings = {}
|
|
seen = {}
|
|
|
|
def reset():
|
|
guest('command', command='clear @s')
|
|
guest('command', command='give @s minecraft:diamond 32')
|
|
for i, n in enumerate((8, 13, 21)):
|
|
guest('command', command=f'item replace block {chest[0]} {chest[1]} {chest[2]} container.{i} '
|
|
f'with minecraft:diamond {n}')
|
|
guest('command', command=f'item replace block {chest[0]} {chest[1]} {chest[2]} container.3 '
|
|
f'with minecraft:coal 5')
|
|
guest.wait(10)
|
|
guest.raw('interact', *chest)
|
|
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
|
guest.wait(5)
|
|
# Take the 32 on the cursor, then a wait past Horizon's 250 ms double click window.
|
|
mouse(guest, hotbar_slot(guest, 'minecraft:diamond'))
|
|
time.sleep(0.4)
|
|
|
|
def clicks(gap):
|
|
point = slot_point(guest, 300)
|
|
guest.raw('key', 'shift', 'down')
|
|
try:
|
|
started = time.monotonic()
|
|
guest.raw('drag-at', f'{point[0]:.1f}', f'{point[1]:.1f}')
|
|
if gap:
|
|
time.sleep(gap)
|
|
guest.raw('drag-at', f'{point[0]:.1f}', f'{point[1]:.1f}')
|
|
return round((time.monotonic() - started) * 1000)
|
|
finally:
|
|
guest.raw('key', 'shift', 'up')
|
|
guest.wait(10)
|
|
|
|
reset()
|
|
timings['slow'] = clicks(0.4)
|
|
seen['slow chest'] = container_of(guest, 4)
|
|
seen['slow carried'] = held(guest)
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(5)
|
|
seen['slow after'] = server_count(guest, 'diamond')
|
|
reset()
|
|
timings['fast'] = clicks(0)
|
|
seen['fast chest'] = container_of(guest, 4)
|
|
seen['fast carried'] = held(guest)
|
|
guest.shot(out, 'shift-double-click')
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(5)
|
|
# Closing returns the carried stack to the inventory: the server's own count.
|
|
seen['fast after'] = server_count(guest, 'diamond')
|
|
empty = (None, None)
|
|
# Vanilla 26.3's verdict, from AbstractContainerScreen.mouseReleased's double click:
|
|
# the pair's first release QUICK_MOVEs the clicked stack (lastQuickMoved), the second
|
|
# (Shift held) QUICK_MOVEs every stack of that item in the clicked slot's container;
|
|
# the carried stack waits on the cursor and closing hands it back.
|
|
want = {'fast chest': {0: empty, 1: empty, 2: empty, 3: ('minecraft:coal', 5)},
|
|
'fast carried': ('minecraft:diamond', 32), 'fast after': 74,
|
|
'slow chest': {0: empty, 1: ('minecraft:diamond', 13), 2: ('minecraft:diamond', 21),
|
|
3: ('minecraft:coal', 5)},
|
|
'slow carried': ('minecraft:diamond', 32), 'slow after': 40}
|
|
assert seen == want, f'the Shift clicks left {seen}'
|
|
for at in (chest,):
|
|
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
|
|
host_unaffected(host, "the guest's Shift double click")
|
|
return {**seen, 'click interval ms': timings}
|
|
|
|
|
|
def check_recipe_shift(host, guest, out):
|
|
"""Shift-clicking a recipe book button fills the crafting grid with as many sets as the
|
|
inventory makes, and Shift-clicking the result crafts them all (#4166)."""
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
guest('command', command='recipe give @s minecraft:diamond_block')
|
|
guest('command', command='give @s minecraft:diamond 20')
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
table = (x, y, z + 2)
|
|
guest('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:crafting_table')
|
|
guest.wait(10)
|
|
grab(host)
|
|
guest.raw('interact', *table)
|
|
guest.raw('wait-for', 'screen', 'inventory', '--timeout', 10) # a table screen reads 'inventory'
|
|
guest.wait(5)
|
|
assert guest('screen').get('craft_size') == 3, 'the table opened without its 3x3 grid'
|
|
book = guest('screen')['recipe_book']
|
|
seen = {'book open': book['open']}
|
|
# The book button, then the visible recipes.
|
|
def recipes():
|
|
targets = guest('screen')['recipe_book']['targets']
|
|
return [t for t in targets if abs(t[2] - 25.0) < 0.001 and abs(t[3] - 25.0) < 0.001]
|
|
if not book['open']:
|
|
for t in guest('screen')['recipe_book']['targets']:
|
|
if abs(t[2] - 20.0) < 0.001 and abs(t[3] - 18.0) < 0.001:
|
|
guest.raw('click-at', f'{t[0] + 10:.1f}', f'{t[1] + 9:.1f}')
|
|
guest.wait(5)
|
|
seen['book open'] = guest('screen')['recipe_book']['open']
|
|
break
|
|
# Filter to craftable recipes before choosing the Building Blocks tab.
|
|
if not guest('screen')['recipe_book']['filtering']:
|
|
for t in guest('screen')['recipe_book']['targets']:
|
|
if abs(t[2] - 26.0) < 0.001 and abs(t[3] - 16.0) < 0.001:
|
|
guest.raw('click-at', f'{t[0] + 13:.1f}', f'{t[1] + 8:.1f}')
|
|
guest.wait(5)
|
|
break
|
|
# Diamond armour is also craftable; select a tab with only the block recipe.
|
|
buttons = recipes()
|
|
for t in (t for t in guest('screen')['recipe_book']['targets']
|
|
if abs(t[2] - 35.0) < 0.001 and abs(t[3] - 27.0) < 0.001):
|
|
guest.raw('click-at', f'{t[0] + 17:.1f}', f'{t[1] + 13:.1f}')
|
|
guest.wait(5)
|
|
buttons = recipes()
|
|
seen['buttons'] = len(buttons)
|
|
if len(buttons) == 1:
|
|
break
|
|
assert len(buttons) == 1, (f'with the craftable filter and its tabs the book shows '
|
|
f'{len(buttons)} recipes, not 1',
|
|
[t for t in guest('screen')['recipe_book']['targets']],
|
|
guest('screen')['recipe_book']['filtering'])
|
|
centre = (buttons[0][0] + 12.5, buttons[0][1] + 12.5)
|
|
guest.raw('key', 'shift', 'down')
|
|
try:
|
|
guest.raw('click-at', f'{centre[0]:.1f}', f'{centre[1]:.1f}')
|
|
finally:
|
|
guest.raw('key', 'shift', 'up')
|
|
guest.wait(10)
|
|
seen['grid'] = grid_of(guest, 100, 109)
|
|
seen['result'] = result_of(guest)
|
|
seen['carried'] = held(guest)
|
|
seen['inventory'] = guest('inventory')['counts'].get('minecraft:diamond', 0)
|
|
guest.shot(out, 'recipe-shift-filled')
|
|
# Take all available crafts with a real Shift click on the result.
|
|
guest.raw('key', 'shift', 'down')
|
|
try:
|
|
point = slot_point(guest, 200)
|
|
guest.raw('click-at', f'{point[0]:.1f}', f'{point[1]:.1f}')
|
|
finally:
|
|
guest.raw('key', 'shift', 'up')
|
|
guest.wait(10)
|
|
seen['after grid'] = grid_of(guest, 100, 109)
|
|
seen['after result'] = result_of(guest)
|
|
seen['after carried'] = held(guest)
|
|
guest.shot(out, 'recipe-shift-crafted')
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(5)
|
|
seen['after blocks'] = server_count(guest, 'diamond_block')
|
|
seen['after diamonds'] = server_count(guest, 'diamond')
|
|
guest('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:air')
|
|
host_unaffected(host, "the guest's recipe Shift clicks")
|
|
filled = {s: ('minecraft:diamond', 2) for s in range(100, 109)}
|
|
blank = {s: (None, None) for s in range(100, 109)}
|
|
# Vanilla 26.3: RecipeBookComponent's Shift click sends PlaceRecipe with useMaxItems and
|
|
# ServerPlaceRecipe fills the grid with as many sets as the inventory makes (18 diamonds,
|
|
# 2 left); the result shows one craft. The result's Shift click is QUICK_MOVE: it crafts
|
|
# every set the grid allows (2 blocks), leaves the grid empty and 2 diamonds.
|
|
want = {'book open': True, 'buttons': 1, 'grid': filled, 'result': ('minecraft:diamond_block', 1),
|
|
'carried': (None, None), 'inventory': 2, 'after grid': blank, 'after result': (None, None),
|
|
'after carried': (None, None), 'after blocks': 2, 'after diamonds': 2}
|
|
assert seen == want, f'the recipe Shift clicks left {seen}'
|
|
return seen
|
|
|
|
|
|
def check_shift_drag(host, guest, out):
|
|
"""A 20 diamond stack dragged over three slots of a crafting table spreads 6 each with the left
|
|
button and 1 each with the right, with Shift held over the drag or not; closing hands the rest
|
|
back to the guest's inventory (#4166)."""
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
guest('command', command='give @s minecraft:diamond 20')
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
table = (x, y, z + 2)
|
|
guest('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:crafting_table')
|
|
guest.wait(10)
|
|
grab(host)
|
|
guest.raw('interact', *table)
|
|
guest.raw('wait-for', 'screen', 'inventory', '--timeout', 10) # a table screen reads 'inventory'
|
|
guest.wait(5)
|
|
assert guest('screen').get('craft_size') == 3, 'the table opened without its 3x3 grid'
|
|
|
|
def drag(right, shift):
|
|
mouse(guest, hotbar_slot(guest, 'minecraft:diamond'))
|
|
guest.wait(5)
|
|
if shift:
|
|
guest.raw('key', 'shift', 'down')
|
|
try:
|
|
mouse(guest, 100, 101, 102, right=right)
|
|
finally:
|
|
if shift:
|
|
guest.raw('key', 'shift', 'up')
|
|
grid = grid_of(guest, 100, 109)
|
|
carried = held(guest)
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(10)
|
|
return grid, carried
|
|
|
|
seen = {}
|
|
seen['left'], seen['left carried'] = drag(False, False)
|
|
seen['after'] = server_count(guest, 'diamond')
|
|
guest.raw('interact', *table)
|
|
guest.raw('wait-for', 'screen', 'inventory', '--timeout', 10) # a table screen reads 'inventory'
|
|
guest.wait(5)
|
|
assert guest('screen').get('craft_size') == 3, 'the table opened without its 3x3 grid'
|
|
seen['right'], seen['right carried'] = drag(True, False)
|
|
guest.raw('interact', *table)
|
|
guest.raw('wait-for', 'screen', 'inventory', '--timeout', 10) # a table screen reads 'inventory'
|
|
guest.wait(5)
|
|
assert guest('screen').get('craft_size') == 3, 'the table opened without its 3x3 grid'
|
|
seen['left shift'], seen['left shift carried'] = drag(False, True)
|
|
guest.raw('interact', *table)
|
|
guest.raw('wait-for', 'screen', 'inventory', '--timeout', 10) # a table screen reads 'inventory'
|
|
guest.wait(5)
|
|
assert guest('screen').get('craft_size') == 3, 'the table opened without its 3x3 grid'
|
|
seen['right shift'], seen['right shift carried'] = drag(True, True)
|
|
seen['after shift'] = server_count(guest, 'diamond')
|
|
guest.shot(out, 'shift-drag')
|
|
guest('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:air')
|
|
host_unaffected(host, "the guest's Shift drag")
|
|
left = {**{s: ('minecraft:diamond', 6) for s in range(100, 103)},
|
|
**{s: (None, None) for s in range(103, 109)}}
|
|
right = {**{s: ('minecraft:diamond', 1) for s in range(100, 103)},
|
|
**{s: (None, None) for s in range(103, 109)}}
|
|
# Vanilla 26.3: a carried press starts the quick craft drag whatever Shift does
|
|
# (AbstractContainerScreen.mouseClicked); mouseDragged selects slots regardless of Shift.
|
|
# The left drag
|
|
# over three slots divides the stack as it does without it, the right one drops one
|
|
# each; the table hands the rest back when it closes.
|
|
want = {'left': left, 'left carried': ('minecraft:diamond', 2), 'after': 20,
|
|
'right': right, 'right carried': ('minecraft:diamond', 17),
|
|
'left shift': left, 'left shift carried': ('minecraft:diamond', 2),
|
|
'right shift': right, 'right shift carried': ('minecraft:diamond', 17),
|
|
'after shift': 20}
|
|
assert seen == want, f'the Shift drag left {seen}'
|
|
return seen
|
|
|
|
|
|
def wheel(game, slot, steps):
|
|
"""Real wheel events over slot `slot` of the open screen (positive scrolls up)."""
|
|
x, y = slot_point(game, slot)
|
|
game.raw('scroll-at', f'{x:.1f}', f'{y:.1f}', steps)
|
|
game.wait(4)
|
|
|
|
|
|
def wheel_fast(game, slot, steps):
|
|
"""Wheel events with no pause after them."""
|
|
x, y = slot_point(game, slot)
|
|
game.raw('scroll-at', f'{x:.1f}', f'{y:.1f}', steps)
|
|
|
|
|
|
def set_mouse_tweaks(game, on):
|
|
"""The Extras page's switch, as a player flips it."""
|
|
game('options', page='extras')
|
|
switch = next(w for w in game.raw('ui')['widgets'] if w.get('label', '').startswith('Mouse Tweaks controls'))
|
|
if switch['label'].endswith('On') != on:
|
|
game('click', id=switch['id'])
|
|
game('close')
|
|
game.wait(5)
|
|
|
|
|
|
def check_wheel(host, guest, out):
|
|
set_mouse_tweaks(guest, True)
|
|
# On the host's cleared floor, with the host behind: nothing stands in the way of the look at the
|
|
# one spot each block takes in turn.
|
|
hx, hy, hz = pos_of(host)
|
|
guest('command', command=f'tp @s {hx:.1f} {hy:.1f} {hz + 4:.1f}')
|
|
guest.wait(10)
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
spot = (x, y, z + 2)
|
|
guest('command', command='gamemode survival')
|
|
|
|
def place(block):
|
|
guest('command', command=f'setblock {spot[0]} {spot[1]} {spot[2]} minecraft:{block}')
|
|
guest.wait(20) # until the client has the block, to look at it
|
|
|
|
def held(*stacks):
|
|
guest('command', command='clear @s')
|
|
for item, count in stacks:
|
|
guest('command', command=f'give @s minecraft:{item} {count}')
|
|
guest.wait(10)
|
|
|
|
def opened(screen='container'):
|
|
guest.raw('interact', *spot)
|
|
guest.raw('wait-for', 'screen', screen, '--timeout', 10)
|
|
guest.wait(10)
|
|
|
|
def slots():
|
|
return {s['slot']: (s['item'].removeprefix('minecraft:'), s['count'])
|
|
for s in guest('screen').get('slots', []) if s.get('item')}
|
|
|
|
def shut():
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(10)
|
|
|
|
def server_view():
|
|
"""Closing and opening again resends the whole menu: what the server has."""
|
|
shut()
|
|
opened()
|
|
return slots()
|
|
|
|
def where(view, item):
|
|
return sorted(s for s, (i, _) in view.items() if i == item)
|
|
|
|
try:
|
|
# A chest always worked: the extra is on, and the wheel reaches the screen.
|
|
held(('cobblestone', 3))
|
|
place('chest')
|
|
opened()
|
|
wheel(guest, hotbar_slot(guest, 'minecraft:cobblestone'), -1)
|
|
view = server_view()
|
|
assert view.get(300) == ('cobblestone', 1), f'chest: {view}'
|
|
shut()
|
|
# The furnace: each item goes to the slot vanilla's quick move picks, never the output.
|
|
# Raw copper can't join the ingots in the output, so the furnace stays out and burns nothing.
|
|
held(('coal', 3), ('raw_copper', 3), ('dirt', 3))
|
|
place('furnace{Items:[{Slot:2b,id:"minecraft:iron_ingot",count:5}]}')
|
|
opened()
|
|
wheel(guest, hotbar_slot(guest, 'minecraft:coal'), -1)
|
|
wheel(guest, hotbar_slot(guest, 'minecraft:raw_copper'), -1)
|
|
wheel(guest, hotbar_slot(guest, 'minecraft:dirt'), -1)
|
|
view = server_view()
|
|
guest.shot(out, 'wheel-furnace')
|
|
assert view.get(300) == ('raw_copper', 1), f'smeltable, input: {view}'
|
|
assert view.get(301) == ('coal', 1), f'fuel, fuel slot: {view}'
|
|
assert view.get(302) == ('iron_ingot', 5), f'the output changed: {view}'
|
|
assert sum(c for i, c in view.values() if i == 'dirt') == 3, f'dirt went astray: {view}'
|
|
# Up over the fuel slot: one more coal. Down: one back.
|
|
wheel(guest, 301, 1)
|
|
pulled = server_view()
|
|
assert pulled.get(301) == ('coal', 2), f'pulled: {pulled}'
|
|
wheel(guest, 301, -1)
|
|
pushed = server_view()
|
|
assert pushed.get(301) == ('coal', 1), f'pushed: {pushed}'
|
|
# Out of the input again, or taking the output would light the furnace.
|
|
wheel(guest, 300, -1)
|
|
# Up over the output brings nothing in; down takes it, whole, to the inventory.
|
|
wheel(guest, 302, 1)
|
|
kept = server_view()
|
|
assert kept.get(302) == ('iron_ingot', 5), f'wheel up over the output: {kept}'
|
|
wheel(guest, 302, -1)
|
|
taken = server_view()
|
|
assert 302 not in taken and [taken[s] for s in where(taken, 'iron_ingot')] == [('iron_ingot', 5)], \
|
|
f'wheel down over the output: {taken}'
|
|
# An ingot from the inventory has no way back into the output.
|
|
wheel(guest, where(taken, 'iron_ingot')[0], -1)
|
|
back = server_view()
|
|
assert 302 not in back, f'an item went into the output: {back}'
|
|
# The right-drag: neither an empty fuel slot (dirt isn't fuel) nor the output takes it.
|
|
wheel(guest, 301, -1)
|
|
dirt = where(back, 'dirt')[0]
|
|
mouse(guest, dirt)
|
|
mouse(guest, 301, 302, right=True)
|
|
mouse(guest, dirt)
|
|
dragged = server_view()
|
|
guest.shot(out, 'wheel-furnace-drag')
|
|
assert 301 not in dragged and 302 not in dragged, f'right-drag: {dragged}'
|
|
assert sum(c for i, c in dragged.values() if i == 'dirt') == 3, f'right-drag lost items: {dragged}'
|
|
shut()
|
|
|
|
# The crafting table: into the grid; down over the result crafts once, into the inventory.
|
|
held(('oak_log', 2))
|
|
place('crafting_table')
|
|
opened('inventory')
|
|
wheel(guest, hotbar_slot(guest, 'minecraft:oak_log'), -1)
|
|
grid = slots()
|
|
assert grid.get(100) == ('oak_log', 1) and grid.get(200) == ('oak_planks', 4), f'grid: {grid}'
|
|
wheel(guest, 200, 1)
|
|
assert slots().get(100) == ('oak_log', 1), f'wheel up over the result: {slots()}'
|
|
wheel(guest, 200, -1)
|
|
crafted = slots()
|
|
guest.shot(out, 'wheel-crafting')
|
|
assert 100 not in crafted and 200 not in crafted, f'after the result: {crafted}'
|
|
assert [crafted[s] for s in where(crafted, 'oak_planks')] == [('oak_planks', 4)], f'after the result: {crafted}'
|
|
shut()
|
|
counts = guest('inventory')['counts']
|
|
assert counts.get('minecraft:oak_planks') == 4 and counts.get('minecraft:oak_log') == 1, f'the server has {counts}'
|
|
|
|
# Wheel events as fast as they come, with no time for the server to answer: nothing stays in the
|
|
# cursor and no item appears or goes.
|
|
for mode in ('survival', 'creative'):
|
|
guest('command', command=f'gamemode {mode}')
|
|
held(('cobblestone', 40), ('coal', 40))
|
|
place('air') # a chest placed on a chest keeps its items
|
|
place('chest')
|
|
opened()
|
|
first, second = hotbar_slot(guest, 'minecraft:cobblestone'), hotbar_slot(guest, 'minecraft:coal')
|
|
for round_ in range(12):
|
|
wheel_fast(guest, first if round_ % 3 else second, -2)
|
|
wheel_fast(guest, 300, 1 if round_ % 2 else -1)
|
|
guest.wait(30)
|
|
assert guest('screen').get('carried') is None, f'{mode}: a stack stayed in the cursor'
|
|
total = {}
|
|
for item, count in server_view().values():
|
|
total[item] = total.get(item, 0) + count
|
|
assert total == {'cobblestone': 40, 'coal': 40}, f'{mode}: the items became {total}'
|
|
shut()
|
|
guest('command', command='gamemode survival')
|
|
|
|
# A furnace burning the fuel the wheel is taking: the click on the emptied slot takes nothing, and
|
|
# what comes next picks up nothing either.
|
|
held(('coal', 40), ('raw_copper', 40))
|
|
place('furnace')
|
|
opened()
|
|
coal, copper = hotbar_slot(guest, 'minecraft:coal'), hotbar_slot(guest, 'minecraft:raw_copper')
|
|
for round_ in range(10):
|
|
wheel_fast(guest, coal, -1)
|
|
wheel_fast(guest, copper, -1)
|
|
wheel_fast(guest, 301, -1)
|
|
guest.wait(4)
|
|
guest.wait(30)
|
|
assert guest('screen').get('carried') is None, 'a stack stayed in the cursor over a burning furnace'
|
|
shut()
|
|
|
|
# The crafting table, as fast: into the grid and out, and over the result.
|
|
held(('oak_log', 20), ('dirt', 20))
|
|
place('crafting_table')
|
|
opened('inventory')
|
|
log, dirt = hotbar_slot(guest, 'minecraft:oak_log'), hotbar_slot(guest, 'minecraft:dirt')
|
|
for round_ in range(12):
|
|
wheel_fast(guest, log if round_ % 2 else dirt, -2)
|
|
wheel_fast(guest, 100 + round_ % 3, -1)
|
|
wheel_fast(guest, 200, -1)
|
|
guest.wait(30)
|
|
assert guest('screen').get('carried') is None, 'a stack stayed in the cursor over a crafting table'
|
|
shut()
|
|
counts = guest('inventory')['counts']
|
|
assert counts.get('minecraft:dirt') == 20, f'the dirt became {counts}'
|
|
guest.raw('key', 'e', 'tap')
|
|
guest.wait(5)
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(5)
|
|
|
|
# Hopper, dropper: plain slots, both ways.
|
|
held(('cobblestone', 3))
|
|
for name in ('hopper', 'dropper'):
|
|
place(name)
|
|
opened()
|
|
wheel(guest, hotbar_slot(guest, 'minecraft:cobblestone'), -1)
|
|
view = server_view()
|
|
assert view.get(300) == ('cobblestone', 1), f'{name}: {view}'
|
|
wheel(guest, 300, -1)
|
|
view = server_view()
|
|
assert 300 not in view, f'{name}, pushed back: {view}'
|
|
shut()
|
|
finally:
|
|
back_to_game(guest)
|
|
set_mouse_tweaks(guest, False)
|
|
place('air')
|
|
return {'furnace': dragged, 'crafting': crafted}
|
|
|
|
|
|
def check_feedback(host, guest, out):
|
|
x, y, z = (round(v, 1) for v in pos_of(guest))
|
|
guest('command', command='item replace entity @s weapon.mainhand with minecraft:stick')
|
|
guest('command', command=f'tp @s {x:.1f} {y:.1f} {z:.1f}')
|
|
guest.wait(10)
|
|
chat = guest.raw('messages', 5)['chat']
|
|
# The host is an operator: broadcastToAdmins tells it, as `[Guest: ...]`.
|
|
host_chat = host.raw('messages', 5)['chat']
|
|
guest.raw('key', 't', 'tap')
|
|
guest.wait(5)
|
|
guest.shot(out, 'feedback')
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(5)
|
|
screen = guest('state').get('screen')
|
|
replaced = f'Replaced 1 slot(s) on {GUEST_NAME} with [Stick]'
|
|
assert any(replaced in line for line in chat), f'no "{replaced}" in {chat}'
|
|
assert any(line.startswith(f'Teleported {GUEST_NAME} to {x:.6f}') for line in chat), f'teleport feedback: {chat}'
|
|
assert screen in (None, 'playing'), f'the chat did not close: {screen}'
|
|
told = f'[{GUEST_NAME}: Teleported {GUEST_NAME} to {x:.6f}'
|
|
assert any(line.startswith(told) for line in host_chat), f'no "{told}" for the host: {host_chat}'
|
|
return chat + host_chat
|
|
|
|
|
|
def chat_input(game):
|
|
return game('screen').get('chat_input')
|
|
|
|
|
|
def complete(guest, text):
|
|
"""Opens the chat on `/`, types `text` and presses Tab: the input after it."""
|
|
guest.raw('key', 'slash', 'tap')
|
|
guest.until(lambda: guest('screen').get('screen'), lambda s: s == 'chat', ticks=3)
|
|
guest.raw('type', text)
|
|
guest.until(lambda: guest('screen').get('chat_suggestions'), bool, ticks=2)
|
|
guest.raw('key', 'tab', 'tap')
|
|
return guest.until(lambda: chat_input(guest), lambda value: value != '/' + text, ticks=2)
|
|
|
|
|
|
def check_commands(host, guest, out):
|
|
literal = complete(guest, 'gamem')
|
|
# The first Escape hides the suggestions, the next closes the chat.
|
|
for _ in range(3):
|
|
if guest('screen').get('screen') != 'chat':
|
|
break
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(3)
|
|
argument = complete(guest, 'gamemode cr')
|
|
guest.shot(out, 'commands')
|
|
guest.raw('key', 'enter', 'tap')
|
|
guest.wait(20)
|
|
mode = guest('state').get('gamemode')
|
|
screen = guest('state').get('screen')
|
|
guest('command', command='gamemode survival')
|
|
guest.wait(10)
|
|
# A node the tree marks ask_server (a bossbar id): the guest asks the
|
|
# server, which completes it from its own bossbars.
|
|
host('command', command='bossbar add pt6:check "Check"')
|
|
host.wait(5)
|
|
guest.raw('key', 'slash', 'tap')
|
|
guest.wait(3)
|
|
guest.raw('type', 'bossbar get pt')
|
|
guest.wait(10)
|
|
guest.raw('key', 'tab', 'tap')
|
|
guest.wait(2)
|
|
asked = chat_input(guest)
|
|
for _ in range(3):
|
|
if guest('screen').get('screen') != 'chat':
|
|
break
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(3)
|
|
host('command', command='bossbar remove pt6:check')
|
|
assert literal == '/gamemode', f'Tab after /gamem: {literal!r}'
|
|
assert argument == '/gamemode creative', f'Tab after /gamemode cr: {argument!r}'
|
|
assert mode == 'creative', f'the completed command did not run: {mode}'
|
|
assert screen in (None, 'playing'), f'the chat did not close: {screen}'
|
|
assert asked == '/bossbar get pt6:check', f'Tab after /bossbar get pt: {asked!r}'
|
|
return {'literal': literal, 'argument': argument, 'mode': mode, 'ask_server': asked}
|
|
|
|
|
|
def centre(rect):
|
|
x, y, w, h = rect
|
|
return x + w / 2, y + h / 2
|
|
|
|
|
|
def grid_items(game):
|
|
slots = game('screen').get('slots') or []
|
|
return {s['slot']: s.get('item') for s in slots if 100 <= s['slot'] < 109 and s.get('item')}
|
|
|
|
|
|
def check_recipes(host, guest, out):
|
|
guest('command', command='clear @s')
|
|
guest('command', command='give @s minecraft:oak_log 4')
|
|
guest.wait(40)
|
|
guest.raw('key', 'e', 'tap')
|
|
guest.wait(5)
|
|
try:
|
|
book = guest('screen').get('recipe_book')
|
|
assert book, 'the guest\'s inventory has no recipe book'
|
|
if len(book['targets']) == 1:
|
|
guest.raw('click-at', *centre(book['targets'][0]))
|
|
guest.wait(5)
|
|
book = guest('screen').get('recipe_book')
|
|
known = book['known']
|
|
assert known > 0, f'the guest\'s book is empty: {book}'
|
|
guest.raw('click-at', *centre(book['targets'][1]))
|
|
guest.wait(10)
|
|
grid = grid_items(guest)
|
|
guest.shot(out, 'recipes')
|
|
finally:
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(5)
|
|
assert any('oak_log' in str(item) for item in grid.values()), f'nothing placed in the grid: {grid}'
|
|
# #2019: the alternatives popup belongs to one screen. Check both the
|
|
# host and the remote guest, with both
|
|
# inventory-close keys and a different recipe-book type on reopening.
|
|
for game in (host, guest):
|
|
game('command', command='recipe give @s minecraft:oak_planks')
|
|
game('command', command='recipe give @s minecraft:birch_planks')
|
|
game.wait(10)
|
|
for close_key, reopen in (('e', 'inventory'), ('esc', 'furnace')):
|
|
game.raw('key', 'e', 'tap')
|
|
game.wait(5)
|
|
book = game('screen')['recipe_book']
|
|
if len(book['targets']) == 1:
|
|
game.raw('click-at', *centre(book['targets'][0]))
|
|
game.wait(5)
|
|
book = game('screen')['recipe_book']
|
|
for rect in book['targets']:
|
|
if rect[2:] != [25.0, 25.0]:
|
|
continue
|
|
game.raw('click-at', *centre(rect), 'right')
|
|
game.wait(2)
|
|
targets = game('screen')['recipe_book']['targets']
|
|
if targets and all(r[2:] == [24.0, 24.0] for r in targets):
|
|
break
|
|
else:
|
|
raise AssertionError(f'{game.name}: no alternatives popup opened: {book}')
|
|
game.shot(out, f'recipe-overlay-{close_key}-before')
|
|
game.raw('key', close_key, 'tap')
|
|
game.wait(5)
|
|
if reopen == 'inventory':
|
|
game.raw('key', 'e', 'tap')
|
|
else:
|
|
x, y, z = (int(v // 1) for v in pos_of(game))
|
|
game('command', command=f'setblock {x} {y} {z - 2} minecraft:furnace')
|
|
game.wait(5)
|
|
game.raw('interact', x, y, z - 2)
|
|
game.wait(5)
|
|
targets = game('screen')['recipe_book']['targets']
|
|
if reopen == 'furnace' and len(targets) == 1 and targets[0][2:] == [20.0, 18.0]:
|
|
game.raw('click-at', *centre(targets[0]))
|
|
game.wait(5)
|
|
targets = game('screen')['recipe_book']['targets']
|
|
game.shot(out, f'recipe-overlay-{close_key}-after')
|
|
assert any(r[2:] == [20.0, 18.0] for r in targets), f'{game.name}: stale popup: {targets}'
|
|
if reopen == 'inventory':
|
|
assert len(targets) > 1, f'{game.name}: the book forgot its open setting'
|
|
game.raw('key', 'esc', 'tap')
|
|
game.wait(5)
|
|
return {'known': known, 'grid': grid}
|
|
|
|
|
|
def check_titles(host, guest, out):
|
|
# TitleCommand sends the guest its title packets; its HUD shows and clears them.
|
|
for command in ('title @s times 10 400 10', 'title @s subtitle "Under"', 'title @s title "Over"',
|
|
'title @s actionbar "Bar"'):
|
|
guest('command', command=command)
|
|
guest.wait(5)
|
|
shown = {k: guest.raw('messages', 1)[k] for k in ('title', 'subtitle', 'actionbar')}
|
|
guest.shot(out, 'titles')
|
|
guest('command', command='title @s clear')
|
|
guest.wait(5)
|
|
cleared = {k: guest.raw('messages', 1)[k] for k in ('title', 'subtitle')}
|
|
assert shown == {'title': 'Over', 'subtitle': 'Under', 'actionbar': 'Bar'}, f'titles shown: {shown}'
|
|
assert cleared == {'title': None, 'subtitle': None}, f'titles cleared: {cleared}'
|
|
return {'shown': shown, 'cleared': cleared}
|
|
|
|
|
|
def check_chat(host, guest, out):
|
|
# The agent's event log follows a remote client's chat: `wait-for chat` sees the line another
|
|
# player sends (and on the server thread the host window's own line, back from the server).
|
|
line = f'chat check {int(time.time())}'
|
|
host.raw('chat', line)
|
|
seen = {g.name: g.raw('wait-for', 'chat', line, '--timeout', 10, '--since', 0).get('ok')
|
|
for g in (guest, host)}
|
|
guest.shot(out, 'chat')
|
|
return seen
|
|
|
|
|
|
def styled_parts(component):
|
|
"""Every component in a chat line's tree (the line, its `extra` and `with` parts)."""
|
|
if isinstance(component, dict):
|
|
yield component
|
|
for key in ('extra', 'with'):
|
|
for part in component.get(key) or []:
|
|
yield from styled_parts(part)
|
|
elif isinstance(component, list):
|
|
for part in component:
|
|
yield from styled_parts(part)
|
|
|
|
|
|
def check_styles(host, guest, out):
|
|
# ChatComponent keeps what the server sent: the style, the click and the hover event.
|
|
stamp = f'STYLE {int(time.time())}'
|
|
line = {'text': stamp, 'color': 'green', 'bold': True,
|
|
'click_event': {'action': 'suggest_command', 'command': '/say clicked'},
|
|
'hover_event': {'action': 'show_text', 'value': 'Tip'}}
|
|
seen = {}
|
|
for g in (host, guest):
|
|
# Each to itself: tellraw reaches only the player whose command it is here.
|
|
since = g.raw('events')['last']
|
|
g('command', command='tellraw @s ' + json.dumps(line))
|
|
g.raw('wait-for', 'chat', stamp, '--timeout', 10, '--since', since)
|
|
part = next((p for c in g.raw('messages', 10)['styled'] for p in styled_parts(c)
|
|
if stamp in str(p.get('text', ''))), None) or {}
|
|
seen[g.name] = {k: part.get(k) for k in ('color', 'bold', 'click_event', 'hover_event')}
|
|
hover = seen[g.name]['hover_event'] or {}
|
|
seen[g.name]['hover_event'] = [hover.get('action'), 'Tip' in str(hover.get('value'))]
|
|
# InventoryChangeTrigger fires inside /give, before its message; the operator echo
|
|
# (broadcastToAdmins) skips the player whose command it was.
|
|
order = {}
|
|
for g in (host, guest):
|
|
for command in ('advancement revoke @s everything', 'clear @s'):
|
|
g('command', command=command)
|
|
g.wait(5)
|
|
since = g.raw('events')['last']
|
|
g('command', command='give @s minecraft:diamond')
|
|
g.raw('wait-for', 'chat', 'Diamonds!', '--timeout', 10, '--since', since)
|
|
g.wait(20)
|
|
chat = g.raw('messages', 8)['chat']
|
|
name = GUEST_NAME if g is guest else host.player
|
|
gave = next((i for i, l in enumerate(chat) if l.startswith('Gave 1 [Diamond]')), None)
|
|
made = next((i for i, l in enumerate(chat) if '[Diamonds!]' in l), None)
|
|
echo = [l for l in chat if l.startswith(f'[{name}: ')]
|
|
# Newest first: the give's message is newer than the advancement's.
|
|
order[g.name] = {'gave': gave, 'advancement': made, 'echo': echo}
|
|
guest.raw('key', 't', 'tap')
|
|
guest.wait(5)
|
|
guest.shot(out, 'styles')
|
|
guest.raw('key', 'esc', 'tap')
|
|
want = {'color': 'green', 'bold': True,
|
|
'click_event': {'action': 'suggest_command', 'command': '/say clicked'},
|
|
'hover_event': ['show_text', True]}
|
|
for name, got in seen.items():
|
|
assert got == want, f'{name} styled line: {got}'
|
|
for name, o in order.items():
|
|
assert o['gave'] is not None and o['advancement'] is not None, f'{name} chat: {o}'
|
|
assert o['gave'] < o['advancement'], f'{name}: the give came before the advancement: {o}'
|
|
assert not o['echo'], f'{name} heard its own command: {o}'
|
|
return {'styles': seen, 'order': order}
|
|
|
|
|
|
def png_rows(path):
|
|
"""The rows of an 8-bit RGB or RGBA PNG, unfiltered, as bytes of 4 (or 3) per pixel."""
|
|
data = Path(path).read_bytes()
|
|
pos, idat, head = 8, b'', None
|
|
while pos < len(data):
|
|
size = int.from_bytes(data[pos:pos + 4], 'big')
|
|
kind, body = data[pos + 4:pos + 8], data[pos + 8:pos + 8 + size]
|
|
if kind == b'IHDR':
|
|
head = body
|
|
elif kind == b'IDAT':
|
|
idat += body
|
|
pos += 12 + size
|
|
width, height = int.from_bytes(head[:4], 'big'), int.from_bytes(head[4:8], 'big')
|
|
assert head[8] == 8 and head[9] in (2, 6) and head[12] == 0, f'unsupported PNG {head.hex()}'
|
|
bpp = 4 if head[9] == 6 else 3
|
|
raw, stride, rows, prev = zlib.decompress(idat), width * bpp, [], bytearray(width * bpp)
|
|
for y in range(height):
|
|
kind, line = raw[y * (stride + 1)], bytearray(raw[y * (stride + 1) + 1:(y + 1) * (stride + 1)])
|
|
for i in range(stride):
|
|
a = line[i - bpp] if i >= bpp else 0
|
|
b, c = prev[i], prev[i - bpp] if i >= bpp else 0
|
|
if kind == 1:
|
|
line[i] = (line[i] + a) & 255
|
|
elif kind == 2:
|
|
line[i] = (line[i] + b) & 255
|
|
elif kind == 3:
|
|
line[i] = (line[i] + (a + b) // 2) & 255
|
|
elif kind == 4:
|
|
pa, pb, pc = abs(b - c), abs(a - c), abs(a + b - 2 * c)
|
|
line[i] = (line[i] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)) & 255
|
|
rows.append(bytes(line))
|
|
prev = line
|
|
return rows, bpp
|
|
|
|
|
|
def changed_pixels(a, b):
|
|
(ra, bpp), (rb, _) = png_rows(a), png_rows(b)
|
|
return sum(1 for la, lb in zip(ra, rb) for i in range(0, len(la), bpp)
|
|
if max(abs(la[i + k] - lb[i + k]) for k in range(3)) > 24)
|
|
|
|
|
|
def sneaking_on_host(host, want, ticks=40):
|
|
"""Whether the host sees the guest crouching, once it matches `want` or after `ticks`."""
|
|
for _ in range(ticks // 5):
|
|
sneaking = lan_guest(host)['sneaking']
|
|
if sneaking == want:
|
|
return sneaking
|
|
host.wait(5)
|
|
return sneaking
|
|
|
|
|
|
def check_walls(host, guest, out):
|
|
# A stone wall between the two: the host still sees the guest's name through it
|
|
# (TEXT_SEE_THROUGH), but not once the guest crouches (Entity.isDiscrete).
|
|
for g in (host, guest):
|
|
back_to_game(g)
|
|
# The trial spawner's zombies may have killed the guest.
|
|
if dead(guest):
|
|
guest.raw('respawn')
|
|
guest.wait(40)
|
|
face_each_other(host, guest)
|
|
hx, hy, hz = pos_of(host)
|
|
gx, gy, gz = pos_of(guest)
|
|
# Halfway: the players stand 3 apart, so the wall's block touches neither.
|
|
wall = int((hx + gx) / 2 // 1)
|
|
host('command', command=f'fill {wall} {int(hy // 1)} {int(hz // 1) - 3} {wall} {int(hy // 1) + 4} '
|
|
f'{int(hz // 1) + 3} minecraft:stone')
|
|
try:
|
|
host.raw('look-at', f'{gx:.2f}', f'{gy + 2.3:.2f}', f'{gz:.2f}')
|
|
# Chat lines fade out after 200 ticks; let them go so only the name tag can differ.
|
|
host.wait(210)
|
|
standing = host.shot(out, 'walls-standing')
|
|
guest.raw('key', 'shift', 'down')
|
|
crouched = sneaking_on_host(host, True)
|
|
guest.wait(10)
|
|
crouching = host.shot(out, 'walls-crouching')
|
|
guest.raw('key', 'shift', 'up')
|
|
stood = not sneaking_on_host(host, False)
|
|
guest.wait(10)
|
|
again = host.shot(out, 'walls-standing-again')
|
|
finally:
|
|
guest.raw('key', 'shift', 'up')
|
|
host('command', command=f'fill {wall} {int(hy // 1)} {int(hz // 1) - 3} {wall} {int(hy // 1) + 4} '
|
|
f'{int(hz // 1) + 3} minecraft:air')
|
|
face_each_other(host, guest)
|
|
assert crouched and stood, f'the host never saw the guest crouch and stand: {crouched}, {stood}'
|
|
seen, steady = changed_pixels(standing, crouching), changed_pixels(standing, again)
|
|
assert seen > 50, f'no name tag through the wall: {seen} pixels differ standing vs crouching'
|
|
assert steady < seen // 4, f'the view itself changed: {steady} pixels between two standing shots ({seen})'
|
|
return {'standing_vs_crouching': seen, 'standing_twice': steady}
|
|
|
|
|
|
def state_of(game, pos, key):
|
|
state = game.raw('block', *pos)['state']
|
|
return dict(kv.split('=') for kv in state.partition('[')[2].rstrip(']').split(',') if kv).get(key)
|
|
|
|
|
|
def wait_state(game, pos, key, want, ticks):
|
|
for _ in range(ticks // 10):
|
|
if state_of(game, pos, key) == want:
|
|
return want
|
|
game.wait(10)
|
|
return state_of(game, pos, key)
|
|
|
|
|
|
def check_piston_entities(host, guest, out):
|
|
"""A moving piston pushes a falling block and an arrow standing in front of it."""
|
|
hx, _, hz = pos_of(host)
|
|
x, y, z = (int(hx // 16) * 16 + 8, 100, int(hz // 16) * 16 + 8)
|
|
host('command', command=f'fill {x-4} {y-1} {z-4} {x+7} {y-1} {z+4} minecraft:stone')
|
|
host('command', command=f'fill {x-4} {y} {z-4} {x+7} {y+5} {z+4} minecraft:air')
|
|
host('command', command=f'setblock {x} {y} {z} minecraft:piston[facing=east]')
|
|
host('command', command=f'tp @s {x+5.5} {y} {z+3.5}')
|
|
kinds = {
|
|
'falling_block': '{BlockState:{Name:"minecraft:sand"},Time:1,NoGravity:1b}',
|
|
'arrow': '{}',
|
|
}
|
|
for i, (kind, nbt) in enumerate(kinds.items()):
|
|
host('command', command=f'summon minecraft:{kind} {x+1.5} {y} {z+0.5} {nbt}')
|
|
host.wait(20)
|
|
def xs():
|
|
got = {}
|
|
for kind in kinds:
|
|
objs = [o for o in host('status')['projectiles'] if kind in str(o.get('kind', '')).lower()
|
|
and abs(o["position"][2] - z - 0.5) < 0.6]
|
|
assert objs, f'{kind} not found before the push'
|
|
got[kind] = objs[0]['position'][0]
|
|
return got
|
|
before = xs()
|
|
host('command', command=f'setblock {x-1} {y} {z} minecraft:redstone_block')
|
|
host.wait(30)
|
|
after = xs()
|
|
results = {}
|
|
for kind in kinds:
|
|
results[kind] = after[kind] - before[kind]
|
|
assert results[kind] > 0.3, f'piston did not push {kind}: {before[kind]} -> {after[kind]}'
|
|
return results
|
|
|
|
|
|
def check_piston_push(host, guest, out):
|
|
"""A moving piston pushes both players and a stationary mob on each host path."""
|
|
hx, _, hz = pos_of(host)
|
|
x, y, z = (int(hx // 16) * 16 + 8, 100, int(hz // 16) * 16 + 8)
|
|
host('command', command=f'fill {x-4} {y-1} {z-4} {x+7} {y-1} {z+4} minecraft:stone')
|
|
host('command', command=f'fill {x-4} {y} {z-4} {x+7} {y+5} {z+4} minecraft:air')
|
|
host('command', command=f'setblock {x} {y} {z} minecraft:piston[facing=east]')
|
|
results = {}
|
|
for player, name in ((host, host.player), (guest, GUEST_NAME)):
|
|
host('command', command=f'setblock {x-1} {y} {z} minecraft:air')
|
|
host.wait(10)
|
|
host('command', command=f'tp @s {x+5.5} {y} {z+2.5}')
|
|
host('command', command=f'tp {GUEST_NAME} {x+5.5} {y} {z-1.5}')
|
|
host('command', command=f'tp {"@s" if player is host else GUEST_NAME} {x+1.65} {y} {z+0.5}')
|
|
player.wait(15)
|
|
before = pos_of(player)
|
|
assert abs(before[0] - (x + 1.65)) < 0.1, f'{name} did not reach piston: {before}'
|
|
host('command', command=f'setblock {x-1} {y} {z} minecraft:redstone_block')
|
|
player.wait(30)
|
|
after = pos_of(player)
|
|
results[name] = after[0] - before[0]
|
|
assert results[name] > 0.3, f'piston did not push {name}: {before} -> {after}'
|
|
host('command', command=f'setblock {x-1} {y} {z} minecraft:air')
|
|
host.wait(10)
|
|
host('command', command=f'tp {GUEST_NAME} {x+5.5} {y} {z-1.5}')
|
|
host('command', command=f'tp @s {x+1.65} {y} {z+0.5}')
|
|
host.wait(10)
|
|
host.raw('key', 'e', 'tap')
|
|
host.raw('wait-for', 'screen', 'inventory')
|
|
before = pos_of(host)
|
|
guest('command', command=f'setblock {x-1} {y} {z} minecraft:redstone_block')
|
|
host.wait(30)
|
|
after = pos_of(host)
|
|
results['inventory'] = after[0] - before[0]
|
|
assert results['inventory'] > 0.3, f'piston did not push player with inventory open: {before} -> {after}'
|
|
host.raw('key', 'esc', 'tap')
|
|
host('command', command=f'setblock {x-1} {y} {z} minecraft:air')
|
|
host.wait(10)
|
|
host('command', command=f'tp @s {x+5.5} {y} {z+2.5}')
|
|
host('command', command=f'summon minecraft:pig {x+1.65} {y} {z+0.5} {{NoAI:1b}}')
|
|
host.wait(10)
|
|
pigs = host('entities', radius=16, type='pig')['entities']
|
|
pig = min(pigs, key=lambda p: abs(p['pos'][0] - (x + 1.65)) + abs(p['pos'][2] - (z + 0.5)))
|
|
before = pig['pos'][0]
|
|
host('command', command=f'setblock {x-1} {y} {z} minecraft:redstone_block')
|
|
host.wait(30)
|
|
pigs = host('entities', radius=16, type='pig')['entities']
|
|
after = next(p['pos'][0] for p in pigs if p['id'] == pig['id'])
|
|
results['pig'] = after - before
|
|
assert results['pig'] > 0.3, f'piston did not push pig: {before} -> {after}'
|
|
results.update(piston_push_at_border(host, guest))
|
|
return results
|
|
|
|
|
|
def piston_push_at_border(host, guest):
|
|
"""A piston next to the world border: the border's wall stops the pushed
|
|
body, as it stops ordinary movement (`Entity.collectCollidersIgnoringWorldBorder`)."""
|
|
hx, _, hz = pos_of(host)
|
|
x, y, z = (int(hx // 16) * 16 + 8, 100, int(hz // 16) * 16 + 8)
|
|
host('command', command=f'fill {x-4} {y-1} {z-4} {x+7} {y-1} {z+4} minecraft:stone')
|
|
host('command', command=f'fill {x-4} {y} {z-4} {x+7} {y+5} {z+4} minecraft:air')
|
|
host('command', command=f'setblock {x} {y} {z} minecraft:piston[facing=east]')
|
|
# The border's east wall stands at `x + 2`, the east face of the block the
|
|
# head extends into: a body pushed east stops there instead of being
|
|
# pushed out through the border. `worldborder center` centres integers on
|
|
# the block, so the centre is `x - 8.5` and the wall lands on `x + 2`.
|
|
wall = x + 2
|
|
center = [x - 8.5, z + 0.5]
|
|
results = {}
|
|
try:
|
|
host('command', command=f'worldborder center {x - 9} {z}')
|
|
host('command', command='worldborder set 20.5')
|
|
seen = None
|
|
for _ in range(10):
|
|
host.wait(2)
|
|
seen = [g('status').get('border') for g in (host, guest)]
|
|
if all(b and b['size'] == 20.5 and b['center'] == center for b in seen):
|
|
break
|
|
results['border'] = seen
|
|
for player, name in ((host, host.player), (guest, GUEST_NAME)):
|
|
host('command', command=f'setblock {x-1} {y} {z} minecraft:air')
|
|
host.wait(10)
|
|
host('command', command=f'tp @s {x+5.5} {y} {z+2.5}')
|
|
host('command', command=f'tp {GUEST_NAME} {x+5.5} {y} {z-1.5}')
|
|
host('command', command=f'tp {"@s" if player is host else GUEST_NAME} {x+1.65} {y} {z+0.5}')
|
|
player.wait(15)
|
|
before = pos_of(player)
|
|
assert abs(before[0] - (x + 1.65)) < 0.1, f'{name} did not reach the piston: {before}'
|
|
host('command', command=f'setblock {x-1} {y} {z} minecraft:redstone_block')
|
|
player.wait(30)
|
|
after = pos_of(player)
|
|
# The wall's inner face is at `wall`: the body's box stops there.
|
|
results[name] = round(after[0] - before[0], 3)
|
|
assert after[0] > before[0], f'the piston did not push {name}: {before} -> {after}'
|
|
assert after[0] <= wall - 0.3 + 0.01, f'the border did not stop {name}: {before} -> {after}'
|
|
host('command', command=f'setblock {x-1} {y} {z} minecraft:air')
|
|
host.wait(10)
|
|
finally:
|
|
host('command', command='worldborder center 0 0')
|
|
host('command', command='worldborder set 59999968')
|
|
return results
|
|
|
|
|
|
def check_piston_slime(host, guest, out):
|
|
"""A piston pushing a slime block: the block pushes the body in front of
|
|
it and bounces it (`PistonMovingBlockEntity.moveCollidedEntities`)."""
|
|
hx, _, hz = pos_of(host)
|
|
x, y, z = (int(hx // 16) * 16 + 8, 100, int(hz // 16) * 16 + 8)
|
|
results = {}
|
|
|
|
def setup():
|
|
# The previous case may have left a mob where the body stands.
|
|
host('command', command='kill @e[type=minecraft:pig]')
|
|
host('command', command=f'setblock {x} {y} {z} minecraft:air')
|
|
host.wait(10)
|
|
host('command', command=f'fill {x-4} {y-1} {z-4} {x+7} {y-1} {z+4} minecraft:stone')
|
|
host('command', command=f'fill {x-4} {y} {z-4} {x+7} {y+5} {z+4} minecraft:air')
|
|
host('command', command=f'setblock {x} {y} {z} minecraft:piston[facing=east]')
|
|
host.wait(5)
|
|
|
|
for player, name in ((host, host.player), (guest, GUEST_NAME)):
|
|
setup()
|
|
host('command', command=f'setblock {x+1} {y} {z} minecraft:slime_block')
|
|
host('command', command=f'tp @s {x+5.5} {y} {z+2.5}')
|
|
host('command', command=f'tp {GUEST_NAME} {x+5.5} {y} {z-1.5}')
|
|
host('command', command=f'tp {"@s" if player is host else GUEST_NAME} {x+2.5} {y} {z+0.5}')
|
|
player.wait(15)
|
|
before = pos_of(player)
|
|
assert abs(before[0] - (x + 2.5)) < 0.1, f'{name} did not reach the slime: {before}'
|
|
host('command', command=f'setblock {x-1} {y} {z} minecraft:redstone_block')
|
|
# The bounce sets the velocity along the push axis; it decays fast, so
|
|
# sample it while the block is still moving.
|
|
speed = 0.0
|
|
for _ in range(6):
|
|
player.wait(2)
|
|
speed = max(speed, player('state')['player']['velocity'][0])
|
|
after = pos_of(player)
|
|
results[name] = round(after[0] - before[0], 3)
|
|
# The block pushes the body out of its way, and the bounce launches it.
|
|
assert results[name] > 1.5, f'the slime block did not push and bounce {name}: {before} -> {after}'
|
|
assert speed > 0.05, f'the slime block did not bounce {name}: {speed}'
|
|
host('command', command=f'setblock {x-1} {y} {z} minecraft:air')
|
|
player.wait(10)
|
|
# A plain block only pushes: the body is not launched.
|
|
setup()
|
|
host('command', command=f'setblock {x+1} {y} {z} minecraft:stone')
|
|
host('command', command=f'tp @s {x+2.5} {y} {z+0.5}')
|
|
host.wait(15)
|
|
before = pos_of(host)
|
|
host('command', command=f'setblock {x-1} {y} {z} minecraft:redstone_block')
|
|
speed = 0.0
|
|
for _ in range(6):
|
|
host.wait(2)
|
|
speed = max(speed, host('state')['player']['velocity'][0])
|
|
after = pos_of(host)
|
|
results['stone'] = round(after[0] - before[0], 3)
|
|
assert results['stone'] < 1.5, f'a plain block bounced the player: {before} -> {after}'
|
|
host('command', command=f'setblock {x-1} {y} {z} minecraft:air')
|
|
host.wait(10)
|
|
return results
|
|
|
|
|
|
def check_plates(host, guest, out):
|
|
guest('command', command='gamemode survival')
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
plate = (x + 2, y, z)
|
|
guest('command', command=f'setblock {plate[0]} {plate[1]} {plate[2]} minecraft:stone_pressure_plate')
|
|
guest.wait(10)
|
|
guest('command', command=f'tp @s {plate[0] + 0.5} {plate[1]} {plate[2] + 0.5}')
|
|
pressed = {'host': wait_state(host, plate, 'powered', 'true', 40),
|
|
'guest': wait_state(guest, plate, 'powered', 'true', 40)}
|
|
guest.shot(out, 'plate-pressed')
|
|
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
# PressurePlateBlock.getPressedTime: a stone plate checks again every 20 ticks.
|
|
released = {'host': wait_state(host, plate, 'powered', 'false', 60),
|
|
'guest': wait_state(guest, plate, 'powered', 'false', 60)}
|
|
guest('command', command=f'setblock {plate[0]} {plate[1]} {plate[2]} minecraft:air')
|
|
detail = {'pressed': pressed, 'released': released}
|
|
assert pressed == {'host': 'true', 'guest': 'true'}, f'the plate under the guest: {detail}'
|
|
assert released == {'host': 'false', 'guest': 'false'}, f'the plate after the guest left: {detail}'
|
|
return detail
|
|
|
|
|
|
def check_plate_projectiles(host, guest, out):
|
|
guest('command', command='gamemode creative')
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
|
|
def press(block, dx, entity, nbt):
|
|
plate = (x + dx, y, z + 5)
|
|
guest('command', command=f'setblock {plate[0]} {plate[1]} {plate[2]} minecraft:{block}')
|
|
guest.wait(5)
|
|
at = f'{plate[0] + 0.5} {plate[1] + 0.05} {plate[2] + 0.5}'
|
|
guest('command', command=f'summon minecraft:{entity} {at} {nbt}')
|
|
state = wait_state(host, plate, 'powered', 'true', 15)
|
|
guest('command', command=f'kill @e[type=minecraft:{entity}]')
|
|
guest('command', command=f'setblock {plate[0]} {plate[1]} {plate[2]} minecraft:air')
|
|
return state
|
|
|
|
detail = {'rocket': press('oak_pressure_plate', 0, 'firework_rocket', '{LifeTime:60}')}
|
|
# A field of plates under the cast, so the bobber lands on one wherever it falls.
|
|
field = (x - 4, y, z + 2, x + 4, y, z + 7)
|
|
guest('command', command='fill {} {} {} {} {} {} minecraft:oak_pressure_plate'.format(*field))
|
|
guest('command', command='item replace entity @s weapon.mainhand with minecraft:fishing_rod')
|
|
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 25')
|
|
guest.wait(10)
|
|
guest('use_item')
|
|
guest.wait(40)
|
|
detail['bobber'] = sum('powered=true' in b[3] for b in host.raw('blocks', *field)['blocks'])
|
|
guest('use_item')
|
|
# A real throw on each kind of plate: a stone plate senses only players and mobs.
|
|
for block, key in (('oak_pressure_plate', 'trident'), ('stone_pressure_plate', 'stone')):
|
|
guest('command', command='fill {} {} {} {} {} {} minecraft:air'.format(*field))
|
|
guest.wait(30)
|
|
guest('command', command='fill {} {} {} {} {} {} minecraft:{}'.format(*field, block))
|
|
guest('command', command='item replace entity @s weapon.mainhand with minecraft:trident')
|
|
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 30')
|
|
guest.wait(10)
|
|
guest('use_item', hold=1)
|
|
guest.wait(30)
|
|
detail[key] = sum('powered=true' in b[3] for b in host.raw('blocks', *field)['blocks'])
|
|
guest('command', command='kill @e[type=minecraft:trident]')
|
|
guest('command', command='fill {} {} {} {} {} {} minecraft:air'.format(*field))
|
|
assert detail['rocket'] == 'true', f'a rocket on a wooden plate: {detail}'
|
|
assert detail['bobber'] > 0, f'no plate under the bobber: {detail}'
|
|
assert detail['trident'] > 0, f'no plate under the trident: {detail}'
|
|
assert detail['stone'] == 0, f'a stone plate senses no trident: {detail}'
|
|
return detail
|
|
|
|
|
|
def check_trials(host, guest, out):
|
|
# The host watches as a spectator, whom neither block detects: only the guest can wake them.
|
|
hx, hy, hz = pos_of(host)
|
|
host('command', command='gamemode spectator')
|
|
host('command', command='difficulty normal')
|
|
host('command', command='gamerule spawn_mobs true')
|
|
guest('command', command='gamemode survival')
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
spawner, vault = (x, y, z + 3), (x, y, z - 2)
|
|
# Open air from the vault to the spawner: the spawner wants to see the guest's eyes.
|
|
guest('command', command=f'fill {x - 1} {y} {z - 2} {x + 1} {y + 2} {z + 3} minecraft:air')
|
|
try:
|
|
guest('command', command=f'setblock {spawner[0]} {spawner[1]} {spawner[2]} minecraft:trial_spawner'
|
|
'{spawn_data:{entity:{id:"minecraft:zombie"}},required_player_range:6}')
|
|
guest('command', command=f'setblock {vault[0]} {vault[1]} {vault[2]} minecraft:vault'
|
|
'{config:{key_item:{id:"minecraft:trial_key",count:1}}}')
|
|
trial = wait_state(host, spawner, 'trial_spawner_state', 'active', 100)
|
|
active = wait_state(host, vault, 'vault_state', 'active', 60)
|
|
host.shot(out, 'trials-active')
|
|
guest('command', command='item replace entity @s weapon.mainhand with minecraft:trial_key')
|
|
guest.wait(5)
|
|
guest.raw('interact', *vault)
|
|
unlocking = wait_state(host, vault, 'vault_state', 'unlocking', 20)
|
|
guest.shot(out, 'trials-unlocking')
|
|
rewarded = host('command', command=f'data get block {vault[0]} {vault[1]} {vault[2]} server_data.rewarded_players')
|
|
host_uuid = host('command', command='data get entity @s UUID')
|
|
finally:
|
|
host('command', command='gamerule spawn_mobs false')
|
|
host('command', command='kill @e[type=minecraft:zombie]')
|
|
host('command', command='gamemode survival')
|
|
# Back where it stood: a spectator drifts, and may be inside the ground when it lands.
|
|
host('command', command=f'tp @s {hx:.2f} {hy:.2f} {hz:.2f}')
|
|
guest('command', command=f'setblock {spawner[0]} {spawner[1]} {spawner[2]} minecraft:air')
|
|
guest('command', command=f'setblock {vault[0]} {vault[1]} {vault[2]} minecraft:air')
|
|
uuid = lambda reply: re.findall(r'-?\d+', json.dumps(reply).partition('[I;')[2].partition(']')[0])
|
|
assert trial == 'active', f'the trial spawner never saw the guest: {trial}'
|
|
assert active == 'active', f'the vault never saw the guest: {active}'
|
|
assert unlocking == 'unlocking', f'the guest\'s key did not unlock the vault: {unlocking}'
|
|
assert uuid(rewarded) and uuid(rewarded) != uuid(host_uuid), f'rewarded {rewarded}, host {host_uuid}'
|
|
return {'spawner': trial, 'vault': active, 'rewarded': uuid(rewarded), 'host': uuid(host_uuid)}
|
|
|
|
|
|
def bed_at(game, foot):
|
|
"""A bed whose foot is at `foot`, its head one block east."""
|
|
x, y, z = foot
|
|
game('command', command=f'setblock {x} {y} {z} minecraft:red_bed[facing=east,part=foot]')
|
|
game('command', command=f'setblock {x + 1} {y} {z} minecraft:red_bed[facing=east,part=head]')
|
|
return [x + 1, y, z]
|
|
|
|
|
|
def check_sleep(host, guest, out):
|
|
for g in (host, guest):
|
|
g('command', command='gamemode survival')
|
|
host('command', command='time set midnight')
|
|
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
guest_bed = bed_at(guest, (gx, gy, gz + 2))
|
|
host_bed = bed_at(host, (hx, hy, hz + 2))
|
|
guest.wait(10)
|
|
guest.raw('interact', *guest_bed)
|
|
guest.wait(10)
|
|
asleep = {'guest': guest('state').get('sleeping'), 'host': host('state').get('sleeping'),
|
|
'server': lan_guest(host).get('sleeping')}
|
|
guest.shot(out, 'sleep-guest')
|
|
# The host draws the guest lying in its bed (#1045): its client's (SLEEPING_POS), and a client
|
|
# pairing with the sleeper later too.
|
|
host.wait(5)
|
|
lying = {'host': bool(remote(host)['lying_players'])}
|
|
host.shot(out, 'sleep-host-sees-guest')
|
|
# Alone in bed: still night 120 ticks later (everyone must sleep).
|
|
guest.wait(120)
|
|
night = host('state')['time']['time_of_day']
|
|
# Escape gets the guest up (InBedChatScreen), then back to bed.
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(10)
|
|
up = guest('state').get('sleeping')
|
|
guest.raw('interact', *guest_bed)
|
|
host.raw('interact', *host_bed)
|
|
host.wait(5)
|
|
# Both in bed: the guest draws the host lying too.
|
|
lying['guest'] = bool(remote(guest)['lying_players'])
|
|
host.wait(125)
|
|
after = {'guest': guest('state').get('sleeping'), 'host': host('state').get('sleeping'),
|
|
'time': host('state')['time']['time_of_day']}
|
|
lying['after'] = bool(remote(guest)['lying_players'])
|
|
guest.shot(out, 'sleep-morning')
|
|
for at in (guest_bed, host_bed):
|
|
guest('command', command=f'fill {at[0] - 1} {at[1]} {at[2]} {at[0]} {at[1]} {at[2]} minecraft:air')
|
|
host('command', command='time set day')
|
|
detail = {'asleep': asleep, 'night': night, 'up': up, 'after': after, 'lying': lying}
|
|
assert asleep['guest'] == guest_bed and asleep['server'] == guest_bed, f'the guest is not in its bed: {detail}'
|
|
assert asleep['host'] is None, f'the guest\'s bed put the host to sleep: {detail}'
|
|
assert 13000 < night < 23000, f'one of two players asleep ended the night: {detail}'
|
|
assert up is None, f'Escape did not get the guest up: {detail}'
|
|
assert after['guest'] is None and after['host'] is None and after['time'] < 1000, f'no morning: {detail}'
|
|
assert lying['host'] and lying['guest'], f'a sleeping player is drawn standing: {detail}'
|
|
assert not lying['after'], f'a player up again is still drawn lying: {detail}'
|
|
return detail
|
|
|
|
|
|
def check_sleep_villager(host, guest, out):
|
|
"""AbstractBedBlock.kickVillagerOutOfBed: using a bed a villager sleeps in wakes the villager and
|
|
leaves the player up; the next use lies down. The sleeper's own body is drawn in its bed, in first
|
|
person too (LevelExtractor skips the camera entity unless it sleeps)."""
|
|
guest('command', command='gamemode survival')
|
|
host('command', command='time set midnight')
|
|
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
|
|
x, z = gx, gz + 3
|
|
guest('command', command=f'fill {x - 3} {gy - 1} {z - 2} {x + 4} {gy - 1} {z + 3} minecraft:stone')
|
|
guest('command', command=f'fill {x - 3} {gy} {z - 2} {x + 4} {gy + 3} {z + 3} minecraft:air')
|
|
bed = bed_at(guest, (x, gy, z))
|
|
guest('command', command=f'summon minecraft:villager {x - 1.5} {gy} {z + 1.5}')
|
|
|
|
def villager():
|
|
found = guest('entities', radius=12, type='villager')['entities']
|
|
return found[0]['pos'] if found else None
|
|
|
|
def in_bed(at):
|
|
# Lying at the head (setPosToBed), not standing beside it.
|
|
return at is not None and abs(at[0] - bed[0] - 0.5) < 0.5 and abs(at[2] - bed[2] - 0.5) < 0.5 \
|
|
and at[1] - gy > 0.4
|
|
seen = {}
|
|
try:
|
|
for _ in range(90):
|
|
if in_bed(villager()):
|
|
break
|
|
guest.wait(20)
|
|
seen['villager_asleep'] = villager()
|
|
assert in_bed(seen['villager_asleep']), f'the villager never lay down in the bed: {seen}'
|
|
guest.raw('interact', *bed)
|
|
guest.wait(10)
|
|
seen['villager_kicked'] = villager()
|
|
seen['up'] = guest('state').get('sleeping')
|
|
assert not in_bed(seen['villager_kicked']), f'the bed did not wake its villager: {seen}'
|
|
assert seen['up'] is None, f'the player lay down beside the villager: {seen}'
|
|
guest.raw('interact', *bed)
|
|
guest.wait(10)
|
|
seen['asleep'] = guest('state').get('sleeping')
|
|
seen['own_body_asleep'] = remote(guest).get('own_body')
|
|
guest.shot(out, 'sleep-villager')
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(10)
|
|
seen['own_body_up'] = remote(guest).get('own_body')
|
|
finally:
|
|
guest('command', command='kill @e[type=minecraft:villager,distance=..16]')
|
|
guest('command', command=f'fill {bed[0] - 1} {bed[1]} {bed[2]} {bed[0]} {bed[1]} {bed[2]} minecraft:air')
|
|
host('command', command='time set day')
|
|
assert seen['asleep'] == bed, f'the emptied bed did not take the player: {seen}'
|
|
assert seen['own_body_asleep'] and not seen['own_body_up'], f'the sleeper\'s own body: {seen}'
|
|
return seen
|
|
def check_bed_occupied(host, guest, out):
|
|
"""BedBlock.OCCUPIED (LivingEntity.startSleeping / stopSleeping): the bed the host sleeps in is
|
|
occupied, head and foot, and the guest cannot lie down in it (AbstractBedBlock.useWithoutItem's
|
|
"This bed is occupied"); the bed is free again after each way out of it: Escape, death, the bed
|
|
broken, and the sleeper leaving the game. Then the guest sleeps in it."""
|
|
port = host.raw('lan', 'status')['port']
|
|
for g in (host, guest):
|
|
g('command', command='gamemode survival')
|
|
host('command', command='time set midnight')
|
|
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
|
|
x, z = gx, gz + 3
|
|
guest('command', command=f'fill {x - 3} {gy - 1} {z - 2} {x + 4} {gy - 1} {z + 3} minecraft:stone')
|
|
guest('command', command=f'fill {x - 3} {gy} {z - 2} {x + 4} {gy + 3} {z + 3} minecraft:air')
|
|
bed = bed_at(guest, (x, gy, z))
|
|
foot = [bed[0] - 1, bed[1], bed[2]]
|
|
hx, hy, hz = pos_of(host)
|
|
beside = f'tp @s {x + 0.5} {gy} {z + 1.5}'
|
|
|
|
def occupied():
|
|
return [state_of(host, bed, 'occupied'), state_of(host, foot, 'occupied')]
|
|
|
|
def lie_down(game):
|
|
game('command', command=beside)
|
|
game.wait(10)
|
|
game.raw('interact', *bed)
|
|
game.wait(10)
|
|
return game('state').get('sleeping')
|
|
seen = {}
|
|
try:
|
|
host.wait(5)
|
|
seen['free'] = occupied()
|
|
seen['host_asleep'] = lie_down(host)
|
|
seen['occupied'] = occupied()
|
|
guest('command', command=f'tp @s {x + 0.5} {gy} {z - 0.5}')
|
|
guest.wait(10)
|
|
# The foot: the sleeper's body lies over the head.
|
|
guest.raw('interact', *foot)
|
|
guest.wait(5)
|
|
seen['refused'] = {'sleeping': guest('state').get('sleeping'), 'actionbar': guest.raw('messages', 1)['actionbar']}
|
|
guest.shot(out, 'bed-occupied')
|
|
host.raw('key', 'esc', 'tap')
|
|
host.wait(10)
|
|
seen['after_escape'] = occupied()
|
|
seen['asleep_again'] = lie_down(host)
|
|
host('command', command='kill @s')
|
|
host.wait(20)
|
|
respawn_when_ready(host)
|
|
host.wait(20)
|
|
seen['after_death'] = occupied()
|
|
seen['asleep_before_break'] = lie_down(host)
|
|
guest('command', command=f'setblock {foot[0]} {foot[1]} {foot[2]} minecraft:air')
|
|
guest.wait(10)
|
|
seen['up_after_break'] = host('state').get('sleeping')
|
|
host('command', command=f'tp @s {hx:.2f} {hy:.2f} {hz:.2f}')
|
|
bed_at(guest, (x, gy, z))
|
|
guest.wait(5)
|
|
seen['replaced'] = occupied()
|
|
seen['guest_asleep'] = lie_down(guest)
|
|
seen['occupied_by_guest'] = occupied()
|
|
guest.raw('disconnect')
|
|
time.sleep(2)
|
|
seen['after_leave'] = occupied()
|
|
guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME)
|
|
guest.raw('wait-for', 'loaded', '--timeout', 300)
|
|
guest.wait(20)
|
|
seen['screen_back'] = guest('state').get('screen')
|
|
back_to_game(guest)
|
|
seen['guest_back'] = lie_down(guest)
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(10)
|
|
finally:
|
|
back_to_game(host)
|
|
back_to_game(guest)
|
|
host('command', command=f'tp @s {hx:.2f} {hy:.2f} {hz:.2f}')
|
|
guest('command', command=f'fill {foot[0]} {foot[1]} {foot[2]} {bed[0]} {bed[1]} {bed[2]} minecraft:air')
|
|
host('command', command='time set day')
|
|
free, taken = ['false', 'false'], ['true', 'true']
|
|
assert seen['free'] == free and seen['host_asleep'] == bed, f'the host did not lie down: {seen}'
|
|
assert seen['occupied'] == taken, f'the bed the host sleeps in is not occupied: {seen}'
|
|
assert seen['refused']['sleeping'] is None, f'two players share a bed: {seen}'
|
|
assert 'occupied' in (seen['refused']['actionbar'] or ''), f'no "This bed is occupied": {seen}'
|
|
for way in ('after_escape', 'after_death', 'replaced', 'after_leave'):
|
|
assert seen[way] == free, f'the bed stays occupied {way}: {seen}'
|
|
assert seen['asleep_again'] == bed and seen['asleep_before_break'] == bed, f'the host could not lie down again: {seen}'
|
|
assert seen['up_after_break'] is None, f'the host sleeps on in a broken bed: {seen}'
|
|
assert seen['guest_asleep'] == bed and seen['occupied_by_guest'] == taken, f'the guest\'s bed: {seen}'
|
|
assert seen['guest_back'] == bed, f'the bed the guest left is not free for it: {seen}'
|
|
return seen
|
|
|
|
|
|
def check_sleep_reload(host, guest, out):
|
|
"""LivingEntity's sleeping_pos is saved: a villager asleep in its bed when the host saves and quits
|
|
is still asleep in it, its bed still occupied, when the world opens again; the bed the guest slept
|
|
in is saved free (ServerPlayer.disconnect gets every player up before the last save)."""
|
|
guest('command', command='gamemode survival')
|
|
host('command', command='time set midnight')
|
|
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
|
|
x, z = gx, gz + 3
|
|
guest('command', command=f'fill {x - 3} {gy - 1} {z - 2} {x + 4} {gy - 1} {z + 6} minecraft:stone')
|
|
guest('command', command=f'fill {x - 3} {gy} {z - 2} {x + 4} {gy + 3} {z + 6} minecraft:air')
|
|
villager_bed = bed_at(guest, (x, gy, z))
|
|
guest_bed = bed_at(guest, (x, gy, z + 4))
|
|
guest('command', command=f'summon minecraft:villager {x - 1.5} {gy} {z + 1.5}')
|
|
sleeping_pos = lambda: host('command', command='data get entity @e[type=minecraft:villager,limit=1,sort=nearest] '
|
|
'sleeping_pos')
|
|
seen = {}
|
|
try:
|
|
for _ in range(90):
|
|
seen['villager_asleep'] = sleeping_pos()
|
|
if str(villager_bed[0]) in json.dumps(seen['villager_asleep']):
|
|
break
|
|
guest.wait(20)
|
|
seen['villager_bed'] = state_of(guest, villager_bed, 'occupied')
|
|
guest('command', command=f'tp @s {x + 0.5} {gy} {z + 5.5}')
|
|
guest.wait(10)
|
|
guest.raw('interact', *guest_bed)
|
|
guest.wait(10)
|
|
seen['guest_asleep'] = guest('state').get('sleeping')
|
|
seen['guest_bed'] = state_of(guest, guest_bed, 'occupied')
|
|
host.raw('disconnect')
|
|
for attempt in range(60):
|
|
time.sleep(1)
|
|
try:
|
|
host.raw('open', 'Lan')
|
|
break
|
|
except Failed:
|
|
if attempt == 59:
|
|
raise
|
|
host('wait_ready', seconds=400)
|
|
port = host.raw('lan', 'open', 0, 'commands')['port']
|
|
back_to_game(guest)
|
|
guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME)
|
|
guest.raw('wait-for', 'loaded', '--timeout', 300)
|
|
guest.wait(20)
|
|
seen['reopened'] = {'sleeping_pos': sleeping_pos(), 'villager_bed': state_of(host, villager_bed, 'occupied'),
|
|
'guest_bed': state_of(host, guest_bed, 'occupied'),
|
|
'guest': guest('state').get('sleeping')}
|
|
guest.shot(out, 'sleep-reload')
|
|
finally:
|
|
back_to_game(guest)
|
|
guest('command', command='kill @e[type=minecraft:villager,distance=..16]')
|
|
for at in (villager_bed, guest_bed):
|
|
guest('command', command=f'fill {at[0] - 1} {at[1]} {at[2]} {at[0]} {at[1]} {at[2]} minecraft:air')
|
|
host('command', command='time set day')
|
|
pos = lambda reply: [int(v) for v in re.findall(r'-?\d+', json.dumps(reply).partition('[I;')[2].partition(']')[0])]
|
|
assert pos(seen['villager_asleep']) == villager_bed and seen['villager_bed'] == 'true', \
|
|
f'the villager never lay down in its bed: {seen}'
|
|
assert seen['guest_asleep'] == guest_bed and seen['guest_bed'] == 'true', f'the guest did not lie down: {seen}'
|
|
reopened = seen['reopened']
|
|
assert pos(reopened['sleeping_pos']) == villager_bed, f'the villager woke up with the reload: {seen}'
|
|
assert reopened['villager_bed'] == 'true', f'the sleeping villager\'s bed is free after the reload: {seen}'
|
|
assert reopened['guest_bed'] == 'false' and reopened['guest'] is None, f'the guest\'s bed stays occupied: {seen}'
|
|
return seen
|
|
|
|
|
|
def check_predict(host, guest, out):
|
|
# MultiPlayerGameMode.useItemOn under startPrediction (#869): the guest's door opens on its own screen
|
|
# at once, with the host (the server) frozen, and the server's answer later agrees; an iron door
|
|
# (no hand use) stays shut on both sides.
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
wood, iron = (x - 1, y, z + 2), (x + 1, y, z + 2)
|
|
for (bx, by, bz), kind in ((wood, 'oak_door'), (iron, 'iron_door')):
|
|
for half, dy in (('lower', 0), ('upper', 1)):
|
|
host('command', command=f'setblock {bx} {by + dy} {bz} minecraft:{kind}[facing=north,hinge=right,half={half}]')
|
|
guest.wait(20)
|
|
state = lambda game, at: game.raw('block', *at)['state']
|
|
seen = {}
|
|
creaks = lambda game: sum(n for s, n in game('status')['heard'].items() if 'door' in s)
|
|
heard = {'guest': creaks(guest), 'host': creaks(host)}
|
|
host.raw('server-hold', 'on')
|
|
held_at = host.raw('simulation')['game_time']
|
|
try:
|
|
guest.raw('interact', *wood)
|
|
seen['guest_frozen'] = (state(guest, wood), state(guest, (wood[0], wood[1] + 1, wood[2])))
|
|
# level.playSound(player, ...): the clicker hears its own door at once.
|
|
seen['guest_heard_frozen'] = creaks(guest) - heard['guest']
|
|
guest.raw('interact', *iron)
|
|
seen['guest_iron_frozen'] = state(guest, iron)
|
|
assert host.raw('simulation')['game_time'] == held_at, 'server barrier advanced the world'
|
|
finally:
|
|
host.raw('server-hold', 'off')
|
|
guest.wait(20)
|
|
seen['guest_after'] = state(guest, wood)
|
|
seen['host_after'] = state(host, wood)
|
|
seen['iron_after'] = (state(guest, iron), state(host, iron))
|
|
# ... and not a second time from the server; the host hears it once.
|
|
seen['heard_after'] = {'guest': creaks(guest) - heard['guest'], 'host': creaks(host) - heard['host']}
|
|
guest.shot(out, 'predict')
|
|
for (bx, by, bz) in (wood, iron):
|
|
host('command', command=f'fill {bx} {by} {bz} {bx} {by + 1} {bz} minecraft:air')
|
|
assert all('open=true' in s for s in seen['guest_frozen']), f'the door waited for the server: {seen}'
|
|
assert 'open=true' in seen['guest_after'] and 'open=true' in seen['host_after'], f'the door did not stay open: {seen}'
|
|
assert 'open=false' in seen['guest_iron_frozen'], f'the guest opened an iron door by hand: {seen}'
|
|
assert all('open=false' in s for s in seen['iron_after']), f'an iron door opened: {seen}'
|
|
assert seen['guest_heard_frozen'] == 1 and seen['heard_after'] == {'guest': 1, 'host': 1}, \
|
|
f'the door is not heard once on each side: {seen}'
|
|
return seen
|
|
|
|
|
|
def check_push(host, guest, out):
|
|
guest('command', command='gamemode survival')
|
|
x, y, z = (int(v // 1) + 0.5 for v in pos_of(guest))
|
|
guest('command', command=f'tp @s {x} {y - 0.5} {z}')
|
|
guest.wait(20)
|
|
x, y, z = pos_of(guest)
|
|
# A cow that doesn't walk, 0.3 east of the guest: the two boxes overlap until their centres are
|
|
# 0.75 apart, so the push stops a fraction of a block away.
|
|
guest('command', command=f'summon minecraft:cow {x + 0.3:.2f} {y:.2f} {z:.2f} {{NoAI:1b}}')
|
|
guest.wait(60)
|
|
nx, _, nz = pos_of(guest)
|
|
guest.shot(out, 'push')
|
|
guest('command', command='kill @e[type=minecraft:cow]')
|
|
moved = ((nx - x) ** 2 + (nz - z) ** 2) ** 0.5
|
|
detail = {'from': [round(x, 3), round(z, 3)], 'to': [round(nx, 3), round(nz, 3)], 'moved': round(moved, 3)}
|
|
assert nx < x, f'the cow did not push the guest west: {detail}'
|
|
assert moved < 1.5, f'the cow pushed the guest too far: {detail}'
|
|
return detail
|
|
def check_eat(host, guest, out):
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
# Hungry: the eating needs food below 20 (Player.canEat).
|
|
guest('command', command='effect give @s minecraft:hunger 3 255')
|
|
guest.wait(70)
|
|
guest('command', command='effect clear @s')
|
|
guest('command', command='give @s minecraft:bread 3')
|
|
guest.wait(5)
|
|
guest.raw('select', 'minecraft:bread')
|
|
hungry = guest('state')['player']['food']
|
|
start = guest('status')
|
|
# use-item --hold returns once released: watch the guest's screen meanwhile.
|
|
hold = threading.Thread(target=guest.raw, args=('use-item', '--hold', 2.2))
|
|
hold.start()
|
|
using = []
|
|
for _ in range(4):
|
|
time.sleep(0.2)
|
|
using.append(guest('state').get('using'))
|
|
guest.shot(out, 'eat')
|
|
hold.join()
|
|
guest.wait(10)
|
|
fed = guest('state')['player']['food']
|
|
left = guest('inventory')['counts'].get('minecraft:bread', 0)
|
|
end = guest('status')
|
|
# Every bite the guest's client draws (5 crumbs, 16 for the last) plays its own sound; the
|
|
# server plays them for the others only (Player.playSound) and adds FoodProperties.onConsume's
|
|
# sound for each loaf, so the eater hears each bite once (#3129).
|
|
eat = 'minecraft:entity.generic.eat'
|
|
heard = end['heard'].get(eat, 0) - start['heard'].get(eat, 0)
|
|
crumbs = end['particles'].get('item', 0) - start['particles'].get('item', 0)
|
|
eaten = 3 - left
|
|
bites = (crumbs - 16 * eaten) / 5
|
|
detail = {'hungry': hungry, 'fed': fed, 'bread': left, 'using': using, 'after': guest('state').get('using'),
|
|
'heard': heard, 'crumbs': crumbs}
|
|
assert hungry < 20, f'the guest is not hungry: {detail}'
|
|
ticks = [u['ticks'] for u in using if u]
|
|
assert len(ticks) >= 3 and ticks == sorted(ticks) and ticks[-1] > ticks[0], f'no eating on the guest\'s screen: {detail}'
|
|
assert left == 2 and fed > hungry, f'the server did not eat one loaf: {detail}'
|
|
assert detail['after'] is None, f'still eating after the release: {detail}'
|
|
assert heard == bites + 2 * eaten, f'the guest heard {heard} bites for {bites:g} crumb bursts: {detail}'
|
|
return detail
|
|
|
|
|
|
def check_eat_on_block(host, guest, out):
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
guest('command', command='effect give @s minecraft:hunger 3 255')
|
|
guest.wait(70)
|
|
guest('command', command='effect clear @s')
|
|
guest('command', command='give @s minecraft:sweet_berries 4')
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
stone, dirt = (x, y - 1, z + 1), (x + 1, y - 1, z)
|
|
guest('command', command=f'fill {x - 1} {y} {z - 1} {x + 1} {y + 2} {z + 1} minecraft:air')
|
|
guest('command', command=f'setblock {stone[0]} {stone[1]} {stone[2]} minecraft:stone')
|
|
guest('command', command=f'setblock {dirt[0]} {dirt[1]} {dirt[2]} minecraft:dirt')
|
|
guest.wait(10)
|
|
guest.raw('select', 'minecraft:sweet_berries')
|
|
berries = lambda: guest('inventory')['counts'].get('minecraft:sweet_berries', 0)
|
|
hungry = guest('state')['player']['food']
|
|
|
|
# Held on stone: the use runs on this client as well as on the server.
|
|
guest.raw('look-at-block', *stone, 'up')
|
|
guest.wait(2)
|
|
guest.raw('mouse', 'right', 'down')
|
|
using = []
|
|
for _ in range(4):
|
|
time.sleep(0.2)
|
|
using.append(guest('state').get('using'))
|
|
time.sleep(0.8)
|
|
guest.raw('mouse', 'right', 'up')
|
|
guest.wait(10)
|
|
held = {'using': using, 'after': guest('state').get('using'), 'left': berries(),
|
|
'food': guest('state')['player']['food']}
|
|
|
|
# Let go at once: the release stops the server's use too.
|
|
guest.raw('mouse', 'right', 'down')
|
|
guest.wait(2)
|
|
started = guest('state').get('using')
|
|
guest.raw('mouse', 'right', 'up')
|
|
guest.wait(50)
|
|
short = {'started': started, 'after': guest('state').get('using'), 'left': berries()}
|
|
|
|
# Dirt is where they are planted, and a planted berry isn't eaten.
|
|
guest.raw('look-at-block', *dirt, 'up')
|
|
guest.wait(2)
|
|
guest.raw('mouse', 'right', 'click')
|
|
guest.wait(50)
|
|
planted = {'block': guest('block', x=dirt[0], y=y, z=dirt[2]), 'using': guest('state').get('using'),
|
|
'left': berries()}
|
|
detail = {'hungry': hungry, 'held': held, 'short': short, 'planted': planted}
|
|
ticks = [u['ticks'] for u in using if u]
|
|
assert hungry < 20, f'the guest is not hungry: {detail}'
|
|
assert len(ticks) >= 3 and ticks == sorted(ticks) and ticks[-1] > ticks[0], f"no eating on the guest's screen: {detail}"
|
|
assert held['left'] == 3 and held['food'] > hungry and held['after'] is None, f'the server did not eat one berry: {detail}'
|
|
assert short['started'] and short['after'] is None and short['left'] == 3, f'an early release still ate: {detail}'
|
|
assert 'sweet_berry_bush' in str(planted['block']) and planted['left'] == 2 and planted['using'] is None, f'not planted on dirt: {detail}'
|
|
return detail
|
|
|
|
|
|
def check_cauldron(host, guest, out):
|
|
"""A bucket used on a cauldron's side fills or empties the cauldron only: the guest's
|
|
client ends the click at the block (CauldronInteractions answers SUCCESS there too), so
|
|
no UseItem follows that would pour the bucket again beside it."""
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
cauldron, beside = (x + 2, y, z), (x + 1, y, z)
|
|
guest('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 3} {y - 1} {z + 1} minecraft:stone')
|
|
guest('command', command=f'fill {x - 1} {y} {z - 1} {x + 3} {y + 2} {z + 1} minecraft:air')
|
|
block = lambda p: guest.raw('block', *p).get('block')
|
|
|
|
def use(item, gamemode, start):
|
|
guest('command', command=f'gamemode {gamemode}')
|
|
guest('command', command='clear @s')
|
|
guest('command', command=f'give @s {item}')
|
|
guest('command', command=f'setblock {beside[0]} {beside[1]} {beside[2]} minecraft:air')
|
|
guest('command', command=f'setblock {cauldron[0]} {cauldron[1]} {cauldron[2]} {start}')
|
|
for _ in range(20):
|
|
guest.wait(5)
|
|
if guest.raw('block', *cauldron)['state'].startswith(start.partition('[')[0]):
|
|
break
|
|
guest.raw('select', item)
|
|
guest.raw('look-at-block', *cauldron, 'west')
|
|
guest.wait(2)
|
|
# A pour the client predicts beside it lasts only until the server's ack.
|
|
replies = guest.script(['mouse right click'] + [f'block {beside[0]} {beside[1]} {beside[2]}'] * 12,
|
|
every=True)
|
|
seen = {r['block'] for r in replies if 'block' in r}
|
|
guest.wait(20)
|
|
seen.add(block(beside))
|
|
return {'cauldron': guest.raw('block', *cauldron)['state'], 'beside': sorted(seen),
|
|
'counts': guest('inventory')['counts']}
|
|
|
|
detail = {
|
|
'creative': use('minecraft:water_bucket', 'creative', 'minecraft:cauldron'),
|
|
'survival': use('minecraft:lava_bucket', 'survival', 'minecraft:cauldron'),
|
|
'fill': use('minecraft:bucket', 'survival', 'minecraft:water_cauldron[level=3]'),
|
|
}
|
|
guest('command', command='gamemode creative')
|
|
creative, survival, fill = detail['creative'], detail['survival'], detail['fill']
|
|
assert 'water_cauldron' in creative['cauldron'] and creative['beside'] == ['minecraft:air'], \
|
|
f'the water bucket was poured beside the cauldron too: {detail}'
|
|
assert 'lava_cauldron' in survival['cauldron'] and survival['beside'] == ['minecraft:air'] \
|
|
and survival['counts'].get('minecraft:bucket') == 1, f'the lava bucket was not emptied into the cauldron: {detail}'
|
|
assert fill['cauldron'].startswith('minecraft:cauldron') and fill['beside'] == ['minecraft:air'] \
|
|
and fill['counts'].get('minecraft:water_bucket') == 1 and 'minecraft:bucket' not in fill['counts'], \
|
|
f'the bucket was not filled from the cauldron alone: {detail}'
|
|
return detail
|
|
|
|
|
|
def watch_mining(miner, pos, seen):
|
|
"""Mine the block at `pos` (by hand, for seconds) and sample `seen()` meanwhile."""
|
|
dig = threading.Thread(target=miner.raw, args=('break', *pos))
|
|
dig.start()
|
|
samples = []
|
|
for _ in range(8):
|
|
time.sleep(0.25)
|
|
samples.append(seen())
|
|
dig.join()
|
|
return samples
|
|
|
|
|
|
def check_swings(host, guest, out):
|
|
for g in (host, guest):
|
|
g('command', command='gamemode survival')
|
|
g('command', command='clear @s')
|
|
face_each_other(host, guest)
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
|
|
# Stone by hand takes 7.5 seconds: ServerboundPunchPacket every tick keeps the arm swinging.
|
|
guest('command', command=f'setblock {gx} {gy} {gz + 1} minecraft:stone')
|
|
host('command', command=f'setblock {hx} {hy} {hz + 1} minecraft:stone')
|
|
host.wait(10)
|
|
def swinging(game):
|
|
# The host's window is a client of its server too.
|
|
return any(e.get('swinging') for e in remote(game).get('player_equipment', []))
|
|
# Most samples: a swing restarts only past its first half (SwingState.startIfAble), so a sample can
|
|
# fall between two.
|
|
host_sees = watch_mining(guest, (gx, gy, gz + 1), lambda: swinging(host))
|
|
guest_sees = watch_mining(host, (hx, hy, hz + 1), lambda: swinging(guest))
|
|
# A hoe on dirt: the server tills it and swings the guest's hand, on the guest's screen too.
|
|
# Where the guest stands now (an earlier check can leave it on a ledge it then drops from).
|
|
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
|
|
soil = (gx, gy - 1, gz + 1)
|
|
guest('command', command=f'setblock {soil[0]} {soil[1]} {soil[2]} minecraft:dirt')
|
|
# HoeItem.useOn tills only under air.
|
|
guest('command', command=f'setblock {soil[0]} {soil[1] + 1} {soil[2]} minecraft:air')
|
|
guest('command', command='give @s minecraft:wooden_hoe')
|
|
guest.wait(10)
|
|
guest.raw('select', 'minecraft:wooden_hoe')
|
|
before = guest('state')['swings']
|
|
guest.raw('look-at', soil[0] + 0.5, soil[1] + 1, soil[2] + 0.5)
|
|
guest.wait(1)
|
|
target = guest('state')['looking_at']['block']
|
|
assert target and target['pos'] == list(soil) and target['face'] == 'up', target
|
|
guest.raw('mouse', 'right', 'click')
|
|
guest.wait(10)
|
|
hoe = {'swings': guest('state')['swings'] - before, 'soil': host.raw('block', *soil)['block']}
|
|
guest('command', command=f'setblock {soil[0]} {soil[1]} {soil[2]} minecraft:stone')
|
|
detail = {'host_sees_guest': host_sees, 'guest_sees_host': guest_sees, 'hoe': hoe}
|
|
assert sum(map(bool, host_sees)) >= 4, f'the host does not see the guest mine: {detail}'
|
|
assert sum(map(bool, guest_sees)) >= 4, f'the guest does not see the host mine: {detail}'
|
|
assert hoe == {'swings': 1, 'soil': 'minecraft:farmland'}, f'the guest\'s hoe: {detail}'
|
|
return detail
|
|
|
|
|
|
def check_cracks(host, guest, out):
|
|
# Another player's crack (#1096): ServerLevel.destroyBlockProgress sends each new stage to the
|
|
# others (ClientboundBlockDestructionPacket), drawn by LevelRenderer from destroyingBlocks.
|
|
for g in (host, guest):
|
|
g('command', command='gamemode survival')
|
|
g('command', command='clear @s')
|
|
face_each_other(host, guest)
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
|
|
guest('command', command=f'setblock {gx} {gy} {gz + 1} minecraft:stone')
|
|
host('command', command=f'setblock {hx} {hy} {hz + 1} minecraft:stone')
|
|
host.wait(10)
|
|
shots = []
|
|
|
|
def cracks(game, pos, name):
|
|
stage = max((c['stage'] for c in game('status')['destroying'] if tuple(c['pos']) == pos), default=None)
|
|
if stage is not None and stage >= 2 and name not in shots:
|
|
shots.append(name)
|
|
game.shot(out, name)
|
|
return stage
|
|
|
|
def watch(miner, watcher, pos, name):
|
|
# The watcher looks at the block the other mines.
|
|
watcher.raw('look-at', f'{pos[0] + 0.5:.2f}', f'{pos[1] + 0.5:.2f}', f'{pos[2] + 0.5:.2f}')
|
|
watcher.wait(5)
|
|
return watch_mining(miner, pos, lambda: cracks(watcher, pos, name))
|
|
host_sees = watch(guest, host, (gx, gy, gz + 1), 'cracks-guest-mines')
|
|
face_each_other(host, guest)
|
|
guest_sees = watch(host, guest, (hx, hy, hz + 1), 'cracks-host-mines')
|
|
host.wait(10)
|
|
# The breaker's -1 (ServerLevel.destroyBlockProgress once the block is broken) clears each crack.
|
|
gone = {name: [(c['pos'], game.raw('block', *c['pos'])['block']) for c in game('status')['destroying']]
|
|
for name, game in (('host', host), ('guest', guest))}
|
|
detail = {'host_sees_guest': host_sees, 'guest_sees_host': guest_sees, 'after': gone}
|
|
stages = lambda s: [x for x in s if x is not None]
|
|
assert len(stages(host_sees)) >= 3 and stages(host_sees) == sorted(stages(host_sees)), \
|
|
f'the host does not see the guest\'s crack grow: {detail}'
|
|
assert len(stages(guest_sees)) >= 3 and stages(guest_sees) == sorted(stages(guest_sees)), \
|
|
f'the guest does not see the host\'s crack grow: {detail}'
|
|
assert not any(gone.values()), f'a crack stays after the block broke: {detail}'
|
|
return detail
|
|
|
|
|
|
def check_eating(host, guest, out):
|
|
"""Each player eats a loaf in front of the other: the other side draws the eater eating
|
|
(DATA_LIVING_ENTITY_FLAGS) and stops when it is done. The eater and the player beside it
|
|
each hear every bite once (#3129): the eater's own client plays its bites and the server
|
|
sends them to the others only (Player.playSound), and FoodProperties.onConsume's sound
|
|
reaches both. One bite per crumb burst the eater draws (5 crumbs, 16 for the last) plus
|
|
FoodProperties' sound, for each loaf eaten. One loaf each: with a second, the use held on
|
|
starts it and lets go a few ticks in, where the eater's client and the server can disagree
|
|
on whether its first bite came (each times the use on its own ticks)."""
|
|
eat = 'minecraft:entity.generic.eat'
|
|
|
|
def sounds(game):
|
|
status = game('status')
|
|
return status['heard'].get(eat, 0), status['particles'].get('item', 0)
|
|
|
|
def settled(eater, watcher):
|
|
"""Both sides' sounds once neither has changed for 20 ticks: the last bite can still be on
|
|
its way to the watcher when the eater is done."""
|
|
last, still = None, 0
|
|
for _ in range(100):
|
|
now = sounds(eater), sounds(watcher)
|
|
still = still + 1 if now == last else 0
|
|
if still >= 10:
|
|
break
|
|
last = now
|
|
eater.wait(2)
|
|
return now
|
|
|
|
for g in (host, guest):
|
|
g('command', command='gamemode survival')
|
|
g('command', command='clear @s')
|
|
# Hungry: Player.canEat.
|
|
g('command', command='effect give @s minecraft:hunger 3 255')
|
|
host.wait(70)
|
|
for g in (host, guest):
|
|
g('command', command='effect clear @s')
|
|
g('command', command='give @s minecraft:bread')
|
|
host.wait(5)
|
|
face_each_other(host, guest)
|
|
|
|
def using(game):
|
|
# The host's window is a client of its server too.
|
|
return max((e.get('using') or 0 for e in remote(game).get('player_equipment', [])), default=0)
|
|
seen = {}
|
|
for name, eater, watcher in (('host eats', host, guest), ('guest eats', guest, host)):
|
|
eater.raw('select', 'minecraft:bread')
|
|
loaves = eater('inventory')['counts'].get('minecraft:bread', 0)
|
|
(eater_heard, crumbs), (watcher_heard, _) = sounds(eater), sounds(watcher)
|
|
hold = threading.Thread(target=eater.raw, args=('use-item', '--hold', 2.2))
|
|
hold.start()
|
|
samples = []
|
|
for _ in range(4):
|
|
time.sleep(0.3)
|
|
samples.append(using(watcher))
|
|
watcher.shot(out, 'eating-' + name.replace(' ', '-'))
|
|
hold.join()
|
|
(eater_after, crumbs_after), (watcher_after, _) = settled(eater, watcher)
|
|
eaten = loaves - eater('inventory')['counts'].get('minecraft:bread', 0)
|
|
seen[name] = {'during': samples, 'after': using(watcher), 'eaten': eaten,
|
|
'bites': (crumbs_after - crumbs - 16 * eaten) / 5,
|
|
'eater_heard': eater_after - eater_heard, 'watcher_heard': watcher_after - watcher_heard}
|
|
for name, s in seen.items():
|
|
assert sum(1 for u in s['during'] if u > 0) >= 3 and s['after'] == 0, f'{name}: {seen}'
|
|
assert s['eaten'] == 1, f'{name}: the server did not eat one loaf: {seen}'
|
|
want = s['bites'] + 2 * s['eaten']
|
|
assert s['eater_heard'] == want, f'{name}: the eater heard {s["eater_heard"]} bites, wants {want:g}: {seen}'
|
|
assert s['watcher_heard'] == want, \
|
|
f'{name}: the player beside the eater heard {s["watcher_heard"]} bites, wants {want:g}: {seen}'
|
|
return seen
|
|
|
|
|
|
def check_xp(host, guest, out):
|
|
for g in (host, guest):
|
|
g('command', command='gamemode survival')
|
|
g('command', command='xp set @s 0 levels')
|
|
g('command', command='xp set @s 0 points')
|
|
# Out of the orbs' way wherever an earlier check left the host (ExperienceOrb follows the
|
|
# nearest player who isn't a spectator).
|
|
hx, hy, hz = pos_of(host)
|
|
host('command', command='gamemode spectator')
|
|
try:
|
|
guest.wait(10)
|
|
# `xp set` leaves Player.totalExperience (the score): experience from earlier checks stays in it.
|
|
total = guest('state')['player']['xp']['total']
|
|
host_total = host('state')['player']['xp']['total']
|
|
x, y, z = pos_of(guest)
|
|
# Three orbs of 7 points 4 blocks east of the guest (out of reach, within the 8 blocks orbs
|
|
# follow from): they fly to the guest.
|
|
# Freeze setup so command/IPC latency cannot let all three be collected
|
|
# before the two-tick in-flight observation.
|
|
host('command', command='tick freeze')
|
|
for _ in range(3):
|
|
guest('command', command=f'summon minecraft:experience_orb {x + 4:.2f} {y + 0.5:.2f} {z:.2f} {{Value:7s}}')
|
|
host('command', command='tick step 2')
|
|
guest.wait(2)
|
|
seen = remote(guest).get('entities', {}).get('minecraft:experience_orb', 0)
|
|
guest.shot(out, 'xp-orbs')
|
|
host('command', command='tick unfreeze')
|
|
guest.wait(60)
|
|
xp = guest('state')['player']['xp']
|
|
host_xp = host('state')['player']['xp']
|
|
left = remote(guest).get('entities', {}).get('minecraft:experience_orb', 0)
|
|
finally:
|
|
host('command', command='tick unfreeze')
|
|
host('command', command='gamemode survival')
|
|
# Back where it stood: a spectator sinks (Player.travel keeps 0.6 of its fall speed each tick,
|
|
# about 0.2 blocks from standing), and a later check that makes it survival again would land
|
|
# it inside the floor, with the guest teleported beside it (swings).
|
|
host('command', command=f'tp @s {hx:.2f} {hy:.2f} {hz:.2f}')
|
|
detail = {'seen': seen, 'guest': xp, 'host': host_xp, 'left': left}
|
|
# ExperienceOrb.scanForMerges: orbs of one value side by side can merge into one.
|
|
assert 1 <= seen <= 3, f'the guest saw {seen} orbs: {detail}'
|
|
assert xp['total'] - total == 21 and xp['level'] == 2, f'the guest\'s experience after three 7-point orbs: {detail}'
|
|
assert host_xp['total'] == host_total, f'the host took the orbs: {detail}'
|
|
assert left == 0, f'orbs left on the guest\'s screen: {detail}'
|
|
return {'seen': seen, 'guest': xp, 'host': host_xp}
|
|
|
|
|
|
def check_time(host, guest, out):
|
|
def times():
|
|
return host('state')['time']['day_time'], guest('state')['time']['day_time']
|
|
host('command', command='time set 1000')
|
|
host('command', command='time pause')
|
|
host.wait(60)
|
|
paused = times()
|
|
host('command', command='time resume')
|
|
guest.wait(20)
|
|
# A stalled game runs at most 10 missed ticks when it wakes (DeltaTracker); the server's
|
|
# game time, sent every second, moves the guest's clock on (ClientClockManager.tick).
|
|
guest.raw('stall', 4000)
|
|
guest.wait(60)
|
|
stalled = times()
|
|
detail = {'paused': paused, 'after_stall': stalled}
|
|
assert paused[0] == paused[1] and 1000 <= paused[0] < 1010, f'the clock while paused: {detail}'
|
|
# The two reads are a tick or two apart.
|
|
assert abs(stalled[0] - stalled[1]) <= 3, f'the guest\'s clock after its game stalled: {detail}'
|
|
return detail
|
|
|
|
|
|
def objects_near(game, centre, key='objects'):
|
|
"""`status`'s objects within 8 blocks of centre, without their ids (each side numbers its own)."""
|
|
out = []
|
|
for o in game('status')[key]:
|
|
if all(abs(o['position'][i] - centre[i]) <= 8 for i in (0, 2)):
|
|
o = dict(o)
|
|
o.pop('id')
|
|
o['position'] = [round(v, 2) for v in o['position']]
|
|
if 'yaw' in o: # as the packets carry it, in 256ths of a turn
|
|
o['yaw'] = round(o['yaw'] * 256 / 360) % 256
|
|
out.append(o)
|
|
return sorted(out, key=json.dumps)
|
|
|
|
|
|
def check_objects(host, guest, out):
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
commands = [
|
|
f'fill {x - 6} {y} {z - 6} {x + 6} {y + 3} {z - 6} minecraft:stone',
|
|
f'summon minecraft:minecart {x - 4.5} {y} {z + 3.5}',
|
|
f'summon minecraft:chest_minecart {x - 2.5} {y} {z + 3.5}',
|
|
f'summon minecraft:furnace_minecart {x + 1.5} {y} {z + 3.5}',
|
|
f'summon minecraft:tnt_minecart {x + 4.5} {y} {z + 3.5}',
|
|
f'summon minecraft:oak_boat {x - 4.5} {y} {z + 6.5} {{Rotation:[45f,0f]}}',
|
|
f'summon minecraft:birch_chest_boat {x - 1.5} {y} {z + 6.5}',
|
|
f'summon minecraft:bamboo_raft {x + 2.5} {y} {z + 6.5}',
|
|
f'summon minecraft:armor_stand {x - 2.5} {y} {z - 2.5} {{Rotation:[90f,0f]}}',
|
|
f'summon minecraft:armor_stand {x - 4.5} {y} {z - 2.5} {{ShowArms:1b,Small:1b,NoBasePlate:1b,'
|
|
'equipment:{head:{id:"minecraft:diamond_helmet",count:1}}}',
|
|
# Falls to the floor: the guest's copy has to follow the server's moves.
|
|
f'summon minecraft:armor_stand {x + 2.5} {y + 3} {z - 2.5} {{Rotation:[200f,0f]}}',
|
|
f'summon minecraft:item_frame {x - 3.5} {y + 1} {z - 4.97} {{block_pos:[I;{x - 4},{y + 1},{z - 5}],Facing:3b,'
|
|
'Item:{id:"minecraft:diamond",count:1},ItemRotation:3b}',
|
|
f'summon minecraft:glow_item_frame {x - 1.5} {y + 1} {z - 4.97} {{block_pos:[I;{x - 2},{y + 1},{z - 5}],Facing:3b}}',
|
|
f'summon minecraft:painting {x + 1.5} {y + 1} {z - 4.97} {{block_pos:[I;{x + 1},{y + 1},{z - 5}],facing:0b,'
|
|
'variant:"minecraft:kebab"}',
|
|
f'summon minecraft:painting {x + 4} {y + 1} {z - 4.97} {{block_pos:[I;{x + 4},{y + 1},{z - 5}],facing:0b,'
|
|
'variant:"minecraft:courbet"}',
|
|
]
|
|
try:
|
|
for command in commands:
|
|
host('command', command=command)
|
|
host.wait(60)
|
|
guest.raw('look-at', x, y, z - 5)
|
|
guest.wait(10)
|
|
guest.shot(out, 'objects')
|
|
host.shot(out, 'objects')
|
|
mine, theirs = objects_near(host, (x, y, z)), objects_near(guest, (x, y, z))
|
|
finally:
|
|
host('command', command=f'kill @e[type=!minecraft:player,x={x - 8},y={y - 2},z={z - 8},dx=16,dy=8,dz=16]')
|
|
host('command', command=f'fill {x - 6} {y} {z - 6} {x + 6} {y + 3} {z - 6} minecraft:air')
|
|
assert len(mine) == len(commands) - 1, f'the host has {len(mine)} objects: {mine}'
|
|
assert theirs == mine, f'the guest sees {theirs}\nthe host has {mine}'
|
|
return f'{len(theirs)} objects identical'
|
|
|
|
|
|
def check_projectiles(host, guest, out):
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
commands = [
|
|
f'summon minecraft:tnt {x + 4.5} {y} {z + 4.5} {{fuse:2000s}}',
|
|
f'summon minecraft:end_crystal {x - 4.5} {y} {z + 4.5} {{ShowBottom:0b}}',
|
|
f'summon minecraft:tnt {x + 2.5} {y + 5} {z - 3.5} {{fuse:2000s}}',
|
|
f'summon minecraft:arrow {x - 2.5} {y + 4} {z - 2.5} {{Motion:[0.2d,0d,0d]}}',
|
|
f'summon minecraft:falling_block {x - 0.5} {y + 6} {z - 4.5} {{BlockState:{{Name:"minecraft:sand"}}}}',
|
|
]
|
|
try:
|
|
for command in commands:
|
|
host('command', command=command)
|
|
host.wait(80)
|
|
guest.raw('look-at', x, y, z)
|
|
guest.wait(5)
|
|
guest.shot(out, 'projectiles')
|
|
mine = objects_near(host, (x, y, z), 'projectiles')
|
|
theirs = objects_near(guest, (x, y, z), 'projectiles')
|
|
finally:
|
|
host('command', command=f'kill @e[type=!minecraft:player,x={x - 8},y={y - 2},z={z - 8},dx=16,dy=10,dz=16]')
|
|
host('command', command=f'setblock {x - 1} {y} {z - 5} minecraft:air')
|
|
assert len(mine) == len(commands) - 1, f'the host has {len(mine)}: {mine}'
|
|
# The fuse counts down every tick: the guest's, read a moment later, may be a tick or two behind.
|
|
fuses = [(m.pop('fuse', 0), t.pop('fuse', 0)) for m, t in zip(mine, theirs)]
|
|
assert theirs == mine, f'the guest sees {theirs}\nthe host has {mine}'
|
|
assert all(abs(a - b) <= 2 for a, b in fuses), f'fuses (host, guest): {fuses}'
|
|
return f'{len(theirs)} identical, fuses (host, guest) {fuses}'
|
|
|
|
|
|
def check_thrown(host, guest, out):
|
|
spots = apart(host, guest)
|
|
result = {}
|
|
for label, player, other in (('host', host, guest), ('guest', guest, host)):
|
|
x, y, z = spots[label]
|
|
player('command', command='gamemode survival')
|
|
for item, kind in (('splash_potion', 'minecraft:splash_potion'), ('ender_eye', 'minecraft:eye_of_ender')):
|
|
player('command', command=f'item replace entity @s weapon.mainhand with minecraft:{item}')
|
|
player.wait(10)
|
|
# Up at 45 degrees, into open sky.
|
|
player.raw('look-at', f'{x + 0.5:.2f}', f'{y + 11.6:.2f}', f'{z + 10.5:.2f}')
|
|
player.raw('use-item')
|
|
seen = {'own': 0, 'other': 0}
|
|
for _ in range(10):
|
|
player.wait(2)
|
|
for side, game in (('own', player), ('other', other)):
|
|
if any(p['kind'] == kind for p in game('status')['projectiles']):
|
|
seen[side] += 1
|
|
if all(seen.values()):
|
|
break
|
|
player.shot(out, f'thrown-{label}-{item}')
|
|
other.shot(out, f'thrown-{label}-{item}-seen')
|
|
player.wait(100) # it lands or drops
|
|
result[f'{label} {item}'] = seen
|
|
for name, seen in result.items():
|
|
assert all(seen.values()), f'{name}: seen in flight (samples) {result}'
|
|
return result
|
|
|
|
|
|
def made(game, kind):
|
|
"""How many `kind` particles `game` has made so far."""
|
|
return game('status').get('particles', {}).get(kind, 0)
|
|
|
|
|
|
def sparked(player, other, kinds, act, ticks=20):
|
|
"""The `kinds` particles each side makes while `act` runs and for up to `ticks` after."""
|
|
before = {(side, k): made(g, k) for side, g in (('own', player), ('other', other)) for k in kinds}
|
|
act()
|
|
seen = {}
|
|
for _ in range(ticks // 2):
|
|
player.wait(2)
|
|
seen = {f'{side} {k}': made(g, k) - before[(side, k)]
|
|
for side, g in (('own', player), ('other', other)) for k in kinds}
|
|
if all(seen.values()):
|
|
break
|
|
return seen
|
|
|
|
|
|
def copy_recreate_command(game, check):
|
|
"""F3+I (KeyboardHandler.copyRecreateCommand): what it copied once `check` accepts it."""
|
|
game.raw('key', 'f3', 'down')
|
|
game.raw('key', 'i', 'tap')
|
|
game.raw('key', 'f3', 'up')
|
|
return game.until(lambda: game('status')['debug']['clipboard'], lambda c: bool(c) and check(c), ticks=40)
|
|
|
|
|
|
def check_tag_query(host, guest, out):
|
|
"""An operator guest's F3+I copies the server's data (#3518): DebugQueryHandler's tag queries,
|
|
answered by ServerGamePacketListenerImpl.handleBlockEntityTagQuery and handleEntityTagQuery.
|
|
A chest's items and an entity's tags never reach the client, so only the server's answer has them."""
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
chest = (x, y, z + 2)
|
|
guest('command', command=f'setblock {chest[0]} {chest[1]} {chest[2]} '
|
|
'minecraft:chest{Items:[{Slot:0b,id:"minecraft:diamond",count:5}]}')
|
|
guest.wait(10)
|
|
guest.raw('look-at-block', *chest)
|
|
guest.wait(2)
|
|
block = copy_recreate_command(guest, lambda c: c.startswith(f'/setblock {chest[0]} {chest[1]} {chest[2]} '))
|
|
block_said = guest.raw('messages', 1)['chat']
|
|
guest('command', command=f'setblock {chest[0]} {chest[1]} {chest[2]} minecraft:air')
|
|
guest('command', command=f'summon minecraft:pig {x + 0.5} {y} {z + 2.5} {{NoAI:1b,Tags:["tag_query"]}}')
|
|
guest.wait(20)
|
|
pig = guest.raw('entities', 6, 'pig')['entities'][0]
|
|
guest.raw('look-at-entity', pig['id'])
|
|
guest.wait(2)
|
|
entity = copy_recreate_command(guest, lambda c: c.startswith('/summon minecraft:pig '))
|
|
entity_said = guest.raw('messages', 1)['chat']
|
|
guest.shot(out, 'tag-query')
|
|
guest('command', command='kill @e[type=minecraft:pig,tag=tag_query]')
|
|
assert 'id:"minecraft:diamond"' in block, f"the chest's items are missing: {block}"
|
|
assert block_said == ['[Debug]: Copied server-side block data to clipboard'], block_said
|
|
assert 'Tags: ["tag_query"]' in entity, f"the pig's tags are missing: {entity}"
|
|
assert entity_said == ['[Debug]: Copied server-side entity data to clipboard'], entity_said
|
|
return {'block': block, 'entity': entity}
|
|
|
|
|
|
def check_fireworks(host, guest, out):
|
|
"""Event 17 must arrive before removal, with the item's colored, trailing explosion (#1478).
|
|
|
|
FireworkRocketEntity.explode / handleEntityEvent.
|
|
The flash proves a configured explosion reached both clients; red pixels in later screenshots
|
|
check that its sparks keep rendering after the tracked rocket disappears.
|
|
"""
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
at = (x, y - 1, z + 3)
|
|
host('command', command=f'fill {x - 5} {y - 1} {z - 2} {x + 5} {y - 1} {z + 8} minecraft:stone')
|
|
host('command', command=f'fill {x - 5} {y} {z - 2} {x + 5} {y + 4} {z + 8} minecraft:air')
|
|
host('command', command=f'fill {x - 2} {y + 2} {z + 1} {x + 2} {y + 2} {z + 5} minecraft:stone')
|
|
for game, dx in ((host, -2), (guest, 2)):
|
|
game('command', command='gamemode creative')
|
|
game('command', command=f'tp @s {x + dx + 0.5} {y} {z + 0.5}')
|
|
game.raw('look-at', x + 0.5, y + 1.5, z + 3.5)
|
|
|
|
def reds(path):
|
|
"""The red pixels' positions, above the hotbar."""
|
|
rows, bpp = png_rows(path)
|
|
return {(y, i) for y, row in enumerate(rows[:len(rows) * 3 // 4]) for i in range(0, len(row), bpp)
|
|
if row[i] > 80 and row[i] > 2 * row[i + 1] and row[i] > 2 * row[i + 2]}
|
|
|
|
# Red where the view had none: the setup's own red chat lines (a fill that changed nothing) are in the
|
|
# baseline, and fade out before the later shots, which a plain count difference would take as missing sparks.
|
|
baseline = {g.name: reds(g.shot(out, 'fireworks-before')) for g in (host, guest)}
|
|
before = {g.name: made(g, 'flash') for g in (host, guest)}
|
|
host('command', command='item replace entity @s weapon.mainhand with minecraft:firework_rocket'
|
|
'[fireworks={flight_duration:0,explosions:[{shape:"large_ball",colors:[I;16711680],has_trail:true}]}]')
|
|
host.raw('interact', *at)
|
|
# The held item's red texture must not count as a burst or a later spark.
|
|
host('command', command='item replace entity @s weapon.mainhand with minecraft:air')
|
|
for _ in range(30):
|
|
host.wait(2)
|
|
if all(made(g, 'flash') > before[g.name] for g in (host, guest)):
|
|
break
|
|
flashes = {g.name: made(g, 'flash') - before[g.name] for g in (host, guest)}
|
|
assert all(n == 1 for n in flashes.values()), f'configured burst missing or duplicated: {flashes}'
|
|
# Capture both views before decoding PNGs: decoding must not consume the sparks' lifetime (48-59 ticks). A
|
|
# screenshot takes ~0.6 s where the frame is drawn on the CPU (lavapipe), so the later shots come 4 ticks after
|
|
# the burst's, not more: by then the tracked rocket is gone.
|
|
burst_shots = {g.name: g.shot(out, 'fireworks-burst') for g in (host, guest)}
|
|
host.wait(4)
|
|
trail_shots = {g.name: g.shot(out, 'fireworks-trail-fade') for g in (host, guest)}
|
|
burst = {name: len(reds(path) - baseline[name]) for name, path in burst_shots.items()}
|
|
trails = {name: len(reds(path) - baseline[name]) for name, path in trail_shots.items()}
|
|
rockets = {g.name: g('status')['rockets'] for g in (host, guest)}
|
|
assert not any(rockets.values()), f'exploded rocket remains: {rockets}'
|
|
assert all(n > 20 for n in burst.values()), f'no red burst: {burst}'
|
|
assert all(n > 20 for n in trails.values()), f'no later red trail/fade: {trails}'
|
|
return {'flashes': flashes, 'red_burst': burst, 'red_trail_fade': trails, 'rockets': rockets}
|
|
|
|
|
|
def check_piston_slide(host, guest, out):
|
|
"""A piston's head reaches the clients after their own copy of it finished moving (#8430).
|
|
|
|
The block event makes each client move the blocks itself (PistonBaseBlock.moveBlocks), for three ticks of
|
|
PistonMovingBlockEntity.tick. ServerLevel.tick broadcasts the changed blocks before the block events and the
|
|
block entities, so what those change goes out at the start of the next tick: the landed head three ticks
|
|
after the block event, not two, and at a slow tick rate the client's head slides all the way instead of
|
|
jumping the rest. That broadcast comes before the tick's block events, with the blocks as they are then:
|
|
the tick after the retraction put the base back, a repeating command block puts a stone in the head's cell
|
|
and its chain powers the piston, and the client must have both the base and the stone (not the moving block
|
|
the push makes of it) to run that extension itself. The host reads its own copy of the head's cell once a
|
|
tick (each read follows the client tick that saw what arrived), at /tick rate 1, and whether the client
|
|
animates the move itself (`block`'s `moving`).
|
|
"""
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
y += 3
|
|
head = f'{x + 1} {y} {z}'
|
|
host('command', command=f'fill {x - 2} {y - 1} {z - 3} {x + 4} {y + 1} {z + 1} minecraft:air')
|
|
host('command', command=f'setblock {x} {y} {z} minecraft:piston[facing=east]')
|
|
host('command', command=f'setblock {head} minecraft:stone')
|
|
again = (f'execute if block {head} minecraft:air if block {x} {y} {z} minecraft:piston[extended=false] '
|
|
f'if block {x - 1} {y} {z} minecraft:air run setblock {head} minecraft:stone')
|
|
host('command', command=f'setblock {x} {y} {z - 2} minecraft:repeating_command_block[facing=up]'
|
|
f'{{Command:"{again}"}}')
|
|
host('command', command=f'setblock {x} {y + 1} {z - 2} minecraft:chain_command_block[facing=up,conditional=true]'
|
|
f'{{auto:1b,Command:"setblock {x - 1} {y} {z} minecraft:redstone_block"}}')
|
|
# Powered from below, two blocks from the piston: it runs every tick.
|
|
host('command', command=f'setblock {x} {y - 1} {z - 2} minecraft:redstone_block')
|
|
host.wait(5)
|
|
host('command', command='tick rate 1')
|
|
sample = ['wait 1', f'block {head}', 'status']
|
|
try:
|
|
replies = host.script([f'command setblock {x - 1} {y} {z} minecraft:redstone_block'] + sample * 6
|
|
+ [f'command setblock {x - 1} {y} {z} minecraft:air'] + sample * 9, every=True)
|
|
finally:
|
|
host('command', command='tick rate 20')
|
|
host('command', command=f'fill {x - 1} {y - 1} {z - 2} {x + 3} {y + 1} {z} minecraft:air')
|
|
|
|
def moves(replies):
|
|
seen = [(status['ticks'], block['block'].removeprefix('minecraft:'), 'moving' in block)
|
|
for block, status in zip(replies[1::3], replies[2::3])]
|
|
start = next((i for i, (_, name, _) in enumerate(seen) if name == 'moving_piston'), None)
|
|
# The head that lands after this move: one left from the last move (the second
|
|
# extension starts with it) comes before the moving block.
|
|
end = None if start is None else next(
|
|
(i for i in range(start, len(seen)) if seen[i][1] == 'piston_head'), None)
|
|
# The count is exact when the read before the first moving one, and the read before
|
|
# the landed one, are each one client tick earlier.
|
|
skipped = start is not None and any(
|
|
0 < i < len(seen) and seen[i][0] - seen[i - 1][0] != 1 for i in (start, end or start))
|
|
return {'seen': seen, 'own': start is not None and seen[start][2],
|
|
'ticks_moving': None if end is None else seen[end][0] - seen[start][0],
|
|
'skipped': skipped}
|
|
|
|
result = {'extend': moves(replies[1:19]), 'extend_again': moves(replies[20:])}
|
|
for label, move in result.items():
|
|
# A loaded machine can run two client ticks between reads: the count is then a guess.
|
|
assert not move['skipped'], f'{label}: a read skipped a client tick, rerun on a quieter machine: {result}'
|
|
assert move['own'], f'{label}: the client did not move the head itself: {result}'
|
|
assert move['ticks_moving'] == 3, (
|
|
f'{label}: the landed head came {move["ticks_moving"]} ticks after the move: {result}')
|
|
return result
|
|
|
|
|
|
def check_rocket_walls(host, guest, out):
|
|
"""A rocket shot at an angle explodes on a block only with explosions (#4281).
|
|
|
|
FireworkRocketEntity.onHitBlock: `hasExplosion()`; a plain one flies on to its lifetime (20-31 ticks). A
|
|
dispenser shoots at an angle as a crossbow does, at a wall three blocks away (about 7 ticks of flight). The
|
|
host samples its status in one ctl process; a rocket's span is the client ticks between the first and the
|
|
last sample that saw it, never more than it lived, and short of it by at most two gaps between samples.
|
|
"""
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
y += 3
|
|
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 2} {y + 2} {z + 6} minecraft:air')
|
|
host('command', command=f'fill {x - 2} {y - 1} {z + 5} {x + 2} {y + 2} {z + 5} minecraft:stone')
|
|
plain = 'minecraft:firework_rocket'
|
|
star = plain + '[fireworks={flight_duration:1,explosions:[{shape:"ball",colors:[I;16711680]}]}]'
|
|
result = {}
|
|
for label, item in (('plain', plain), ('star', star)):
|
|
host('command', command=f'setblock {x} {y + 1} {z} minecraft:air')
|
|
host('command', command=f'setblock {x} {y} {z} minecraft:dispenser[facing=south]')
|
|
host('command', command=f'item replace block {x} {y} {z} container.0 with {item}')
|
|
host.wait(5)
|
|
replies = host.script([f'command setblock {x} {y + 1} {z} minecraft:redstone_block']
|
|
+ ['wait 1', 'status'] * 45, every=True)
|
|
samples = [(r['ticks'], r['rockets']) for r in replies if 'rockets' in r]
|
|
seen = [tick for tick, n in samples if n]
|
|
result[label] = {'span': seen[-1] - seen[0] if seen else None, 'samples': len(samples),
|
|
'last': samples[-1][1], 'guest_last': guest('status')['rockets']}
|
|
host('command', command=f'setblock {x} {y + 1} {z} minecraft:air')
|
|
assert all(r['last'] == 0 and r['guest_last'] == 0 for r in result.values()), (
|
|
f'a rocket outlived its lifetime: {result}')
|
|
# A plain rocket lives 20-31 ticks; one with explosions is gone at the wall.
|
|
assert (result['plain']['span'] or 0) >= 14, f'plain rocket went off on the wall: {result}'
|
|
assert result['star']['span'] is not None and result['star']['span'] < 14, (
|
|
f'star rocket did not explode on the wall: {result}')
|
|
return result
|
|
|
|
|
|
def check_rocket_ceilings(host, guest, out):
|
|
"""A rising rocket explodes on the block above only with explosions.
|
|
|
|
FireworkRocketEntity.tick: every rocket that isn't attached checks its move against blocks
|
|
(`getHitResultOnMoveVector`), not only one shot at an angle; onHitBlock: `hasExplosion()`. Launched from
|
|
the floor under a ceiling two blocks up, it reaches it in about 9 ticks; a plain one lives 20-31. Spans
|
|
are measured as in check_rocket_walls.
|
|
"""
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
host('command', command='gamemode creative')
|
|
host('command', command=f'fill {x - 2} {y - 1} {z + 1} {x + 2} {y - 1} {z + 5} minecraft:stone')
|
|
host('command', command=f'fill {x - 2} {y} {z + 1} {x + 2} {y + 1} {z + 5} minecraft:air')
|
|
host('command', command=f'fill {x - 2} {y + 2} {z + 1} {x + 2} {y + 2} {z + 5} minecraft:stone')
|
|
plain = 'minecraft:firework_rocket'
|
|
star = plain + '[fireworks={flight_duration:1,explosions:[{shape:"ball",colors:[I;16711680]}]}]'
|
|
result = {}
|
|
for label, item in (('plain', plain), ('star', star)):
|
|
host('command', command=f'item replace entity @s weapon.mainhand with {item}')
|
|
host.wait(5)
|
|
replies = host.script([f'interact {x} {y - 1} {z + 3}'] + ['wait 1', 'status'] * 45, every=True)
|
|
samples = [(r['ticks'], r['rockets']) for r in replies if 'rockets' in r]
|
|
seen = [tick for tick, n in samples if n]
|
|
result[label] = {'span': seen[-1] - seen[0] if seen else None, 'samples': len(samples),
|
|
'last': samples[-1][1], 'guest_last': guest('status')['rockets']}
|
|
host('command', command='item replace entity @s weapon.mainhand with minecraft:air')
|
|
assert all(r['last'] == 0 and r['guest_last'] == 0 for r in result.values()), (
|
|
f'a rocket outlived its lifetime: {result}')
|
|
assert (result['plain']['span'] or 0) >= 14, f'plain rocket went off on the ceiling: {result}'
|
|
# It rises about 8 ticks first: gone at once would be a burst at launch.
|
|
assert result['star']['span'] is not None and 3 <= result['star']['span'] < 14, (
|
|
f'star rocket did not explode on the ceiling: {result}')
|
|
return result
|
|
|
|
|
|
def check_sparks(host, guest, out):
|
|
spots = apart(host, guest)
|
|
result = {}
|
|
for label, player, other in (('host', host, guest), ('guest', guest, host)):
|
|
x, y, z = spots[label]
|
|
at = (x, y, z + 2)
|
|
player('command', command='gamemode survival')
|
|
try:
|
|
player('command', command=f'setblock {at[0]} {at[1] - 1} {at[2]} minecraft:farmland')
|
|
player('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:wheat')
|
|
player('command', command='item replace entity @s weapon.mainhand with minecraft:bone_meal 8')
|
|
player.wait(10)
|
|
|
|
def click(height):
|
|
player.raw('look-at', f'{at[0] + 0.5:.2f}', f'{at[1] + height:.2f}', f'{at[2] + 0.5:.2f}')
|
|
player.raw('mouse', 'right', 'click')
|
|
result[f'{label} bone meal'] = sparked(player, other, ['happy_villager'], lambda: click(0.1))
|
|
player('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:copper_block')
|
|
player('command', command='item replace entity @s weapon.mainhand with minecraft:honeycomb')
|
|
player.wait(10)
|
|
result[f'{label} wax'] = sparked(player, other, ['wax_on'], lambda: click(0.5))
|
|
player('command', command='item replace entity @s weapon.mainhand with minecraft:iron_axe')
|
|
player.wait(10)
|
|
result[f'{label} wax off'] = sparked(player, other, ['wax_off'], lambda: click(0.5))
|
|
block = player.raw('block', *at).get('block', '')
|
|
result[f'{label} copper'] = block
|
|
player('command', command=f'fill {at[0]} {at[1] - 1} {at[2]} {at[0] + 1} {at[1] - 1} {at[2]} minecraft:stone')
|
|
player('command', command=f'fill {at[0]} {at[1]} {at[2]} {at[0] + 1} {at[1]} {at[2]} minecraft:lava')
|
|
player('command', command='item replace entity @s weapon.mainhand with minecraft:water_bucket')
|
|
player.wait(10)
|
|
hiss = 'minecraft:block.lava.extinguish'
|
|
hissed = {side: heard(g, hiss) for side, g in (('own', player), ('other', other))}
|
|
result[f'{label} lava'] = sparked(player, other, ['large_smoke'], lambda: click(0.1))
|
|
player.wait(10)
|
|
result[f'{label} hiss'] = {side: heard(g, hiss) - hissed[side]
|
|
for side, g in (('own', player), ('other', other))}
|
|
result[f'{label} obsidian'] = player.raw('block', at[0] + 1, at[1], at[2]).get('block', '')
|
|
finally:
|
|
player('command', command=f'fill {at[0] - 4} {at[1] - 3} {at[2] - 4} {at[0] + 5} {at[1] + 1} {at[2] + 4} '
|
|
'minecraft:air replace minecraft:water')
|
|
player('command', command=f'fill {at[0]} {at[1] - 1} {at[2]} {at[0] + 1} {at[1]} {at[2]} minecraft:air')
|
|
player('command', command=f'setblock {at[0]} {at[1] - 1} {at[2]} minecraft:stone')
|
|
player('command', command='clear @s')
|
|
# A spawner beside the host, at night: its burst when the zombie comes.
|
|
x, y, z = spots['host']
|
|
host('command', command='time set midnight')
|
|
try:
|
|
host('command', command=f'setblock {x} {y} {z + 3} minecraft:spawner{{Delay:20s,SpawnCount:1s,'
|
|
'SpawnData:{entity:{id:"minecraft:zombie",NoAI:1b}}}')
|
|
result['spawner'] = sparked(host, guest, ['flame', 'smoke'], lambda: None, ticks=200)
|
|
finally:
|
|
host('command', command=f'setblock {x} {y} {z + 3} minecraft:air')
|
|
host('command', command=f'kill @e[type=minecraft:zombie,x={x},y={y},z={z},distance=..12]')
|
|
host('command', command='time set day')
|
|
wrong = {k: v for k, v in result.items() if isinstance(v, dict) and not all(n > 0 for n in v.values())}
|
|
assert not wrong, f'no particles: {wrong}\n{result}'
|
|
hisses = {k: v for k, v in result.items() if k.endswith(' hiss') and set(v.values()) != {1}}
|
|
assert not hisses, f'the lava fizz is heard once on each side: {hisses}\n{result}'
|
|
obsidian = {k: v for k, v in result.items() if k.endswith(' obsidian') and v != 'minecraft:obsidian'}
|
|
assert not obsidian, f'the water turns the lava beside it to obsidian: {obsidian}\n{result}'
|
|
return result
|
|
|
|
|
|
def hearts(game):
|
|
"""The player's maximum and current health as its own client holds them."""
|
|
p = game('status')['player']
|
|
return p.get('max_health'), round(p['health'], 1)
|
|
|
|
|
|
def check_hud(host, guest, out):
|
|
result = {}
|
|
for label, player in (('host', host), ('guest', guest)):
|
|
player('command', command='gamemode survival')
|
|
try:
|
|
for maximum in (40, 10):
|
|
player('command', command=f'attribute @s minecraft:max_health base set {maximum}')
|
|
player.wait(10)
|
|
player('command', command='effect give @s minecraft:instant_health 1 10')
|
|
seen = None
|
|
for _ in range(15):
|
|
player.wait(2)
|
|
seen = hearts(player)
|
|
if seen == (maximum, maximum):
|
|
break
|
|
result[f'{label} {maximum}'] = seen
|
|
player.shot(out, f'hearts-{label}-{maximum}')
|
|
finally:
|
|
player('command', command='attribute @s minecraft:max_health base reset')
|
|
player('command', command='effect give @s minecraft:instant_health 1 10')
|
|
# BossBarCommands' new bar starts visible (ServerBossEvent's constructor).
|
|
host('command', command='bossbar add lan_e2e:bar "LAN bar"')
|
|
try:
|
|
host('command', command='bossbar get lan_e2e:bar visible')
|
|
host.wait(5)
|
|
result['bar'] = [line for line in host.raw('messages', 5)['chat'] if 'lan_e2e:bar' in line or 'LAN bar' in line]
|
|
finally:
|
|
host('command', command='bossbar remove lan_e2e:bar')
|
|
wrong = {k: v for k, v in result.items() if k != 'bar' and v != (int(k.split()[1]),) * 2}
|
|
assert not wrong, f'hearts: {wrong}\n{result}'
|
|
assert any('currently visible' in line for line in result['bar']), f'bar: {result["bar"]}'
|
|
return result
|
|
|
|
|
|
def sidebars(host, guest, want, ticks=40):
|
|
"""Each side's sidebar (objective and scores) once both show `want` (host, guest), or after `ticks`."""
|
|
seen = None
|
|
for _ in range(ticks // 2):
|
|
host.wait(2)
|
|
seen = tuple(g('status').get('sidebar') for g in (host, guest))
|
|
got = tuple(s and (s['objective'], s['scores']) for s in seen)
|
|
if got == want:
|
|
break
|
|
return seen
|
|
|
|
|
|
def check_sidebar(host, guest, out):
|
|
result = {}
|
|
run = lambda *cmds: [host('command', command=c) for c in cmds]
|
|
try:
|
|
run('scoreboard objectives add audit dummy {"text":"Audit board","color":"aqua"}',
|
|
'scoreboard players set Alpha audit 7', 'scoreboard players set Beta audit 3',
|
|
'scoreboard objectives setdisplay sidebar audit')
|
|
both = ('audit', {'Alpha': 7, 'Beta': 3})
|
|
result['shown'] = sidebars(host, guest, (both, both))
|
|
host.shot(out, 'sidebar')
|
|
guest.shot(out, 'sidebar')
|
|
run('scoreboard players set Alpha audit 12', 'scoreboard players reset Beta audit')
|
|
both = ('audit', {'Alpha': 12})
|
|
result['changed'] = sidebars(host, guest, (both, both))
|
|
# The guest's team's sidebar replaces the sidebar for the guest only.
|
|
run('team add red', 'team modify red color red', f'team join red {GUEST_NAME}',
|
|
'scoreboard objectives add reds dummy', f'scoreboard players set {GUEST_NAME} reds 5',
|
|
'scoreboard objectives setdisplay sidebar.team.red reds')
|
|
result['team'] = sidebars(host, guest, (both, ('reds', {GUEST_NAME: 5})))
|
|
run('scoreboard objectives remove reds', 'scoreboard objectives setdisplay sidebar')
|
|
result['cleared'] = sidebars(host, guest, (None, None))
|
|
finally:
|
|
run('scoreboard objectives remove audit', 'scoreboard objectives remove reds', 'team remove red')
|
|
want = {'shown': (('audit', {'Alpha': 7, 'Beta': 3}),) * 2, 'changed': (('audit', {'Alpha': 12}),) * 2,
|
|
'team': (('audit', {'Alpha': 12}), ('reds', {GUEST_NAME: 5})), 'cleared': (None, None)}
|
|
got = {k: tuple(s and (s['objective'], s['scores']) for s in v) for k, v in result.items()}
|
|
wrong = {k: v for k, v in got.items() if v != want[k]}
|
|
assert not wrong, f'sidebars: {wrong}\n{result}'
|
|
return result
|
|
|
|
|
|
def fluid_pig_position(host, tag):
|
|
"""Read the tagged pig even after the current carries it past its spawn."""
|
|
reply = server_reply(host, f'data get entity @e[type=pig,tag={tag},limit=1] Pos', 'entity data')
|
|
values = re.findall(r'-?\d+(?:\.\d+)?', reply.rsplit(':', 1)[-1])
|
|
assert len(values) == 3, f'no position for {tag}: {reply}'
|
|
return tuple(map(float, values))
|
|
|
|
|
|
def clear_fluid_arena(host, x, y, z, tags, floor='minecraft:stone'):
|
|
"""Kill the tagged pigs and empty the arena, up in the air at y 100 so its
|
|
walls never close around a player; the last clear takes the floor away too."""
|
|
for tag in tags:
|
|
host('command', command=f'kill @e[type=pig,tag={tag}]')
|
|
host('command', command=f'fill {x-2} {y} {z-2} {x+9} {y+6} {z+7} minecraft:air')
|
|
host('command', command=f'fill {x-2} {y-1} {z-2} {x+9} {y-1} {z+7} {floor}')
|
|
host.wait(10)
|
|
|
|
|
|
def check_flow_push(host, guest, out):
|
|
"""A current pushes a tagged pig farther than a pool of source blocks."""
|
|
hx, _, hz = pos_of(host)
|
|
x, y, z = (int(hx // 16) * 16 + 8, 100, int(hz // 16) * 16 + 8)
|
|
tags = ('horizon_flow_test', 'horizon_still_test')
|
|
host('command', command='gamemode survival')
|
|
try:
|
|
clear_fluid_arena(host, x, y, z, tags)
|
|
# Enclosed trough: its walls keep water inside the area cleaned below.
|
|
host('command', command=f'fill {x-1} {y} {z-1} {x+8} {y+1} {z+1} minecraft:stone')
|
|
host('command', command=f'fill {x} {y} {z} {x+7} {y+2} {z} minecraft:air')
|
|
host('command', command=f'setblock {x} {y} {z} minecraft:water')
|
|
# Enclosed source pool, whose centre has no horizontal current.
|
|
host('command', command=f'fill {x+2} {y} {z+2} {x+6} {y+1} {z+6} minecraft:stone')
|
|
host('command', command=f'fill {x+3} {y} {z+3} {x+5} {y} {z+5} minecraft:water')
|
|
host('command', command=f'fill {x+3} {y+1} {z+3} {x+5} {y+3} {z+5} minecraft:air')
|
|
host.wait(40)
|
|
for tag, px, pz in [(tags[0], x+1.5, z+0.5), (tags[1], x+4.5, z+4.5)]:
|
|
host('command', command=f'summon minecraft:pig {px} {y+0.1} {pz} {{attributes:[{{id:"minecraft:movement_speed",base:0.0d}}],Tags:["{tag}"]}}')
|
|
host.wait(40)
|
|
flowing = fluid_pig_position(host, tags[0])[0] - (x + 1.5)
|
|
still = fluid_pig_position(host, tags[1])[0] - (x + 4.5)
|
|
assert flowing - still > 0.2, f'the current did not carry the mob: {flowing} vs {still}'
|
|
return {'flowing': round(flowing, 3), 'still': round(still, 3)}
|
|
finally:
|
|
clear_fluid_arena(host, x, y, z, tags, floor='minecraft:air')
|
|
|
|
|
|
def check_bubble_column(host, guest, out):
|
|
"""A bubble column lifts a tagged pig farther than an ordinary water source."""
|
|
hx, _, hz = pos_of(host)
|
|
x, y, z = (int(hx // 16) * 16 + 8, 100, int(hz // 16) * 16 + 8)
|
|
tags = ('horizon_bubble_test', 'horizon_plain_test')
|
|
host('command', command='gamemode survival')
|
|
try:
|
|
clear_fluid_arena(host, x, y, z, tags)
|
|
for px in (x, x+3):
|
|
host('command', command=f'fill {px-1} {y} {z-1} {px+1} {y} {z+1} minecraft:stone')
|
|
host('command', command=f'setblock {px} {y} {z} minecraft:water')
|
|
host('command', command=f'setblock {x} {y-1} {z} minecraft:soul_sand')
|
|
host.wait(20)
|
|
for tag, px in [(tags[0], x+0.5), (tags[1], x+3.5)]:
|
|
host('command', command=f'summon minecraft:pig {px} {y+0.1} {z+0.5} {{attributes:[{{id:"minecraft:movement_speed",base:0.0d}}],Tags:["{tag}"]}}')
|
|
samples = {tag: [] for tag in tags}
|
|
for _ in range(8):
|
|
host.wait(5)
|
|
for tag in tags:
|
|
samples[tag].append(fluid_pig_position(host, tag)[1])
|
|
column, plain = (max(samples[tag]) for tag in tags)
|
|
assert column - plain > 0.2, f'the bubble column did not carry the pig up: {column} vs {plain}'
|
|
return {'column': round(column-y, 3), 'plain': round(plain-y, 3)}
|
|
finally:
|
|
clear_fluid_arena(host, x, y, z, tags, floor='minecraft:air')
|
|
|
|
|
|
def check_border(host, guest, out):
|
|
spots = apart(host, guest)
|
|
result = {}
|
|
try:
|
|
for label, player, other in (('host', host, guest), ('guest', guest, host)):
|
|
x, y, z = spots[label]
|
|
# A whole-block centre: the wall's collision box is the border's, rounded out to blocks.
|
|
cx, cz = float(x), float(z)
|
|
# The other player keeps out of harm outside the border meanwhile.
|
|
other('command', command='gamemode creative')
|
|
host('command', command=f'fill {x - 3} {y - 1} {z} {x + 4} {y - 1} {z + 10} minecraft:stone')
|
|
host('command', command=f'fill {x - 3} {y} {z} {x + 4} {y + 3} {z + 10} minecraft:air')
|
|
# A border 10 wide around the player, its wall 5 blocks ahead: both sides know it.
|
|
host('command', command=f'worldborder center {cx} {cz}')
|
|
host('command', command='worldborder set 10')
|
|
seen = None
|
|
for _ in range(10):
|
|
player.wait(2)
|
|
seen = [g('status').get('border') for g in (player, other)]
|
|
if all(b and b['size'] == 10 and b['center'] == [cx, cz] for b in seen):
|
|
break
|
|
result[f'{label} border'] = seen
|
|
# Sprint-jumping into the wall: it stops the player at the border.
|
|
player('command', command=f'tp @s {cx} {y} {cz} 0 0')
|
|
player.wait(10)
|
|
player.raw('look-at', f'{cx:.2f}', f'{y + 1.6:.2f}', f'{cz + 20:.2f}')
|
|
for k in ('w', 'ctrl', 'space'):
|
|
player.raw('key', k, 'down')
|
|
player.wait(40)
|
|
for k in ('space', 'ctrl', 'w'):
|
|
player.raw('key', k, 'up')
|
|
player.wait(10)
|
|
result[f'{label} reached'] = round(pos_of(player)[2] - cz, 2)
|
|
player.shot(out, f'border-{label}')
|
|
# Two blocks outside, with no safe zone: the border hurts.
|
|
host('command', command='worldborder damage buffer 0')
|
|
host('command', command='worldborder damage amount 1')
|
|
player('command', command='effect give @s minecraft:instant_health 1 5')
|
|
player.wait(20)
|
|
player('command', command=f'tp @s {cx} {y} {cz + 7}')
|
|
player.wait(5)
|
|
before = player('status')['player']['health']
|
|
player.wait(40)
|
|
result[f'{label} hurt'] = [before, player('status')['player']['health']]
|
|
player('command', command=f'tp @s {cx} {y} {cz}')
|
|
player('command', command='effect give @s minecraft:instant_health 1 5')
|
|
host('command', command='worldborder damage amount 0.2')
|
|
host('command', command='worldborder damage buffer 5')
|
|
other('command', command='gamemode survival')
|
|
finally:
|
|
guest('command', command='gamemode survival')
|
|
host('command', command='gamemode survival')
|
|
host('command', command='worldborder damage amount 0.2')
|
|
host('command', command='worldborder damage buffer 5')
|
|
host('command', command='worldborder center 0 0')
|
|
host('command', command='worldborder set 59999968')
|
|
wrong = {}
|
|
for label in ('host', 'guest'):
|
|
x, y, z = spots[label]
|
|
known = result[f'{label} border']
|
|
if not all(b and b['size'] == 10 and b['center'] == [x, z] for b in known):
|
|
wrong[f'{label} border'] = known
|
|
if not 4.0 < result[f'{label} reached'] <= 4.71:
|
|
wrong[f'{label} reached'] = result[f'{label} reached']
|
|
before, after = result[f'{label} hurt']
|
|
if not after < before:
|
|
wrong[f'{label} hurt'] = result[f'{label} hurt']
|
|
assert not wrong, f'border: {wrong}\n{result}'
|
|
return result
|
|
|
|
|
|
def check_particles(host, guest, out):
|
|
spots = apart(host, guest)
|
|
result = {}
|
|
for label, player, other in (('host', host, guest), ('guest', guest, host)):
|
|
# For the player alone (`@s`), then for every player near.
|
|
before = {side: made(g, 'flame') for side, g in (('own', player), ('other', other))}
|
|
player('command', command='particle minecraft:flame ~ ~1 ~3 .2 .2 .2 0 60 force @s')
|
|
player.wait(10)
|
|
result[f'{label} @s'] = {side: made(g, 'flame') - before[side] for side, g in (('own', player), ('other', other))}
|
|
player.shot(out, f'flame-{label}')
|
|
# No spread: one point, wherever the player stands.
|
|
player('command', command='particle minecraft:flame ~ ~1 ~3 0 0 0 0 200 force @s')
|
|
player.wait(3)
|
|
player.shot(out, f'zero-spread-{label}')
|
|
result[f'{label} all'] = sparked(player, other, ['dust'], lambda: player(
|
|
'command', command='particle minecraft:dust{color:[1,0,0],scale:2} ~ ~1 ~3 0 0 0 0 10'))
|
|
# A TNT's blast between the players: its burst on both sides.
|
|
x, y, z = spots['host']
|
|
host('command', command='gamemode creative')
|
|
guest('command', command='gamemode creative')
|
|
try:
|
|
result['blast'] = sparked(host, guest, ['explosion_emitter'], lambda: host(
|
|
'command', command=f'summon minecraft:tnt {x + 6.5} {y} {z + 4.5} {{fuse:10s}}'), ticks=40)
|
|
host.shot(out, 'blast')
|
|
finally:
|
|
host('command', command='gamemode survival')
|
|
guest('command', command='gamemode survival')
|
|
host('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 16} {y - 1} {z + 8} minecraft:stone')
|
|
wrong = {k: v for k, v in result.items() if not k.endswith('@s') and not all(n > 0 for n in v.values())}
|
|
wrong.update({k: v for k, v in result.items() if k.endswith('@s') and not (v['own'] >= 30 and v['other'] == 0)})
|
|
assert not wrong, f'particles: {wrong}\n{result}'
|
|
return result
|
|
|
|
|
|
def check_blast_projectiles(host, guest, out):
|
|
host('command', command='gamemode creative')
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
gx, gz = x + 6, z
|
|
floor = f'fill {gx - 6} {y - 1} {gz - 4} {gx + 6} {y - 1} {gz + 4} minecraft:bedrock'
|
|
detail = {}
|
|
try:
|
|
host('command', command=floor)
|
|
host('command', command=f'fill {gx - 6} {y} {gz - 4} {gx + 6} {y + 3} {gz + 4} minecraft:air')
|
|
host('command', command='kill @e[type=minecraft:arrow]')
|
|
host('command', command='kill @e[type=minecraft:snowball]')
|
|
# A snowball hanging north of the blast (the host stands west of it), and an arrow that settles into the floor.
|
|
host('command', command=f'summon minecraft:snowball {gx + 0.5} {y + 1.2} {gz - 1.5} {{NoGravity:1b}}')
|
|
host('command', command=f'summon minecraft:arrow {gx + 0.5} {y} {gz + 2.5}')
|
|
host.wait(20)
|
|
# `entities` lists mobs, items and orbs only: the server's own projectiles come from `simulation`.
|
|
def flying():
|
|
return host.raw('simulation')['projectiles']
|
|
|
|
def find(kind, near):
|
|
found = [e for e in flying() if e['kind'] == kind]
|
|
return min(found, key=lambda e: abs(e['position'][0] - near[0]) + abs(e['position'][2] - near[1]))
|
|
ball = find('Snowball', (gx + 0.5, gz - 1.5))
|
|
stuck = find('Arrow', (gx + 0.5, gz + 2.5))
|
|
# Arrows ignore NoGravity (arrow_from_nbt), so the flying one is summoned beside the fuse it shares.
|
|
host('command', command=f'summon minecraft:arrow {gx + 2.5} {y + 2.2} {gz + 0.5}')
|
|
host('command', command=f'summon minecraft:tnt {gx + 0.5} {y + 1} {gz + 0.5} {{fuse:5s}}')
|
|
air = find('Arrow', (gx + 2.5, gz + 0.5))
|
|
before = {'air': air['position'], 'ball': ball['position'], 'stuck': stuck['position']}
|
|
host.wait(8) # the fuse is 5 ticks; a snowball that flies on would soon hit the wall and vanish
|
|
left = {e['id']: e['position'] for e in flying()}
|
|
after = {name: left.get(e['id']) for name, e in (('air', air), ('ball', ball), ('stuck', stuck))}
|
|
detail = {name: (None if after[name] is None else
|
|
[round(after[name][i] - before[name][i], 2) for i in range(3)]) for name in before}
|
|
host.shot(out, 'blast-projectiles')
|
|
finally:
|
|
host('command', command='kill @e[type=minecraft:arrow]')
|
|
host('command', command='kill @e[type=minecraft:snowball]')
|
|
host('command', command=floor)
|
|
host('command', command='gamemode survival')
|
|
assert detail['air'] and detail['air'][0] > 0.5, f'the blast never pushed the flying arrow east: {detail}'
|
|
assert detail['ball'] and detail['ball'][2] < -0.5, f'the blast never pushed the snowball north: {detail}'
|
|
assert detail['stuck'] and max(abs(v) for v in detail['stuck']) < 0.05, f'an arrow in the floor moved: {detail}'
|
|
return detail
|
|
|
|
|
|
def check_blast(host, guest, out):
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
host('command', command='gamemode creative')
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
gx, gz = x + 6, z
|
|
floor = f'fill {gx - 6} {y - 1} {gz - 4} {gx + 6} {y - 1} {gz + 4} minecraft:bedrock'
|
|
detail = {}
|
|
try:
|
|
for shielded in (False, True):
|
|
if remote(guest)['health'] <= 0:
|
|
guest.raw('respawn')
|
|
guest.wait(40)
|
|
host('command', command=floor)
|
|
host('command', command=f'fill {gx - 6} {y} {gz - 4} {gx + 6} {y + 3} {gz + 4} minecraft:air')
|
|
guest('command', command=f'tp @s {gx + 0.5} {y} {gz + 0.5}')
|
|
guest('command', command='effect give @s minecraft:instant_health 1 5')
|
|
guest.wait(30)
|
|
hold = None
|
|
if shielded:
|
|
guest('command', command='item replace entity @s weapon.offhand with minecraft:shield')
|
|
guest.wait(5)
|
|
guest.raw('look-at', gx + 5.5, y + 0.5, gz + 0.5)
|
|
hold = threading.Thread(target=guest.raw, args=('use-item', '--hold', 2.5))
|
|
hold.start()
|
|
time.sleep(0.5)
|
|
before = remote(guest)['health']
|
|
at = pos_of(guest)
|
|
# Five blocks off: a blast that hurts without killing (about 14 at full exposure).
|
|
host('command', command=f'summon minecraft:tnt {gx + 5.5} {y} {gz + 0.5} {{fuse:10s}}')
|
|
# The guest's own controller is busy holding the shield up: the host waits.
|
|
host.wait(14)
|
|
after = remote(guest)['health']
|
|
moved = pos_of(guest)
|
|
if hold:
|
|
hold.join()
|
|
guest.shot(out, f'blast-{"shield" if shielded else "open"}')
|
|
detail['shield' if shielded else 'open'] = {
|
|
'before': before, 'after': after, 'pushed': round(at[0] - moved[0], 2)}
|
|
finally:
|
|
host('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
host('command', command=floor)
|
|
open_, shield = detail['open'], detail['shield']
|
|
assert open_['after'] < open_['before'], f'the blast never hurt the guest: {detail}'
|
|
assert open_['pushed'] > 0, f'the blast never pushed the guest away: {detail}'
|
|
assert shield['after'] >= shield['before'], f'the shield did not stop the blast: {detail}'
|
|
return detail
|
|
|
|
|
|
def light_differences(host, guest, lo, hi):
|
|
"""Positions of the box loaded on both sides, and those whose sky or block light differ."""
|
|
h, g = host.raw('light', *lo, *hi), guest.raw('light', *lo, *hi)
|
|
both = [i for i in range(len(h['sky'])) if h['sky'][i] != '.' and g['sky'][i] != '.']
|
|
differ = [i for i in both if (h['sky'][i], h['block'][i]) != (g['sky'][i], g['block'][i])]
|
|
return h, g, both, differ
|
|
|
|
|
|
def same_light(host, guest, lo, hi, what, ticks=100):
|
|
"""Wait until the guest's light over the box is the host's; the number of positions compared."""
|
|
for _ in range(ticks // 10):
|
|
h, g, both, differ = light_differences(host, guest, lo, hi)
|
|
if not differ:
|
|
assert both, f'{what}: no position of the box is loaded on both sides'
|
|
return len(both)
|
|
host.wait(10)
|
|
i = differ[0]
|
|
nx, nz = hi[0] - lo[0] + 1, hi[2] - lo[2] + 1
|
|
pos = (lo[0] + i % nx, lo[1] + i // (nx * nz), lo[2] + i // nx % nz)
|
|
raise AssertionError(f'{what}: {len(differ)} of {len(both)} positions differ, first {pos}: host sky '
|
|
f'{h["sky"][i]} block {h["block"][i]}, guest sky {g["sky"][i]} block {g["block"][i]}')
|
|
|
|
|
|
def check_flight(host, guest, out):
|
|
def double_jump(game):
|
|
# LocalPlayer.aiStep: a second jump press within 7 ticks of the first toggles flight.
|
|
lines = []
|
|
for hold in (1, 4):
|
|
lines += ['key space down', f'wait {hold}', 'key space up', 'wait 2']
|
|
game.script(lines + ['wait 10'])
|
|
return game('state')['player']
|
|
|
|
def set_mode(game, mode):
|
|
# On a client of a server the mode and its abilities (may fly) come back with the server's
|
|
# answer, later when the machine is busy: a double jump before them is two plain jumps.
|
|
game('command', command=f'gamemode {mode}')
|
|
for _ in range(100):
|
|
if game('state').get('gamemode') == mode:
|
|
break
|
|
game.wait(1)
|
|
game.wait(10)
|
|
|
|
players = [('guest', guest), ('host', host)]
|
|
seen = {}
|
|
# The rule is seven simulation ticks, independent of wall time. A slow
|
|
# server clock leaves enough time to deliver the key events even on busy CI.
|
|
host('command', command='tick rate 4')
|
|
try:
|
|
for name, game in players:
|
|
set_mode(game, 'creative')
|
|
ground = game('state')['player']
|
|
up = double_jump(game)
|
|
game.wait(20)
|
|
later = game('state')['player']
|
|
set_mode(game, 'survival')
|
|
fell = game('state')['player']
|
|
seen[name] = {'flying': up['flying'], 'rose': round(up['position'][1] - ground['position'][1], 2),
|
|
'stayed': round(later['position'][1] - up['position'][1], 2),
|
|
'survival_flying': fell['flying']}
|
|
finally:
|
|
host('command', command='tick rate 20')
|
|
for _, game in players:
|
|
game('command', command='gamemode survival')
|
|
for name, s in seen.items():
|
|
assert s['flying'] and s['rose'] > 1 and abs(s['stayed']) < 0.1, f'{name} does not fly in creative: {seen}'
|
|
assert not s['survival_flying'], f'{name} still flies in survival: {seen}'
|
|
return seen
|
|
|
|
|
|
def check_drops(host, guest, out):
|
|
"""A dropped item lying where a block is then placed comes out of it (#1252): the server moves
|
|
it towards the closest open side (ItemEntity.tick's noPhysics, Entity.moveTowardsClosestSpace),
|
|
and both sides see it outside the block. The host places one, then the guest."""
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
seen = {}
|
|
for game, dz in ((host, -3), (guest, 3)):
|
|
x, y, z = hx + 3, hy, hz + dz
|
|
game('command', command=f'fill {x - 1} {y} {z - 1} {x + 1} {y + 2} {z + 1} minecraft:air')
|
|
game('command', command=f'summon minecraft:item {x + 0.5} {y} {z + 0.5} '
|
|
'{Item:{id:"minecraft:diamond",count:1},PickupDelay:32767}')
|
|
game.wait(20)
|
|
game('command', command=f'setblock {x} {y} {z} minecraft:stone')
|
|
game.wait(60)
|
|
for viewer in (host, guest):
|
|
near = [e['pos'] for e in viewer.raw('entities', 24, 'minecraft:item')['entities']
|
|
if abs(e['pos'][0] - x - 0.5) < 2 and abs(e['pos'][2] - z - 0.5) < 2]
|
|
seen[f'{game.name} places, {viewer.name} sees'] = ([x, y, z], near)
|
|
game.shot(out, 'drops')
|
|
host('command', command='kill @e[type=minecraft:item]')
|
|
host('command', command=f'setblock {x} {y} {z} minecraft:air')
|
|
for name, (block, near) in seen.items():
|
|
inside = [p for p in near if all(block[i] <= p[i] < block[i] + 1 for i in range(3))]
|
|
assert near and not inside, f'{name}: items {near} in block {block} (all: {seen})'
|
|
return seen
|
|
|
|
|
|
def check_underwater(host, guest, out):
|
|
"""Digging dirt by hand underwater, standing on the bottom of a pool: five times slower than in
|
|
the air (SUBMERGED_MINING_SPEED), on the player's own client as on the server. Halfway through
|
|
the client is about halfway (it doesn't break the block early, which the server would put
|
|
back), and the block breaks (#1254). The dirt is at eye height, the player's head under water.
|
|
The host, then the guest."""
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
seen = {}
|
|
for game, dz in ((host, -4), (guest, 4)):
|
|
x, y, z = hx + 3, hy, hz + dz
|
|
game('command', command='gamemode survival')
|
|
game('command', command='item replace entity @s weapon.mainhand with minecraft:air')
|
|
game('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 2} {y + 3} {z + 1} minecraft:stone')
|
|
game('command', command=f'fill {x} {y} {z} {x} {y + 2} {z} minecraft:water')
|
|
game('command', command=f'setblock {x + 1} {y + 1} {z} minecraft:dirt')
|
|
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
game.wait(40)
|
|
game.raw('look-at', f'{x + 1.5:.2f}', f'{y + 1.5:.2f}', f'{z + 0.5:.2f}')
|
|
game.wait(5)
|
|
game.raw('mouse', 'left', 'down')
|
|
# Dirt by hand: 15 ticks in the air, 75 under water.
|
|
game.wait(35)
|
|
halfway = {'mining': game('state').get('mining'), 'client': game.raw('block', x + 1, y + 1, z).get('block'),
|
|
'server': host.raw('block', x + 1, y + 1, z).get('block'), 'y': y + 1}
|
|
game.wait(70)
|
|
game.raw('mouse', 'left', 'up')
|
|
game.wait(10)
|
|
done = host.raw('block', x + 1, y + 1, z).get('block')
|
|
game.shot(out, 'underwater')
|
|
seen[game.name] = {'halfway': halfway, 'done': done}
|
|
host('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 2} {y + 3} {z + 1} minecraft:air')
|
|
host('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 2} {y - 1} {z + 1} minecraft:stone')
|
|
for name, s in seen.items():
|
|
h = s['halfway']
|
|
assert h['client'] == 'minecraft:dirt' == h['server'], f'{name} broke it early: {seen}'
|
|
assert h['mining'] and h['mining']['pos'][1] == h['y'] and 0.3 < h['mining']['progress'] < 0.7, f'{name} digs at the wrong speed: {seen}'
|
|
# Broken: air, or the pool's water flowing in.
|
|
assert s['done'] in ('minecraft:air', 'minecraft:water'), f'{name} never broke it: {seen}'
|
|
return seen
|
|
|
|
|
|
|
|
|
|
|
|
def own(game):
|
|
"""The player's own state, as its client has it."""
|
|
return game('state')['player']
|
|
|
|
|
|
|
|
|
|
def check_contacts(host, guest, out):
|
|
"""Blocks that hurt what touches them, on the server: standing on magma (but not sneaking on it)
|
|
and on a lit campfire, walking against a cactus and through grown sweet berry bushes, for the
|
|
host and the guest (#1305, #1311, #1312, #1313); a young bush pricks no one."""
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
host('command', command='gamerule natural_health_regeneration false')
|
|
seen = {}
|
|
try:
|
|
for game, dz in ((host, -4), (guest, 4)):
|
|
x, y, z = hx + 2, hy, hz + dz
|
|
game('command', command='gamemode survival')
|
|
game('command', command='effect clear @s')
|
|
for case, blocks, walk in (
|
|
('magma', [(0, -1, 'minecraft:magma_block')], False),
|
|
('sneaking', [(0, -1, 'minecraft:magma_block')], False),
|
|
('campfire', [(0, 0, 'minecraft:campfire[lit=true]')], False),
|
|
('cactus', [(2, -1, 'minecraft:sand'), (2, 0, 'minecraft:cactus')], True),
|
|
('berries', [(k, -1, 'minecraft:grass_block') for k in range(1, 4)]
|
|
+ [(k, 0, 'minecraft:sweet_berry_bush[age=3]') for k in range(1, 4)], True),
|
|
('young', [(k, -1, 'minecraft:grass_block') for k in range(1, 4)]
|
|
+ [(k, 0, 'minecraft:sweet_berry_bush[age=0]') for k in range(1, 4)], True)):
|
|
game('command', command='effect give @s minecraft:instant_health 1 5 true')
|
|
game('command', command=f'fill {x - 1} {y - 2} {z - 1} {x + 4} {y - 1} {z + 1} minecraft:stone')
|
|
game('command', command=f'fill {x - 1} {y} {z - 1} {x + 4} {y + 2} {z + 1} minecraft:air')
|
|
# A wall ends the walk before the player leaves the floor.
|
|
game('command', command=f'fill {x + 5} {y - 1} {z - 1} {x + 5} {y + 2} {z + 1} minecraft:stone')
|
|
for dx, dy, block in blocks:
|
|
game('command', command=f'setblock {x + dx} {y + dy} {z} {block}')
|
|
stand = 0.5 if case == 'campfire' else 0.0
|
|
# Sneaking from before the player lands on the magma.
|
|
held = ['shift'] if case == 'sneaking' else []
|
|
for k in held:
|
|
game.raw('key', k, 'down')
|
|
game.wait(5)
|
|
game('command', command=f'tp @s {x + 0.5} {y + stand} {z + 0.5}')
|
|
game.raw('look-at', f'{x + 10:.2f}', f'{y + 1.6:.2f}', f'{z + 0.5:.2f}')
|
|
game.wait(30)
|
|
before = own(game)['health']
|
|
keys = ['w'] if walk else []
|
|
for k in keys:
|
|
game.raw('key', k, 'down')
|
|
game.wait(60)
|
|
# Read before letting go: standing up on the magma would hurt.
|
|
after = own(game)['health']
|
|
for k in keys + held:
|
|
game.raw('key', k, 'up')
|
|
game.wait(10)
|
|
seen[f'{game.name}-{case}'] = (before, after)
|
|
game.shot(out, f'contacts-{case}')
|
|
host('command', command=f'fill {x - 1} {y} {z - 1} {x + 5} {y + 2} {z + 1} minecraft:air')
|
|
finally:
|
|
host('command', command='gamerule natural_health_regeneration true')
|
|
for case, (before, after) in seen.items():
|
|
safe = case.endswith(('sneaking', 'young'))
|
|
assert (after == before) if safe else (after < before), f'{case}: {before} -> {after} (all: {seen})'
|
|
return seen
|
|
|
|
|
|
def server_player(host, game):
|
|
"""The player's entry on the server: the host's `lan status` lists the guest and the host."""
|
|
name = GUEST_NAME if game is not host else host.player
|
|
return next(g for g in host.raw('lan', 'status')['guests'] if g['name'] == name)
|
|
|
|
|
|
def check_shield(host, guest, out):
|
|
"""An axe disables a raised shield for 5 s (100 ticks), counted down once a tick on the
|
|
server: a vindicator's axe hits each player's shield, then the cooldown left falls by about 40
|
|
over 40 ticks (#1316)."""
|
|
host('command', command='gamerule natural_health_regeneration false')
|
|
rates = {}
|
|
try:
|
|
for game, dz in ((host, -4), (guest, 4)):
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
z += dz
|
|
game('command', command='gamemode survival')
|
|
game('command', command='effect give @s minecraft:resistance 60 4 true')
|
|
game('command', command='effect give @s minecraft:instant_health 1 5 true')
|
|
game('command', command='item replace entity @s weapon.mainhand with minecraft:air')
|
|
game('command', command='item replace entity @s weapon.offhand with minecraft:shield')
|
|
game('command', command=f'fill {x - 1} {y} {z - 1} {x + 4} {y + 2} {z + 1} minecraft:air')
|
|
game('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 4} {y - 1} {z + 1} minecraft:stone')
|
|
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
game.wait(10)
|
|
game.raw('look-at', f'{x + 2.5:.2f}', f'{y + 1.5:.2f}', f'{z + 0.5:.2f}')
|
|
game.raw('mouse', 'right', 'down')
|
|
game.wait(10)
|
|
host('command', command=f'summon minecraft:vindicator {x + 2.5} {y} {z + 0.5} '
|
|
'{PersistenceRequired:1b,Rotation:[90f,0f]}')
|
|
host('command', command='item replace entity @e[type=minecraft:vindicator] weapon.mainhand '
|
|
'with minecraft:iron_axe')
|
|
first = None
|
|
for _ in range(60):
|
|
game.wait(5)
|
|
first = server_player(host, game).get('shield_cooldown', 0)
|
|
if first:
|
|
break
|
|
host('command', command='kill @e[type=minecraft:vindicator]')
|
|
game.raw('mouse', 'right', 'up')
|
|
assert first, f'{game.name}: the vindicator never disabled the shield'
|
|
start = server_player(host, game)['shield_cooldown']
|
|
game.wait(40)
|
|
end = server_player(host, game)['shield_cooldown']
|
|
rates[game.name] = {'first': first, 'start': start, 'end': end}
|
|
game.shot(out, 'shield')
|
|
game('command', command='item replace entity @s weapon.offhand with minecraft:air')
|
|
game('command', command='effect clear @s')
|
|
finally:
|
|
host('command', command='kill @e[type=minecraft:vindicator]')
|
|
host('command', command='gamerule natural_health_regeneration true')
|
|
for name, r in rates.items():
|
|
assert r['first'] <= 100, f'{name}: {rates}'
|
|
# Once a tick: 40 down over 40 ticks, give or take the ticks the two queries straddle.
|
|
assert 30 <= r['start'] - r['end'] <= 50, f'{name}: {rates}'
|
|
return rates
|
|
|
|
|
|
def respawn_when_ready(game):
|
|
"""Press Respawn once the death screen lets it (DeathScreen's 20-tick delay, counted from when
|
|
the screen opens)."""
|
|
for _ in range(20):
|
|
try:
|
|
return game.raw('respawn')
|
|
except Failed as e:
|
|
if 'not active yet' not in str(e):
|
|
raise
|
|
game.wait(10)
|
|
return game.raw('respawn')
|
|
|
|
|
|
def check_world_spawn(host, guest, out):
|
|
"""`setworldspawn` moves the world spawn: the host and the guest, with no bed of their own,
|
|
respawn there once killed (#1310)."""
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
sx, sz = hx + 24, hz
|
|
host('command', command='gamerule respawn_radius 0')
|
|
host('command', command=f'fill {sx - 2} {hy - 1} {sz - 2} {sx + 2} {hy - 1} {sz + 2} minecraft:stone')
|
|
host('command', command=f'fill {sx - 2} {hy} {sz - 2} {sx + 2} {hy + 3} {sz + 2} minecraft:air')
|
|
seen = {}
|
|
try:
|
|
host('command', command=f'setworldspawn {sx} {hy} {sz}')
|
|
for game in (host, guest):
|
|
game('command', command='gamemode survival')
|
|
game('command', command='kill @s')
|
|
game.wait(20)
|
|
respawn_when_ready(game)
|
|
game.wait(60)
|
|
seen[game.name] = [round(v, 2) for v in pos_of(game)]
|
|
game.shot(out, 'world-spawn')
|
|
finally:
|
|
host('command', command=f'setworldspawn {hx} {hy} {hz}')
|
|
host('command', command='gamerule respawn_radius 10')
|
|
for game in (host, guest):
|
|
game('command', command=f'tp @s {hx + 0.5} {hy} {hz + 0.5}')
|
|
guest.wait(20)
|
|
for name, at in seen.items():
|
|
assert abs(at[0] - (sx + 0.5)) < 1 and abs(at[2] - (sz + 0.5)) < 1, f'{name}: {seen}, spawn {sx} {sz}'
|
|
return seen
|
|
|
|
|
|
def check_beds(host, guest, out):
|
|
"""A bed sets the respawn point (day or night): respawning stands the player up beside it; a bed
|
|
that is gone, or boxed in by stone so there is nowhere to stand up, sends the player to the world
|
|
spawn instead (#1308). The host, then the guest, each for the three beds."""
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
host('command', command='gamerule respawn_radius 0')
|
|
host('command', command=f'setworldspawn {hx} {hy} {hz}')
|
|
host('command', command='time set 1000')
|
|
seen = {}
|
|
try:
|
|
for game, dz in ((host, -6), (guest, 6)):
|
|
game('command', command='gamemode survival')
|
|
x, y, z = hx + 12, hy, hz + dz
|
|
for case in ('intact', 'gone', 'boxed'):
|
|
game('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 4} {y - 1} {z + 3} minecraft:stone')
|
|
game('command', command=f'fill {x - 3} {y} {z - 3} {x + 4} {y + 3} {z + 3} minecraft:air')
|
|
game('command', command=f'setblock {x} {y} {z} minecraft:red_bed[facing=east,part=foot]')
|
|
game('command', command=f'setblock {x + 1} {y} {z} minecraft:red_bed[facing=east,part=head]')
|
|
game('command', command=f'tp @s {x + 0.5} {y} {z + 1.5}')
|
|
game.wait(20)
|
|
game.raw('interact', x + 1, y, z)
|
|
game.wait(10)
|
|
# Die elsewhere, by the world spawn.
|
|
game('command', command=f'tp @s {hx + 0.5} {hy} {hz + 3.5}')
|
|
game.wait(10)
|
|
if case == 'gone':
|
|
game('command', command=f'fill {x} {y} {z} {x + 1} {y} {z} minecraft:air')
|
|
elif case == 'boxed':
|
|
game('command', command=f'fill {x - 2} {y} {z - 2} {x + 3} {y + 2} {z + 2} minecraft:stone replace minecraft:air')
|
|
game.wait(5)
|
|
game('command', command='kill @s')
|
|
game.wait(20)
|
|
respawn_when_ready(game)
|
|
game.wait(60)
|
|
at = [round(v, 2) for v in pos_of(game)]
|
|
seen[f'{game.name}-{case}'] = {'at': at, 'bed': [x + 1, y, z], 'health': own(game)['health']}
|
|
game.shot(out, f'beds-{case}')
|
|
finally:
|
|
host('command', command='gamerule respawn_radius 10')
|
|
host('command', command='time set 1000')
|
|
for game in (host, guest):
|
|
game('command', command=f'tp @s {hx + 0.5} {hy} {hz + 0.5}')
|
|
guest.wait(20)
|
|
for case, s in seen.items():
|
|
(px, py, pz), (bx, by, bz) = s['at'], s['bed']
|
|
if case.endswith('intact'):
|
|
near = ((px - bx - 0.5) ** 2 + (pz - bz - 0.5) ** 2) ** 0.5
|
|
assert near < 2.6 and abs(py - by) < 0.6, f'{case}: not beside its bed: {seen}'
|
|
else:
|
|
assert abs(px - (hx + 0.5)) < 1 and abs(pz - (hz + 0.5)) < 1, f'{case}: not at the world spawn: {seen}'
|
|
assert s['health'] > 0, f'{case}: died again after respawning: {seen}'
|
|
return seen
|
|
|
|
|
|
def check_death_mining(host, guest, out):
|
|
"""A player who dies while breaking obsidian, and lets go of attack on the death screen, doesn't
|
|
break it once respawned: death forgets the digging (#1315)."""
|
|
host('command', command='gamerule keep_inventory true')
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
seen = {}
|
|
try:
|
|
for game, dz in ((host, -3), (guest, 3)):
|
|
x, y, z = hx + 2, hy, hz + dz
|
|
game('command', command='gamemode survival')
|
|
game('command', command='item replace entity @s weapon.mainhand with minecraft:diamond_pickaxe')
|
|
game('command', command=f'setblock {x + 2} {y} {z} minecraft:obsidian')
|
|
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
game.wait(10)
|
|
game.raw('look-at', f'{x + 2.5:.2f}', f'{y + 0.5:.2f}', f'{z + 0.5:.2f}')
|
|
game.wait(5)
|
|
game.raw('mouse', 'left', 'down')
|
|
game.wait(10)
|
|
game('command', command='kill @s')
|
|
game.wait(10)
|
|
# The death screen lets go of the button (Minecraft.setScreen); let go again once back.
|
|
try:
|
|
game.raw('mouse', 'left', 'up')
|
|
except Failed:
|
|
pass
|
|
game.wait(10)
|
|
respawn_when_ready(game)
|
|
game.wait(2)
|
|
try:
|
|
game.raw('mouse', 'left', 'up')
|
|
except Failed:
|
|
pass
|
|
# Obsidian takes a diamond pickaxe about 190 ticks.
|
|
game.wait(240)
|
|
seen[game.name] = host.raw('block', x + 2, y, z).get('block')
|
|
game.shot(out, 'death-mining')
|
|
host('command', command=f'setblock {x + 2} {y} {z} minecraft:air')
|
|
game('command', command='item replace entity @s weapon.mainhand with minecraft:air')
|
|
finally:
|
|
host('command', command='gamerule keep_inventory false')
|
|
for name, block in seen.items():
|
|
assert block == 'minecraft:obsidian', f'{name}: {seen}'
|
|
return seen
|
|
|
|
|
|
def check_absorption(host, guest, out):
|
|
"""Absorption II's golden hearts on each player's own HUD: 8 of them, and 5 once a hit of 3 took
|
|
its share: the client shows what the server's player has (#1306)."""
|
|
seen = {}
|
|
for game in (host, guest):
|
|
game('command', command='gamemode survival')
|
|
game('command', command='effect give @s minecraft:absorption 120 1')
|
|
game.wait(10)
|
|
given = own(game)['absorption']
|
|
game('command', command='damage @s 3 minecraft:generic')
|
|
game.wait(20)
|
|
me = own(game)
|
|
server = server_player(host, game)
|
|
game.shot(out, 'absorption')
|
|
game('command', command='effect clear @s minecraft:absorption')
|
|
seen[game.name] = {'given': given, 'left': me['absorption'], 'health': me['health'],
|
|
'server': server.get('absorption')}
|
|
for name, s in seen.items():
|
|
assert s['given'] == 8.0 and s['left'] == 5.0 == s['server'] and s['health'] == 20.0, \
|
|
f'{name}: {seen}'
|
|
return seen
|
|
|
|
|
|
def check_breath(host, guest, out):
|
|
"""Underwater in a Respiration III helmet for 15 s: the enchantment makes the air last on the
|
|
server, and each player's own bubbles follow the server's air (#1304)."""
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
seen = {}
|
|
for game, dz in ((host, -4), (guest, 4)):
|
|
x, y, z = hx + 3, hy, hz + dz
|
|
game('command', command='gamemode survival')
|
|
game('command', command='item replace entity @s armor.head with '
|
|
'minecraft:diamond_helmet[minecraft:enchantments={"minecraft:respiration":3}]')
|
|
game('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 1} {y + 2} {z + 1} minecraft:stone')
|
|
game('command', command=f'fill {x} {y} {z} {x} {y + 1} {z} minecraft:water')
|
|
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
host.wait(300)
|
|
for game in (host, guest):
|
|
game.shot(out, 'breath')
|
|
seen[game.name] = {'own': own(game)['air'], 'server': server_player(host, game).get('air')}
|
|
for game, dz in ((host, -4), (guest, 4)):
|
|
x, y, z = hx + 3, hy, hz + dz
|
|
game('command', command='item replace entity @s armor.head with minecraft:air')
|
|
host('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 1} {y + 2} {z + 1} minecraft:air')
|
|
host('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 1} {y - 1} {z + 1} minecraft:stone')
|
|
guest.wait(40)
|
|
# 300 ticks lose about 75 of the 300 with Respiration III, all of it without.
|
|
for name, s in seen.items():
|
|
assert s['server'] > 150 and abs(s['own'] - s['server']) <= 10, f'{name}: {seen}'
|
|
return seen
|
|
|
|
|
|
def check_lava(host, guest, out):
|
|
"""Standing in a settled Nether lava current with Fire Resistance: the
|
|
Nether's FAST_LAVA pushes each player about three times as hard as the Overworld's (#1309)."""
|
|
seen = {}
|
|
back = {g.name: pos_of(g) for g in (host, guest)}
|
|
try:
|
|
for game, z in ((host, 0), (guest, 6)):
|
|
game('command', command=f'execute in minecraft:the_nether run tp @s 0.5 100 {z + 0.5}')
|
|
state = wait_dimension(game, 'minecraft:the_nether', 600)
|
|
assert state.get('dimension') == 'minecraft:the_nether', f'{game.name} is in {state.get("dimension")}'
|
|
game.wait(40)
|
|
# A one-wide obsidian channel along x: a source at x = -3, the current flowing east.
|
|
game('command', command=f'fill -5 98 {z - 2} 6 104 {z + 2} minecraft:air')
|
|
game('command', command=f'fill -5 98 {z - 1} 6 98 {z + 1} minecraft:obsidian')
|
|
game('command', command=f'fill -5 99 {z - 1} 6 99 {z - 1} minecraft:obsidian')
|
|
game('command', command=f'fill -5 99 {z + 1} 6 99 {z + 1} minecraft:obsidian')
|
|
game('command', command=f'fill -4 99 {z} -4 99 {z} minecraft:obsidian')
|
|
game('command', command='effect give @s minecraft:fire_resistance 120 0 true')
|
|
game('command', command='gamemode survival')
|
|
game('command', command=f'setblock -3 99 {z} minecraft:lava')
|
|
host.wait(100)
|
|
for game, z in ((host, 0), (guest, 6)):
|
|
game('command', command=f'tp @s 0.5 99 {z + 0.5}')
|
|
host.wait(60)
|
|
for game, z in ((host, 0), (guest, 6)):
|
|
v = own(game)['velocity']
|
|
seen[game.name] = {'vx': v[0], 'level': game.raw('block', 0, 99, z).get('state')}
|
|
game.shot(out, 'lava')
|
|
finally:
|
|
for game, z in ((host, 0), (guest, 6)):
|
|
host('command', command=f'execute in minecraft:the_nether run fill -5 98 {z - 2} 6 104 {z + 2} minecraft:air')
|
|
x, y, zz = back[game.name]
|
|
game('command', command='effect clear @s minecraft:fire_resistance')
|
|
game('command', command=f'execute in minecraft:overworld run tp @s {x:.2f} {y:.2f} {zz:.2f}')
|
|
for game in (host, guest):
|
|
wait_dimension(game, 'minecraft:overworld', 600)
|
|
guest.wait(40)
|
|
# The Overworld's current settles at about 0.0047 a tick, the Nether's at about 0.014.
|
|
for name, s in seen.items():
|
|
assert s['vx'] > 0.009, f'{name}: {seen}'
|
|
return seen
|
|
|
|
|
|
def check_ladder(host, guest, out):
|
|
"""#1255: climbing down a ladder doesn't hurt where the guest lands."""
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
lx, lz = x + 6, z
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command=f'fill {lx - 2} {y} {lz - 2} {lx + 2} {y + 12} {lz + 2} minecraft:air')
|
|
guest('command', command=f'fill {lx - 2} {y - 1} {lz - 2} {lx + 2} {y - 1} {lz + 2} minecraft:stone')
|
|
guest('command', command=f'fill {lx} {y} {lz + 1} {lx} {y + 10} {lz + 1} minecraft:stone')
|
|
guest('command', command=f'fill {lx} {y} {lz} {lx} {y + 10} {lz} minecraft:ladder[facing=north]')
|
|
guest.wait(10)
|
|
try:
|
|
before = remote(guest)['health']
|
|
# No input on a ladder: the guest slides down at 0.15 a tick (LivingEntity.handleOnClimbable).
|
|
guest('command', command=f'tp @s {lx + 0.5} {y + 10} {lz + 0.4} 0 0')
|
|
for _ in range(40):
|
|
guest.wait(5)
|
|
if pos_of(guest)[1] < y + 0.01:
|
|
break
|
|
guest.wait(20)
|
|
landed, after = pos_of(guest), remote(guest)['health']
|
|
assert landed[1] < y + 0.01, f'the guest stayed on the ladder at {landed}'
|
|
assert after >= before, f'climbing down ten blocks of ladder took the guest from {before} to {after} health'
|
|
return {'landed': landed, 'health': after}
|
|
finally:
|
|
guest('command', command=f'fill {lx} {y} {lz} {lx} {y + 10} {lz + 1} minecraft:air')
|
|
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
|
|
|
|
def apart(host, guest):
|
|
"""The host where it stands and the guest 12 blocks east, each on a stone floor with room
|
|
around: neither stands in the other's way (a ray from inside a player's box hits that player)."""
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
spots = {}
|
|
for label, player, dx in (('host', host, 0), ('guest', guest, 12)):
|
|
px = x + dx
|
|
host('command', command=f'fill {px - 3} {y - 1} {z - 3} {px + 4} {y - 1} {z + 6} minecraft:stone')
|
|
host('command', command=f'fill {px - 3} {y} {z - 3} {px + 4} {y + 3} {z + 6} minecraft:air')
|
|
player('command', command=f'tp @s {px + 0.5} {y} {z + 0.5} 0 0')
|
|
spots[label] = (px, y, z)
|
|
host.wait(10)
|
|
guest.wait(10)
|
|
return spots
|
|
|
|
|
|
def board(player, kind, at):
|
|
"""Right-click the `kind` object near `at` and check `player` rides it."""
|
|
found = [o for o in player('status')['objects'] if o['kind'].endswith(kind)
|
|
and abs(o['position'][0] - at[0]) < 2 and abs(o['position'][2] - at[2]) < 2]
|
|
assert found, f'no {kind} near {at}'
|
|
vehicle = found[0]
|
|
vp = vehicle['position']
|
|
player.raw('look-at', f'{vp[0]:.2f}', f'{vp[1] + 0.3:.2f}', f'{vp[2]:.2f}')
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(10)
|
|
riding = player('status').get('riding')
|
|
assert riding == vehicle['id'], f'right-clicking the {kind} {vehicle["id"]}: riding {riding}'
|
|
return vehicle
|
|
|
|
|
|
def leave_vehicle(player, kind):
|
|
player.raw('key', 'shift', 'down')
|
|
player.wait(10)
|
|
player.raw('key', 'shift', 'up')
|
|
player.wait(10)
|
|
riding = player('status').get('riding')
|
|
assert riding is None, f'sneaking out of the {kind}: riding {riding}'
|
|
|
|
|
|
def break_vehicle(player, vehicle_id, kind):
|
|
"""Hit it with fists until it breaks. VehicleEntity.hurtServer adds the hit's damage times 10 and
|
|
breaks it past 40; AbstractBoat.tick and AbstractMinecart.tick take 1 off every tick.
|
|
Player.attack scales a hit by its charge, 0.2 + s * s * 0.8, and a fist charges in
|
|
20 / 4 = 5 ticks. Punches 2 ticks apart do 0.4 each, a net 2 after decay, and break it only on
|
|
the 17th. Punches 5 ticks apart are fully charged: 10, then a net 5 each, broken on the 8th. A
|
|
tick of drift either way still nets at least 4 a punch."""
|
|
here = [o for o in player('status')['objects'] if o['id'] == vehicle_id]
|
|
if not here:
|
|
return
|
|
vp = here[0]['position']
|
|
# One connection: a ctl process per hit spaces the hits by however long it takes to start.
|
|
player.script([f'look-at {vp[0]:.2f} {vp[1] + 0.3:.2f} {vp[2]:.2f}',
|
|
*[line for _ in range(12) for line in ('mouse left click', 'wait 5')]])
|
|
assert not any(o['id'] == vehicle_id for o in player('status')['objects']), \
|
|
f'twelve punches 5 ticks apart left the {kind} in place'
|
|
|
|
|
|
def check_boats(host, guest, out):
|
|
"""#1222: the host (single player, on its server thread or not) and the guest each board a boat
|
|
on water with a right click, paddle it forward (the other side sees it move), sneak out, and
|
|
break it with their fists."""
|
|
result = {}
|
|
spots = apart(host, guest)
|
|
for label, player, other in (('host', host, guest), ('guest', guest, host)):
|
|
x, y, z = spots[label]
|
|
bx = x + 3
|
|
player('command', command='gamemode survival')
|
|
# A pool ten blocks long in a stone basin (the world may float), with a path beside it to
|
|
# walk back to the boat.
|
|
player('command', command=f'fill {bx - 3} {y} {z - 2} {bx + 2} {y + 3} {z + 10} minecraft:air')
|
|
player('command', command=f'fill {bx - 3} {y - 2} {z - 2} {bx + 2} {y - 1} {z + 10} minecraft:stone')
|
|
player('command', command=f'fill {bx - 1} {y - 1} {z - 1} {bx + 1} {y - 1} {z + 9} minecraft:water')
|
|
player('command', command=f'summon minecraft:oak_boat {bx + 0.5} {y - 0.1} {z + 0.5}')
|
|
player.wait(30)
|
|
try:
|
|
boat = board(player, 'oak_boat', (bx + 0.5, y, z + 0.5))
|
|
start = boat['position']
|
|
# The summoned boat faces south: forward paddles it along the pool.
|
|
player.raw('key', 'w', 'down')
|
|
player.wait(30)
|
|
player.raw('key', 'w', 'up')
|
|
player.wait(20)
|
|
mine = [o for o in player('status')['objects'] if o['id'] == boat['id']]
|
|
assert mine, f'the {label} lost its boat'
|
|
moved = math.dist(start, mine[0]['position'])
|
|
assert moved > 0.5, f'paddling forward moved the {label}\'s boat {moved:.2f} blocks'
|
|
# The other side sees the boat where its rider took it (the server accepted its moves).
|
|
theirs = [o for o in objects_near(other, mine[0]['position'], 'objects')
|
|
if o['kind'].endswith('oak_boat')]
|
|
assert theirs, f'the other side sees no boat near the {label}\'s'
|
|
far = min(math.dist(o['position'], mine[0]['position']) for o in theirs)
|
|
assert far < 1.0, f'the other side sees the {label}\'s boat {far:.2f} blocks off'
|
|
leave_vehicle(player, 'boat')
|
|
player('command', command=f'tp @s {bx - 1.5} {y} {mine[0]["position"][2]:.2f}')
|
|
player.wait(5)
|
|
break_vehicle(player, boat['id'], 'boat')
|
|
result[label] = {'boat': boat['id'], 'paddled': round(moved, 2), 'broken': True}
|
|
finally:
|
|
player('command', command=f'kill @e[type=minecraft:oak_boat,x={bx},y={y},z={z + 4},distance=..10]')
|
|
player('command', command=f'fill {bx - 1} {y - 1} {z - 1} {bx + 1} {y - 1} {z + 9} minecraft:stone')
|
|
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
player.wait(5)
|
|
return result
|
|
|
|
|
|
|
|
def check_boat_ice_reboard(host, guest, out):
|
|
"""A boat's cached spin belongs to its controlling client only (AbstractBoat.tick)."""
|
|
hx, _, hz = pos_of(host)
|
|
x, y, z = int(hx // 16) * 16 + 8, 100, int(hz // 16) * 16 + 8
|
|
tag = 'ice_reboard'
|
|
seen = {}
|
|
host('command', command=f'fill {x-30} {y} {z-30} {x+30} {y+5} {z+30} minecraft:air')
|
|
host('command', command=f'fill {x-30} {y-1} {z-30} {x+30} {y-1} {z+30} minecraft:ice')
|
|
try:
|
|
for player in (host, guest):
|
|
other = guest if player is host else host
|
|
other('command', command=f'tp @s {x+25.5} {y} {z+25.5}')
|
|
for kind in ('oak_boat', 'bamboo_raft'):
|
|
label = f'{player.name}-{kind}'
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
player('command', command='gamemode survival')
|
|
player('command', command=f'tp @s {x+0.5} {y} {z-0.5}')
|
|
player('command', command=f'summon minecraft:{kind} {x+0.5} {y} {z+2.5} {{Tags:["{tag}"]}}')
|
|
player.wait(10)
|
|
boat = board(player, kind, (x+0.5, y, z+2.5))
|
|
player.script(['key w down', 'key d down', 'wait 30',
|
|
'key shift down', 'wait 2', 'key w up', 'key d up', 'key shift up', 'wait 10'])
|
|
assert player('status').get('riding') is None, f'{label}: did not dismount while steering'
|
|
current = next(o for o in player('status')['objects'] if o['id'] == boat['id'])
|
|
bx, by, bz = current['position']
|
|
player('command', command=f'tp @s {bx-2} {by} {bz}')
|
|
player.wait(5)
|
|
current = next(o for o in player('status')['objects'] if o['id'] == boat['id'])
|
|
board(player, kind, current['position'])
|
|
player.wait(5)
|
|
replies = player.script(['status', 'state'] + ['wait 2', 'status', 'state'] * 15, every=True)
|
|
boat_yaws = [next(o for o in r['objects'] if o['id'] == boat['id'])['yaw']
|
|
for r in replies if 'objects' in r]
|
|
camera_yaws = [r['player']['yaw'] for r in replies if r.get('player') and 'yaw' in r['player']]
|
|
assert len(boat_yaws) == len(camera_yaws) == 16, f'{label}: missing rotation samples'
|
|
turn = lambda angles: sum(abs((b-a+180) % 360-180) for a, b in zip(angles, angles[1:]))
|
|
seen[label] = dict(boat_turn=turn(boat_yaws), camera_turn=turn(camera_yaws),
|
|
boat_yaws=boat_yaws, camera_yaws=camera_yaws,
|
|
ticks=[r['ticks'] for r in replies if 'objects' in r])
|
|
lab.write_json(out / 'boat-ice-reboard.json', seen)
|
|
player.shot(out, 'boat-ice-reboard-' + label)
|
|
# One previously latched steering input can still add one
|
|
# degree of turn momentum; ice then damps it by 0.98 per tick.
|
|
limit = sum(0.98 ** tick for tick in range(30)) + 2
|
|
assert seen[label]['boat_turn'] < limit, f'{label}: reboarded boat keeps spinning: {seen[label]}'
|
|
assert seen[label]['camera_turn'] < limit, f'{label}: camera keeps spinning: {seen[label]}'
|
|
player.wait(50)
|
|
late = player.script(['status', 'state'] + ['wait 2', 'status', 'state'] * 15, every=True)
|
|
boat_late = [next(o for o in r['objects'] if o['id'] == boat['id'])['yaw']
|
|
for r in late if 'objects' in r]
|
|
camera_late = [r['player']['yaw'] for r in late if r.get('player') and 'yaw' in r['player']]
|
|
assert len(boat_late) == len(camera_late) == 16, f'{label}: missing settled rotation samples'
|
|
seen[label].update(settled_boat_turn=turn(boat_late), settled_camera_turn=turn(camera_late),
|
|
settled_boat_yaws=boat_late, settled_camera_yaws=camera_late,
|
|
settled_ticks=[r['ticks'] for r in late if 'objects' in r])
|
|
lab.write_json(out / 'boat-ice-reboard.json', seen)
|
|
assert seen[label]['settled_boat_turn'] < 5, f'{label}: boat never settles: {seen[label]}'
|
|
assert seen[label]['settled_camera_turn'] < 5, f'{label}: camera never settles: {seen[label]}'
|
|
leave_vehicle(player, 'boat')
|
|
return seen
|
|
finally:
|
|
for player in (host, guest):
|
|
player.raw('release')
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
|
|
|
|
def check_boat_shoved(host, guest, out):
|
|
"""A piston shoves the boat its rider drives, then `/tp` moves it. The server moved it, so its
|
|
last good position is where it is now (`ServerGamePacketListenerImpl.tick` resets it every
|
|
tick). Before, it kept the old one: every move the rider's client sent failed, was sent back,
|
|
and failed again on each answer, and the boat stayed stuck (up to 6,700 "moved wrongly" lines a
|
|
second on the official server)."""
|
|
result = {}
|
|
spots = apart(host, guest)
|
|
log = host.socket.with_suffix('.log')
|
|
|
|
def wrongly():
|
|
return log.read_text(errors='replace').count('moved wrongly') if log.exists() else 0
|
|
|
|
for label, player, other in (('host', host, guest), ('guest', guest, host)):
|
|
x, y, z = spots[label]
|
|
# The boat east of the player, a piston north of it that shoves it south (out of the way
|
|
# of the player's right click).
|
|
bx = x + 3
|
|
player('command', command='gamemode survival')
|
|
player('command', command=f'fill {x - 1} {y} {z - 4} {bx + 2} {y + 3} {z + 3} minecraft:air')
|
|
player('command', command=f'fill {x - 1} {y - 1} {z - 4} {bx + 2} {y - 1} {z + 3} minecraft:stone')
|
|
player('command', command=f'setblock {bx} {y} {z - 2} minecraft:piston[facing=south]')
|
|
player('command', command=f'summon minecraft:oak_boat {bx + 0.5} {y} {z - 0.4}')
|
|
player.wait(30)
|
|
try:
|
|
boat = board(player, 'oak_boat', (bx + 0.5, y, z - 0.4))
|
|
player.wait(20)
|
|
before, start = wrongly(), boat['position']
|
|
player('command', command=f'setblock {bx} {y} {z - 3} minecraft:redstone_block')
|
|
player.wait(40)
|
|
assert player('status').get('riding') == boat['id'], f'the {label} left its boat'
|
|
shoved = [o for o in player('status')['objects'] if o['id'] == boat['id']][0]['position']
|
|
# Then it paddles on: the server takes these moves.
|
|
player.raw('key', 'w', 'down')
|
|
player.wait(30)
|
|
player.raw('key', 'w', 'up')
|
|
player.wait(20)
|
|
# A command moves it too (`/tp`, as an operator would): it paddles on from there.
|
|
player('command', command=f'tp @e[type=minecraft:oak_boat,x={bx},y={y},z={z},distance=..8] '
|
|
f'{bx - 1.5} {y} {z - 2.5}')
|
|
player.wait(20)
|
|
moved = [o for o in player('status')['objects'] if o['id'] == boat['id']][0]['position']
|
|
player.raw('key', 'w', 'down')
|
|
player.wait(30)
|
|
player.raw('key', 'w', 'up')
|
|
player.wait(20)
|
|
lines = wrongly() - before
|
|
mine = [o for o in player('status')['objects'] if o['id'] == boat['id']][0]['position']
|
|
# The server and the other side agree with the rider about where the boat is.
|
|
theirs = [o for o in objects_near(other, mine, 'objects') if o['kind'].endswith('oak_boat')]
|
|
assert theirs, f'the other side sees no boat near the {label}\'s'
|
|
far = min(math.dist(o['position'], mine) for o in theirs)
|
|
result[label] = {'shoved': round(shoved[2] - start[2], 2),
|
|
'paddled': round(math.dist(moved, mine), 2), 'moved_wrongly_lines': lines,
|
|
'other_side_off': round(far, 2)}
|
|
assert lines <= 5, f'moving it made {lines} "moved wrongly" lines: {result[label]}'
|
|
assert result[label]['paddled'] > 0.5, f'the {label}\'s boat stays where it was shoved: {result[label]}'
|
|
assert far < 1.0, f'the other side sees the {label}\'s boat {far:.2f} blocks off'
|
|
leave_vehicle(player, 'boat')
|
|
finally:
|
|
player('command', command=f'kill @e[type=minecraft:oak_boat,x={bx},y={y},z={z},distance=..8]')
|
|
player('command', command=f'fill {x - 1} {y} {z - 4} {bx + 2} {y + 3} {z + 3} minecraft:air')
|
|
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
|
|
player.wait(5)
|
|
return result
|
|
|
|
|
|
def check_minecarts(host, guest, out):
|
|
"""#1222, #5812: the host and the guest each get into a minecart on rails with a right click, sneak out
|
|
beside it, and break it with their fists; also check opening a chest minecart's container."""
|
|
result = {}
|
|
spots = apart(host, guest)
|
|
for label, player in (('host', host), ('guest', guest)):
|
|
x, y, z = spots[label]
|
|
cx = x + 3
|
|
player('command', command='gamemode survival')
|
|
player('command', command=f'fill {cx - 2} {y} {z - 2} {cx + 2} {y + 3} {z + 6} minecraft:air')
|
|
player('command', command=f'fill {cx - 2} {y - 1} {z - 2} {cx + 2} {y - 1} {z + 6} minecraft:stone')
|
|
player('command', command=f'fill {cx} {y} {z} {cx} {y} {z + 4} minecraft:rail')
|
|
player('command', command=f'summon minecraft:minecart {cx + 0.5} {y} {z + 0.5}')
|
|
player.wait(30)
|
|
try:
|
|
cart = board(player, 'minecart', (cx + 0.5, y, z + 0.5))
|
|
seated = pos_of(player)
|
|
assert abs(seated[0] - (cx + 0.5)) < 0.6, f'the {label} sits at {seated}, not in the cart'
|
|
leave_vehicle(player, 'minecart')
|
|
out_at = pos_of(player)
|
|
assert math.dist(out_at, seated) > 0.3, f'the {label} stayed in its seat at {out_at}'
|
|
player.wait(5)
|
|
break_vehicle(player, cart['id'], 'minecart')
|
|
result[label] = {'cart': cart['id'], 'out_at': [round(v, 2) for v in out_at], 'broken': True}
|
|
|
|
player('command', command=f'summon minecraft:chest_minecart {cx + 0.5} {y} {z + 0.5}')
|
|
player.wait(10)
|
|
player.raw('look-at', f'{cx + 0.5:.2f}', f'{y + 0.3:.2f}', f'{z + 0.5:.2f}')
|
|
player.raw('mouse', 'right', 'click')
|
|
player.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
|
items, size = container_items(player)
|
|
assert size == 27, f'the {label} opened chest minecart with {size} slots, expected 27'
|
|
player.raw('key', 'esc', 'tap')
|
|
player.wait(10)
|
|
player('command', command=f'kill @e[type=minecraft:chest_minecart,x={cx},y={y},z={z},distance=..8]')
|
|
finally:
|
|
player('command', command=f'kill @e[type=minecraft:chest_minecart,x={cx},y={y},z={z},distance=..8]')
|
|
player('command', command=f'kill @e[type=minecraft:minecart,x={cx},y={y},z={z},distance=..8]')
|
|
player('command', command=f'kill @e[type=minecraft:item,x={cx},y={y},z={z},distance=..8]')
|
|
player('command', command=f'fill {cx} {y} {z} {cx} {y} {z + 4} minecraft:air')
|
|
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
player.wait(5)
|
|
return result
|
|
|
|
|
|
def check_frames(host, guest, out):
|
|
"""#1222: the host and the guest each put a diamond in an item frame and turn it, and put a
|
|
helmet on an armor stand and take it back, with right clicks."""
|
|
result = {}
|
|
spots = apart(host, guest)
|
|
for label, player, other in (('host', host, guest), ('guest', guest, host)):
|
|
x, y, z = spots[label]
|
|
player('command', command='gamemode survival')
|
|
# Within a survival player's 3 blocks of reach: a frame on a wall, an armor stand beside it.
|
|
player('command', command=f'fill {x - 2} {y} {z + 1} {x + 2} {y + 2} {z + 2} minecraft:air')
|
|
player('command', command=f'fill {x - 2} {y} {z + 3} {x + 2} {y + 2} {z + 3} minecraft:stone')
|
|
frame_kind = 'item_frame' if label == 'host' else 'glow_item_frame'
|
|
player('command', command=f'summon minecraft:{frame_kind} {x + 0.5} {y + 1.5} {z + 2.97} '
|
|
f'{{block_pos:[I;{x},{y + 1},{z + 2}],Facing:2b}}')
|
|
stand_tag = f'frame_stand_{label}'
|
|
player('command', command=f'summon minecraft:armor_stand {x - 1.5} {y} {z + 1.5} '
|
|
f'{{Tags:["{stand_tag}"]}}')
|
|
player('command', command='clear @s')
|
|
frame_name = f'{label} framed diamond'
|
|
player('command', command='item replace entity @s weapon.mainhand with '
|
|
f"minecraft:diamond[minecraft:custom_name='{frame_name}']")
|
|
player.wait(30)
|
|
try:
|
|
frame = [o for o in player('status')['objects'] if o['kind'].endswith('item_frame')
|
|
and o['position'] == [x, y + 1, z + 2]]
|
|
assert frame, f'the {label} sees no item frame at {[x, y + 1, z + 2]}'
|
|
player.raw('look-at', f'{x + 0.5:.2f}', f'{y + 1.5:.2f}', f'{z + 2.95:.2f}')
|
|
assert frame_name not in (player('status').get('name_tags') or []), 'empty frame name visible'
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(10)
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(10)
|
|
frame = [o for o in player('status')['objects'] if o['id'] == frame[0]['id']][0]
|
|
assert frame['item'] == 'minecraft:diamond', f'the {label}\'s frame holds {frame["item"]}'
|
|
assert frame['rotation'] == 1, f'the {label}\'s frame turned to {frame["rotation"]}'
|
|
assert frame_name in (player('status').get('name_tags') or []), \
|
|
f"the {label}'s targeted frame has no item name"
|
|
player.shot(out, f'{label}-framed-item-name')
|
|
player.raw('key', 'f1', 'tap')
|
|
player.wait(2)
|
|
assert frame_name not in (player('status').get('name_tags') or []), 'F1 left the frame name visible'
|
|
player.raw('key', 'f1', 'tap')
|
|
player.raw('look-at', f'{x + 2:.2f}', f'{y + 1.5:.2f}', f'{z + 2.95:.2f}')
|
|
assert frame_name not in (player('status').get('name_tags') or []), 'untargeted frame name visible'
|
|
theirs = [o for o in other('status')['objects'] if o['kind'].endswith('item_frame')
|
|
and o['position'] == [x, y + 1, z + 2]]
|
|
assert theirs and theirs[0]['item'] == 'minecraft:diamond' and theirs[0]['rotation'] == 1, \
|
|
f'the other side sees the {label}\'s frame as {theirs}'
|
|
player('command', command='item replace entity @s weapon.mainhand with minecraft:iron_helmet')
|
|
player.wait(5)
|
|
stand = [o for o in player('status')['objects'] if o['kind'] == 'minecraft:armor_stand'
|
|
and abs(o['position'][0] - (x - 1.5)) < 0.5 and abs(o['position'][2] - (z + 1.5)) < 0.5]
|
|
assert stand, f'the {label} sees no armor stand'
|
|
player.raw('look-at', f'{x - 1.5:.2f}', f'{y + 1.7:.2f}', f'{z + 1.5:.2f}')
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(10)
|
|
worn = [o for o in player('status')['objects'] if o['id'] == stand[0]['id']][0]['equipment']
|
|
assert 'minecraft:iron_helmet' in worn, f'the {label}\'s armor stand wears {worn}'
|
|
player.raw('look-at', f'{x - 1.5:.2f}', f'{y + 1.7:.2f}', f'{z + 1.5:.2f}')
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(10)
|
|
worn = [o for o in player('status')['objects'] if o['id'] == stand[0]['id']][0]['equipment']
|
|
assert 'minecraft:iron_helmet' not in worn, f'taking the helmet back left {worn}'
|
|
selector = f'@e[type=minecraft:armor_stand,tag={stand_tag},limit=1]'
|
|
player('command', command='item replace entity @s weapon.mainhand with minecraft:name_tag 3')
|
|
player.wait(5)
|
|
player.raw('look-at', f'{x - 1.5:.2f}', f'{y + 1:.2f}', f'{z + 1.5:.2f}')
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(10)
|
|
assert player('inventory')['counts'].get('minecraft:name_tag') == 3, 'unnamed tag was consumed'
|
|
server_reply(player, f'execute unless data entity {selector} CustomName', 'Test passed')
|
|
name = f'{label} Bob'
|
|
player('command', command='item replace entity @s weapon.mainhand with '
|
|
f'minecraft:name_tag[minecraft:custom_name="{name}"] 3')
|
|
player.wait(5)
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(10)
|
|
assert player('inventory')['counts'].get('minecraft:name_tag') == 2, 'named tag was not consumed once'
|
|
server_reply(player, f'data get entity {selector} CustomName', name)
|
|
server_reply(other, f'data get entity {selector} CustomName', name)
|
|
# NameTagItem shrinks its stack in creative too.
|
|
player('command', command='gamemode creative')
|
|
player('command', command='item replace entity @s weapon.mainhand with '
|
|
f'minecraft:name_tag[minecraft:custom_name="{name}"] 3')
|
|
player.wait(5)
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(10)
|
|
assert player('inventory')['counts'].get('minecraft:name_tag') == 2, 'creative tag was not consumed once'
|
|
player('command', command='gamemode survival')
|
|
result[label] = {'frame': 'diamond, turned', 'stand': 'helmet on and off; named tag consumed'}
|
|
finally:
|
|
player('command', command=f'kill @e[type=!minecraft:player,x={x - 3},y={y - 1},z={z},dx=6,dy=4,dz=6]')
|
|
player('command', command=f'fill {x - 2} {y} {z + 3} {x + 2} {y + 2} {z + 3} minecraft:air')
|
|
player('command', command='clear @s')
|
|
return result
|
|
|
|
|
|
def check_hands(host, guest, out):
|
|
"""#1350, #1355, #1362: right clicks a cow passes go on to the item, the off hand gets its
|
|
turn on the cow, and a frame punched empty is empty for everyone."""
|
|
result = {}
|
|
spots = apart(host, guest)
|
|
for label, player, other in (('host', host, guest), ('guest', guest, host)):
|
|
x, y, z = spots[label]
|
|
player('command', command='gamemode survival')
|
|
player('command', command='clear @s')
|
|
player('command', command='effect clear @s')
|
|
# A cow two blocks ahead that stays put, and a framed diamond on a wall behind the player.
|
|
player('command', command=f'summon minecraft:cow {x + 0.5} {y} {z + 3.5} {{NoAI:1b}}')
|
|
player('command', command=f'fill {x - 1} {y} {z - 2} {x + 1} {y + 2} {z - 2} minecraft:stone')
|
|
player('command', command=f'summon minecraft:item_frame {x + 0.5} {y + 1.5} {z - 0.97} '
|
|
f'{{block_pos:[I;{x},{y + 1},{z - 1}],Facing:3b,Item:{{id:"minecraft:diamond",count:1}}}}')
|
|
player.wait(20)
|
|
counts = lambda: player('inventory')['counts']
|
|
aim_cow = lambda: player.raw('look-at', f'{x + 0.5:.2f}', f'{y + 0.8:.2f}', f'{z + 3.5:.2f}')
|
|
try:
|
|
player('command', command='give @s minecraft:bow')
|
|
player('command', command='give @s minecraft:arrow 6')
|
|
player.wait(5)
|
|
player.raw('select', 'minecraft:bow')
|
|
aim_cow()
|
|
assert player('looking_at')['kind'] == 'entity', f'the {label} does not aim at the cow'
|
|
# Half drawn: a full draw is a crit, up to 10 damage (AbstractArrow.onHitEntity), which
|
|
# kills the 10 health cow one shot in five. Under 20 ticks the arrow does at most 6.
|
|
player.raw('mouse', 'right', 'down')
|
|
player.wait(10)
|
|
drawn = player('state')['using']
|
|
player.raw('mouse', 'right', 'up')
|
|
player.wait(20)
|
|
assert drawn, f'the {label}\'s bow is not drawn at the cow'
|
|
assert counts().get('minecraft:arrow') == 5, f'the {label} shot no arrow: {counts()}'
|
|
aim_cow()
|
|
cow = player('looking_at').get('entity') or {}
|
|
assert cow.get('health', 0) > 0, f'the {label}\'s arrow killed the cow: {cow}'
|
|
player('command', command='clear @s')
|
|
player('command', command='give @s minecraft:golden_apple')
|
|
player.wait(5)
|
|
player.raw('select', 'minecraft:golden_apple')
|
|
aim_cow()
|
|
player.raw('mouse', 'right', 'down')
|
|
player.wait(5)
|
|
player.raw('mouse', 'right', 'up')
|
|
player.wait(40)
|
|
assert counts().get('minecraft:golden_apple') == 1, f'the {label} ate the apple let go of: {counts()}'
|
|
# The main hand empty, a bucket and then wheat in the off hand.
|
|
player('command', command='clear @s')
|
|
player('command', command='item replace entity @s weapon.offhand with minecraft:bucket')
|
|
player.wait(5)
|
|
aim_cow()
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(15)
|
|
offhand = player('inventory')['offhand']
|
|
assert offhand and offhand['item'] == 'minecraft:milk_bucket', f'the {label}\'s off hand holds {offhand}'
|
|
player('command', command='item replace entity @s weapon.offhand with minecraft:wheat 3')
|
|
player.wait(5)
|
|
aim_cow()
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(15)
|
|
offhand = player('inventory')['offhand']
|
|
assert offhand and offhand['count'] == 2, f'the {label}\'s cow ate no off hand wheat: {offhand}'
|
|
# The framed diamond, punched out.
|
|
frame = [o for o in player('status')['objects'] if o['kind'].endswith('item_frame')
|
|
and o['position'] == [x, y + 1, z - 1]]
|
|
assert frame and frame[0]['item'] == 'minecraft:diamond', f'the {label} sees the frame as {frame}'
|
|
player.raw('look-at', f'{x + 0.5:.2f}', f'{y + 1.5:.2f}', f'{z - 0.95:.2f}')
|
|
player.raw('mouse', 'left', 'click')
|
|
player.wait(20)
|
|
for side, game in ((label, player), ('other side', other)):
|
|
seen = [o for o in game('status')['objects'] if o['kind'].endswith('item_frame')
|
|
and o['position'] == [x, y + 1, z - 1]]
|
|
assert seen and seen[0].get('item') is None, f'the {side} sees the punched frame as {seen}'
|
|
result[label] = {'bow': 'shot', 'apple': 'kept', 'off hand': 'milked, fed', 'frame': 'emptied'}
|
|
finally:
|
|
player('command', command=f'kill @e[type=!minecraft:player,x={x - 3},y={y - 1},z={z - 3},dx=6,dy=4,dz=8]')
|
|
player('command', command=f'fill {x - 1} {y} {z - 2} {x + 1} {y + 2} {z - 2} minecraft:air')
|
|
player('command', command='clear @s')
|
|
return result
|
|
|
|
|
|
# Animals a player rides: (kind, summon NBT, what readies it, what is held to ride, the test
|
|
# while riding). `move` walks forward, `jump` charges the horse's jump bar, `rise` holds jump on
|
|
# a happy ghast, `sit` only rides (a llama goes where it likes).
|
|
RIDES = [
|
|
('horse', '{Tame:1b}', 'minecraft:saddle', None, 'move jump'),
|
|
('pig', '', 'minecraft:saddle', 'minecraft:carrot_on_a_stick', 'move'),
|
|
('strider', '', 'minecraft:saddle', 'minecraft:warped_fungus_on_a_stick', 'move'),
|
|
('camel', '', 'minecraft:saddle', None, 'move'),
|
|
('donkey', '{Tame:1b}', 'minecraft:saddle', None, 'move'),
|
|
('llama', '{Tame:1b}', None, None, 'sit'),
|
|
('happy_ghast', '', 'minecraft:white_harness', None, 'rise'),
|
|
]
|
|
HEIGHTS = {'horse': 1.6, 'donkey': 1.5, 'pig': 0.9, 'strider': 1.7, 'camel': 2.375, 'llama': 1.87,
|
|
'happy_ghast': 4.0}
|
|
|
|
|
|
def mob_seen(game, mob_id):
|
|
found = [m for m in game.raw('mobs', 96).get('near', []) if m['id'] == mob_id]
|
|
return found[0] if found else None
|
|
|
|
|
|
def hold(game, key, ticks):
|
|
game.raw('key', key, 'down')
|
|
game.wait(ticks)
|
|
game.raw('key', key, 'up')
|
|
|
|
|
|
def ride(player, other, label, spot, kind, nbt, ready, stick, test):
|
|
"""Summon `kind` in front of `player`, ready it (saddle, harness), get on with a right click,
|
|
run `test`, and get off by sneaking. The other side sees the animal move."""
|
|
x, y, z = spot
|
|
# In reach (3 blocks), clear of the player: a happy ghast is 4 blocks wide.
|
|
at = (x + 0.5, z + {'happy_ghast': 3.2, 'camel': 3.0}.get(kind, 2.5))
|
|
# The first item in hand before it appears: a happy ghast drifts out of reach quickly.
|
|
player('command', command=f'item replace entity @s weapon.mainhand with {ready or stick or "minecraft:air"}')
|
|
player('command', command=f'summon minecraft:{kind} {at[0]} {y} {at[1]} {nbt}')
|
|
player.wait(3)
|
|
found = [m for m in player.raw('mobs', 16).get('near', []) if m['kind'].endswith(kind) and m['health'] > 0]
|
|
assert found, f'the {label} sees no {kind}'
|
|
mob = found[0]
|
|
def click(item):
|
|
player('command', command=f'item replace entity @s weapon.mainhand with {item or "minecraft:air"}')
|
|
player.wait(2)
|
|
m = mob_seen(player, mob['id'])
|
|
assert m, f'the {label} lost its {kind}'
|
|
p = m['pos']
|
|
# Back in front of it if it wandered off (a happy ghast drifts away fast).
|
|
if abs(p[0] - (x + 0.5)) > 0.5 or abs(p[2] - at[1]) > 0.5:
|
|
player('command', command=f'tp @s {p[0]:.2f} {y} {p[2] - (at[1] - z - 0.5):.2f} 0 0')
|
|
player.wait(1)
|
|
player.raw('look-at', f'{p[0]:.2f}', f'{p[1] + HEIGHTS[kind] / 2:.2f}', f'{p[2]:.2f}')
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(5)
|
|
try:
|
|
if ready:
|
|
click(ready)
|
|
click(stick)
|
|
riding = player('status').get('riding')
|
|
assert riding == mob['id'], f'the {label} right-clicked the {kind} {mob["id"]}: riding {riding}'
|
|
result = {'rode': True}
|
|
# South, along the cleared floor.
|
|
player.raw('look-at', f'{x + 0.5:.2f}', f'{y + 1.6:.2f}', f'{z + 40.5:.2f}')
|
|
player.wait(5)
|
|
start = mob_seen(player, mob['id'])['pos']
|
|
if 'move' in test:
|
|
hold(player, 'w', 60)
|
|
player.wait(5)
|
|
mine = mob_seen(player, mob['id'])['pos']
|
|
theirs = mob_seen(other, mob['id'])
|
|
moved = math.dist(start, mine)
|
|
assert moved > 1.0, f"the {label}'s {kind} moved {moved:.2f} blocks in 3 s of forward"
|
|
assert theirs and math.dist(theirs['pos'], mine) < 1.5, \
|
|
f"the other side sees the {label}'s {kind} at {theirs and theirs['pos']}, not {mine}"
|
|
seated = pos_of(player)
|
|
assert math.dist(seated[::2], mine[::2]) < 1.5, f'the {label} sits at {seated}, its {kind} at {mine}'
|
|
result['moved'] = round(moved, 2)
|
|
if 'jump' in test:
|
|
base = mob_seen(player, mob['id'])['pos'][1]
|
|
player.raw('key', 'space', 'down')
|
|
player.wait(10)
|
|
player.raw('key', 'space', 'up')
|
|
top = base
|
|
for _ in range(10):
|
|
player.wait(2)
|
|
top = max(top, mob_seen(player, mob['id'])['pos'][1])
|
|
assert top - base > 0.4, f"the {label}'s {kind} jumped {top - base:.2f} blocks"
|
|
result['jumped'] = round(top - base, 2)
|
|
if 'rise' in test:
|
|
base = mob_seen(player, mob['id'])['pos'][1]
|
|
hold(player, 'space', 40)
|
|
player.wait(5)
|
|
rose = mob_seen(player, mob['id'])['pos'][1] - base
|
|
assert rose > 0.5, f"the {label}'s {kind} rose {rose:.2f} blocks while it held jump"
|
|
result['rose'] = round(rose, 2)
|
|
hold(player, 'shift', 10)
|
|
player.wait(10)
|
|
riding = player('status').get('riding')
|
|
assert riding is None, f'sneaking off the {kind}: the {label} still rides {riding}'
|
|
return result
|
|
finally:
|
|
player('command', command=f'kill @e[type=minecraft:{kind},x={x},y={y},z={z},distance=..48]')
|
|
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
|
|
player.wait(5)
|
|
|
|
|
|
def check_riding(host, guest, out):
|
|
"""The host (single player, on its server thread or not) and the guest each saddle and ride a
|
|
horse (walk and jump), a pig and a strider (with their sticks), a camel and a donkey, sit on a
|
|
llama, and harness and fly a happy ghast; they get off by sneaking."""
|
|
spots = apart(host, guest)
|
|
result = {}
|
|
for label, player, other in (('host', host, guest), ('guest', guest, host)):
|
|
x, y, z = spots[label]
|
|
player('command', command='gamemode survival')
|
|
player('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 4} {y - 1} {z + 40} minecraft:stone')
|
|
player('command', command=f'fill {x - 3} {y} {z - 3} {x + 4} {y + 12} {z + 40} minecraft:air')
|
|
player.wait(10)
|
|
result[label] = {}
|
|
# LAN_E2E_RIDES=pig,horse picks some of them.
|
|
only = [k for k in os.environ.get('LAN_E2E_RIDES', '').split(',') if k]
|
|
for kind, nbt, ready, stick, test in (r for r in RIDES if not only or r[0] in only):
|
|
result[label][kind] = ride(player, other, label, (x, y, z), kind, nbt, ready, stick, test)
|
|
player('command', command='item replace entity @s weapon.mainhand with minecraft:air')
|
|
return result
|
|
|
|
|
|
def check_occupied_horse(host, guest, out):
|
|
"""#1759: a mob passenger fills a zombie horse's seat, for both real players."""
|
|
spots = apart(host, guest)
|
|
result = {}
|
|
tag = 'occupied_horse'
|
|
host('command', command='time set midnight')
|
|
for label, player in (('host', host), ('guest', guest)):
|
|
x, y, z = spots[label]
|
|
at = (x + 0.5, y, z + 2.5)
|
|
player('command', command='gamemode creative')
|
|
player('command', command='clear @s')
|
|
result[label] = {}
|
|
for occupied in (True, False):
|
|
passengers = ',Passengers:[{id:"minecraft:zombie",NoAI:1b,PersistenceRequired:1b}]' if occupied else ''
|
|
try:
|
|
player('command', command=f'summon minecraft:zombie_horse {at[0]} {y} {at[2]} '
|
|
f'{{Tame:1b,NoAI:1b,NoGravity:1b,Tags:["{tag}"]{passengers}}}')
|
|
player.wait(5)
|
|
horse = next(m for m in player.raw('mobs', 16)['near']
|
|
if m['kind'] == 'minecraft:zombie_horse' and m['health'] > 0
|
|
and math.dist(m['pos'], at) < 0.1)
|
|
if occupied:
|
|
# Assert the setup too: a missing passenger would make this a false positive.
|
|
passengers_nbt = host('command', command=f'data get entity @e[tag={tag},limit=1] Passengers')
|
|
assert 'minecraft:zombie' in json.dumps(passengers_nbt), passengers_nbt
|
|
player.raw('look-at', at[0], y + 0.8, at[2])
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(10)
|
|
riding = player('status').get('riding')
|
|
expected = None if occupied else horse['id']
|
|
assert riding == expected, f'{label}: occupied={occupied}, riding={riding}, expected={expected}'
|
|
result[label]['occupied' if occupied else 'free'] = riding
|
|
player.shot(out, f'occupied-horse-{label}-{occupied}')
|
|
if not occupied:
|
|
hold(player, 'shift', 5)
|
|
finally:
|
|
player('command', command=f'kill @e[tag={tag}]')
|
|
player('command', command=f'kill @e[type=minecraft:zombie,x={x},y={y},z={z},distance=..6]')
|
|
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
|
|
player.wait(25)
|
|
return result
|
|
|
|
|
|
# Mount inventories: (kind, summon NBT, what goes in by shift-click and the menu slot it lands
|
|
# in: 700 the saddle, 701 the body armour or carpet, 710 the chest's first slot).
|
|
MOUNT_MENUS = [
|
|
('horse', '{Tame:1b,NoAI:1b}', [('minecraft:saddle', 700), ('minecraft:iron_horse_armor', 701)]),
|
|
('donkey', '{Tame:1b,NoAI:1b,ChestedHorse:1b}', [('minecraft:saddle', 700), ('minecraft:dirt', 710)]),
|
|
('llama', '{Tame:1b,NoAI:1b,ChestedHorse:1b,Strength:3}',
|
|
[('minecraft:red_carpet', 701), ('minecraft:dirt', 710)]),
|
|
]
|
|
|
|
|
|
def mount_screen(game, ticks=20):
|
|
"""The open mount menu's slots and items ({700: 'minecraft:saddle', ...}), waiting up to
|
|
`ticks` for it to open; None when no mount menu opened."""
|
|
for _ in range(ticks // 2):
|
|
slots = {}
|
|
for w in game.raw('ui').get('widgets', []):
|
|
wid = w.get('id', '')
|
|
if wid.startswith('slot-') and wid[5:].isdigit() and int(wid[5:]) >= 700:
|
|
slots[int(wid[5:])] = (w.get('stack') or {}).get('item')
|
|
if slots:
|
|
return slots
|
|
game.wait(2)
|
|
return None
|
|
|
|
|
|
def check_mounts(host, guest, out):
|
|
"""The host and the guest each open a tamed horse's, donkey's and llama's inventory with
|
|
shift + right click (`ClientboundMountScreenOpenPacket` for a server's player), put on a
|
|
saddle, horse armour or a carpet and fill the chest by shift-clicking, close it, then ride the
|
|
mount and open its inventory with the inventory key (OPEN_INVENTORY): it shows what was put
|
|
in, and the server took the items from the inventory."""
|
|
spots = apart(host, guest)
|
|
result = {}
|
|
for label, player in (('host', host), ('guest', guest)):
|
|
x, y, z = spots[label]
|
|
player('command', command='gamemode survival')
|
|
result[label] = {}
|
|
for kind, nbt, puts in MOUNT_MENUS:
|
|
player('command', command='clear @s')
|
|
for item, _ in puts:
|
|
player('command', command=f'give @s {item}')
|
|
player('command', command=f'summon minecraft:{kind} {x + 0.5} {y} {z + 2.5} {nbt}')
|
|
player.wait(5)
|
|
found = [m for m in player.raw('mobs', 16).get('near', [])
|
|
if m['kind'].endswith(kind) and m['health'] > 0]
|
|
assert found, f'the {label} sees no {kind}'
|
|
mob = found[0]
|
|
p = mob['pos']
|
|
aim = (f'{p[0]:.2f}', f'{p[1] + HEIGHTS[kind] / 2:.2f}', f'{p[2]:.2f}')
|
|
try:
|
|
player.raw('look-at', *aim)
|
|
player.raw('key', 'shift', 'down')
|
|
player.wait(3)
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(5)
|
|
player.raw('key', 'shift', 'up')
|
|
slots = mount_screen(player)
|
|
assert slots is not None, f"the {label}'s shift + right click on the {kind} opened no inventory"
|
|
for item, _ in puts:
|
|
player.raw('click-slot', hotbar_slot(player, item), 'shift')
|
|
player.wait(5)
|
|
slots = mount_screen(player)
|
|
for item, slot in puts:
|
|
assert slots.get(slot) == item, f"the {label}'s {kind}: {item} shift-clicked, slot {slot} holds {slots.get(slot)}"
|
|
player.raw('key', 'esc', 'tap')
|
|
player.wait(5)
|
|
for item, _ in puts:
|
|
left = server_count(player, item.split(':')[1])
|
|
assert left == 0, f'the server still counts {left} {item} on the {label}'
|
|
player.raw('look-at', *aim)
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(8)
|
|
riding = player('status').get('riding')
|
|
assert riding == mob['id'], f'the {label} right-clicked its {kind}: riding {riding}'
|
|
player.raw('key', 'e', 'tap')
|
|
again = mount_screen(player)
|
|
assert again is not None, f'the inventory key on the {label}\'s {kind} opened no inventory'
|
|
for item, slot in puts:
|
|
assert again.get(slot) == item, f"the {label}'s {kind} reopened: slot {slot} holds {again.get(slot)}, not {item}"
|
|
player.raw('key', 'esc', 'tap')
|
|
player.wait(3)
|
|
hold(player, 'shift', 10)
|
|
player.wait(5)
|
|
result[label][kind] = {slot: item for item, slot in puts}
|
|
finally:
|
|
back_to_game(player)
|
|
player('command', command=f'kill @e[type=minecraft:{kind},x={x},y={y},z={z},distance=..16]')
|
|
player.wait(2)
|
|
player('command', command=f'kill @e[type=minecraft:item,x={x},y={y},z={z},distance=..16]')
|
|
player('command', command='clear @s')
|
|
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
|
|
player.wait(5)
|
|
return result
|
|
|
|
|
|
def check_lightning(host, guest, out):
|
|
"""A bolt summoned beside each player: both sides get the bolt entity and flash the sky (the
|
|
host's window too on its server thread, FEATURES 4z(f))."""
|
|
result = {}
|
|
for label, player in (('host', host), ('guest', guest)):
|
|
x, y, z = (int(v // 1) for v in pos_of(player))
|
|
before = {g.name: g('status')['lightning'] for g in (host, guest)}
|
|
# No fire where it strikes: on stone with nothing around.
|
|
player('command', command=f'fill {x + 4} {y - 1} {z - 2} {x + 8} {y - 1} {z + 2} minecraft:stone')
|
|
player('command', command=f'fill {x + 4} {y} {z - 2} {x + 8} {y + 3} {z + 2} minecraft:air')
|
|
player('command', command=f'summon minecraft:lightning_bolt {x + 6.5} {y} {z + 0.5}')
|
|
seen = {}
|
|
for _ in range(40):
|
|
player.wait(1)
|
|
seen = {g.name: g('status')['lightning'] for g in (host, guest)}
|
|
if all(seen[n]['struck'] > before[n]['struck'] and seen[n]['flashes'] > before[n]['flashes']
|
|
for n in seen):
|
|
break
|
|
player('command', command=f'fill {x + 4} {y} {z - 2} {x + 8} {y + 3} {z + 2} minecraft:air')
|
|
for n in seen:
|
|
assert seen[n]['struck'] > before[n]['struck'], f"{label}'s bolt: the {n} got no bolt ({seen[n]})"
|
|
assert seen[n]['flashes'] > before[n]['flashes'], f"{label}'s bolt: the {n}'s sky didn't flash ({seen[n]})"
|
|
result[label] = {n: {'bolts': seen[n]['struck'] - before[n]['struck'],
|
|
'flashes': seen[n]['flashes'] - before[n]['flashes']} for n in seen}
|
|
return result
|
|
|
|
|
|
def check_growth(host, guest, out):
|
|
"""Block ticks near a guest 1000 blocks from the host: only the guest's tickets tick them."""
|
|
x0, y0, z0 = pos_of(guest)
|
|
guest('command', command='gamemode creative')
|
|
guest('command', command=f'tp @s {x0 + 1000:.2f} 200 {z0:.2f}')
|
|
guest.wait(100)
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
# A platform in the air over wherever the guest landed: a water source two blocks east of the
|
|
# guest, wheat on farmland two blocks west. Vanilla's water spreads only towards the nearest
|
|
# drop (FlowingFluid.getSpread), so any of the source's four neighbours may take it. Wheat
|
|
# grows only in daylight (raw brightness 9 or more).
|
|
top = max(y + 12, 120)
|
|
water = (x + 2, top + 1, z)
|
|
beside = [(water[0] + dx, water[1], water[2] + dz) for dx, dz in ((1, 0), (-1, 0), (0, 1), (0, -1))]
|
|
wheat = (x - 2, top + 1, z)
|
|
flowed = []
|
|
try:
|
|
host('command', command='time set noon')
|
|
guest('command', command=f'fill {x - 4} {top} {z - 2} {x + 4} {top} {z + 2} minecraft:stone')
|
|
guest('command', command=f'setblock {wheat[0]} {top} {wheat[2]} minecraft:farmland[moisture=7]')
|
|
guest('command', command=f'setblock {wheat[0]} {wheat[1]} {wheat[2]} minecraft:wheat[age=0]')
|
|
guest('command', command='gamerule random_tick_speed 1000')
|
|
guest('command', command=f'setblock {water[0]} {water[1]} {water[2]} minecraft:water')
|
|
for _ in range(40):
|
|
guest.wait(10)
|
|
flowed = [p for p in beside if guest.raw('block', *p).get('block') == 'minecraft:water']
|
|
if flowed:
|
|
break
|
|
age = wait_state(guest, wheat, 'age', '7', 2400)
|
|
guest.shot(out, 'growth')
|
|
finally:
|
|
guest('command', command='gamerule random_tick_speed 3')
|
|
host('command', command='time set day')
|
|
guest('command', command=f'tp @s {x0:.2f} {y0:.2f} {z0:.2f}')
|
|
guest('command', command='gamemode survival')
|
|
guest.wait(40)
|
|
detail = {'flowed': flowed, 'wheat age': age}
|
|
assert flowed, f'the water beside the guest did not flow: {detail}'
|
|
assert age == '7', f'the wheat beside the guest did not grow: {detail}'
|
|
return detail
|
|
|
|
|
|
def check_doors(host, guest, out):
|
|
"""A villager shut in a room opens its oak door on its way to a free composter outside
|
|
(InteractWithDoor), takes the composter as its job site and closes the door behind it; the
|
|
guest sees the door open and close again."""
|
|
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
|
|
x, y, z = gx + 4, gy, gz - 8
|
|
lower, upper, composter, marker = (x + 2, y, z), (x + 2, y + 1, z), (x + 7, y, z), (x + 7, y, z + 2)
|
|
tag = 'dg_door_villager'
|
|
farmer = (f'execute if entity @e[type=minecraft:villager,tag={tag},'
|
|
f'nbt={{VillagerData:{{profession:"minecraft:farmer"}}}}] run setblock {marker[0]} {marker[1]} {marker[2]} '
|
|
'minecraft:gold_block')
|
|
seen = {'opened': False, 'farmer': False, 'closed': None}
|
|
try:
|
|
host('command', command='time set 2000')
|
|
host('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 9} {y - 1} {z + 3} minecraft:stone')
|
|
host('command', command=f'fill {x - 3} {y} {z - 3} {x + 9} {y + 3} {z + 3} minecraft:air')
|
|
host('command', command=f'fill {x - 2} {y} {z - 2} {x + 2} {y + 2} {z + 2} minecraft:oak_planks')
|
|
host('command', command=f'fill {x - 1} {y} {z - 1} {x + 1} {y + 1} {z + 1} minecraft:air')
|
|
for (bx, by, bz), half in ((lower, 'lower'), (upper, 'upper')):
|
|
host('command', command=f'setblock {bx} {by} {bz} minecraft:oak_door[facing=east,half={half},'
|
|
'hinge=left,open=false,powered=false]')
|
|
host('command', command=f'setblock {composter[0]} {composter[1]} {composter[2]} minecraft:composter')
|
|
host('command', command=f'summon minecraft:villager {x + 0.5} {y} {z + 0.5} {{Tags:["{tag}"],'
|
|
'PersistenceRequired:1b,VillagerData:{profession:"minecraft:none",level:1,'
|
|
'type:"minecraft:plains"}}')
|
|
for _ in range(120):
|
|
guest.wait(10)
|
|
seen['opened'] = seen['opened'] or state_of(guest, lower, 'open') == 'true'
|
|
host('command', command=farmer)
|
|
if host.raw('block', *marker).get('block') == 'minecraft:gold_block':
|
|
seen['farmer'] = True
|
|
break
|
|
seen['closed'] = wait_state(guest, lower, 'open', 'false', 200) == 'false'
|
|
seen['server_door'] = state_of(host, lower, 'open')
|
|
guest.raw('look-at', f'{lower[0] + 0.5:.2f}', f'{lower[1] + 1:.2f}', f'{lower[2] + 0.5:.2f}')
|
|
guest.wait(10)
|
|
guest.shot(out, 'doors')
|
|
finally:
|
|
host('command', command=f'kill @e[type=minecraft:villager,tag={tag}]')
|
|
host('command', command=f'fill {x - 3} {y} {z - 3} {x + 9} {y + 3} {z + 3} minecraft:air')
|
|
host('command', command='time set day')
|
|
assert seen['opened'], f'the villager never opened its door: {seen}'
|
|
assert seen['farmer'], f'the villager did not take the composter outside: {seen}'
|
|
assert seen['closed'] and seen['server_door'] == 'false', f'the villager left its door open: {seen}'
|
|
return seen
|
|
|
|
|
|
|
|
def check_light(host, guest, out):
|
|
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
|
|
results = {}
|
|
# A torch near the guest: the guest lights it itself, as vanilla's client does.
|
|
torch = (gx + 3, gy, gz + 3)
|
|
host('command', command=f'setblock {torch[0]} {torch[1]} {torch[2]} minecraft:torch')
|
|
try:
|
|
results['torch'] = same_light(host, guest, (gx - 12, gy - 1, gz - 12), (gx + 16, gy + 3, gz + 16), 'torch')
|
|
finally:
|
|
host('command', command=f'setblock {torch[0]} {torch[1]} {torch[2]} minecraft:air')
|
|
# A roof over the guest, then a hole in it: sky light falls through.
|
|
roof = f'{gx - 3} {gy + 4} {gz - 3} {gx + 3} {gy + 4} {gz + 3}'
|
|
box = (gx - 6, gy - 1, gz - 6), (gx + 6, gy + 5, gz + 6)
|
|
host('command', command=f'fill {roof} minecraft:stone')
|
|
try:
|
|
results['roof'] = same_light(host, guest, *box, 'roof')
|
|
host('command', command=f'setblock {gx} {gy + 4} {gz} minecraft:air')
|
|
results['hole'] = same_light(host, guest, *box, 'hole in the roof')
|
|
finally:
|
|
host('command', command=f'fill {roof} minecraft:air')
|
|
# The last chunk the guest tracks eastwards, and glowstone high up just past it in a chunk it
|
|
# doesn't track: the guest never sees that block, only the light updates of its border chunk.
|
|
y = 200
|
|
row = guest.raw('light', gx, y, gz, gx + 16 * 24, y, gz)['sky']
|
|
assert '.' in row, 'the guest has every chunk 24 chunks east'
|
|
untracked = (gx + row.index('.')) >> 4
|
|
near = untracked * 16 + 1
|
|
# The host's own game is a client too, sent the same light updates.
|
|
host('command', command=f'setblock {near} {y} {gz} minecraft:glowstone')
|
|
try:
|
|
lo, hi = (untracked * 16 - 16, y - 3, gz - 8), (untracked * 16 - 1, y + 3, gz + 8)
|
|
# On the server thread the host's game is a client too, as dark as the guest until the light
|
|
# update arrives: wait for it before comparing them.
|
|
for _ in range(10):
|
|
lit = guest.raw('light', near - 2, y, gz, near - 2, y, gz)['block']
|
|
if lit == 'd':
|
|
break
|
|
host.wait(10)
|
|
assert lit == 'd', f'the border chunk by the glowstone has block light {lit}, not 13, after 100 ticks'
|
|
results['border'] = same_light(host, guest, lo, hi, 'border chunk')
|
|
finally:
|
|
host('command', command=f'setblock {near} {y} {gz} minecraft:air')
|
|
results['border_cleared'] = same_light(host, guest, lo, hi, 'border chunk without the glowstone')
|
|
return results
|
|
|
|
|
|
def check_fireball(host, guest, out):
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
fx, fz = x + 4, z
|
|
# A stone floor, a dispenser facing down three blocks over it, a fire charge in it, powered.
|
|
host('command', command=f'fill {fx - 2} {y - 1} {fz - 2} {fx + 2} {y - 1} {fz + 2} minecraft:stone')
|
|
host('command', command=f'fill {fx - 2} {y} {fz - 2} {fx + 2} {y + 4} {fz + 2} minecraft:air')
|
|
host('command', command=f'setblock {fx} {y + 3} {fz} minecraft:dispenser[facing=down]')
|
|
host('command', command=f'item replace block {fx} {y + 3} {fz} container.0 with minecraft:fire_charge')
|
|
seen, gone = {}, None
|
|
try:
|
|
host('command', command=f'setblock {fx} {y + 4} {fz} minecraft:redstone_block')
|
|
host.wait(10)
|
|
seen = {'host': host.raw('block', fx, y, fz).get('block'),
|
|
'guest': guest.raw('block', fx, y, fz).get('block')}
|
|
# FireBlock.tick every 30-39 ticks ages it by one a third of the time; past age 3 it goes.
|
|
for waited in range(40, 1240, 40):
|
|
host.wait(40)
|
|
if host.raw('block', fx, y, fz).get('block') == 'minecraft:air':
|
|
gone = waited
|
|
break
|
|
guest.wait(5)
|
|
seen['guest_after'] = guest.raw('block', fx, y, fz).get('block')
|
|
finally:
|
|
host('command', command=f'fill {fx - 2} {y} {fz - 2} {fx + 2} {y + 4} {fz + 2} minecraft:air')
|
|
assert seen.get('host') == 'minecraft:fire' and seen.get('guest') == 'minecraft:fire', \
|
|
f'no fire where the fire charge landed: {seen}'
|
|
assert gone is not None, f'the fire on stone never went out (1200 ticks): {seen}'
|
|
assert seen['guest_after'] == 'minecraft:air', f'the guest still sees the fire: {seen}'
|
|
return {'out_after_ticks': gone, **seen}
|
|
|
|
|
|
def check_deflect(host, guest, out):
|
|
"""The host punches a ghast's fireball back: it flies along the host's view and kills the ghast
|
|
(Player.attack: AIM_DEFLECT on #redirectable_projectile; Ghast.isReflectedFireball). The ghast is caged
|
|
(a window toward the host); the game runs at 4 ticks a second, and once the fireball is within 4.5 blocks
|
|
the host looks at the ghast and clicks."""
|
|
back = pos_of(host)
|
|
x, y, z = (int(v // 1) for v in back)
|
|
guest('command', command='gamemode creative')
|
|
host('command', command='gamemode survival')
|
|
host('command', command='difficulty normal')
|
|
gx, gz = x, z + 30
|
|
gy = y + 20
|
|
host('command', command=f'fill {gx - 3} {gy - 1} {gz - 3} {gx + 24} {gy - 1} {gz + 3} minecraft:glass')
|
|
host('command', command=f'fill {gx - 3} {gy} {gz - 3} {gx + 24} {gy + 8} {gz + 3} minecraft:air')
|
|
host('command', command=f'tp @s {gx + 0.5} {gy} {gz + 0.5} -90 0')
|
|
host.wait(20)
|
|
host.raw('hurtlog')
|
|
# The ghast in a glass cage it can't drift out of, a 3 x 3 window toward the host.
|
|
host('command', command=f'fill {gx + 13} {gy} {gz - 3} {gx + 19} {gy + 6} {gz + 3} minecraft:glass hollow')
|
|
host('command', command=f'fill {gx + 13} {gy + 2} {gz - 1} {gx + 13} {gy + 4} {gz + 1} minecraft:air')
|
|
host('command', command=f'summon minecraft:ghast {gx + 16.5} {gy + 1} {gz + 0.5}')
|
|
eye = (gx + 0.5, gy + 1.62, gz + 0.5)
|
|
host('command', command='tick rate 4')
|
|
turned, near, ghast, hurt = None, None, None, []
|
|
try:
|
|
deadline = time.monotonic() + 120
|
|
while time.monotonic() < deadline:
|
|
# Aim and click at the projectile the client actually sees. Holding
|
|
# non-player simulation keeps the reach window open while ordinary
|
|
# player input and its attack packet cross the connection.
|
|
balls = [q for q in host('status')['projectiles'] if q['kind'] == 'LargeFireball']
|
|
near = min((math.dist(q['position'], eye) for q in balls), default=None)
|
|
if near is not None and near < 4.5:
|
|
ball = min(balls, key=lambda q: math.dist(q['position'], eye))
|
|
host('command', command='tick freeze')
|
|
held_tick = host.raw('simulation')['game_time']
|
|
g = [m['pos'] for m in host.raw('mobs', 60).get('near', []) if m['kind'].endswith('ghast')][0]
|
|
host.script([f'look-at {g[0]:.2f} {g[1] + 2:.2f} {g[2]:.2f}',
|
|
'wait 1', 'mouse left click', 'wait 1'])
|
|
host.until(lambda: host.raw('simulation')['projectiles'],
|
|
lambda qs: any(q.get('id') == ball['id'] and q.get('player_owned') for q in qs),
|
|
ticks=60)
|
|
assert host.raw('simulation')['game_time'] == held_tick, 'the deflection barrier advanced simulation'
|
|
host('command', command='tick unfreeze')
|
|
turned = near
|
|
break
|
|
host('command', command='tick rate 20')
|
|
host.wait(60)
|
|
ghast = [m for m in host.raw('mobs', 60).get('near', []) if m['kind'].endswith('ghast') and m['health'] > 0]
|
|
hurt = [h for pl in host.raw('hurtlog')['players'] if pl['name'] == host.player for h in pl['hurts']
|
|
if 'fireball' in h['source'] or 'explosion' in h['source']]
|
|
finally:
|
|
host('command', command='tick unfreeze')
|
|
host('command', command='tick rate 20')
|
|
host('command', command='kill @e[type=minecraft:ghast]')
|
|
host('command', command=f'tp @s {back[0]} {back[1]} {back[2]}')
|
|
host.until(lambda: pos_of(host), lambda p: math.dist(p, back) < 0.1)
|
|
host('command', command=f'fill {gx - 3} {gy - 1} {gz - 3} {gx + 24} {gy + 8} {gz + 3} minecraft:air')
|
|
guest('command', command='gamemode survival')
|
|
assert turned is not None, f'no fireball came within 4.5 blocks (nearest {near})'
|
|
assert not hurt, f'the host was hit: {hurt}'
|
|
assert not ghast, f'the ghast lived: the fireball was not turned back ({ghast})'
|
|
return {'struck_at': round(turned, 2)}
|
|
|
|
|
|
def check_piglin_offspring(host, guest, out):
|
|
"""SpawnEggItem.spawnOffspringFromSpawnEgg constructs a baby piglin without adult gear (#5867)."""
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
for game in (host, guest):
|
|
game('command', command='gamemode creative')
|
|
host('command', command=f'tp @s {x - 4.5} {y} {z + 0.5}')
|
|
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} -90 0')
|
|
host('command', command=f'summon minecraft:piglin {x + 2.5} {y} {z + 0.5} '
|
|
'{NoAI:1b,IsImmuneToZombification:1b,Tags:["egg_parent"]}')
|
|
try:
|
|
guest('command', command='item replace entity @s weapon.mainhand with minecraft:piglin_spawn_egg')
|
|
guest.wait(20)
|
|
# The suite clears natural terrain on startup, which leaves items a
|
|
# newborn piglin can legitimately equip as soon as it is created.
|
|
host('command', command='kill @e[type=minecraft:item]')
|
|
guest.raw('look-at', x + 2.5, y + 1, z + 0.5)
|
|
guest.raw('use-item')
|
|
guest.wait(20)
|
|
seen = []
|
|
for game in (host, guest):
|
|
babies = [m for m in game.raw('mobs', 20)['near']
|
|
if m['kind'] == 'minecraft:piglin' and m['baby']]
|
|
assert len(babies) == 1, f'expected one baby piglin on {game.player}: {babies}'
|
|
assert babies[0]['held'] is None, f"baby piglin has adult gear: {babies[0]}"
|
|
seen.append(babies[0]['id'])
|
|
assert seen[0] == seen[1], f'clients disagree about the baby piglin: {seen}'
|
|
guest.shot(out, 'piglin_offspring')
|
|
return {'baby': seen[0], 'held': None}
|
|
finally:
|
|
host('command', command=f'kill @e[type=minecraft:piglin,x={x - 5},y={y - 2},z={z - 5},dx=15,dy=8,dz=10]')
|
|
guest('command', command='clear @s')
|
|
for game in (host, guest):
|
|
game('command', command='gamemode survival')
|
|
|
|
|
|
def check_barter(host, guest, out):
|
|
"""The guest, in gold boots, drops a gold ingot to a piglin: the piglin stays calm, takes it, and throws its
|
|
barter toward the nearest player, the guest (PiglinAi.throwItemsTowardPlayer: NEAREST_VISIBLE_PLAYER), who
|
|
picks it up. The host is nearby on the other side, so a barter thrown at the host misses the guest. A glass
|
|
corridor (the hollow box) keeps the piglin between them."""
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
cz = z + 4
|
|
host('command', command='gamemode creative')
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
guest('command', command='effect give @s minecraft:instant_health 1 5')
|
|
host('command', command=f'fill {x + 1} {y - 1} {cz - 1} {x + 8} {y + 2} {cz + 1} minecraft:glass hollow')
|
|
host('command', command=f'fill {x + 2} {y - 1} {cz} {x + 7} {y - 1} {cz} minecraft:stone')
|
|
host('command', command=f'tp @s {x - 0.5} {y} {cz + 0.5} -90 0')
|
|
guest('command', command=f'tp @s {x + 7.3} {y} {cz + 0.5} 90 30')
|
|
guest('command', command='item replace entity @s armor.feet with minecraft:golden_boots')
|
|
guest('command', command='give @s minecraft:gold_ingot 1')
|
|
host('command', command=f'summon minecraft:piglin {x + 4.5} {y} {cz + 0.5} {{IsImmuneToZombification:1b}}')
|
|
guest.wait(20)
|
|
before = remote(guest)['health']
|
|
kept = dict(guest('inventory')['counts'])
|
|
try:
|
|
guest.raw('select', 'minecraft:gold_ingot')
|
|
guest.raw('key', 'q', 'tap')
|
|
bought, trace = {}, []
|
|
for _ in range(20):
|
|
guest.wait(20)
|
|
trace.append([(e['type'].split(':')[-1], round(e['pos'][0] - x, 1), round(e['pos'][2] - cz, 1))
|
|
for e in host.raw('entities', 24)['entities'] if e['type'] in ('minecraft:item', 'minecraft:piglin')])
|
|
counts = guest('inventory')['counts']
|
|
bought = {k: n - kept.get(k, 0) for k, n in counts.items()
|
|
if k not in ('minecraft:gold_ingot', 'minecraft:golden_boots') and n > kept.get(k, 0)}
|
|
if bought:
|
|
guest.wait(40)
|
|
break
|
|
counts = guest('inventory')['counts']
|
|
bought = {k: n - kept.get(k, 0) for k, n in counts.items()
|
|
if k not in ('minecraft:gold_ingot', 'minecraft:golden_boots') and n > kept.get(k, 0)}
|
|
piglins = [m for m in host.raw('mobs', 20).get('near', []) if m['kind'].split(':')[-1] == 'piglin']
|
|
health = remote(guest)['health']
|
|
finally:
|
|
host('command', command=f'kill @e[type=minecraft:piglin,x={x},y={y - 2},z={cz - 2},dx=10,dy=6,dz=4]')
|
|
# What lies on the floor, on the host's side of the piglin's start and on the guest's.
|
|
floor = [host('command', command=f'kill @e[type=minecraft:item,x={x0},y={y - 2},z={cz - 3},dx={dx},dy=6,dz=6]')
|
|
['chat'][0] for x0, dx in ((x - 2, 6.5), (x + 4.5, 6))]
|
|
host('command', command=f'fill {x + 1} {y - 1} {cz - 1} {x + 8} {y + 2} {cz + 1} minecraft:air')
|
|
host('command', command=f'fill {x - 8} {y - 1} {z - 8} {x + 8} {y - 1} {z + 8} minecraft:stone')
|
|
guest('command', command='clear @s')
|
|
host('command', command='gamemode survival')
|
|
assert piglins, 'the piglin is gone'
|
|
assert health >= before, f'the piglin hurt the guest in gold boots: {before} -> {health}'
|
|
assert bought, f'no barter reached the guest (on the floor, host side then guest side: {floor}; items and the piglin, every second: {trace})'
|
|
return {'bought': bought, 'floor': floor}
|
|
|
|
|
|
def check_worn(host, guest, out):
|
|
pieces = ['iron_helmet', 'iron_chestplate', 'iron_leggings', 'iron_boots']
|
|
guest('command', command='gamemode survival')
|
|
taken = {}
|
|
try:
|
|
for worn in (False, True):
|
|
guest('command', command='clear @s')
|
|
if remote(guest)['health'] <= 0:
|
|
guest.raw('respawn')
|
|
guest.wait(40)
|
|
if worn:
|
|
for piece in pieces:
|
|
guest('command', command=f'give @s minecraft:{piece}')
|
|
guest.wait(10)
|
|
# Shift-clicked from the hotbar onto the body in the inventory menu (the hub moves them).
|
|
guest.raw('key', 'e', 'tap')
|
|
guest.raw('wait-for', 'screen', 'inventory')
|
|
for piece in pieces:
|
|
guest.raw('click-slot', hotbar_slot(guest, f'minecraft:{piece}'), 'shift')
|
|
guest.wait(2)
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(5)
|
|
guest('command', command='effect give @s minecraft:instant_health 1 5')
|
|
guest.wait(30)
|
|
before = remote(guest)['health']
|
|
# A mob's blow (no attacker: no difficulty scaling); armour counts against it.
|
|
guest('command', command='damage @s 10 minecraft:mob_attack')
|
|
# The server's hurt log isn't on a control socket here: the health the guest is
|
|
# sent, read as soon as it drops (natural regeneration heals it again after).
|
|
health = before
|
|
for _ in range(40):
|
|
guest.wait(1)
|
|
health = remote(guest)['health']
|
|
if health < before:
|
|
break
|
|
taken['worn' if worn else 'bare'] = [round(before - health, 2)]
|
|
finally:
|
|
guest('command', command='clear @s')
|
|
guest('command', command='effect give @s minecraft:instant_health 1 5')
|
|
# Natural regeneration may heal up to a point before the health arrives.
|
|
assert len(taken['bare']) == 1 and 9.0 <= taken['bare'][0] <= 10.0, f'10 damage on no armour: {taken}'
|
|
assert len(taken['worn']) == 1 and 5.0 <= taken['worn'][0] <= 6.0, \
|
|
f'10 damage through iron armour (15) should take 6: {taken}'
|
|
return taken
|
|
|
|
|
|
CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour,
|
|
'nametags': check_nametags, 'chest': check_chest, 'menu-transfer': check_menu_transfer, 'crafting': check_crafting,
|
|
'spawned': check_spawned,
|
|
'sign': check_sign, 'damage': check_damage, 'mobs': check_mobs,
|
|
'flow-push': check_flow_push,
|
|
'bubble-column': check_bubble_column,
|
|
'hazards': check_hazards, 'pvp': check_pvp, 'deaths': check_deaths, 'respawn': check_respawn,
|
|
'containers': check_containers, 'names': check_names, 'feedback': check_feedback, 'titles': check_titles,
|
|
'chat': check_chat, 'styles': check_styles, 'machines': check_machines, 'brewing': check_brewing,
|
|
'loom': check_loom, 'stonecutter': check_stonecutter, 'grindstone': check_grindstone,
|
|
'anvil': check_anvil, 'smithing': check_smithing, 'enchanting': check_enchanting,
|
|
'beacon': check_beacon, 'crafter': check_crafter, 'lectern': check_lectern,
|
|
'cartography': check_cartography, 'hurt': check_hurt, 'commands': check_commands,
|
|
'recipes': check_recipes, 'trials': check_trials, 'walls': check_walls,
|
|
'effects': check_effects, 'portals': check_portals, 'distance': check_distance,
|
|
'clicks': check_clicks, 'shift-double-click': check_shift_double_click, 'recipe-shift': check_recipe_shift,
|
|
'shift-drag': check_shift_drag, 'wheel': check_wheel, 'sleep': check_sleep, 'sleep-villager': check_sleep_villager,
|
|
'modifier-release': check_modifier_release,
|
|
'bed-occupied': check_bed_occupied, 'sleep-reload': check_sleep_reload,
|
|
'push': check_push, 'piston-push': check_piston_push, 'piston-entities': check_piston_entities,
|
|
'piston-slime': check_piston_slime,
|
|
'eat': check_eat, 'eat-on-block': check_eat_on_block, 'cauldron': check_cauldron, 'eating': check_eating, 'xp': check_xp,
|
|
'objects': check_objects, 'projectiles': check_projectiles, 'blast': check_blast, 'blast-projectiles': check_blast_projectiles,
|
|
'light': check_light, 'plates': check_plates, 'plate-projectiles': check_plate_projectiles, 'swings': check_swings,
|
|
'cracks': check_cracks, 'time': check_time, 'growth': check_growth, 'doors': check_doors,
|
|
'flight': check_flight, 'fireball': check_fireball, 'deflect': check_deflect,
|
|
'barter': check_barter, 'piglin_offspring': check_piglin_offspring,
|
|
'worn': check_worn, 'kills': check_kills, 'looting': check_looting, 'leave': check_leave,
|
|
'arrows': check_arrows, 'projectile-vehicles': check_projectile_vehicles, 'flame-arrows': check_flame_arrows, 'predict': check_predict, 'ladder': check_ladder,
|
|
'boats': check_boats, 'boat_ice_reboard': check_boat_ice_reboard, 'boat_shoved': check_boat_shoved, 'minecarts': check_minecarts, 'frames': check_frames, 'hands': check_hands,
|
|
'lightning': check_lightning, 'riding': check_riding, 'occupied_horse': check_occupied_horse, 'mounts': check_mounts,
|
|
'worldspawn': check_world_spawn, 'beds': check_beds, 'shield': check_shield, 'deathmining': check_death_mining,
|
|
'contacts': check_contacts, 'absorption': check_absorption, 'breath': check_breath,
|
|
'lava': check_lava, 'levels': check_levels, 'drops': check_drops,
|
|
'underwater': check_underwater,
|
|
'looks': check_looks,
|
|
'leads': check_leads, 'entity-tags': check_entity_tags,
|
|
'thrown': check_thrown,
|
|
'sparks': check_sparks,
|
|
'hud': check_hud,
|
|
'sidebar': check_sidebar,
|
|
'border': check_border,
|
|
'particles': check_particles, 'fireworks': check_fireworks,
|
|
'rocket-walls': check_rocket_walls, 'rocket-ceilings': check_rocket_ceilings,
|
|
'piston-slide': check_piston_slide,
|
|
'tag-query': check_tag_query}
|
|
|
|
|
|
|
|
def check_raid_captain_fields(host, guest, out):
|
|
"""Real first-wave leader initialization, observed through saved fields (#7849)."""
|
|
def command(text):
|
|
return server_reply(host, text)
|
|
|
|
def game_time():
|
|
text = server_reply(host, 'time query gametime', 'time is')
|
|
return int(re.search(r'time is (\d+)', text).group(1))
|
|
|
|
def poll(observe, predicate, label, seconds=90):
|
|
deadline = time.monotonic() + seconds
|
|
while True:
|
|
value = observe()
|
|
if predicate(value):
|
|
return value
|
|
assert time.monotonic() < deadline, (label, value)
|
|
time.sleep(0.05)
|
|
|
|
def test(text):
|
|
line = server_reply(host, text, 'Test ')
|
|
assert 'Test passed' in line or 'Test failed' in line, line
|
|
return 'Test passed' in line
|
|
|
|
def saved(target, path):
|
|
line = command(f'data get entity {target} {path}')
|
|
assert 'No entity was found' not in line, (target, path, line)
|
|
if 'Found no elements matching' in line:
|
|
return None
|
|
prefix = 'has the following entity data: '
|
|
assert prefix in line, (target, path, line)
|
|
return line.split(prefix, 1)[1]
|
|
|
|
positions = [(g, pos_of(g)) for g in (host, guest)]
|
|
rule_names = ['spawn_mobs', 'spawn_patrols', 'advance_time', 'mob_griefing', 'mob_drops']
|
|
rules = {}
|
|
for name in rule_names:
|
|
line = command(f'gamerule {name}')
|
|
value = re.search(r'\b(true|false)\b', line)
|
|
assert value, line
|
|
rules[name] = value.group(1)
|
|
try:
|
|
for g in (host, guest):
|
|
g('command', command='gamemode creative')
|
|
g('command', command='effect clear @s')
|
|
for text in ['difficulty normal', 'gamerule spawn_mobs false',
|
|
'gamerule spawn_patrols false', 'gamerule advance_time false',
|
|
'gamerule mob_griefing false', 'gamerule mob_drops false',
|
|
'time set noon', 'forceload add -128 -128 127 127']:
|
|
command(text)
|
|
for x, z in [(-128, -128), (-128, 127), (127, -128), (127, 127)]:
|
|
poll(lambda: test(f'execute if loaded {x} 199 {z}'), bool, 'loaded raid floor')
|
|
for x0, x1 in [(-128, -1), (0, 127)]:
|
|
for z0, z1 in [(-128, -1), (0, 127)]:
|
|
line = command(f'fill {x0} 199 {z0} {x1} 199 {z1} stone')
|
|
assert 'filled' in line.lower(), line
|
|
for text in ['fill -3 199 -3 5 203 5 glass hollow',
|
|
'setblock 2 200 2 red_bed[facing=south,part=head]',
|
|
'setblock 2 200 1 red_bed[facing=south,part=foot]',
|
|
'summon villager 0.5 200 0.5 {PersistenceRequired:1b,NoAI:0b,Tags:["h163_village"]}',
|
|
'tick unfreeze', 'tick rate 200']:
|
|
command(text)
|
|
start = game_time()
|
|
prepared = poll(game_time, lambda t: t >= start + 300, 'bed-claim ticks')
|
|
command('tick rate 20')
|
|
command('tick freeze')
|
|
frozen = game_time()
|
|
for x in (4.5, 3.5):
|
|
guest('command', command=f'tp @s {x} 200 3.5')
|
|
guest.wait(5)
|
|
line = server_reply(guest, 'data get entity @s Pos', f'[{x}d, 200.0d, 3.5d]')
|
|
assert f'[{x}d, 200.0d, 3.5d]' in line, line
|
|
command('tick rate 1000')
|
|
omen = f'execute if entity @a[name={GUEST_NAME},nbt={{active_effects:[{{id:"minecraft:raid_omen"}}]}}]'
|
|
guest('command', command='effect give @s minecraft:bad_omen 100 0')
|
|
poll(lambda: test(omen), bool, 'Raid Omen appeared', seconds=60)
|
|
poll(lambda: test(omen), lambda present: not present, 'Raid Omen expired', seconds=60)
|
|
assert game_time() == frozen, 'omen processing must leave world ticks frozen'
|
|
elapsed = 0
|
|
for ticks in (1, 149, 150):
|
|
command(f'tick step {ticks}')
|
|
elapsed += ticks
|
|
actual = poll(game_time, lambda t: t >= frozen + elapsed, 'completed raid step')
|
|
assert actual == frozen + elapsed, (ticks, actual, frozen + elapsed)
|
|
captain = '@e[nbt={PatrolLeader:1b},limit=1]'
|
|
line = server_reply(host, 'execute if entity @e[nbt={PatrolLeader:1b}]', 'Test ')
|
|
assert 'Count: 1' in line, line
|
|
for tag, extra in [('h163_ordinary', ''), ('h163_explicit', ',drop_chances:{head:2.0f}')]:
|
|
command('summon pillager 4.5 200 4.5 '
|
|
f'{{NoAI:1b,NoGravity:1b,PersistenceRequired:1b,Tags:["{tag}"]{extra}}}')
|
|
rows = {}
|
|
for name, target in [('captain', captain), ('ordinary', '@e[tag=h163_ordinary,limit=1]'),
|
|
('explicit', '@e[tag=h163_explicit,limit=1]')]:
|
|
exists = test(f'execute if entity {target}')
|
|
assert exists, name
|
|
head, chance = saved(target, 'equipment.head'), saved(target, 'drop_chances')
|
|
present = test(f'execute as {target} if data entity @s drop_chances.head')
|
|
has_chance = name != 'ordinary'
|
|
checks = {'exists': exists,
|
|
'head': (head is not None and 'white_banner' in head and 'banner_patterns' in head)
|
|
if name == 'captain' else head is None,
|
|
'chance': chance == '{head: 2.0f}' if has_chance else chance is None,
|
|
'presence': present == has_chance}
|
|
rows[name] = {'head': head, 'drop_chances': chance, 'presence': present, 'checks': checks}
|
|
failures = [f'{name}/{key}' for name, row in rows.items()
|
|
for key, passed in row['checks'].items() if not passed]
|
|
detail = {'passed': 12 - len(failures), 'observations': 12, 'failures': failures,
|
|
'bed_claim_ticks': prepared - start, 'raid_ticks': elapsed, 'subjects': rows}
|
|
(out / 'raid-captain-fields.json').write_text(json.dumps(detail, indent=2) + '\n')
|
|
assert not failures, detail
|
|
return detail
|
|
finally:
|
|
for text in ['effect clear @a', 'tick rate 20', 'tick unfreeze',
|
|
'kill @e[type=pillager]', 'kill @e[tag=h163_village]', 'forceload remove all']:
|
|
host('command', command=text)
|
|
for name, value in rules.items():
|
|
host('command', command=f'gamerule {name} {value}')
|
|
for game, at in positions:
|
|
game('command', command='tp @s ' + ' '.join(str(v) for v in at))
|
|
game('command', command='gamemode survival')
|
|
|
|
|
|
def run(ctl, out, resources, checks):
|
|
host, guest = Game('host', ctl, resources), Game('guest', ctl, resources)
|
|
# A headless run (HORIZON_E2E_HEADLESS, or a Linux without vscreen) has no focused window to draw
|
|
# in: the games render offscreen and never pause on the focus loss, or the world would stand still
|
|
# (a host paused before its world loads waits at "Connecting to the server..." for ever).
|
|
headless = bool(os.environ.get('HORIZON_E2E_HEADLESS')) or (
|
|
sys.platform.startswith('linux') and not shutil.which('vscreen'))
|
|
launch = ['launch']
|
|
if headless or sys.platform == 'darwin':
|
|
launch.append('--offscreen')
|
|
if headless:
|
|
launch.append('--no-pause')
|
|
results = {}
|
|
try:
|
|
if 'recipes-hmod' in checks:
|
|
prepare_recipes_hmod(host, guest)
|
|
host.pid = host.raw(*launch)['pid']
|
|
# Guests' commands need the world's Allow Commands as well as the LAN's (#372): a creative
|
|
# world has them; everyone plays in survival.
|
|
host.raw('open', 'Lan', 5, 'creative')
|
|
host('wait_ready', seconds=400)
|
|
host('command', command='defaultgamemode survival')
|
|
host('command', command='gamemode survival')
|
|
host('command', command='time set day')
|
|
# An open stone floor around the host, so the players see each other and nothing is in the way.
|
|
host('command', command='fill ~-8 ~-1 ~-8 ~8 ~-1 ~8 minecraft:stone')
|
|
host('command', command='fill ~-8 ~ ~-8 ~8 ~6 ~8 minecraft:air')
|
|
host('command', command='gamerule spawn_mobs false')
|
|
port = host.raw('lan', 'open', 0, 'commands')['port']
|
|
host.player = host.raw('lan', 'status').get('host', 'Player') # the host's profile name
|
|
guest.pid = guest.raw(*launch)['pid']
|
|
guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME)
|
|
guest.raw('wait-for', 'loaded', '--timeout', 300)
|
|
guest.wait(40)
|
|
# Where each joined, before anything moves them (check_spawned).
|
|
host.joined_at, guest.joined_at = pos_of(host), pos_of(guest)
|
|
face_each_other(host, guest)
|
|
host.shot(out, 'joined')
|
|
guest.shot(out, 'joined')
|
|
for name in checks:
|
|
try:
|
|
# Cases assert their own deaths, menus and held actions before returning.
|
|
# Do not let those states turn later independent cases into false failures.
|
|
for game in (host, guest):
|
|
game.raw('release')
|
|
back_to_game(game)
|
|
state = game('state')
|
|
assert state.get('player') is not None, f'{game.name} lost its session before {name}: {state}'
|
|
if state.get('screen') == 'dead' or state['player']['health'] <= 0:
|
|
game.raw('respawn')
|
|
game.until(lambda: game('state'),
|
|
lambda s: s.get('screen') == 'playing' and s['player']['health'] > 0,
|
|
ticks=40)
|
|
detail = CHECK_FNS[name](host, guest, out)
|
|
skipped = isinstance(detail, str) and detail.startswith('skipped')
|
|
results[name] = {'pass': not skipped, 'skipped': skipped, 'detail': detail}
|
|
except (Failed, AssertionError, StopIteration, KeyError, TypeError, IndexError) as e:
|
|
results[name] = {'pass': False, 'detail': repr(e)}
|
|
for g in (host, guest):
|
|
back_to_game(g)
|
|
print(f'[lan] {name}: {"PASS" if results[name]["pass"] else "FAIL"} {results[name]["detail"]}')
|
|
finally:
|
|
logs = {g.name: g.close() for g in (guest, host)}
|
|
for name, text in logs.items():
|
|
(out / f'{name}.log').write_text(text)
|
|
for g in (host, guest):
|
|
shutil.rmtree(g.work, ignore_errors=True)
|
|
return results
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument('--bin-dir', help='prebuilt horizon-client and horizon-ctl (default: build with the fast profile)')
|
|
parser.add_argument('--check', action='append', choices=CHECKS, help='run only these checks (repeatable)')
|
|
parser.add_argument('--out', default=str(ROOT / '.local/lan-e2e'), help='screenshots and game logs go here')
|
|
# The host's world always runs on its server thread; the flag stays for existing callers.
|
|
parser.add_argument('--server-thread', action='store_true', help=argparse.SUPPRESS)
|
|
args = parser.parse_args()
|
|
if args.bin_dir:
|
|
bins = Path(args.bin_dir).resolve()
|
|
else:
|
|
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '-p', 'horizon-client-lib',
|
|
'--bin', 'horizon-client', '--bin', 'horizon-ctl'],
|
|
cwd=ROOT, check=True)
|
|
meta = json.loads(subprocess.run(['cargo', 'metadata', '--format-version', '1', '--no-deps'], cwd=ROOT,
|
|
capture_output=True, text=True, check=True).stdout)
|
|
bins = Path(meta['target_directory']) / 'fast'
|
|
resources = lab.RESOURCES.resolve()
|
|
if not (resources / 'horizon-resources.json').exists():
|
|
sys.exit(f'no prepared resources in {resources}')
|
|
try:
|
|
require_server_reports(resources)
|
|
except ValueError as error:
|
|
sys.exit(str(error))
|
|
out = Path(args.out).resolve()
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
# Recipes is an opt-in native mod; this check requires a game built with it.
|
|
checks = args.check or [name for name in CHECKS if name != 'recipes-hmod']
|
|
results = run(bins / 'horizon-ctl', out, resources, checks)
|
|
(out / 'results.json').write_text(json.dumps({'checks': results}, indent=2) + '\n')
|
|
print(json.dumps(results, indent=1))
|
|
sys.exit(0 if results and all(r['pass'] for r in results.values()) else 1)
|
|
|
|
|
|
|
|
# Checks added after the list above (kept apart so open branches that extend it don't conflict).
|
|
|
|
|
|
def heard(game, sound):
|
|
return game('status')['heard'].get(sound, 0)
|
|
|
|
|
|
def check_totem(host, guest, out):
|
|
"""LivingEntity.checkTotemDeathProtection on the server for each player: 100 damage holding a
|
|
totem in the off hand, then in the main hand, uses the totem up, leaves 1 health with
|
|
Regeneration II, Absorption II and Fire Resistance, and broadcasts entity event 35, which both
|
|
clients play once (#1292)."""
|
|
sound = 'minecraft:item.totem.use'
|
|
seen = {}
|
|
for game in (host, guest):
|
|
for hand in ('offhand', 'mainhand'):
|
|
game('command', command='gamemode survival')
|
|
game('command', command='clear @s')
|
|
game('command', command='effect clear @s')
|
|
game('command', command='effect give @s minecraft:instant_health 1 5 true')
|
|
game('command', command=f'item replace entity @s weapon.{hand} with minecraft:totem_of_undying')
|
|
game.wait(10)
|
|
before = {g.name: heard(g, sound) for g in (host, guest)}
|
|
game('command', command='damage @s 100 minecraft:generic')
|
|
game.wait(10)
|
|
me = own(game)
|
|
case = f'{game.name}-{hand}'
|
|
seen[case] = {
|
|
'screen': game('state').get('screen'),
|
|
'health': me['health'],
|
|
'server_health': server_player(host, game)['health'],
|
|
'effects': sorted(effect_ids(game)),
|
|
'totems': server_count(game, 'totem_of_undying'),
|
|
'heard': {g.name: heard(g, sound) - before[g.name] for g in (host, guest)},
|
|
}
|
|
game.shot(out, f'totem-{hand}')
|
|
game('command', command='effect clear @s')
|
|
for case, s in seen.items():
|
|
# 1 health, and what Regeneration II and the full food bar gave back since.
|
|
assert s['screen'] == 'playing' and 0 < s['health'] < 5 and 0 < s['server_health'] < 5, f'{case}: {seen}'
|
|
assert s['effects'] == ['absorption', 'fire_resistance', 'regeneration'], f'{case}: {seen}'
|
|
assert s['totems'] == 0 and s['heard'] == {'host': 1, 'guest': 1}, f'{case}: {seen}'
|
|
return seen
|
|
|
|
|
|
def check_keep_inventory(host, guest, out):
|
|
"""ServerPlayer.die's dropAllDeathLoot for each player (Player.dropEquipment): with keep_inventory
|
|
off, the diamonds and the helmet a player dies with lie where it died and it respawns with
|
|
neither; with it on, nothing drops and it respawns with both (#1284)."""
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
seen = {}
|
|
# Totem coverage leaves both players near one health. Protect the waiting
|
|
# player from residual fire/damage while the other player's death is tested.
|
|
for game in (host, guest):
|
|
game('command', command='gamemode creative')
|
|
game('command', command='effect clear @s')
|
|
game('command', command='effect give @s minecraft:instant_health 1 5 true')
|
|
host('command', command=f'setworldspawn {hx} {hy} {hz}')
|
|
host('command', command='gamerule respawn_radius 0')
|
|
# Keep the inactive player alive and away from the death drops. Earlier
|
|
# fixtures may leave a personal respawn point or an unsafe waiting position.
|
|
host('command', command=f'fill {hx - 6} {hy - 1} {hz - 6} {hx + 6} {hy - 1} {hz + 6} minecraft:stone')
|
|
host('command', command=f'fill {hx - 6} {hy} {hz - 6} {hx + 6} {hy + 3} {hz + 6} minecraft:air')
|
|
for game, dz in ((host, -3), (guest, 3)):
|
|
waiting = (hx - 3.5, hy, hz + dz + 0.5)
|
|
game('command', command=f'spawnpoint @s {hx} {hy} {hz}')
|
|
game('command', command=f'tp @s {waiting[0]} {waiting[1]} {waiting[2]}')
|
|
game.until(lambda: pos_of(game), lambda p: math.dist(p, waiting) < 0.1)
|
|
try:
|
|
for keep in ('false', 'true'):
|
|
host('command', command=f'gamerule keep_inventory {keep}')
|
|
for game, dz in ((host, -3), (guest, 3)):
|
|
# 14 blocks from the world spawn: nothing dropped is picked up on respawning.
|
|
x, z = hx + 14, hz + dz
|
|
# Death drops scatter several blocks before landing. Keep the
|
|
# entire observation area solid so they cannot fall off a ledge.
|
|
host('command', command=f'fill {x - 6} {hy - 1} {z - 6} {x + 6} {hy - 1} {z + 6} minecraft:stone')
|
|
host('command', command=f'fill {x - 6} {hy} {z - 6} {x + 6} {hy + 3} {z + 6} minecraft:air')
|
|
host('command', command='kill @e[type=minecraft:item]')
|
|
game('command', command='gamemode survival')
|
|
game('command', command='clear @s')
|
|
game('command', command='give @s minecraft:diamond 7')
|
|
game('command', command='item replace entity @s armor.head with minecraft:iron_helmet')
|
|
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5}')
|
|
game.wait(20)
|
|
assert game('state').get('screen') == 'playing', f'{game.name} died during setup'
|
|
game('command', command='kill @s')
|
|
game.wait(30)
|
|
assert game('state').get('screen') == 'dead', f'{game.name} did not die'
|
|
dropped = sorted((e['item']['item'], e['item']['count'])
|
|
for e in game.raw('entities', 8, 'minecraft:item')['entities'])
|
|
game.shot(out, f'keepinv-{keep}')
|
|
respawn_when_ready(game)
|
|
game.wait(40)
|
|
back_to_game(game)
|
|
seen[f'{game.name}-keep-{keep}'] = {
|
|
'dropped': dropped,
|
|
'kept': {item: server_count(game, item) for item in ('diamond', 'iron_helmet')}}
|
|
game('command', command='gamemode creative')
|
|
finally:
|
|
host('command', command='gamerule keep_inventory false')
|
|
host('command', command='gamerule respawn_radius 10')
|
|
host('command', command='kill @e[type=minecraft:item]')
|
|
for game in (host, guest):
|
|
game('command', command='clear @s')
|
|
game('command', command='gamemode survival')
|
|
face_each_other(host, guest)
|
|
for case, s in seen.items():
|
|
want = ({'dropped': [], 'kept': {'diamond': 7, 'iron_helmet': 1}} if case.endswith('true') else
|
|
{'dropped': [('minecraft:diamond', 7), ('minecraft:iron_helmet', 1)],
|
|
'kept': {'diamond': 0, 'iron_helmet': 0}})
|
|
assert s == want, f'{case}: {seen}'
|
|
return seen
|
|
|
|
|
|
def check_signs(host, guest, out):
|
|
"""SignBlock.openTextEdit on the placing player's client, for the host and the guest: placing
|
|
an oak sign opens its editor there and nowhere else, the lines typed into it go to the server
|
|
when it closes, and both sides then show them (#1380)."""
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
seen = {}
|
|
for game, other, dz in ((host, guest, -2), (guest, host, 2)):
|
|
spot = (hx + 2, hy, hz + dz)
|
|
for viewer in (game, other):
|
|
back_to_game(viewer)
|
|
# Earlier death/travel checks leave the players at different spawn points.
|
|
# Give this placement case its own floor, line of sight and reachable target.
|
|
host('command', command=f'fill {spot[0] - 3} {hy - 1} {spot[2] - 1} {spot[0] + 3} {hy - 1} {spot[2] + 1} minecraft:stone')
|
|
host('command', command=f'fill {spot[0] - 3} {hy} {spot[2] - 1} {spot[0] + 3} {hy + 3} {spot[2] + 1} minecraft:air')
|
|
for viewer, dx in ((game, -2), (other, 2)):
|
|
at = (spot[0] + dx + 0.5, hy, spot[2] + 0.5)
|
|
viewer('command', command=f'tp @s {at[0]} {at[1]} {at[2]}')
|
|
viewer.until(lambda: pos_of(viewer), lambda p: math.dist(p, at) < 0.1)
|
|
game('command', command=f'setblock {spot[0]} {spot[1] - 1} {spot[2]} minecraft:stone')
|
|
game('command', command=f'setblock {spot[0]} {spot[1]} {spot[2]} minecraft:air')
|
|
game('command', command='gamemode survival')
|
|
game('command', command='clear @s')
|
|
game('command', command='give @s minecraft:oak_sign 1')
|
|
game.wait(10)
|
|
game.raw('place', *spot, '--item', 'minecraft:oak_sign')
|
|
game.until(lambda: game('state').get('screen'), lambda s: s == 'sign', ticks=10)
|
|
# Drain the placement's initial block-entity data before typing. The
|
|
# reply travels on the same connection after that data, unlike a local
|
|
# screen check which may see the editor before the initial data arrives.
|
|
server_reply(game, f'data get block {spot[0]} {spot[1]} {spot[2]} front_text.messages',
|
|
'following block data')
|
|
opened = {g.name: g('state').get('screen') for g in (game, other)}
|
|
game.shot(out, 'signs-editor')
|
|
lines = [f'{game.name} wrote', 'line two']
|
|
game.raw('type', lines[0])
|
|
game.raw('key', 'enter', 'tap')
|
|
game.raw('type', lines[1])
|
|
game.raw('key', 'esc', 'tap')
|
|
game.until(lambda: game('state').get('screen'), lambda s: s == 'playing', ticks=20)
|
|
back_to_game(other)
|
|
shown = {g.name: g.until(lambda: (g.raw('block', *spot).get('sign') or {}).get('front'),
|
|
lambda front: front == lines + ['', ''], ticks=20)
|
|
for g in (game, other)}
|
|
seen[game.name] = {'opened': opened,
|
|
'shown': shown}
|
|
other.shot(out, 'signs-shown')
|
|
for name, s in seen.items():
|
|
other = 'guest' if name == 'host' else 'host'
|
|
want = [f'{name} wrote', 'line two', '', '']
|
|
assert s['opened'] == {name: 'sign', other: 'playing'}, f'{name}: {seen}'
|
|
assert s['shown'] == {name: want, other: want}, f'{name}: {seen}'
|
|
return seen
|
|
|
|
|
|
def check_sign_copy(host, guest, out):
|
|
"""A sign copied with Ctrl + Pick Block keeps its text when placed, for the host and the guest:
|
|
the client's own BlockItem.place gives the predicted sign the item's sign_text_front
|
|
(updateBlockEntityComponents), so the editor that opens shows the copied lines and Done sends
|
|
them back unchanged (#8102)."""
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
lines = ['Line one', 'Line two', 'Line three', 'Line four']
|
|
seen = {}
|
|
for game, other, dz in ((host, guest, -3), (guest, host, 3)):
|
|
for viewer in (game, other):
|
|
back_to_game(viewer)
|
|
source, copy = (hx + 2, hy, hz + dz), (hx + 4, hy, hz + dz)
|
|
host('command', command=f'fill {hx - 1} {hy - 1} {hz + dz - 2} {hx + 6} {hy - 1} {hz + dz + 2} minecraft:stone')
|
|
host('command', command=f'fill {hx - 1} {hy} {hz + dz - 2} {hx + 6} {hy + 3} {hz + dz + 2} minecraft:air')
|
|
messages = ','.join(f'"{line}"' for line in lines)
|
|
host('command', command=f'setblock {source[0]} {source[1]} {source[2]} minecraft:oak_sign[rotation=8]'
|
|
f'{{front_text:{{messages:[{messages}]}}}}')
|
|
# Two blocks in front of the signs, towards the middle.
|
|
at = (hx + 3.5, hy, hz + dz + 0.5 - (2 if dz > 0 else -2))
|
|
game('command', command=f'tp @s {at[0]} {at[1]} {at[2]}')
|
|
game.until(lambda: pos_of(game), lambda p: math.dist(p, at) < 0.1)
|
|
game('command', command='gamemode creative')
|
|
game('command', command='clear @s')
|
|
game.wait(10)
|
|
# Ctrl + middle click: ServerboundPickItemFromBlockPacket with includeData.
|
|
game.raw('look-at', 'block', *source)
|
|
game.raw('select', 1)
|
|
game.raw('key', 'ctrl', 'down')
|
|
game.wait(3)
|
|
game.raw('mouse', 'middle', 'click')
|
|
game.wait(3)
|
|
game.raw('key', 'ctrl', 'up')
|
|
picked = game.until(lambda: game('inventory').get('held') or {},
|
|
lambda h: h.get('item') == 'minecraft:oak_sign', ticks=20)
|
|
game.raw('place', *copy)
|
|
game.until(lambda: game('state').get('screen'), lambda s: s == 'sign', ticks=10)
|
|
game.shot(out, 'sign-copy-editor')
|
|
game.raw('key', 'esc', 'tap')
|
|
game.until(lambda: game('state').get('screen'), lambda s: s == 'playing', ticks=20)
|
|
reply = server_reply(game, f'data get block {copy[0]} {copy[1]} {copy[2]} front_text.messages',
|
|
'following block data')
|
|
shown = {g.name: g.until(lambda: (g.raw('block', *copy).get('sign') or {}).get('front'),
|
|
lambda front: front == lines, ticks=20)
|
|
for g in (game, other)}
|
|
seen[game.name] = {'picked': picked, 'server': reply, 'shown': shown}
|
|
other.shot(out, 'sign-copy-shown')
|
|
for name, s in seen.items():
|
|
assert all(line in s['server'] for line in lines), f'{name}: {seen}'
|
|
assert all(front == lines for front in s['shown'].values()), f'{name}: {seen}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['sign_copy']
|
|
CHECK_FNS.update(sign_copy=check_sign_copy)
|
|
|
|
|
|
def book_screen(game):
|
|
"""The open book (`book`'s page and page count), once `game` shows it."""
|
|
game.raw('wait-for', 'screen', 'book', '--timeout', 10)
|
|
return game.raw('book')
|
|
|
|
|
|
def check_books(host, guest, out):
|
|
"""Books in hand for the host and the guest: a book and quill opens its editor on the player's
|
|
own client (LocalPlayer.openItemGui), what it writes and signs reaches the server
|
|
(ServerboundEditBookPacket), and the written book opens on the player's client when the server
|
|
says so (ServerPlayer.openItemGui's ClientboundOpenBookPacket); dropped, the other side sees the
|
|
server's book (#1366)."""
|
|
seen = {}
|
|
for game, other in ((host, guest), (guest, host)):
|
|
game('command', command='gamemode survival')
|
|
game('command', command='clear @s')
|
|
game('command', command='item replace entity @s weapon.mainhand with minecraft:writable_book')
|
|
x, y, z = pos_of(game)
|
|
game.raw('look-at', f'{x + 0.5:.2f}', f'{y + 30:.2f}', f'{z:.2f}')
|
|
game.wait(10)
|
|
case = seen[game.name] = {}
|
|
game.raw('use-item')
|
|
case['edit'] = book_screen(game)
|
|
case['other_screen'] = other('state').get('screen')
|
|
game.raw('type', f'{game.name} page one')
|
|
game.shot(out, 'books-edit')
|
|
game.raw('book', 'sign')
|
|
game.raw('type', f'{game.name} book')
|
|
game.raw('book', 'sign')
|
|
game.wait(20)
|
|
case['signed'] = {item: server_count(game, item) for item in ('writable_book', 'written_book')}
|
|
game.raw('use-item')
|
|
case['view'] = book_screen(game)
|
|
game.shot(out, 'books-view')
|
|
game.raw('book', 'done')
|
|
game.wait(5)
|
|
game.raw('key', 'q', 'tap')
|
|
game.wait(30)
|
|
books = [e['item'].get('components', '') for e in other.raw('entities', 16, 'minecraft:item')['entities']
|
|
if e['item']['item'] == 'minecraft:written_book']
|
|
case['dropped'] = [all(f'{game.name} {t}' in c for t in ('page one', 'book')) for c in books]
|
|
host('command', command='kill @e[type=minecraft:item]')
|
|
for name, case in seen.items():
|
|
assert case['edit'] == {'page': 0, 'pages': 1, 'lectern': None}, f'{name}: {seen}'
|
|
assert case['other_screen'] == 'playing', f'{name}: {seen}'
|
|
assert case['signed'] == {'writable_book': 0, 'written_book': 1}, f'{name}: {seen}'
|
|
assert case['view'] == {'page': 0, 'pages': 1, 'lectern': None}, f'{name}: {seen}'
|
|
assert case['dropped'] == [True], f'{name}: {seen}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['totem', 'keepinv', 'signs', 'books']
|
|
CHECK_FNS.update(totem=check_totem, keepinv=check_keep_inventory, signs=check_signs, books=check_books)
|
|
|
|
|
|
def check_elytra(host, guest, out):
|
|
"""LivingEntity.updateFallFlying for each player: a jump while falling with an Elytra on starts a
|
|
glide that the server takes too (Player.tryToStartFallFlying), the glide carries the player
|
|
forward rather than down, and landing ends it on both sides (canGlide fails on the ground)."""
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
seen = {}
|
|
try:
|
|
for game, dx in ((host, -2), (guest, 2)):
|
|
x, z = hx + dx, hz
|
|
host('command', command=f'fill {x - 1} {hy - 1} {z - 1} {x + 1} {hy - 1} {z + 1} minecraft:stone')
|
|
host('command', command=f'fill {x - 1} {hy} {z - 1} {x + 1} {hy + 3} {z + 1} minecraft:air')
|
|
game('command', command='gamemode survival')
|
|
game('command', command='clear @s')
|
|
game('command', command='effect give @s minecraft:resistance 60 4 true')
|
|
game('command', command='item replace entity @s armor.chest with minecraft:elytra')
|
|
game('command', command=f'tp @s {x + 0.5} {hy + 60} {z + 0.5} 0 0')
|
|
game.wait(6)
|
|
start = pos_of(game)
|
|
game.raw('key', 'space', 'down')
|
|
game.wait(1)
|
|
game.raw('key', 'space', 'up')
|
|
game.wait(30)
|
|
end = pos_of(game)
|
|
glide = {'client': own(game).get('fall_flying'),
|
|
'server': server_player(host, game).get('fall_flying'),
|
|
'forward': round(math.hypot(end[0] - start[0], end[2] - start[2]), 1),
|
|
'down': round(start[1] - end[1], 1)}
|
|
game.shot(out, 'elytra')
|
|
game('command', command=f'tp @s {x + 0.5} {hy + 1} {z + 0.5}')
|
|
game.wait(30)
|
|
landed = {'on_ground': own(game).get('on_ground'),
|
|
'client': own(game).get('fall_flying'),
|
|
'server': server_player(host, game).get('fall_flying')}
|
|
seen[game.name] = {'glide': glide, 'landed': landed}
|
|
finally:
|
|
for game in (host, guest):
|
|
game('command', command='clear @s')
|
|
game('command', command='effect clear @s')
|
|
face_each_other(host, guest)
|
|
for name, s in seen.items():
|
|
g, l = s['glide'], s['landed']
|
|
# Falling alone covers about 25 blocks in those 30 ticks and goes nowhere.
|
|
assert g['client'] and g['server'] and g['forward'] > 8 and g['down'] < 20, f'{name} glide: {seen}'
|
|
assert l['on_ground'] and not l['client'] and not l['server'], f'{name} landed: {seen}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['elytra']
|
|
CHECK_FNS.update(elytra=check_elytra)
|
|
|
|
|
|
def server_using(host, game):
|
|
"""The item the server has the player using ({slot, ticks}), or None."""
|
|
return server_player(host, game).get('using')
|
|
|
|
|
|
def flying(game, kind):
|
|
"""How many of the projectiles this side draws are of `kind` (a trident, an arrow)."""
|
|
return sum(kind in q['kind'].lower() for q in game('status')['projectiles'])
|
|
|
|
|
|
CHECKS += ['cart_mob_rider']
|
|
|
|
|
|
def check_cart_mob_rider(host, guest, out):
|
|
"""A minecart with a mob in it slows like a ridden one (AbstractMinecart.getSlowdownFactor: 0.997
|
|
when isVehicle, else 0.96), so with the same push it runs far past an empty one. 40 ticks on a
|
|
straight rail from Motion 0.4: the empty cart ends 2.7 to 3.3 blocks short of the occupied one."""
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
z += 8
|
|
host('command', command=f'fill {x - 2} {y - 1} {z - 1} {x + 40} {y - 1} {z + 4} minecraft:stone')
|
|
host('command', command=f'fill {x - 2} {y} {z - 1} {x + 40} {y + 3} {z + 4} minecraft:air')
|
|
host('command', command=f'fill {x} {y} {z} {x + 40} {y} {z} minecraft:rail[shape=east_west]')
|
|
host('command', command=f'fill {x} {y} {z + 3} {x + 40} {y} {z + 3} minecraft:rail[shape=east_west]')
|
|
host('command', command='tick freeze')
|
|
try:
|
|
host('command', command=f'summon minecraft:minecart {x + 0.5} {y + 0.1} {z + 0.5} {{Motion:[0.4d,0.0d,0.0d],Tags:["cmr_empty"]}}')
|
|
host('command', command=f'summon minecraft:minecart {x + 2.5} {y + 0.1} {z + 3.5} {{Motion:[0.4d,0.0d,0.0d],'
|
|
'Tags:["cmr_pig"],Passengers:[{id:"minecraft:pig",NoAI:1b}]}')
|
|
host('command', command='tick step 40')
|
|
host.wait(40)
|
|
carts = [o for o in host('status')['objects'] if o['kind'].endswith('minecart')]
|
|
empty = [o for o in carts if abs(o['position'][2] - (z + 0.5)) < 1][0]['position'][0] - (x + 0.5)
|
|
laden = [o for o in carts if abs(o['position'][2] - (z + 3.5)) < 1][0]['position'][0] - (x + 0.5)
|
|
finally:
|
|
host('command', command='tick unfreeze')
|
|
host('command', command='kill @e[type=minecraft:minecart,tag=cmr_empty]')
|
|
host('command', command='kill @e[type=minecraft:minecart,tag=cmr_pig]')
|
|
host('command', command='kill @e[type=minecraft:pig,distance=..60]')
|
|
detail = {'empty': round(empty, 3), 'with_pig': round(laden, 3)}
|
|
assert laden - empty > 2.0, f'a cart with a pig should outrun the empty one by more than 2 blocks: {detail}'
|
|
return detail
|
|
|
|
|
|
CHECK_FNS.update(cart_mob_rider=check_cart_mob_rider)
|
|
|
|
|
|
def check_held_use(host, guest, out):
|
|
"""Items used until the button lets go, for each player: the client starts the use too
|
|
(Item.use runs on both sides), so letting go sends RELEASE_USE_ITEM and the server ends it."""
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
seen = {}
|
|
try:
|
|
for game, dz in ((host, -3), (guest, 3)):
|
|
x, z = hx, hz + dz
|
|
host('command', command=f'fill {x - 1} {hy - 1} {z - 1} {x + 12} {hy - 1} {z + 1} minecraft:stone')
|
|
host('command', command=f'fill {x - 1} {hy} {z - 1} {x + 12} {hy + 3} {z + 1} minecraft:air')
|
|
host('command', command=f'fill {x + 12} {hy} {z - 1} {x + 12} {hy + 3} {z + 1} minecraft:stone')
|
|
game('command', command='gamemode survival')
|
|
game('command', command='clear @s')
|
|
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} -90 0')
|
|
game.wait(10)
|
|
s = {}
|
|
# ShieldItem: raised while held, lowered once let go.
|
|
game('command', command='item replace entity @s weapon.offhand with minecraft:shield')
|
|
game.wait(5)
|
|
game.raw('mouse', 'right', 'down')
|
|
game.wait(10)
|
|
s['shield'] = {'held': (bool(game('state').get('using')), bool(server_using(host, game)))}
|
|
game.raw('mouse', 'right', 'up')
|
|
game.wait(10)
|
|
s['shield']['let_go'] = (bool(game('state').get('using')), bool(server_using(host, game)))
|
|
game('command', command='item replace entity @s weapon.offhand with minecraft:air')
|
|
# SpyglassItem: scoping while held.
|
|
game('command', command='item replace entity @s weapon.mainhand with minecraft:spyglass')
|
|
game.wait(5)
|
|
game.raw('mouse', 'right', 'down')
|
|
game.wait(10)
|
|
s['spyglass'] = {'held': (bool(game('state').get('using')), bool(server_using(host, game)))}
|
|
game.raw('mouse', 'right', 'up')
|
|
game.wait(10)
|
|
s['spyglass']['let_go'] = (bool(game('state').get('using')), bool(server_using(host, game)))
|
|
# TridentItem.releaseUsing after 10 ticks: thrown.
|
|
host('command', command='kill @e[type=minecraft:trident]')
|
|
game('command', command='item replace entity @s weapon.mainhand with minecraft:trident')
|
|
game.wait(5)
|
|
# /kill finds no thrown trident (Horizon's selectors miss them): count what's new.
|
|
before = flying(game, 'trident')
|
|
game.raw('use-item', '--hold', 1.2)
|
|
game.wait(10)
|
|
s['trident'] = {'kept': server_count(game, 'trident'),
|
|
'thrown': flying(game, 'trident') - before}
|
|
host('command', command='kill @e[type=minecraft:trident]')
|
|
# CrossbowItem: charged by holding (25 ticks), fired by the next use.
|
|
host('command', command='kill @e[type=minecraft:arrow]')
|
|
game('command', command='clear @s')
|
|
game('command', command='item replace entity @s weapon.mainhand with minecraft:crossbow')
|
|
game('command', command='give @s minecraft:arrow 16')
|
|
game.wait(5)
|
|
before = flying(game, 'arrow')
|
|
game.raw('use-item', '--hold', 1.6)
|
|
game.wait(10)
|
|
held = game('state').get('held') or {}
|
|
s['crossbow'] = {'loaded': 'charged_projectiles' in str(held.get('components', '')),
|
|
'arrows': server_count(game, 'arrow')}
|
|
game.raw('use-item')
|
|
game.wait(10)
|
|
held = game('state').get('held') or {}
|
|
s['crossbow'].update(
|
|
fired=flying(game, 'arrow') - before,
|
|
emptied='charged_projectiles' not in str(held.get('components', '')))
|
|
game.shot(out, 'helduse')
|
|
seen[game.name] = s
|
|
finally:
|
|
host('command', command='kill @e[type=minecraft:arrow]')
|
|
host('command', command='kill @e[type=minecraft:trident]')
|
|
for game in (host, guest):
|
|
back_to_game(game)
|
|
game.raw('mouse', 'right', 'up')
|
|
game('command', command='clear @s')
|
|
face_each_other(host, guest)
|
|
for name, s in seen.items():
|
|
for item in ('shield', 'spyglass'):
|
|
assert s[item] == {'held': (True, True), 'let_go': (False, False)}, f'{name} {item}: {seen}'
|
|
assert s['trident'] == {'kept': 0, 'thrown': 1}, f'{name} trident: {seen}'
|
|
assert s['crossbow'] == {'loaded': True, 'arrows': 15, 'fired': 1, 'emptied': True}, \
|
|
f'{name} crossbow: {seen}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['helduse']
|
|
CHECK_FNS.update(helduse=check_held_use)
|
|
|
|
|
|
def check_eat_full(host, guest, out):
|
|
"""Holding right click past a full food bar starts no other bite; chorus fruit's cooldown
|
|
doesn't leave the use stuck (#2471)."""
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
# Full health: natural regeneration at 18 food or more would eat into the bar.
|
|
guest('command', command='effect give @s minecraft:instant_health 1 10')
|
|
guest('command', command='effect give @s minecraft:hunger 60 127')
|
|
for _ in range(400):
|
|
if remote(guest)['food'] <= 17:
|
|
break
|
|
guest.wait(1)
|
|
guest('command', command='effect clear @s')
|
|
guest('command', command='give @s minecraft:melon_slice 16')
|
|
guest.wait(5)
|
|
guest.raw('select', 'minecraft:melon_slice')
|
|
hungry = remote(guest)['food']
|
|
# A slice (2 food) takes 32 ticks (1.6 s): the button stays down well past the slices that fill
|
|
# the bar, with room for a slow server.
|
|
slices = -(-(20 - hungry) // 2)
|
|
hold = threading.Thread(target=guest.raw, args=('use-item', '--hold', slices * 4.0 + 6.0))
|
|
hold.start()
|
|
deadline = time.monotonic() + slices * 4.0 + 2.0
|
|
while remote(guest)['food'] < 20 and time.monotonic() < deadline:
|
|
time.sleep(0.1)
|
|
# The bar is full and the button is still down: nothing is in use over the next 20 ticks
|
|
# (sleeps, as `wait` is refused while the held use-item runs).
|
|
using = []
|
|
for _ in range(4):
|
|
time.sleep(0.25)
|
|
using.append(guest('state').get('using'))
|
|
guest.shot(out, 'eat-full')
|
|
hold.join()
|
|
guest.wait(10)
|
|
full = {'hungry': hungry, 'fed': remote(guest)['food'], 'using': using,
|
|
'eaten': 16 - guest('inventory')['counts'].get('minecraft:melon_slice', 0), 'needed': slices}
|
|
assert hungry < 20, f'the guest is not hungry: {full}'
|
|
assert full['fed'] == 20 and full['eaten'] == slices, f'the bar did not fill: {full}'
|
|
assert using == [None] * 4, f'another bite started with a full food bar: {full}'
|
|
|
|
# Chorus fruit can always be eaten, then its group cools down for 20 ticks: the client waits
|
|
# it out (MultiPlayerGameMode.useItem) and eats the second fruit with the button still held.
|
|
guest('command', command='clear @s')
|
|
guest('command', command='give @s minecraft:chorus_fruit 2')
|
|
guest.wait(5)
|
|
guest.raw('select', 'minecraft:chorus_fruit')
|
|
guest.raw('use-item', '--hold', 7.0)
|
|
guest.wait(10)
|
|
chorus = {'left': guest('inventory')['counts'].get('minecraft:chorus_fruit', 0),
|
|
'using': guest('state').get('using')}
|
|
assert chorus == {'left': 0, 'using': None}, f'the held use did not eat both chorus fruits: {chorus}'
|
|
return {'full': full, 'chorus': chorus}
|
|
|
|
|
|
CHECKS += ['eat_full']
|
|
CHECK_FNS.update(eat_full=check_eat_full)
|
|
|
|
|
|
def check_consume_release(host, guest, out):
|
|
"""#1293: releasing a consumable in either hand cancels it on client and server, while
|
|
holding it to completion applies its effects and remainder (Consumable.onConsume)."""
|
|
apart(host, guest)
|
|
seen = {}
|
|
try:
|
|
for game in (host, guest):
|
|
game('command', command='gamemode survival')
|
|
x, y, z = pos_of(game)
|
|
game.raw('look-at', x, y + 1.6, z + 2)
|
|
for hand in ('mainhand', 'offhand'):
|
|
for item in ('golden_apple', 'milk_bucket'):
|
|
case = f'{game.name}-{hand}-{item}'
|
|
game('command', command='clear @s')
|
|
game('command', command='item replace entity @s weapon.offhand with minecraft:air')
|
|
game('command', command='effect clear @s')
|
|
if item == 'milk_bucket':
|
|
game('command', command='effect give @s minecraft:speed 60')
|
|
game('command', command=f'item replace entity @s weapon.{hand} with minecraft:{item}')
|
|
game.wait(10)
|
|
assert game('looking_at')['kind'] == 'none', f'{case} is not aimed into the air'
|
|
before = set(effect_ids(game))
|
|
assert before == ({'speed'} if item == 'milk_bucket' else set()), f'{case}: {before}'
|
|
game.raw('mouse', 'right', 'down')
|
|
game.wait(5)
|
|
during = {'client': game('state').get('using'), 'server': server_using(host, game)}
|
|
game.raw('mouse', 'right', 'up')
|
|
game.wait(10)
|
|
released = {'client': game('state').get('using'), 'server': server_using(host, game)}
|
|
# Wait past the full use duration: the server must not finish eating after release.
|
|
game.wait(60)
|
|
effects = set(effect_ids(game))
|
|
inventory = game('inventory')
|
|
stack = inventory['offhand'] if hand == 'offhand' else inventory['held']
|
|
cancelled = {'during': during, 'released': released,
|
|
'stack': stack, 'effects': sorted(effects)}
|
|
seen[case] = {'cancelled': cancelled}
|
|
assert during['client'] and during['server'], f'{case} did not start on both sides: {cancelled}'
|
|
assert released == {'client': None, 'server': None}, f'{case} did not cancel: {cancelled}'
|
|
assert stack and stack['item'] == f'minecraft:{item}' and stack['count'] == 1, \
|
|
f'{case} consumed the released item: {cancelled}'
|
|
assert effects == before, f'{case} applied consumption effects after release: {cancelled}'
|
|
game.shot(out, f'consume-release-{hand}-{item}')
|
|
# A sustained press must still consume normally, including the milk's empty bucket.
|
|
# The use ends with the server's USE_ITEM_COMPLETE, 32 server ticks after its
|
|
# UseItem: room for a busy server before letting go.
|
|
game.raw('mouse', 'right', 'down')
|
|
game.wait(50)
|
|
game.raw('mouse', 'right', 'up')
|
|
game.wait(10)
|
|
inventory = game('inventory')
|
|
stack = inventory['offhand'] if hand == 'offhand' else inventory['held']
|
|
completed = {'stack': stack, 'effects': sorted(effect_ids(game)),
|
|
'using': (game('state').get('using'), server_using(host, game))}
|
|
seen[case]['completed'] = completed
|
|
assert completed['using'] == (None, None), \
|
|
f'{case} did not finish normally: {completed}'
|
|
if item == 'golden_apple':
|
|
assert inventory['counts'].get('minecraft:golden_apple', 0) == 0, \
|
|
f'{case} kept the eaten apple: {completed}'
|
|
assert set(completed['effects']) == {'regeneration', 'absorption'}, f'{case}: {completed}'
|
|
else:
|
|
assert not completed['effects'] and stack and stack['item'] == 'minecraft:bucket' \
|
|
and stack['count'] == 1, f'{case}: {completed}'
|
|
finally:
|
|
for game in (host, guest):
|
|
game.raw('mouse', 'right', 'up')
|
|
game('command', command='clear @s')
|
|
game('command', command='item replace entity @s weapon.offhand with minecraft:air')
|
|
game('command', command='effect clear @s')
|
|
face_each_other(host, guest)
|
|
return seen
|
|
|
|
|
|
CHECKS += ['consume-release']
|
|
CHECK_FNS.update({'consume-release': check_consume_release})
|
|
|
|
|
|
def check_wind(host, guest, out):
|
|
"""A wind charge each player throws at its own feet (ServerExplosion with TRIGGER_BLOCK): the
|
|
server's burst pushes the thrower through its ClientboundExplodePacket (hitPlayers), which
|
|
launches it about six blocks up, and onExplosionHit spares it the fall damage where it lands
|
|
(#1386)."""
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
host('command', command='gamerule natural_health_regeneration false')
|
|
seen = {}
|
|
try:
|
|
for game, dz in ((host, -4), (guest, 4)):
|
|
x, z = hx, hz + dz
|
|
host('command', command=f'fill {x - 2} {hy - 1} {z - 2} {x + 2} {hy - 1} {z + 2} minecraft:stone')
|
|
host('command', command=f'fill {x - 2} {hy} {z - 2} {x + 2} {hy + 10} {z + 2} minecraft:air')
|
|
game('command', command='gamemode survival')
|
|
game('command', command='clear @s')
|
|
game('command', command='effect clear @s')
|
|
game('command', command='effect give @s minecraft:instant_health 1 5 true')
|
|
game('command', command='give @s minecraft:wind_charge 2')
|
|
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} 0 90')
|
|
game.wait(20)
|
|
start = pos_of(game)[1]
|
|
game.raw('use-item')
|
|
peak = start
|
|
for _ in range(30):
|
|
game.wait(2)
|
|
peak = max(peak, pos_of(game)[1])
|
|
game.wait(40)
|
|
game.shot(out, 'wind')
|
|
me = own(game)
|
|
seen[game.name] = {'rose': round(peak - start, 1),
|
|
'landed': round(me['position'][1] - start, 1),
|
|
'health': me['health'],
|
|
'server_health': server_player(host, game)['health'],
|
|
'charges': server_count(game, 'wind_charge')}
|
|
finally:
|
|
host('command', command='gamerule natural_health_regeneration true')
|
|
for game in (host, guest):
|
|
game('command', command='clear @s')
|
|
face_each_other(host, guest)
|
|
for name, s in seen.items():
|
|
# Vanilla rises about 6 blocks from a charge at the feet.
|
|
assert 4 < s['rose'] < 8 and s['landed'] == 0, f'{name}: {seen}'
|
|
assert s['health'] == 20 and s['server_health'] == 20 and s['charges'] == 1, f'{name}: {seen}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['wind']
|
|
CHECK_FNS.update(wind=check_wind)
|
|
|
|
|
|
def bobbers(game):
|
|
"""The fishing bobbers this side draws, as (owner) ids: 0 for its own player's."""
|
|
return [q.get('owner') for q in game('status')['projectiles'] if q['kind'] == 'minecraft:fishing_bobber']
|
|
|
|
|
|
def check_fishing(host, guest, out):
|
|
"""FishingHook for each player, on the server: a cast hook stays out (ticked by the server and
|
|
tracked to the clients as a fishing bobber under its owner), lands on the ground and reels back
|
|
in; cast into open water with Lure III, a fish bites and reeling in
|
|
during the bite catches it for 1 (#1348)."""
|
|
# Both players need a safe floor for the entire other player's fishing wait.
|
|
apart(host, guest)
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
host('command', command='weather clear')
|
|
for game in (host, guest):
|
|
game('command', command='effect give @s minecraft:instant_health 1 5 true')
|
|
seen = {}
|
|
try:
|
|
for game, other, dz in ((host, guest, -5), (guest, host, 5)):
|
|
x, z = hx, hz + dz
|
|
host('command', command=f'fill {x - 1} {hy - 1} {z - 1} {x + 14} {hy - 1} {z + 1} minecraft:stone')
|
|
host('command', command=f'fill {x - 1} {hy} {z - 1} {x + 14} {hy + 4} {z + 1} minecraft:air')
|
|
game('command', command='gamemode survival')
|
|
game('command', command='clear @s')
|
|
game('command', command='item replace entity @s weapon.mainhand with minecraft:fishing_rod')
|
|
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} -90 0')
|
|
game.wait(10)
|
|
# On the ground: the floor four blocks ahead.
|
|
game.raw('look-at', f'{x + 4.5:.2f}', f'{hy:.2f}', f'{z + 0.5:.2f}')
|
|
game.raw('use-item')
|
|
game.wait(40)
|
|
s = {'own': bobbers(game), 'other': bobbers(other)}
|
|
game.shot(out, 'fishing-cast')
|
|
game.raw('use-item')
|
|
game.wait(10)
|
|
s['reeled'] = {'own': bobbers(game), 'damage': (game('state').get('held') or {}).get('damage')}
|
|
# Open water: a pool 7x7 and 3 deep, ahead.
|
|
host('command', command=f'fill {x + 3} {hy - 3} {z - 3} {x + 9} {hy - 1} {z + 3} minecraft:water')
|
|
game('command', command='item replace entity @s weapon.mainhand with minecraft:fishing_rod[enchantments={lure:3}]')
|
|
game.wait(10)
|
|
game.raw('look-at', f'{x + 6.5:.2f}', f'{hy - 1:.2f}', f'{z + 0.5:.2f}')
|
|
game.raw('use-item')
|
|
bit = None
|
|
for t in range(0, 900, 5):
|
|
game.wait(5)
|
|
if (game('status').get('fishing') or {}).get('nibble', 0) > 0:
|
|
bit = t
|
|
break
|
|
game.raw('use-item')
|
|
game.wait(40)
|
|
counts = game.raw('inventory')['counts']
|
|
s['caught'] = {'bit': bit is not None,
|
|
'damage': (game('state').get('held') or {}).get('damage'),
|
|
'items': sum(counts.values()) - 1}
|
|
game.shot(out, 'fishing-caught')
|
|
host('command', command=f'fill {x + 3} {hy - 3} {z - 3} {x + 9} {hy - 2} {z + 3} minecraft:stone')
|
|
host('command', command=f'fill {x + 3} {hy - 1} {z - 3} {x + 9} {hy - 1} {z + 3} minecraft:stone')
|
|
seen[game.name] = s
|
|
finally:
|
|
host('command', command='kill @e[type=minecraft:item]')
|
|
for game in (host, guest):
|
|
game('command', command='clear @s')
|
|
face_each_other(host, guest)
|
|
for name, s in seen.items():
|
|
other = guest if name == 'host' else host
|
|
assert s['own'] == [0], f'{name}: {seen}'
|
|
assert len(s['other']) == 1 and s['other'][0] != 0, f'{name}: {seen}'
|
|
# FishingHook.retrieve costs 2 on the ground, but a resting hook's onGround
|
|
# alternates every tick on the server (Entity.move with no vertical motion
|
|
# clears it, the next tick's gravity sets it again): 2 or nothing.
|
|
assert s['reeled']['own'] == [] and s['reeled']['damage'] in (None, 0, 2), f'{name}: {seen}'
|
|
assert s['caught']['bit'] and s['caught']['damage'] == 1 and s['caught']['items'] >= 1, f'{name}: {seen}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['fishing']
|
|
CHECK_FNS.update(fishing=check_fishing)
|
|
|
|
|
|
def check_fishing_tnt(host, guest, out):
|
|
"""A fishing hook cast at a primed TNT hooks it (Projectile.canHitEntity: PrimedTnt.isPickable),
|
|
and reeling in pulls the TNT toward the player (#6601)."""
|
|
apart(host, guest)
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
host('command', command='weather clear')
|
|
x, z = hx, hz + 5
|
|
host('command', command=f'fill {x - 1} {hy - 1} {z - 1} {x + 14} {hy - 1} {z + 1} minecraft:stone')
|
|
host('command', command=f'fill {x - 1} {hy} {z - 1} {x + 14} {hy + 4} {z + 1} minecraft:air')
|
|
host('command', command='gamemode survival')
|
|
host('command', command='clear @s')
|
|
host('command', command='item replace entity @s weapon.mainhand with minecraft:fishing_rod')
|
|
host('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} -90 0')
|
|
try:
|
|
host('command', command=f'summon minecraft:tnt {x + 4.5} {hy} {z + 0.5} {{fuse:2000s}}')
|
|
host.wait(20)
|
|
host.raw('look-at', f'{x + 4.5:.2f}', f'{hy + 0.5:.2f}', f'{z + 0.5:.2f}')
|
|
host.raw('use-item')
|
|
host.wait(60)
|
|
host.shot(out, 'fishing-tnt')
|
|
host.raw('use-item')
|
|
motion = server_reply(host, 'data get entity @e[type=minecraft:tnt,limit=1] Motion')
|
|
finally:
|
|
host('command', command='kill @e[type=minecraft:tnt]')
|
|
host('command', command='clear @s')
|
|
# FishingHook.pullEntity pushes 0.1 of the way to the owner, 4 blocks west (-0.4); ground friction
|
|
# eats most of it before the read, but an unhooked TNT's x motion stays 0.
|
|
pulled = float(re.search(r'\[(-?[0-9.eE-]+)d', motion).group(1))
|
|
assert pulled < 0, f'reeling in did not pull the hooked primed TNT toward the player: {motion}'
|
|
return {'motion': motion}
|
|
|
|
|
|
CHECKS += ['fishing-tnt']
|
|
CHECK_FNS['fishing-tnt'] = check_fishing_tnt
|
|
|
|
|
|
def check_fishing_item(host, guest, out):
|
|
"""Reeling an item sends its velocity to both clients immediately; they move it smoothly."""
|
|
spots = apart(host, guest)
|
|
x, y, z = spots['host']
|
|
item_x, item_z = x + 4.5, z + 0.5
|
|
item_id = None
|
|
|
|
def item_position(game):
|
|
items = game.raw('entities', 16, 'item')['entities']
|
|
found = [item for item in items if item['id'] == item_id]
|
|
assert len(found) == 1, f'{game.name} does not see hooked item {item_id}: {items}'
|
|
return found[0]['pos']
|
|
|
|
try:
|
|
host('command', command='kill @e[type=minecraft:item]')
|
|
host('command', command='gamemode survival')
|
|
host('command', command='clear @s')
|
|
host('command', command='item replace entity @s weapon.mainhand with minecraft:fishing_rod')
|
|
host.raw('look-at', f'{item_x:.2f}', f'{y + 1.5:.2f}', f'{item_z:.2f}')
|
|
host.raw('use-item')
|
|
for _ in range(10):
|
|
status = host('status')
|
|
bobbers = [
|
|
projectile for projectile in status.get('projectiles', [])
|
|
if projectile.get('kind') == 'minecraft:fishing_bobber'
|
|
]
|
|
if bobbers:
|
|
break
|
|
host.wait(1)
|
|
else:
|
|
raise AssertionError(f'fishing bobber did not spawn: {status}')
|
|
|
|
# Spawn on the moving bobber so the test covers hooking/reeling and
|
|
# client motion sync, not the rod's ballistic aim or item fall timing.
|
|
hook_x, hook_y, hook_z = bobbers[0]['position']
|
|
host('command', command=f'summon minecraft:item {hook_x:.4f} {hook_y:.4f} {hook_z:.4f} '
|
|
'{Item:{id:"minecraft:stone",count:1},PickupDelay:32767}')
|
|
host.wait(1)
|
|
guest.wait(1)
|
|
items = host.raw('entities', 16, 'item')['entities']
|
|
target = next(item for item in items
|
|
if item['item']['item'] == 'minecraft:stone'
|
|
and math.dist(item['pos'], [hook_x, hook_y, hook_z]) < 1)
|
|
item_id = target['id']
|
|
for _ in range(10):
|
|
host_items = host.raw('entities', 16, 'item')['entities']
|
|
guest_items = guest.raw('entities', 16, 'item')['entities']
|
|
if (any(item['id'] == item_id for item in host_items)
|
|
and any(item['id'] == item_id for item in guest_items)):
|
|
break
|
|
host.wait(1)
|
|
guest.wait(1)
|
|
start = item_position(host)
|
|
guest_start = item_position(guest)
|
|
assert math.dist(guest_start, start) < 0.5, \
|
|
f'clients disagree before reeling: {start}, {guest_start}'
|
|
hook_path = []
|
|
for _ in range(80):
|
|
status = host('status')
|
|
fishing = status.get('fishing')
|
|
if _ % 10 == 0:
|
|
hook_path.append([
|
|
projectile for projectile in status.get('projectiles', [])
|
|
if projectile.get('kind') == 'minecraft:fishing_bobber'
|
|
])
|
|
if fishing and fishing['state'] == f'Hooked({item_id})':
|
|
break
|
|
host.wait(1)
|
|
else:
|
|
raise AssertionError(
|
|
f'fishing hook did not catch item {item_id}: {fishing}; '
|
|
f'item={item_position(host)} player={host("state")["player"]} '
|
|
f'aim=({item_x}, {item_y}, {item_z}) path={hook_path}'
|
|
)
|
|
|
|
host.raw('use-item')
|
|
samples = {'host': [item_position(host)], 'guest': [item_position(guest)]}
|
|
for _ in range(5):
|
|
host.wait(1)
|
|
samples['host'].append(item_position(host))
|
|
samples['guest'].append(item_position(guest))
|
|
host.shot(out, 'fishing-item-pull')
|
|
guest.shot(out, 'fishing-item-pull')
|
|
|
|
progress = {}
|
|
for name, positions in samples.items():
|
|
path = [start if name == 'host' else guest_start, *positions]
|
|
steps = [path[i][0] - path[i + 1][0] for i in range(len(path) - 1)]
|
|
moved = path[0][0] - path[-1][0]
|
|
progress[name] = {'x': [p[0] for p in path], 'moved_toward_player': moved}
|
|
assert moved >= 0.5, f'{name} client did not receive item motion promptly: {progress[name]}'
|
|
assert all(-0.02 <= step < 1.0 for step in steps), \
|
|
f'{name} client saw a teleport or reversed movement: {progress[name]}'
|
|
return progress
|
|
finally:
|
|
host('command', command='kill @e[type=minecraft:item]')
|
|
host('command', command='clear @s')
|
|
face_each_other(host, guest)
|
|
|
|
|
|
CHECKS += ['fishing-item']
|
|
CHECK_FNS['fishing-item'] = check_fishing_item
|
|
|
|
|
|
def check_maps(host, guest, out):
|
|
"""MapItem.update for each player, on the server: a map the player makes and holds reveals the
|
|
terrain around them and shows their marker, which reach their client in
|
|
ClientboundMapItemDataPacket (#1382)."""
|
|
seen = {}
|
|
try:
|
|
for game in (host, guest):
|
|
game('command', command='gamemode survival')
|
|
game('command', command='clear @s')
|
|
game('command', command='item replace entity @s weapon.mainhand with minecraft:map')
|
|
x, y, z = pos_of(game)
|
|
game.raw('look-at', f'{x + 5:.2f}', f'{y + 1.6:.2f}', f'{z:.2f}')
|
|
game.wait(5)
|
|
game.raw('use-item')
|
|
# 16 ticks sweep every column once; the marker goes out every 5th tick.
|
|
game.wait(60)
|
|
maps = game('status').get('maps', [])
|
|
game.raw('look-at', f'{x + 1:.2f}', f'{y:.2f}', f'{z:.2f}')
|
|
game.wait(5)
|
|
game.shot(out, 'maps-held')
|
|
seen[game.name] = [{'pixels': m['pixels'], 'decorations': m['decorations']} for m in maps]
|
|
finally:
|
|
for game in (host, guest):
|
|
game('command', command='clear @s')
|
|
face_each_other(host, guest)
|
|
for name, maps in seen.items():
|
|
# Revealed out to the server's view distance, at least a 32-block disk.
|
|
assert len(maps) == 1 and maps[0]['pixels'] > 3000, f'{name}: {seen}'
|
|
assert maps[0]['decorations'] == ['map/decorations/player'], f'{name}: {seen}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['maps']
|
|
CHECK_FNS.update(maps=check_maps)
|
|
|
|
|
|
def check_body(host, guest, out):
|
|
"""What each player's own client decides for its body (#1278, #1303, #1296): walking while
|
|
drawing a bow is slowed to a fifth (LocalPlayer.modifyInput), sprint-swimming follows the look
|
|
down (Player.travel's look angle) and leather boots hold on powder snow
|
|
(PowderSnowBlock.canEntityWalkOnPowderSnow)."""
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
seen = {}
|
|
|
|
def walked(game, x, z, use):
|
|
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} -90 0')
|
|
game.wait(10)
|
|
if use:
|
|
game.raw('mouse', 'right', 'down')
|
|
game.wait(5)
|
|
a = pos_of(game)
|
|
game.raw('move', 'forward', '0.8')
|
|
game.wait(10)
|
|
b = pos_of(game)
|
|
game.raw('mouse', 'right', 'up')
|
|
game.wait(5)
|
|
return round(math.dist((a[0], a[2]), (b[0], b[2])), 2)
|
|
|
|
try:
|
|
for game, dz in ((host, -4), (guest, 4)):
|
|
x, z = hx, hz + dz
|
|
host('command', command=f'fill {x - 1} {hy - 1} {z - 1} {x + 10} {hy - 1} {z + 1} minecraft:stone')
|
|
host('command', command=f'fill {x - 1} {hy} {z - 1} {x + 10} {hy + 3} {z + 1} minecraft:air')
|
|
game('command', command='gamemode survival')
|
|
game('command', command='clear @s')
|
|
game('command', command='effect clear @s')
|
|
s = {'walk': walked(game, x, z, False)}
|
|
game('command', command='item replace entity @s weapon.mainhand with minecraft:bow')
|
|
game('command', command='give @s minecraft:arrow 4')
|
|
game.wait(5)
|
|
s['drawing'] = walked(game, x, z, True)
|
|
game('command', command='clear @s')
|
|
# Powder snow under the feet: leather boots stand on it, bare feet sink.
|
|
host('command', command=f'setblock {x + 3} {hy - 1} {z} minecraft:powder_snow')
|
|
for boots in ('leather_boots', 'air'):
|
|
game('command', command=f'item replace entity @s armor.feet with minecraft:{boots}')
|
|
game('command', command=f'tp @s {x + 3.5} {hy} {z + 0.5} -90 0')
|
|
game.wait(30)
|
|
s[boots] = round(pos_of(game)[1] - hy, 2)
|
|
host('command', command=f'setblock {x + 3} {hy - 1} {z} minecraft:stone')
|
|
# Deep water: sprint-swimming while looking down dives.
|
|
wx, wz = x, z + 2 * (1 if dz > 0 else -1) * 3
|
|
host('command', command=f'fill {wx - 1} {hy - 14} {wz - 2} {wx + 12} {hy + 2} {wz + 2} minecraft:stone')
|
|
host('command', command=f'fill {wx} {hy - 13} {wz - 1} {wx + 11} {hy - 1} {wz + 1} minecraft:water')
|
|
game.wait(10)
|
|
game('command', command=f'tp @s {wx + 1.5} {hy - 6} {wz + 0.5} -90 60')
|
|
game.wait(10)
|
|
a = pos_of(game)
|
|
game.raw('move', 'forward', '1.5', '--sprint')
|
|
game.wait(5)
|
|
s['dive'] = round(pos_of(game)[1] - a[1], 2)
|
|
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} -90 0')
|
|
game.wait(5)
|
|
host('command', command=f'fill {wx - 1} {hy - 14} {wz - 2} {wx + 12} {hy + 2} {wz + 2} minecraft:air')
|
|
host('command', command=f'fill {wx - 1} {hy - 14} {wz - 2} {wx + 12} {hy - 1} {wz + 2} minecraft:stone')
|
|
game.shot(out, 'body')
|
|
seen[game.name] = s
|
|
finally:
|
|
for game in (host, guest):
|
|
back_to_game(game)
|
|
game.raw('mouse', 'right', 'up')
|
|
game('command', command='clear @s')
|
|
face_each_other(host, guest)
|
|
for name, s in seen.items():
|
|
assert s['walk'] > 2.5 and s['drawing'] < s['walk'] * 0.4, f'{name} drawing a bow: {seen}'
|
|
assert s['leather_boots'] > -0.01 and s['air'] < -0.5, f'{name} powder snow: {seen}'
|
|
assert s['dive'] < -2, f'{name} sprint-swimming down: {seen}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['body']
|
|
CHECK_FNS.update(body=check_body)
|
|
|
|
|
|
def check_strikes(host, guest, out):
|
|
"""What a mob or the sky does to every player in reach, the server's players too (#1480,
|
|
#1484): a bolt striking a player standing in water costs them health (Entity.thunderHit), and
|
|
a cave spider's bite poisons them (CaveSpider.doHurtTarget on Normal)."""
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
seen = {}
|
|
try:
|
|
host('command', command='difficulty normal')
|
|
host('command', command='time set 18000')
|
|
for game, dz in ((host, -5), (guest, 5)):
|
|
x, z = hx, hz + dz
|
|
host('command', command=f'fill {x - 3} {hy - 1} {z - 3} {x + 3} {hy + 3} {z + 3} minecraft:stone')
|
|
host('command', command=f'fill {x - 2} {hy} {z - 2} {x + 2} {hy + 3} {z + 2} minecraft:air')
|
|
host('command', command=f'fill {x - 2} {hy} {z - 2} {x + 2} {hy} {z + 2} minecraft:water')
|
|
game('command', command='gamemode survival')
|
|
game('command', command='clear @s')
|
|
game('command', command='effect clear @s')
|
|
game('command', command='effect give @s minecraft:instant_health 1 5')
|
|
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5}')
|
|
game.wait(20)
|
|
before = game('state')['player'].get('health')
|
|
game('command', command=f'summon minecraft:lightning_bolt {x + 0.5} {hy} {z + 0.5}')
|
|
game.wait(20)
|
|
s = {'bolt': (before, game('state')['player'].get('health'))}
|
|
host('command', command=f'summon minecraft:cave_spider {x + 1.5} {hy} {z + 0.5}')
|
|
poisoned = False
|
|
for _ in range(40):
|
|
game.wait(5)
|
|
if 'poison' in effect_ids(game):
|
|
poisoned = True
|
|
break
|
|
s['poisoned'] = poisoned
|
|
s['bitten'] = game('state')['player'].get('health')
|
|
host('command', command='kill @e[type=minecraft:cave_spider]')
|
|
game.wait(5)
|
|
game.shot(out, 'strikes')
|
|
host('command', command=f'fill {x - 3} {hy - 1} {z - 3} {x + 3} {hy + 3} {z + 3} minecraft:air')
|
|
host('command', command=f'fill {x - 3} {hy - 1} {z - 3} {x + 3} {hy - 1} {z + 3} minecraft:stone')
|
|
seen[game.name] = s
|
|
finally:
|
|
host('command', command='kill @e[type=minecraft:cave_spider]')
|
|
host('command', command='time set day')
|
|
for game in (host, guest):
|
|
back_to_game(game)
|
|
game('command', command='effect clear @s')
|
|
game('command', command='effect give @s minecraft:instant_health 1 5')
|
|
face_each_other(host, guest)
|
|
for name, s in seen.items():
|
|
a, b = s['bolt']
|
|
assert a is not None and b is not None and a - b >= 3, f'{name} struck by lightning: {seen}'
|
|
assert s['poisoned'], f'{name} bitten by a cave spider: {seen}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['strikes']
|
|
CHECK_FNS.update(strikes=check_strikes)
|
|
|
|
|
|
def check_cursor(host, guest, out):
|
|
"""Closing the inventory with a stack on the cursor puts it back into the player's inventory
|
|
(AbstractContainerMenu.removed, placeItemBackInInventory) and empties the cursor, for each
|
|
player: nothing is thrown out and no copy stays on the cursor (#1283)."""
|
|
seen = {}
|
|
try:
|
|
for game in (host, guest):
|
|
game('command', command='clear @s')
|
|
host('command', command='kill @e[type=minecraft:item]')
|
|
game('command', command='item replace entity @s hotbar.0 with minecraft:diamond 7')
|
|
game.wait(10)
|
|
game.raw('open-inventory')
|
|
game.raw('wait-for', 'screen', 'inventory', '--timeout', 10)
|
|
game.raw('click-slot', 0)
|
|
game.wait(5)
|
|
held = (game('inventory').get('carried') or {}).get('count')
|
|
game.raw('key', 'esc', 'tap')
|
|
game.wait(40)
|
|
inv = game('inventory')
|
|
seen[game.name] = {'held': held, 'carried': inv.get('carried'),
|
|
'client': inv['counts'].get('minecraft:diamond', 0),
|
|
'server': server_count(game, 'diamond'),
|
|
'thrown': len(game.raw('entities', 8, 'minecraft:item')['entities'])}
|
|
finally:
|
|
for game in (host, guest):
|
|
back_to_game(game)
|
|
game('command', command='clear @s')
|
|
host('command', command='kill @e[type=minecraft:item]')
|
|
for name, s in seen.items():
|
|
assert s == {'held': 7, 'carried': None, 'client': 7, 'server': 7, 'thrown': 0}, f'{name}: {seen}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['cursor']
|
|
CHECK_FNS.update(cursor=check_cursor)
|
|
|
|
|
|
def check_digging(host, guest, out):
|
|
"""The server breaks a block a player digs only once that player's client is done
|
|
(ServerPlayerGameMode: STOP_DESTROY_BLOCK, or the delayed destroy), never on its own count, so
|
|
the player's client always breaks it first, with its particles and sound. The guest holds
|
|
attack across a wall of six oak logs with a copper axe and hears each one break; then it digs
|
|
stone with a wooden pickaxe and its game stalls for a second halfway: the stone is still there
|
|
on both sides when it resumes, and the guest's own crack finishes it."""
|
|
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
|
|
x, y, z = gx + 3, gy, gz + 4
|
|
heard = lambda sound: guest('status')['heard'].get(sound, 0)
|
|
at = lambda b: (f'{b[0] + 0.02:.2f}', f'{b[1] + 0.5:.2f}', f'{b[2] + 0.5:.2f}')
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='item replace entity @s weapon.mainhand with minecraft:copper_axe')
|
|
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 4} {y + 3} {z + 2} minecraft:air')
|
|
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 4} {y - 1} {z + 2} minecraft:stone')
|
|
host('command', command=f'fill {x + 2} {y} {z - 1} {x + 2} {y + 1} {z + 1} minecraft:oak_log')
|
|
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
guest.wait(30)
|
|
logs = [(x + 2, y + dy, z + dz) for dz in (-1, 0, 1) for dy in (0, 1)]
|
|
before = heard('minecraft:block.wood.break')
|
|
guest.raw('look-at', *at(logs[0]))
|
|
guest.raw('mouse', 'left', 'down')
|
|
left = list(logs)
|
|
deadline = time.time() + 20
|
|
while left and time.time() < deadline:
|
|
if guest.raw('block', *left[0]).get('block') != 'minecraft:oak_log':
|
|
left.pop(0)
|
|
if left:
|
|
guest.raw('look-at', *at(left[0]))
|
|
guest.raw('mouse', 'left', 'up')
|
|
guest.wait(10)
|
|
seen = {'logs_left': left, 'log_breaks_heard': heard('minecraft:block.wood.break') - before}
|
|
|
|
# Stone with a wooden pickaxe: 23 ticks. The guest stalls for a second after about 8.
|
|
stone = (x + 2, y, z)
|
|
guest('command', command='item replace entity @s weapon.mainhand with minecraft:wooden_pickaxe')
|
|
host('command', command=f'setblock {stone[0]} {stone[1]} {stone[2]} minecraft:stone')
|
|
guest.wait(10)
|
|
before = heard('minecraft:block.stone.break')
|
|
guest.raw('look-at', *at(stone))
|
|
guest.raw('mouse', 'left', 'down')
|
|
guest.wait(8)
|
|
guest.raw('stall', 1000)
|
|
resumed = {'guest': guest.raw('block', *stone).get('block'), 'server': host.raw('block', *stone).get('block'),
|
|
'mining': guest('state').get('mining')}
|
|
# Let go once it breaks: past it the crosshair reaches the floor.
|
|
deadline = time.time() + 5
|
|
while guest.raw('block', *stone).get('block') == 'minecraft:stone' and time.time() < deadline:
|
|
pass
|
|
guest.raw('mouse', 'left', 'up')
|
|
guest.wait(10)
|
|
seen.update(resumed=resumed, stone_after=host.raw('block', *stone).get('block'),
|
|
stone_breaks_heard=heard('minecraft:block.stone.break') - before)
|
|
guest.shot(out, 'digging')
|
|
assert not left and seen['log_breaks_heard'] == 6, f'the server broke logs before the guest did: {seen}'
|
|
assert resumed['guest'] == resumed['server'] == 'minecraft:stone', f'the server broke the stone while the guest stalled: {seen}'
|
|
assert seen['stone_after'] == 'minecraft:air' and seen['stone_breaks_heard'] == 1, f'the guest did not break the stone: {seen}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['digging']
|
|
CHECK_FNS.update(digging=check_digging)
|
|
|
|
|
|
def check_instant_digging(host, guest, out):
|
|
"""#1908: an instant survival START has no STOP. ServerPlayerGameMode.tick
|
|
forgets a dig once its block is air; it must neither hit air nor break a replacement.
|
|
Exercise the host and the network guest (both are clients of the server).
|
|
"""
|
|
result = {}
|
|
for label, player in (('host', host), ('guest', guest)):
|
|
x, y, z = (int(v // 1) for v in pos_of(player))
|
|
cell = (x, y + 1, z + 2)
|
|
player('command', command='gamemode survival')
|
|
player('command', command='clear @s')
|
|
host('command', command=f'fill {x - 1} {y} {z + 1} {x + 1} {y + 3} {z + 4} minecraft:air')
|
|
host('command', command=f'setblock {x} {y} {z + 2} minecraft:sand')
|
|
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:dead_bush')
|
|
player.wait(10)
|
|
before = heard(player, 'minecraft:block.stone.hit')
|
|
player.raw('look-at', x + 0.5, y + 1.4, z + 2.5)
|
|
player.raw('mouse', 'left', 'click')
|
|
player.wait(20)
|
|
broken = host.raw('block', *cell).get('block')
|
|
hits = heard(player, 'minecraft:block.stone.hit') - before
|
|
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:dead_bush')
|
|
player.wait(20)
|
|
replacement = host.raw('block', *cell).get('block')
|
|
result[label] = {'broken': broken, 'stone_hits': hits, 'replacement': replacement}
|
|
assert broken == 'minecraft:air', f'{label}: the bush did not break: {result}'
|
|
assert hits == 0, f'{label}: hitting air after an instant break: {result}'
|
|
assert replacement == 'minecraft:dead_bush', f'{label}: the server kept digging: {result}'
|
|
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:air')
|
|
return result
|
|
|
|
|
|
CHECKS += ['instant_digging']
|
|
CHECK_FNS.update(instant_digging=check_instant_digging)
|
|
|
|
|
|
def check_vehicles(host, guest, out):
|
|
"""The host and the guest each (#1551):
|
|
- in creative, break a block and have it put back: the server's dig ends with the break
|
|
(ServerPlayerGameMode.handleBlockBreakAction), so the block stays;
|
|
- land on a boat on the ground and stand on it (AbstractBoat.canBeCollidedWith);
|
|
- sit in a boat that lava reaches: lavaHurt twice a tick breaks it at once, and the rider is out;
|
|
- see a minecart in lava break, and a TNT minecart in lava prime and go off;
|
|
- stand half in a minecart off rails and push it away (AbstractMinecart.push from the server's
|
|
player)."""
|
|
result = {}
|
|
spots = apart(host, guest)
|
|
for label, player in (('host', host), ('guest', guest)):
|
|
x, y, z = spots[label]
|
|
seen = result[label] = {}
|
|
near = lambda kind, at: [o for o in host('status')['objects'] if o['kind'].endswith(kind)
|
|
and abs(o['position'][0] - at[0]) < 2 and abs(o['position'][2] - at[2]) < 2]
|
|
try:
|
|
cell = (x, y + 1, z + 2)
|
|
player('command', command='gamemode creative')
|
|
player('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:stone')
|
|
player.wait(5)
|
|
player.raw('look-at', f'{x + 0.5:.2f}', f'{y + 1.5:.2f}', f'{z + 2.5:.2f}')
|
|
player.raw('mouse', 'left', 'click')
|
|
player.wait(5)
|
|
seen['broken'] = host.raw('block', *cell).get('block')
|
|
player('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:stone')
|
|
player.wait(20)
|
|
seen['put_back'] = [g.raw('block', *cell).get('block') for g in (host, player)]
|
|
player('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:air')
|
|
|
|
player('command', command='gamemode survival')
|
|
player('command', command='effect give @s minecraft:fire_resistance 300 0 true')
|
|
boat_at = (x + 0.5, y, z + 3.5)
|
|
player('command', command=f'summon minecraft:oak_boat {boat_at[0]} {y} {boat_at[2]}')
|
|
player.wait(10)
|
|
player('command', command=f'tp @s {boat_at[0]} {y + 1.2:.2f} {boat_at[2]}')
|
|
player.wait(20)
|
|
seen['on_boat'] = [round(pos_of(player)[1] - y, 3), round(server_player(host, player)['position'][1] - y, 3)]
|
|
|
|
player('command', command=f'tp @s {x + 0.5} {y} {z + 1.5} 0 0')
|
|
player.wait(10)
|
|
board(player, 'oak_boat', boat_at)
|
|
player('command', command=f'setblock {x} {y} {z + 3} minecraft:lava')
|
|
player.wait(10)
|
|
seen['rider'] = {'riding': player('status').get('riding'), 'boats': len(near('oak_boat', boat_at))}
|
|
|
|
player('command', command='gamemode creative')
|
|
player('command', command=f'summon minecraft:minecart {boat_at[0]} {y} {boat_at[2]}')
|
|
player.wait(10)
|
|
seen['minecart_in_lava'] = len(near('minecart', boat_at))
|
|
booms = player('status')['heard'].get('minecraft:entity.generic.explode', 0)
|
|
player('command', command=f'summon minecraft:tnt_minecart {boat_at[0]} {y} {boat_at[2]}')
|
|
player.wait(60)
|
|
seen['tnt_minecart'] = {'left': len(near('tnt_minecart', boat_at)),
|
|
'booms': player('status')['heard'].get('minecraft:entity.generic.explode', 0) - booms}
|
|
|
|
player('command', command=f'fill {x - 3} {y} {z - 3} {x + 4} {y + 3} {z + 6} minecraft:air')
|
|
player('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 4} {y - 1} {z + 6} minecraft:stone')
|
|
player('command', command='gamemode survival')
|
|
cart_at = (x + 0.5, y, z + 1.6)
|
|
player('command', command=f'summon minecraft:minecart {cart_at[0]} {y} {cart_at[2]}')
|
|
player.wait(10)
|
|
before = near('minecart', cart_at)[0]['position']
|
|
# Half into the cart from the north: it is pushed south until they are apart.
|
|
player('command', command=f'tp @s {x + 0.5} {y} {before[2] - 0.4:.2f} 0 0')
|
|
player.wait(20)
|
|
after = near('minecart', cart_at)[0]['position']
|
|
seen['cart_pushed'] = round(after[2] - before[2], 3)
|
|
player.shot(out, f'vehicles-{label}')
|
|
finally:
|
|
player('command', command='kill @e[type=minecraft:minecart]')
|
|
player('command', command='kill @e[type=minecraft:oak_boat]')
|
|
player('command', command='kill @e[type=minecraft:item]')
|
|
player('command', command=f'fill {x - 3} {y} {z - 3} {x + 4} {y + 3} {z + 6} minecraft:air')
|
|
player('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 4} {y - 1} {z + 6} minecraft:stone')
|
|
player('command', command='effect clear @s')
|
|
player('command', command='gamemode survival')
|
|
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
player.wait(5)
|
|
for label, seen in result.items():
|
|
assert seen['broken'] == 'minecraft:air', f'{label}: the creative click broke nothing: {seen}'
|
|
assert seen['put_back'] == ['minecraft:stone'] * 2, f'{label}: the server kept digging: {seen}'
|
|
assert all(0.5 < dy < 0.65 for dy in seen['on_boat']), f'{label}: not standing on the boat: {seen}'
|
|
assert seen['rider'] == {'riding': None, 'boats': 0}, f'{label}: lava left the boat or its rider: {seen}'
|
|
assert seen['minecart_in_lava'] == 0, f'{label}: a minecart outlived lava: {seen}'
|
|
assert seen['tnt_minecart'] == {'left': 0, 'booms': 1}, f'{label}: the TNT minecart in lava: {seen}'
|
|
assert seen['cart_pushed'] > 0.1, f'{label}: the minecart stayed in the player: {seen}'
|
|
return result
|
|
|
|
|
|
CHECKS += ['vehicles']
|
|
CHECK_FNS.update(vehicles=check_vehicles)
|
|
|
|
|
|
# How far a plain wooden sword hit slides a standing player in vanilla: LivingEntity.knockback(0.4) from
|
|
# the server copy's (0, -0.0784, 0), then the victim's own client.
|
|
VANILLA_PUSH = 1.989
|
|
|
|
|
|
def check_knockback(host, guest, out):
|
|
if dead(guest):
|
|
guest.raw('respawn')
|
|
guest.wait(40)
|
|
host('command', command='gamerule pvp true')
|
|
for g in (host, guest):
|
|
g('command', command='gamemode survival')
|
|
g('command', command='item replace entity @s weapon.mainhand with minecraft:wooden_sword')
|
|
g('command', command='effect give @s minecraft:resistance 1000 4 true')
|
|
face_each_other(host, guest)
|
|
pushes = {}
|
|
try:
|
|
for name, attacker, victim in (('host hits guest', host, guest), ('guest hits host', guest, host)):
|
|
# The victim is never teleported (a teleport resets its velocity): the attacker steps up to it from
|
|
# the west, then the east, then the west again.
|
|
for side in (-3, 3, -3):
|
|
vx, vy, vz = pos_of(victim)
|
|
attacker('command', command=f'tp @s {vx + side:.2f} {vy:.2f} {vz:.2f}')
|
|
attacker.wait(40) # a full charge, and the last push long settled
|
|
attacker.raw('look-at', f'{vx:.2f}', f'{vy + 1.6:.2f}', f'{vz:.2f}')
|
|
attacker.wait(5)
|
|
before = pos_of(victim)
|
|
attacker.raw('attack')
|
|
victim.wait(30)
|
|
after = pos_of(victim)
|
|
pushes.setdefault(name, []).append(round(math.dist(before[0::2], after[0::2]), 3))
|
|
attacker.shot(out, 'knockback-' + name.replace(' ', '-'))
|
|
finally:
|
|
for g in (host, guest):
|
|
g('command', command='effect clear @s minecraft:resistance')
|
|
for name, distances in pushes.items():
|
|
assert all(abs(d - VANILLA_PUSH) < 0.03 for d in distances), f'{name}: not {VANILLA_PUSH} each time: {pushes}'
|
|
return pushes
|
|
|
|
|
|
CHECKS += ['knockback']
|
|
CHECK_FNS.update(knockback=check_knockback)
|
|
|
|
|
|
def check_sprint_hit_slowdown(host, guest, out):
|
|
"""A sprinting guest's full-strength hit on the host slows the guest and ends its sprint.
|
|
|
|
Player.attack runs on the attacker's client too, and RemotePlayer.hurtClient lets it reach
|
|
causeExtraKnockback: the horizontal speed times 0.6 and setSprinting(false). Ctrl is released
|
|
before the hit (the sprint holds while W stays down), so only the hit can end it. Input and
|
|
the click run in one control script so command latency cannot carry the guest past the host.
|
|
"""
|
|
if dead(guest):
|
|
guest.raw('respawn')
|
|
guest.wait(40)
|
|
host('command', command='gamerule pvp true')
|
|
host('command', command='gamemode survival')
|
|
host('command', command='effect give @s minecraft:resistance 1000 4 true')
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='effect clear @s')
|
|
guest('command', command='item replace entity @s weapon.mainhand with minecraft:air')
|
|
# Drain the server's attack log before the tick-tight click. The raw mouse
|
|
# click deliberately keeps W held; the `attack` agent command would release
|
|
# movement keys before its deferred result arrives.
|
|
host('hurtlog')
|
|
hx, hy, hz = pos_of(host)
|
|
x, y, z = math.floor(hx), math.floor(hy), math.floor(hz)
|
|
host('command', command=f'fill {x - 6} {y} {z - 2} {x + 14} {y + 3} {z + 2} minecraft:air')
|
|
host('command', command=f'fill {x - 6} {y - 1} {z - 2} {x + 14} {y - 1} {z + 2} minecraft:stone')
|
|
host('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
guest('command', command=f'tp @s {x + 12.5} {y} {z + 0.5} 90 0')
|
|
guest.wait(40) # a full attack charge
|
|
guest.raw('look-at', f'{x + 0.5}', f'{y + 1.6}', f'{z + 0.5}')
|
|
try:
|
|
replies = guest.script([
|
|
'key w down',
|
|
'key ctrl down',
|
|
'wait 1',
|
|
f'command tp @s {x + 3.5} {y} {z + 0.5} 90 0',
|
|
'state',
|
|
'key ctrl up',
|
|
'mouse left click',
|
|
'state',
|
|
'wait 1',
|
|
'state',
|
|
'wait 1',
|
|
'state',
|
|
'wait 1',
|
|
'state',
|
|
'key w up',
|
|
], every=True)
|
|
horizontal = lambda p: round(math.hypot(p['velocity'][0], p['velocity'][2]), 4)
|
|
states = [r['player'] for r in replies if 'player' in r]
|
|
logs = host('hurtlog').get('players', [])
|
|
victim_log = next((p for p in logs if p.get('name') == host.player), {})
|
|
attack = next(
|
|
(h for h in victim_log.get('hurts', [])
|
|
if h.get('source') == 'minecraft:player_attack' and h.get('hurt')),
|
|
None,
|
|
)
|
|
before_index = next(
|
|
(i for i, p in enumerate(states) if p['sprinting'] and horizontal(p) > 0.1),
|
|
None,
|
|
)
|
|
before = states[before_index] if before_index is not None else None
|
|
after = states[before_index + 1 :] if before_index is not None else []
|
|
finally:
|
|
guest.raw('key', 'ctrl', 'up')
|
|
guest.raw('key', 'w', 'up')
|
|
host('command', command='effect clear @s minecraft:resistance')
|
|
guest.shot(out, 'sprint-hit-slowdown')
|
|
seen = {
|
|
'before': None if before is None else {'sprinting': before['sprinting'], 'speed': horizontal(before)},
|
|
'after': [{'sprinting': p['sprinting'], 'speed': horizontal(p)} for p in after],
|
|
'attack': attack,
|
|
}
|
|
assert attack, f'the guest did not hit the host: {seen}'
|
|
assert before and seen['before']['speed'] > 0.1, f'the guest was not sprinting at the host: {seen}'
|
|
# The click reaches the game on its next frame, so the samples may start before the hit.
|
|
ended = next((i for i, a in enumerate(seen['after']) if not a['sprinting']), None)
|
|
assert ended is not None and not any(a['sprinting'] for a in seen['after'][ended:]), \
|
|
f'the hit did not end the guest\'s sprint: {seen}'
|
|
# The first post-hit sample includes one local movement step after the 0.6 cut. Without the cut,
|
|
# it would remain at sprint speed until the server's later sprint flag update.
|
|
first = seen['after'][ended]
|
|
assert first['speed'] < 0.9 * seen['before']['speed'], f'the guest sprints on after the hit: {seen}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['sprint-hit-slowdown']
|
|
CHECK_FNS['sprint-hit-slowdown'] = check_sprint_hit_slowdown
|
|
|
|
|
|
|
|
def check_fixture_input(host, guest, out, kind, role):
|
|
"""Complement seeded rules with real client input and authoritative observations."""
|
|
player = host if role == 'host' else guest
|
|
hx, y, hz = (int(v // 1) for v in pos_of(host))
|
|
x, z = hx + 6, hz + (-8 if role == 'host' else 8)
|
|
sensor, dispenser, button = (x + 3, y, z + 1), (x, y, z + 3), (x, y, z + 2)
|
|
player('command', command='gamemode survival')
|
|
player('command', command='item replace entity @s weapon.mainhand with minecraft:air')
|
|
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 5} {y - 1} {z + 5} minecraft:stone')
|
|
host('command', command=f'fill {x - 2} {y} {z - 2} {x + 5} {y + 3} {z + 5} minecraft:air')
|
|
start = (x + 0.5, y, z + 0.5)
|
|
player('command', command=f'tp @s {start[0]} {start[1]} {start[2]} 0 0')
|
|
player.until(lambda: pos_of(player), lambda p: math.dist(p, start) < 0.1)
|
|
player.raw('look', 0, 0)
|
|
try:
|
|
if kind == 'sculk':
|
|
player.wait(2) # finish setup movement before installing the listener
|
|
host.raw('setblock', *sensor, 'minecraft:sculk_sensor')
|
|
phase = lambda: host.raw('getblock', *sensor)['block']
|
|
host.until(phase, lambda state: 'sculk_sensor_phase=inactive' in state)
|
|
player.wait(10)
|
|
assert 'sculk_sensor_phase=inactive' in phase(), 'setup activated the sensor before player input'
|
|
player.raw('key', 'w', 'down')
|
|
try:
|
|
player.wait(15) # defined input duration; no wall-clock simulation sleep
|
|
finally:
|
|
player.raw('key', 'w', 'up')
|
|
moved = {'client': math.dist(pos_of(player), start),
|
|
'server': math.dist(server_player(host, player)['position'], start)}
|
|
assert all(d > 1 for d in moved.values()), f'{role} did not walk on both sides: {moved}'
|
|
active = host.until(phase, lambda state: 'sculk_sensor_phase=active' in state, ticks=85)
|
|
for viewer in (host, guest):
|
|
viewer.until(lambda: state_of(viewer, sensor, 'sculk_sensor_phase'), lambda v: v == 'active')
|
|
return {'moved': moved, 'phase': active}
|
|
host.raw('setblock', *dispenser, 'minecraft:dispenser[facing=east]')
|
|
host.raw('container', *dispenser, 'minecraft:diamond', 1)
|
|
host.raw('setblock', *button, 'minecraft:stone_button[face=wall,facing=north]')
|
|
player.until(lambda: player.raw('block', *button)['block'], lambda v: v == 'minecraft:stone_button')
|
|
player.raw('interact', *button)
|
|
slots = host.until(lambda: host.raw('container', *dispenser)['slots'], lambda v: all(s is None for s in v))
|
|
dropped = {}
|
|
for viewer in (host, guest):
|
|
dropped[viewer.name] = viewer.until(
|
|
lambda: [e for e in viewer.raw('entities', 24, 'minecraft:item')['entities']
|
|
if e.get('item', {}).get('item') == 'minecraft:diamond'
|
|
and math.dist(e['pos'], (x + 1.5, y + 0.5, z + 3.5)) < 3],
|
|
lambda drops: len(drops) == 1 and drops[0]['item']['count'] == 1)
|
|
player.shot(out, f'{kind}-input')
|
|
return {'slots': slots, 'drops': dropped}
|
|
finally:
|
|
player.raw('key', 'w', 'up')
|
|
host('command', command=f'fill {x - 2} {y} {z - 2} {x + 5} {y + 3} {z + 5} minecraft:air')
|
|
|
|
|
|
for _kind in ('sculk', 'dispenser'):
|
|
for _role in ('host', 'guest'):
|
|
_name = f'{_kind}_input_{_role}'
|
|
CHECKS.append(_name)
|
|
CHECK_FNS[_name] = lambda h, g, o, kind=_kind, role=_role: check_fixture_input(h, g, o, kind, role)
|
|
|
|
|
|
def check_hero_trading(host, guest, out):
|
|
"""Villager.updateSpecialPrices keeps the trading player's Hero effect: the offer sent to the
|
|
client and the server's payment consumption agree, independently of the other player's effect."""
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
tag = 'hero_trade'
|
|
seen = {}
|
|
cases = [('ordinary', None, 20), ('hero-i', 0, 14), ('hero-iii', 2, 12), ('cleared', None, 20)]
|
|
try:
|
|
host('command', command=f'fill {x - 4} {y - 1} {z - 2} {x + 4} {y - 1} {z + 2} minecraft:stone')
|
|
host('command', command=f'fill {x - 4} {y} {z - 2} {x + 4} {y + 3} {z + 2} minecraft:air')
|
|
for game, other in ((host, guest), (guest, host)):
|
|
back_to_game(game)
|
|
back_to_game(other)
|
|
other('command', command='effect clear @s')
|
|
other('command', command='effect give @s minecraft:hero_of_the_village 600 4 true')
|
|
other('command', command=f'tp @s {x - 3.5} {y} {z + 0.5}')
|
|
game('command', command='gamemode survival')
|
|
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
for label, amplifier, price in cases:
|
|
game('command', command='effect clear @s')
|
|
if amplifier is not None:
|
|
game('command', command=f'effect give @s minecraft:hero_of_the_village 600 {amplifier} true')
|
|
game('command', command='clear @s')
|
|
game('command', command='give @s minecraft:wheat 20')
|
|
# A fresh, fixed offer avoids reputation, demand and level-ups between cases.
|
|
host('command', command=f'summon minecraft:villager {x + 2.5} {y} {z + 0.5} {{Tags:["{tag}"],'
|
|
'NoAI:1b,PersistenceRequired:1b,Xp:1,VillagerData:{type:"minecraft:plains",'
|
|
'profession:"minecraft:farmer",level:1},Offers:{Recipes:[{buy:{id:"minecraft:wheat",count:20},'
|
|
'sell:{id:"minecraft:emerald",count:1},maxUses:16,uses:0,xp:0,priceMultiplier:0f,demand:0}]}}')
|
|
game.wait(20)
|
|
villager = next(e['id'] for e in game.raw('entities', 8, 'minecraft:villager')['entities']
|
|
if math.dist(e['pos'], [x + 2.5, y, z + 0.5]) < 0.5)
|
|
game.raw('interact-entity', villager)
|
|
game.raw('wait-for', 'screen', 'station', '--timeout', 10)
|
|
assert game('screen')['station'] == 'Merchant', f'{game.name}-{label}: no merchant screen'
|
|
# The first offer button is 50 pixels left of the usual menu origin. Selecting it
|
|
# sends SelectTrade on a client; taking the result uses the authoritative menu.
|
|
game.raw('click-gui', -1, 28)
|
|
game.wait(10)
|
|
assert station_slots(game).get(650) == ('minecraft:emerald', 1), f'{game.name}-{label}: no result'
|
|
game.shot(out, f'hero-trading-{label}')
|
|
game.raw('click-slot', 650, 'shift')
|
|
game.wait(10)
|
|
game.raw('key', 'esc', 'tap')
|
|
game.wait(10)
|
|
offer = server_reply(game, f'data get entity @e[type=minecraft:villager,tag={tag},limit=1] '
|
|
'Offers.Recipes[0]', 'following entity data')
|
|
special = re.search(r'specialPrice"?\s*:\s*(-?\d+)', offer)
|
|
case = {'price': 20 + (int(special[1]) if special else 0),
|
|
'server': {item: server_count(game, item) for item in ('wheat', 'emerald')},
|
|
'hero': 'hero_of_the_village' in effect_ids(game)}
|
|
seen[f'{game.name}-{label}'] = case
|
|
want = {'price': price, 'server': {'wheat': 20 - price, 'emerald': 1},
|
|
'hero': amplifier is not None}
|
|
assert case == want, f'{game.name}-{label}: expected {want}, got {case}'
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
game.wait(5)
|
|
finally:
|
|
for game in (host, guest):
|
|
back_to_game(game)
|
|
game('command', command='effect clear @s')
|
|
game('command', command='clear @s')
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
return seen
|
|
|
|
def check_wind_burst_player(host, guest, out):
|
|
"""A mace smash with Wind Burst on another player launches the attacker up (`MaceItem.hurtEnemy`, the
|
|
enchantment's `post_attack` explosion on the attacker), the host smashing a guest and a guest the host (#3830)."""
|
|
result = {}
|
|
spots = apart(host, guest)
|
|
x, y, z = spots['host']
|
|
for label, attacker, victim in (('host-hits-guest', host, guest), ('guest-hits-host', guest, host)):
|
|
attacker('command', command='gamemode survival')
|
|
victim('command', command='gamemode survival')
|
|
attacker('command', command='clear @s')
|
|
attacker('command', command='effect give @s minecraft:resistance 60 4 true')
|
|
victim('command', command='effect clear @s')
|
|
victim('command', command='effect give @s minecraft:instant_health 1 10 true')
|
|
victim('command', command='effect give @s minecraft:absorption 60 19 true')
|
|
attacker('command', command='give @s minecraft:mace[minecraft:enchantments={"minecraft:wind_burst":1}]')
|
|
victim('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
|
|
attacker('command', command=f'tp @s {x + 0.5} {y + 9} {z + 0.5} 0 90')
|
|
attacker.raw('key', '1', 'tap')
|
|
attacker.wait(1)
|
|
replies = attacker.script(['mouse left down'] + ['state', 'mouse left click', 'wait 1'] * 50 + ['mouse left up'], every=True)
|
|
ys = [r['player']['position'][1] for r in replies if r.get('player')]
|
|
hit = next((i for i in range(1, len(ys)) if ys[i] > ys[i - 1] + 0.05), None)
|
|
result[label] = {'start': round(ys[0], 1), 'min': round(min(ys), 2), 'max_after_hit': None if hit is None else round(max(ys[hit:]), 2)}
|
|
assert hit is not None and max(ys[hit:]) > min(ys) + 5, f'{label}: the smash did not launch the attacker: {ys}'
|
|
attacker.raw('mouse', 'left', 'up')
|
|
result[label]['victim'] = [victim('state')['player']['health'], victim('state')['player']['absorption']]
|
|
attacker.wait(40)
|
|
return result
|
|
|
|
|
|
CHECKS += ['wind_burst_player']
|
|
CHECK_FNS['wind_burst_player'] = check_wind_burst_player
|
|
|
|
def check_boat_input_latch(host, guest, out):
|
|
"""AbstractBoat.tick steers with the input LocalPlayer.rideTick latched on the previous tick, so the
|
|
paddle packet describes the motion the boat just made. A boat on land that starts being paddled moves
|
|
one tick after the key goes down, not in the same tick (GrimAC's Simulation flagged the early move)."""
|
|
result = {}
|
|
spots = apart(host, guest)
|
|
for label, player in (('host', host), ('guest', guest)):
|
|
x, y, z = spots[label]
|
|
player('command', command='gamemode survival')
|
|
player('command', command=f'summon minecraft:oak_boat {x + 0.5} {y} {z + 3.5}')
|
|
player.wait(20)
|
|
try:
|
|
boat = board(player, 'oak_boat', (x + 0.5, y, z + 3.5))
|
|
player.wait(10)
|
|
|
|
def at():
|
|
return next(o for o in player('status')['objects'] if o['id'] == boat['id'])['position']
|
|
|
|
steps = []
|
|
for n in range(6):
|
|
player.wait(1 + n % 3) # vary the tick of the frame the key goes down in
|
|
# One ctl process: W goes down and the ticks that follow are counted exactly.
|
|
replies = player.script(['key w down', 'wait 0', 'status', 'wait 1', 'status', 'wait 1', 'status', 'key w up'],
|
|
every=True)
|
|
marks = [r for r in replies if 'objects' in r]
|
|
ticks = [r['ticks'] for r in replies if 'ticks' in r]
|
|
spots_now = [next(o for o in r['objects'] if o['id'] == boat['id'])['position'] for r in marks]
|
|
distances = [math.dist((a[0], a[2]), (spots_now[0][0], spots_now[0][2])) for a in spots_now]
|
|
steps.append({'ticks': ticks, 'moved': [round(d, 4) for d in distances]})
|
|
player.wait(40)
|
|
# W is read in the first tick after it goes down and steers the boat from the second on: a
|
|
# boat at rest on land moves 0.04, then 0.064 (land friction 0.6, +0.04 each tick). A boat
|
|
# steering in the tick W is read in would be a tick ahead.
|
|
def travelled(n):
|
|
v, total = 0.0, 0.0
|
|
for _ in range(max(0, n - 1)):
|
|
v = v * 0.6 + 0.04
|
|
total += v
|
|
return total
|
|
want = [round(travelled(t - ticks[0]), 4) for t in (ticks[0], ticks[2], ticks[4])]
|
|
assert all(abs(d - w) < 0.002 for d, w in zip(distances, want)), f'{label}: {steps} want {want}'
|
|
result[label] = steps
|
|
finally:
|
|
player.raw('key', 'w', 'up')
|
|
player.raw('key', 'shift', 'tap')
|
|
player.wait(5)
|
|
return result
|
|
|
|
|
|
CHECKS += ['boat_input_latch']
|
|
CHECK_FNS['boat_input_latch'] = check_boat_input_latch
|
|
|
|
|
|
def check_hook_smooth(host, guest, out):
|
|
"""FishingHook.tick runs on a client between the tracker's updates (every 5 ticks): a cast hook moves
|
|
every tick of its flight, not in jumps with two still ticks between them (#3781)."""
|
|
result = {}
|
|
apart(host, guest)
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
host('command', command='weather clear')
|
|
for game, dz in ((host, -5), (guest, 5)):
|
|
x, z = hx, hz + dz
|
|
host('command', command=f'fill {x - 1} {hy - 1} {z - 1} {x + 14} {hy - 1} {z + 1} minecraft:stone')
|
|
host('command', command=f'fill {x - 1} {hy} {z - 1} {x + 14} {hy + 4} {z + 1} minecraft:air')
|
|
game('command', command='gamemode survival')
|
|
game('command', command='clear @s')
|
|
game('command', command='item replace entity @s weapon.mainhand with minecraft:fishing_rod')
|
|
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} -90 0')
|
|
game.wait(10)
|
|
game.raw('look-at', f'{x + 8.5:.2f}', f'{hy + 1:.2f}', f'{z + 0.5:.2f}')
|
|
replies = game.script(['use-item'] + ['status', 'wait 1'] * 30, every=True)
|
|
seq = []
|
|
for r in replies:
|
|
hooks = [o for o in r.get('projectiles', []) if o.get('kind') == 'minecraft:fishing_bobber' and o.get('owner') == 0]
|
|
if hooks:
|
|
seq.append(hooks[0]['position'])
|
|
steps = [round(math.dist(a, b), 3) for a, b in zip(seq, seq[1:])]
|
|
result[game.name] = steps
|
|
game.raw('use-item')
|
|
game.wait(5)
|
|
# In flight every tick moves it (a hook eased between updates stood still two ticks of five).
|
|
assert all(d > 0.05 for d in steps[:6]), f'{game.name}: the hook moved in jumps: {steps}'
|
|
return result
|
|
|
|
|
|
CHECKS += ['hook_smooth']
|
|
CHECK_FNS['hook_smooth'] = check_hook_smooth
|
|
|
|
|
|
def check_merchant_close(host, guest, out):
|
|
"""AbstractVillager.stillValid: a trade screen closes when its villager is out of reach or dead, and
|
|
the payment slots go back to the player as on a normal close; in range it stays open."""
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
tag = 'close_trade'
|
|
seen = {}
|
|
# (label, mode, tp x offset from the player's start, dies?, screen still open?)
|
|
cases = [('survival-near', 'survival', 0, False, True), ('survival-8', 'survival', -6.0, False, False),
|
|
('creative-8', 'creative', -6.0, False, True), ('creative-far', 'creative', -15.0, False, False),
|
|
('killed', 'survival', 0, True, False)]
|
|
try:
|
|
host('command', command=f'fill {x - 20} {y - 1} {z - 2} {x + 8} {y - 1} {z + 2} minecraft:stone')
|
|
host('command', command=f'fill {x - 20} {y} {z - 2} {x + 8} {y + 3} {z + 2} minecraft:air')
|
|
for game, other in ((host, guest), (guest, host)):
|
|
back_to_game(game)
|
|
back_to_game(other)
|
|
other('command', command=f'tp @s {x + 6.5} {y} {z + 1.5}')
|
|
for label, mode, dx, dies, stays in cases:
|
|
game('command', command=f'gamemode {mode}')
|
|
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
game('command', command='clear @s')
|
|
game('command', command='give @s minecraft:wheat 20')
|
|
host('command', command=f'summon minecraft:villager {x + 2.5} {y} {z + 0.5} {{Tags:["{tag}"],'
|
|
'NoAI:1b,PersistenceRequired:1b,Xp:1,VillagerData:{type:"minecraft:plains",'
|
|
'profession:"minecraft:farmer",level:1},Offers:{Recipes:[{buy:{id:"minecraft:wheat",count:20},'
|
|
'sell:{id:"minecraft:emerald",count:1},maxUses:16,uses:0,xp:0,priceMultiplier:0f,demand:0}]}}')
|
|
game.wait(20)
|
|
villager = next(e['id'] for e in game.raw('entities', 8, 'minecraft:villager')['entities']
|
|
if math.dist(e['pos'], [x + 2.5, y, z + 0.5]) < 0.5)
|
|
game.raw('interact-entity', villager)
|
|
game.raw('wait-for', 'screen', 'station', '--timeout', 10)
|
|
assert game('screen')['station'] == 'Merchant', f'{game.name}-{label}: no merchant screen'
|
|
# Selecting the offer moves the wheat into the payment slot.
|
|
game.raw('click-gui', -1, 28)
|
|
game.wait(10)
|
|
paid = station_slots(game).get(600)
|
|
assert paid == ('minecraft:wheat', 20), f'{game.name}-{label}: payment slot {paid}'
|
|
if dies:
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
else:
|
|
game('command', command=f'tp @s {x + 0.5 + dx} {y} {z + 0.5}')
|
|
game.wait(20)
|
|
screen = game('state').get('screen')
|
|
open_now = screen not in (None, 'playing')
|
|
case = {'open': open_now}
|
|
if not stays:
|
|
game.wait(10)
|
|
case['wheat'] = server_count(game, 'wheat')
|
|
seen[f'{game.name}-{label}'] = case
|
|
want = {'open': stays} if stays else {'open': False, 'wheat': 20}
|
|
assert case == want, f'{game.name}-{label}: expected {want}, got {case} (screen {screen})'
|
|
back_to_game(game)
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
game.wait(5)
|
|
finally:
|
|
for game in (host, guest):
|
|
back_to_game(game)
|
|
game('command', command='gamemode survival')
|
|
game('command', command='clear @s')
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
return seen
|
|
|
|
|
|
CHECKS += ['merchant-close']
|
|
CHECK_FNS.update({'merchant-close': check_merchant_close})
|
|
|
|
|
|
def check_merchant_reselect(host, guest, out):
|
|
"""AbstractButton.keyPressed: the offer button clicked last keeps focus through slot clicks, and Space
|
|
(key repeats too) or Enter presses it again, so the emptied payment slot refills after every trade."""
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
tag = 'reselect_trade'
|
|
seen = {}
|
|
try:
|
|
host('command', command=f'fill {x - 4} {y - 1} {z - 2} {x + 4} {y - 1} {z + 2} minecraft:stone')
|
|
host('command', command=f'fill {x - 4} {y} {z - 2} {x + 4} {y + 3} {z + 2} minecraft:air')
|
|
for game, other in ((host, guest), (guest, host)):
|
|
back_to_game(game)
|
|
back_to_game(other)
|
|
other('command', command=f'tp @s {x - 3.5} {y} {z + 0.5}')
|
|
game('command', command='gamemode survival')
|
|
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
game('command', command='clear @s')
|
|
host('command', command=f'summon minecraft:villager {x + 2.5} {y} {z + 0.5} {{Tags:["{tag}"],'
|
|
'NoAI:1b,PersistenceRequired:1b,Xp:1,VillagerData:{type:"minecraft:plains",'
|
|
'profession:"minecraft:farmer",level:1},Offers:{Recipes:[{buy:{id:"minecraft:wheat",count:20},'
|
|
'sell:{id:"minecraft:emerald",count:1},maxUses:16,uses:0,xp:0,priceMultiplier:0f,demand:0}]}}')
|
|
game.wait(20)
|
|
villager = next(e['id'] for e in game.raw('entities', 8, 'minecraft:villager')['entities']
|
|
if math.dist(e['pos'], [x + 2.5, y, z + 0.5]) < 0.5)
|
|
game.raw('interact-entity', villager)
|
|
game.raw('wait-for', 'screen', 'station', '--timeout', 10)
|
|
assert game('screen')['station'] == 'Merchant', f'{game.name}: no merchant screen'
|
|
steps = []
|
|
# tryMoveItems moves all the wheat it finds, so each round gives one trade's worth.
|
|
for press in ('click', 'space repeat', 'enter tap'):
|
|
game('command', command='give @s minecraft:wheat 20')
|
|
game.wait(5)
|
|
if press == 'click':
|
|
game.raw('click-gui', -1, 28)
|
|
else:
|
|
game.raw('key', *press.split())
|
|
game.wait(10)
|
|
paid = station_slots(game).get(600)
|
|
steps.append({'press': press, 'paid': paid})
|
|
assert paid == ('minecraft:wheat', 20), f'{game.name}-{press}: payment slot {paid}'
|
|
game.raw('click-slot', 650, 'shift')
|
|
game.wait(10)
|
|
left = station_slots(game).get(600)
|
|
assert left is None, f'{game.name}-{press}: payment left after the trade: {left}'
|
|
game.shot(out, 'merchant-reselect')
|
|
game.raw('key', 'esc', 'tap')
|
|
game.wait(10)
|
|
case = {'steps': steps, 'server': {item: server_count(game, item) for item in ('wheat', 'emerald')}}
|
|
seen[game.name] = case
|
|
assert case['server'] == {'wheat': 0, 'emerald': 3}, f'{game.name}: expected 3 trades, got {case}'
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
game.wait(5)
|
|
finally:
|
|
for game in (host, guest):
|
|
back_to_game(game)
|
|
game('command', command='clear @s')
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
return seen
|
|
|
|
|
|
CHECKS += ['merchant-reselect']
|
|
CHECK_FNS['merchant-reselect'] = check_merchant_reselect
|
|
|
|
|
|
def check_riptide_rain(host, guest, out):
|
|
"""TridentItem.use/releaseUsing: a Riptide trident launches its user in rain, for the host and for a guest
|
|
(a server's client answers `isInRain` from its own chunks and weather), with an elytra worn or not (#3458)."""
|
|
result = {}
|
|
spots = apart(host, guest)
|
|
x, y, z = spots['host']
|
|
host('command', command=f'fill {x} {y + 4} {z} {x} {y + 120} {z} minecraft:air')
|
|
host('command', command='weather rain')
|
|
for label, player in (('host', host), ('guest', guest)):
|
|
player('command', command='gamemode survival')
|
|
player('command', command='clear @s')
|
|
player('command', command='give @s minecraft:trident[minecraft:enchantments={"minecraft:riptide":3}]')
|
|
try:
|
|
for case, elytra in (('plain', False), ('elytra', True)):
|
|
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
|
|
player('command', command='item replace entity @s armor.chest with '
|
|
+ ('minecraft:elytra' if elytra else 'minecraft:air'))
|
|
player.raw('key', '1', 'tap')
|
|
player.wait(25)
|
|
start = pos_of(player)
|
|
replies = player.script(['mouse right down', 'wait 25', 'mouse right up', 'wait 12', 'state'], every=True)
|
|
moved = round(math.dist(start, replies[-1]['player']['position']), 2)
|
|
result[f'{label}-{case}'] = moved
|
|
assert moved > 4, f'{label}-{case}: the Riptide trident in rain moved the player {moved} blocks: {result}'
|
|
finally:
|
|
player('command', command='clear @s')
|
|
player('command', command='item replace entity @s armor.chest with minecraft:air')
|
|
player.raw('key', 'w', 'up')
|
|
host('command', command='weather clear')
|
|
return result
|
|
|
|
|
|
CHECKS += ['riptide_rain']
|
|
CHECK_FNS['riptide_rain'] = check_riptide_rain
|
|
|
|
|
|
def check_f3_density(host, guest, out):
|
|
"""NoiseBasedChunkGenerator.addDebugScreenInfo: DebugEntryChunkGeneration adds the `Density` line to F3 where
|
|
the game runs the level's generator (the host's own server), and not on a LAN guest."""
|
|
seen = {}
|
|
for label, player in (('host', host), ('guest', guest)):
|
|
player('command', command='gamemode creative')
|
|
player('command', command='tp @s 40.5 90 -30.5')
|
|
player.wait(80)
|
|
player.raw('key', 'f3', 'tap')
|
|
player.wait(10)
|
|
seen[label] = player('status')['debug']['overlay']
|
|
player.shot(out, f'{label}-f3')
|
|
player.raw('key', 'f3', 'tap')
|
|
player.wait(5)
|
|
assert seen == {'host': True, 'guest': True}, f'F3 did not open: {seen}'
|
|
return seen
|
|
|
|
CHECKS += ['f3_density']
|
|
CHECK_FNS['f3_density'] = check_f3_density
|
|
|
|
|
|
def check_explorer_map(host, guest, out):
|
|
"""TreasureMapForEmeralds / ExplorationMapFunction with skip_existing_chunks (a trade's default):
|
|
findNearestMapStructure with createReference adds a reference to the start it finds (StructureStart.addReference,
|
|
at most one), so the next map from the same place points at another structure. Three cartographers' offers
|
|
are made, one after the other: each adds as many new starts as the first did, none is pointed at twice."""
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
file = host.work / 'saves' / 'Lan' / 'data' / 'horizon_structure_references_minecraft_overworld.json'
|
|
host('command', command=f'fill {x - 4} {y - 1} {z - 2} {x + 4} {y - 1} {z + 2} minecraft:stone')
|
|
host('command', command=f'fill {x - 4} {y} {z - 2} {x + 4} {y + 3} {z + 2} minecraft:air')
|
|
guest('command', command=f'tp @s {x - 3.5} {y} {z + 0.5}')
|
|
host('command', command='gamemode survival')
|
|
host('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
sizes = []
|
|
for n in range(3):
|
|
host('command', command=f'summon minecraft:villager {x + 2.5} {y} {z + 0.5} {{Tags:["cartographer{n}"],'
|
|
'NoAI:1b,PersistenceRequired:1b,Xp:1,VillagerData:{type:"minecraft:plains",'
|
|
'profession:"minecraft:cartographer",level:5}}')
|
|
host.wait(20)
|
|
villager = next(e['id'] for e in host.raw('entities', 8, 'minecraft:villager')['entities']
|
|
if math.dist(e['pos'], [x + 2.5, y, z + 0.5]) < 0.5)
|
|
host.raw('interact-entity', villager)
|
|
host.raw('wait-for', 'screen', 'station', '--timeout', 10)
|
|
host.wait(20)
|
|
if n == 0:
|
|
host.shot(out, 'explorer-map-trades')
|
|
host.raw('key', 'esc', 'tap')
|
|
host.wait(10)
|
|
host('command', command=f'kill @e[tag=cartographer{n}]')
|
|
sizes.append(len(json.loads(file.read_text())) if file.exists() else 0)
|
|
assert sizes[0] > 0, f'the first cartographer pointed no map at a structure: {sizes}'
|
|
assert sizes == [sizes[0] * k for k in (1, 2, 3)], f'a map pointed at a structure twice: {sizes}'
|
|
return {'starts_after_each_cartographer': sizes, 'pointed_at': json.loads(file.read_text())}
|
|
|
|
|
|
CHECKS += ['explorer_map']
|
|
CHECK_FNS['explorer_map'] = check_explorer_map
|
|
def check_sulfur_cube_power(host, guest, out):
|
|
"""SulfurCube.primeWhenOnPoweredPosition: an explosive cube standing in or beside redstone power lights
|
|
its fuse (getBestOwnOrNeighbourSignal), then explodes when the fuse is out."""
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
tag = 'power_cube'
|
|
result = {}
|
|
host('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 3} {y + 3} {z + 3} minecraft:stone')
|
|
host('command', command=f'fill {x - 2} {y} {z - 2} {x + 2} {y + 2} {z + 2} minecraft:air')
|
|
host('command', command=f'fill {x - 2} {y + 3} {z - 2} {x + 2} {y + 3} {z + 2} minecraft:glowstone')
|
|
guest('command', command=f'tp @s {x + 0.5} {y} {z + 1.5} 180 25')
|
|
host('command', command=f'tp @s {x + 40} {y} {z + 40}')
|
|
guest.raw('key', 'f1', 'tap')
|
|
host('command', command='gamerule minecraft:mob_griefing false')
|
|
host('command', command=f'summon minecraft:sulfur_cube {x + 0.5} {y} {z - 1.5} '
|
|
f'{{Tags:["{tag}"],Size:1,PersistenceRequired:1b,equipment:{{body:{{id:"minecraft:tnt",count:1}}}}}}')
|
|
host.wait(40)
|
|
calm = guest('entities', radius=12, type='sulfur_cube')['entities']
|
|
result['calm'] = calm
|
|
assert len(calm) == 1, f'the cube is missing: {result}'
|
|
guest.shot(out, 'sulfur-cube-calm')
|
|
host('command', command=f'setblock {x} {y - 1} {z - 1} minecraft:redstone_block')
|
|
host('command', command=f'setblock {x} {y - 1} {z - 2} minecraft:redstone_block')
|
|
host('command', command=f'setblock {x + 1} {y - 1} {z - 1} minecraft:redstone_block')
|
|
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 2} {y - 1} {z + 2} minecraft:redstone_block')
|
|
host.wait(8)
|
|
guest.shot(out, 'sulfur-cube-primed')
|
|
data = host('command', command=f'data get entity @e[tag={tag},limit=1]')['chat'][0]
|
|
result['fuse'] = data.split('fuse: ')[1].split(',')[0]
|
|
assert int(result['fuse']) > 0 and 'Invulnerable: 1b' in data, f'the powered cube is not primed: {result}'
|
|
host.wait(100)
|
|
guest.shot(out, 'sulfur-cube-flash')
|
|
host.wait(40)
|
|
gone = guest('entities', radius=12, type='sulfur_cube')['entities']
|
|
result['after'] = gone
|
|
assert not gone, f'the powered cube did not explode: {result}'
|
|
return result
|
|
|
|
|
|
CHECKS += ['sulfur_cube_power']
|
|
CHECK_FNS['sulfur_cube_power'] = check_sulfur_cube_power
|
|
def check_parrot_follows_mob(host, guest, out):
|
|
"""FollowMobGoal(1.0, 3.0, 7.0), priority 3 of a parrot: it flies after another kind of mob within 7 blocks
|
|
and stops about 3 blocks from it, instead of only wandering."""
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
tag = 'parrot_follow'
|
|
host('command', command=f'fill {x - 12} {y - 1} {z - 12} {x + 12} {y - 1} {z + 12} minecraft:stone')
|
|
host('command', command=f'fill {x - 12} {y} {z - 12} {x + 12} {y + 8} {z + 12} minecraft:air')
|
|
guest('command', command=f'tp @s {x + 9.5} {y} {z + 9.5} 135 25')
|
|
host('command', command=f'tp @s {x - 9.5} {y} {z - 9.5}')
|
|
host('command', command=f'summon minecraft:cow {x + 0.5} {y} {z + 0.5} {{Tags:["{tag}"],NoAI:1b,PersistenceRequired:1b}}')
|
|
host('command', command=f'summon minecraft:parrot {x + 6.5} {y + 2} {z + 0.5} {{Tags:["{tag}"],PersistenceRequired:1b,Variant:0}}')
|
|
host.wait(60)
|
|
distances = []
|
|
for _ in range(12):
|
|
host.wait(20)
|
|
found = guest('entities', radius=24)['entities']
|
|
cow = next(e for e in found if e['type'] == 'minecraft:cow')
|
|
parrot = next(e for e in found if e['type'] == 'minecraft:parrot')
|
|
distances.append(round(math.dist(cow['pos'], parrot['pos']), 2))
|
|
if len(distances) == 6:
|
|
guest.shot(out, 'parrot-follows-cow')
|
|
distances.sort()
|
|
median = distances[len(distances) // 2]
|
|
assert median <= 3.5, f'the parrot did not stay by the cow: {distances}'
|
|
return {'distances_sorted': distances, 'median': median}
|
|
|
|
|
|
CHECKS += ['parrot_follows_mob']
|
|
CHECK_FNS['parrot_follows_mob'] = check_parrot_follows_mob
|
|
|
|
CHECKS += ['hero-trading']
|
|
CHECK_FNS.update({'hero-trading': check_hero_trading})
|
|
|
|
|
|
def check_cure_discount(host, guest, out):
|
|
"""#3306: ZombieVillager.finishConversion credits the player who fed the golden apple
|
|
(conversionStarter, level.getPlayerByUUID): the ZOMBIE_VILLAGER_CURED gossip (+20 major and +25
|
|
minor positive, reputation 125) lowers that player's price by floor(125 * 0.05) = 6, for the
|
|
host on its server thread and for a guest alike."""
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
tag = 'cure_trade'
|
|
at = (x + 2.5, y, z + 0.5)
|
|
seen = {}
|
|
try:
|
|
host('command', command=f'fill {x - 4} {y - 1} {z - 2} {x + 4} {y - 1} {z + 2} minecraft:stone')
|
|
host('command', command=f'fill {x - 4} {y} {z - 2} {x + 4} {y + 3} {z + 2} minecraft:air')
|
|
host('command', command='time set midnight')
|
|
for game, other in ((host, guest), (guest, host)):
|
|
back_to_game(game)
|
|
back_to_game(other)
|
|
other('command', command=f'tp @s {x - 3.5} {y} {z + 0.5}')
|
|
game('command', command='gamemode survival')
|
|
game('command', command='effect clear @s')
|
|
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} -90 0')
|
|
game('command', command='clear @s')
|
|
game('command', command='give @s minecraft:golden_apple')
|
|
game.wait(10)
|
|
game.raw('select', 'minecraft:golden_apple')
|
|
host('command', command=f'summon minecraft:zombie_villager {at[0]} {at[1]} {at[2]} '
|
|
f'{{Tags:["{tag}"],NoAI:1b,PersistenceRequired:1b,Xp:1,VillagerData:{{type:"minecraft:plains",'
|
|
'profession:"minecraft:farmer",level:1},Offers:{Recipes:[{buy:{id:"minecraft:wheat",count:20},'
|
|
'sell:{id:"minecraft:emerald",count:1},maxUses:16,uses:0,xp:0,priceMultiplier:0.05f,demand:0}]}}')
|
|
host('command', command=f'effect give @e[tag={tag}] minecraft:weakness 60 0')
|
|
game.wait(20)
|
|
zombie = next(e['id'] for e in game.raw('entities', 8, 'minecraft:zombie_villager')['entities']
|
|
if math.dist(e['pos'], at) < 0.5)
|
|
# ZombieVillager.mobInteract: the apple starts the cure; then it is cut short.
|
|
game.raw('interact-entity', zombie)
|
|
game.wait(10)
|
|
# Horizon counts the cure down in the AI step, so NoAI goes for the last tick.
|
|
host('command', command=f'data merge entity @e[tag={tag},limit=1] {{ConversionTime:1,NoAI:0b}}')
|
|
villager = None
|
|
for _ in range(30):
|
|
game.wait(5)
|
|
villager = next((e['id'] for e in game.raw('entities', 8, 'minecraft:villager')['entities']
|
|
if math.dist(e['pos'], at) < 2), None)
|
|
if villager is not None:
|
|
break
|
|
assert villager is not None, f'{game.name}: the zombie villager was not cured'
|
|
near = f'x={at[0]},y={at[1]},z={at[2]},distance=..3,sort=nearest,limit=1'
|
|
host('command', command=f'data merge entity @e[type=minecraft:villager,{near}] '
|
|
f'{{NoAI:1b,Tags:["{tag}"]}}')
|
|
game('command', command='clear @s')
|
|
game('command', command='give @s minecraft:wheat 20')
|
|
game.wait(10)
|
|
game.raw('interact-entity', villager)
|
|
game.raw('wait-for', 'screen', 'station', '--timeout', 10)
|
|
assert game('screen')['station'] == 'Merchant', f'{game.name}: no merchant screen'
|
|
game.raw('click-gui', -1, 28)
|
|
game.wait(10)
|
|
assert station_slots(game).get(650) == ('minecraft:emerald', 1), f'{game.name}: no result'
|
|
game.shot(out, f'cure-discount-{game.name}')
|
|
game.raw('click-slot', 650, 'shift')
|
|
game.wait(10)
|
|
game.raw('key', 'esc', 'tap')
|
|
game.wait(10)
|
|
case = {item: server_count(game, item) for item in ('wheat', 'emerald')}
|
|
seen[game.name] = case
|
|
want = {'wheat': 20 - 14, 'emerald': 1}
|
|
assert case == want, f'{game.name}: expected {want}, got {case}'
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
game.wait(5)
|
|
finally:
|
|
for game in (host, guest):
|
|
back_to_game(game)
|
|
game('command', command='clear @s')
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
host('command', command='time set day')
|
|
return seen
|
|
|
|
|
|
CHECKS += ['cure-discount']
|
|
CHECK_FNS.update({'cure-discount': check_cure_discount})
|
|
|
|
|
|
def check_spectator_travel(host, guest, out):
|
|
"""Player.tick's spectator noPhysics survives a replacement body after dimension travel (#1299)."""
|
|
players = (host, guest)
|
|
seen = {}
|
|
for game in players:
|
|
home = pos_of(game)
|
|
game('command', command='gamemode spectator')
|
|
game.wait(10)
|
|
try:
|
|
for dimension in (NETHER, OVERWORLD):
|
|
game('command', command=f'execute in {dimension} run tp @s .5 102 .5 0 0')
|
|
there = wait_dimension(game, dimension, 600)
|
|
assert there.get('dimension') == dimension, f'{game.name} stayed in {there.get("dimension")}'
|
|
# A replacement body arrives before its chunks; fill needs the whole corridor loaded.
|
|
for _ in range(30):
|
|
if all(game.raw('block', *pos).get('loaded') for pos in ((-2, 101, -1), (2, 105, 16))):
|
|
break
|
|
game.wait(20)
|
|
game('command', command='fill -2 101 -1 2 105 16 minecraft:air')
|
|
game('command', command='fill -2 101 3 2 105 3 minecraft:stone')
|
|
game.wait(10)
|
|
wall = game.raw('block', 0, 102, 3)
|
|
assert wall.get('loaded') and wall.get('block') == 'minecraft:stone', f'wall: {wall}'
|
|
label = f'spectator-{dimension.removeprefix("minecraft:")}'
|
|
game.shot(out, f'{label}-before')
|
|
game.raw('key', 'w', 'down')
|
|
game.wait(20)
|
|
game.raw('key', 'w', 'up')
|
|
game.wait(10)
|
|
state = game('state')
|
|
client = state['player']
|
|
server = server_player(host, game)
|
|
seen[f'{game.name}-{dimension}'] = {'client': client['position'], 'server': server['position']}
|
|
game.shot(out, f'{label}-after')
|
|
assert state.get('gamemode') == 'spectator' and client['flying'], f'{game.name} lost spectator flight'
|
|
assert client['position'][2] > 4 and server['position'][2] > 4, \
|
|
f'{game.name} collides after travel to {dimension}: {seen}'
|
|
game('command', command='fill -2 101 3 2 105 3 minecraft:air')
|
|
finally:
|
|
game.raw('key', 'w', 'up')
|
|
game('command', command=f'execute in {OVERWORLD} run tp @s {home[0]:.2f} {home[1]:.2f} {home[2]:.2f}')
|
|
wait_dimension(game, OVERWORLD, 600)
|
|
game('command', command='gamemode survival')
|
|
game.wait(20)
|
|
return seen
|
|
|
|
|
|
CHECKS += ['spectator-travel']
|
|
CHECK_FNS.update({'spectator-travel': check_spectator_travel})
|
|
|
|
|
|
def check_villagers(host, guest, out):
|
|
"""Villager.DATA_VILLAGER_DATA and ZombieVillager's reach the guest: summoned villagers show
|
|
their type, profession and level, and an unemployed villager that takes a composter placed
|
|
afterwards turns into a farmer on the guest too (VillagerProfessionLayer draws from that data)."""
|
|
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
|
|
x, y, z = gx, gy, gz + 5
|
|
tag = 'vs_villager'
|
|
summoned = [('villager', -2, 'desert', 'librarian', 3), ('villager', 0, 'snow', 'weaponsmith', 5),
|
|
('zombie_villager', 2, 'taiga', 'cleric', 2), ('villager', 4, 'plains', 'none', 1)]
|
|
|
|
def villagers(game):
|
|
return {e['id']: e['villager'] for e in game.raw('entities', 16)['entities'] if 'villager' in e}
|
|
|
|
seen = {}
|
|
try:
|
|
host('command', command='time set 2000')
|
|
host('command', command=f'fill {x - 4} {y - 1} {z - 3} {x + 6} {y - 1} {z + 4} minecraft:stone')
|
|
host('command', command=f'fill {x - 4} {y} {z - 3} {x + 6} {y + 3} {z + 4} minecraft:air')
|
|
for kind, dx, t, p, level in summoned:
|
|
# The unemployed one keeps its AI to take the job.
|
|
ai = '' if p == 'none' else 'NoAI:1b,Invulnerable:1b,'
|
|
host('command', command=f'summon minecraft:{kind} {x + dx + 0.5} {y} {z + 0.5} {{Tags:["{tag}"],{ai}'
|
|
f'PersistenceRequired:1b,Rotation:[180f,0f],'
|
|
f'VillagerData:{{type:"minecraft:{t}",profession:"minecraft:{p}",level:{level}}}}}')
|
|
guest.wait(20)
|
|
seen['summoned'] = {'host': villagers(host), 'guest': villagers(guest)}
|
|
host('command', command=f'setblock {x + 5} {y} {z + 2} minecraft:composter')
|
|
for waited in range(10, 1210, 10):
|
|
guest.wait(10)
|
|
farmers = [i for i, v in villagers(host).items() if v['profession'] == 'farmer']
|
|
if farmers:
|
|
seen['ticks to the job'] = waited
|
|
break
|
|
guest.wait(10)
|
|
seen['job'] = {'host': villagers(host), 'guest': villagers(guest)}
|
|
guest('command', command=f'tp @s {x + 1.5} {y} {z - 2.5} 0 10')
|
|
guest.wait(10)
|
|
guest.shot(out, 'villagers')
|
|
finally:
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
host('command', command=f'fill {x - 4} {y} {z - 3} {x + 6} {y + 3} {z + 4} minecraft:air')
|
|
host('command', command='time set day')
|
|
host_view, guest_view = seen['summoned']['host'], seen['summoned']['guest']
|
|
for _, _, t, p, level in summoned:
|
|
want = {'type': t, 'profession': p, 'level': level}
|
|
ids = [i for i, v in host_view.items() if v == want]
|
|
assert ids, f'the host lacks {want}: {seen}'
|
|
assert guest_view.get(ids[0]) == want, f'the guest sees {guest_view.get(ids[0])} for {want}: {seen}'
|
|
farmers = [i for i, v in seen['job']['host'].items() if v['profession'] == 'farmer']
|
|
assert farmers, f'no villager took the job: {seen}'
|
|
assert seen['job']['guest'].get(farmers[0]) == seen['job']['host'][farmers[0]], \
|
|
f'the guest missed the new job: {seen}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['villagers']
|
|
CHECK_FNS.update(villagers=check_villagers)
|
|
|
|
|
|
def check_stopsound(host, guest, out):
|
|
"""StopSoundCommand sends each target ClientboundStopSoundPacket: a player's own /stopsound by
|
|
source and by event reaches its own client's sound engine, and the host's /stopsound of the
|
|
guest reaches the guest's (sounds stopped, counted in the status as `heard` is, audio or not)."""
|
|
event = 'minecraft:music_disc.cat'
|
|
|
|
def stopped(game):
|
|
return game('status')['stopped_sounds']
|
|
|
|
seen = {}
|
|
for game in (host, guest):
|
|
before = stopped(game)
|
|
game('command', command='stopsound @s record')
|
|
game('command', command=f'stopsound @s * {event}')
|
|
game.wait(10)
|
|
after = stopped(game)
|
|
seen[game.name] = {k: after.get(k, 0) - before.get(k, 0) for k in after}
|
|
assert seen[game.name].get('record *') == 1 and seen[game.name].get(f'* {event}') == 1, \
|
|
f'{game.name}: its own /stopsound did not reach its client: {seen}'
|
|
before = {g.name: stopped(g) for g in (host, guest)}
|
|
host('command', command=f'stopsound {GUEST_NAME}')
|
|
guest.wait(10)
|
|
for game in (host, guest):
|
|
after = stopped(game)
|
|
seen[f'{game.name}-by-host'] = after.get('* *', 0) - before[game.name].get('* *', 0)
|
|
assert seen['guest-by-host'] == 1 and seen['host-by-host'] == 0, \
|
|
f"the host's /stopsound of the guest stopped the wrong client: {seen}"
|
|
return seen
|
|
|
|
|
|
CHECKS += ['stopsound']
|
|
CHECK_FNS.update(stopsound=check_stopsound)
|
|
|
|
|
|
def check_riding_teleport(host, guest, out):
|
|
"""#1473: a chat /tp dismounts each player and leaves the horse behind in both modes;
|
|
teleporting another player exercises the guest-target command path too."""
|
|
spots = apart(host, guest)
|
|
result = {}
|
|
for label, player, source in (('host', host, None), ('guest', guest, None),
|
|
('guest-targeted', guest, host)):
|
|
x, y, z = spots['host' if player is host else 'guest']
|
|
horse_at = (x + 0.5, y, z + 2.5)
|
|
destination = (x + 3.5, y, z + 5.5)
|
|
tag = 'tp_horse'
|
|
try:
|
|
player('command', command='clear @s')
|
|
player('command', command='gamemode survival')
|
|
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
|
|
player('command', command='item replace entity @s weapon.mainhand with minecraft:saddle')
|
|
player('command', command=f'summon minecraft:horse {horse_at[0]} {y} {horse_at[2]} '
|
|
f'{{Tame:1b,NoAI:1b,NoGravity:1b,Tags:["{tag}"]}}')
|
|
player.wait(5)
|
|
horses = [m for m in player.raw('mobs', 16)['near']
|
|
if m['kind'] == 'minecraft:horse' and m['health'] > 0]
|
|
horse = next((m for m in horses if math.dist(m['pos'], horse_at) < 0.1), None)
|
|
assert horse, f'{label}: no live horse at {horse_at}: {horses}'
|
|
player.raw('look-at', horse_at[0], y + 0.8, horse_at[2])
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(5)
|
|
player('command', command='item replace entity @s weapon.mainhand with minecraft:air')
|
|
player.wait(2)
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(5)
|
|
assert player('status').get('riding') == horse['id'], f'{label}: not riding the horse'
|
|
player.shot(out, f'riding-tp-{label}-before')
|
|
coordinates = ' '.join(map(str, destination))
|
|
if source is None:
|
|
player.raw('key', 't', 'tap')
|
|
player.raw('type', f'/tp @s {coordinates}')
|
|
player.raw('key', 'enter', 'tap')
|
|
else:
|
|
source('command', command=f'tp {GUEST_NAME} {coordinates}')
|
|
player.wait(60)
|
|
player.shot(out, f'riding-tp-{label}-after')
|
|
seen = {'riding': player('status').get('riding'), 'client': pos_of(player),
|
|
'server': server_player(host, player)['position'],
|
|
'horse': mob_seen(player, horse['id'])['pos'],
|
|
'other_horse': mob_seen(guest if player is host else host, horse['id'])['pos']}
|
|
assert seen['riding'] is None, f'{label}: still mounted after /tp: {seen}'
|
|
assert all(math.dist(seen[side], destination) < 0.01 for side in ('client', 'server')), \
|
|
f'{label}: /tp did not keep its destination after 60 ticks: {seen}'
|
|
assert all(math.dist(seen[side], horse_at) < 0.01 for side in ('horse', 'other_horse')), \
|
|
f'{label}: /tp moved the horse: {seen}'
|
|
result[label] = seen
|
|
finally:
|
|
back_to_game(player)
|
|
player('command', command=f'kill @e[tag={tag}]')
|
|
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
|
|
# The next case uses this spot again: let the previous horse's death animation finish.
|
|
player.wait(25)
|
|
return result
|
|
|
|
|
|
CHECKS += ['riding_teleport']
|
|
CHECK_FNS.update(riding_teleport=check_riding_teleport)
|
|
|
|
|
|
def check_portal_rider(host, guest, out):
|
|
"""#1486: a survival guest rides a horse into a Nether portal. The horse goes
|
|
through at once (NetherPortalBlock.getPortalTransitionTime is 0 for an entity that isn't a
|
|
player; a passenger has no portal process of its own, Entity.canUsePortal) and carries its rider
|
|
(Entity.teleportCrossDimension): the guest leaves well before a player's own 80 ticks and
|
|
arrives in the Nether still on the same horse."""
|
|
x, y, z = apart(host, guest)['guest']
|
|
horse_at = (x + 1.0, y, z + 2.5)
|
|
# The portal's plane crosses the horse's box (z + 1.8 to z + 3.2), the rider's too.
|
|
pz = z + 2
|
|
tag = 'portal_rider'
|
|
home = 'execute in minecraft:overworld run'
|
|
try:
|
|
guest('command', command='clear @s')
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='gamerule players_nether_portal_default_delay 80')
|
|
guest('command', command='item replace entity @s weapon.mainhand with minecraft:saddle')
|
|
guest('command', command=f'summon minecraft:horse {horse_at[0]} {y} {horse_at[2]} '
|
|
f'{{Tame:1b,NoAI:1b,Tags:["{tag}"]}}')
|
|
guest.wait(5)
|
|
horses = [m for m in guest.raw('mobs', 16)['near'] if m['kind'] == 'minecraft:horse' and m['health'] > 0]
|
|
horse = next((m for m in horses if math.dist(m['pos'], horse_at) < 0.1), None)
|
|
assert horse, f'no live horse at {horse_at}: {horses}'
|
|
guest.raw('look-at', horse_at[0], y + 0.8, horse_at[2])
|
|
guest.raw('mouse', 'right', 'click')
|
|
guest.wait(5)
|
|
guest('command', command='item replace entity @s weapon.mainhand with minecraft:air')
|
|
guest.wait(2)
|
|
guest.raw('mouse', 'right', 'click')
|
|
guest.wait(5)
|
|
assert guest('status').get('riding') == horse['id'], 'the guest is not riding the horse'
|
|
# The frame around the horse and its rider, then the portal inside it.
|
|
for corner in (f'{x - 1} {y - 1} {pz} {x + 2} {y - 1} {pz}', f'{x - 1} {y + 3} {pz} {x + 2} {y + 3} {pz}',
|
|
f'{x - 1} {y} {pz} {x - 1} {y + 2} {pz}', f'{x + 2} {y} {pz} {x + 2} {y + 2} {pz}'):
|
|
guest('command', command=f'fill {corner} minecraft:obsidian')
|
|
guest('command', command=f'fill {x} {y} {pz} {x + 1} {y + 2} {pz} minecraft:nether_portal[axis=x]')
|
|
guest.shot(out, 'portal-rider-in')
|
|
left = None
|
|
for waited in range(0, 120, 5):
|
|
if guest('state').get('dimension') == NETHER:
|
|
left = waited
|
|
break
|
|
guest.wait(5)
|
|
assert left is not None, 'the guest on its horse did not go through the portal in 120 ticks'
|
|
assert left < 40, f'the guest left after {left} ticks: its own delay (80), not the horse\'s (0)'
|
|
# The server holds the guest until the chunks around the exit are lit, then places it there.
|
|
there = wait_dimension(guest, NETHER, 600)
|
|
seen = None
|
|
for _ in range(30):
|
|
guest.wait(20)
|
|
seen = {'riding': guest('status').get('riding'), 'horse': mob_seen(guest, horse['id']),
|
|
'at': pos_of(guest)}
|
|
if seen['horse'] and math.dist(seen['horse']['pos'], seen['at']) < 3:
|
|
break
|
|
guest.wait(40)
|
|
seen = {'riding': guest('status').get('riding'), 'horse': mob_seen(guest, horse['id']), 'at': pos_of(guest)}
|
|
guest.shot(out, 'portal-rider-nether')
|
|
assert there.get('dimension') == NETHER, f'the guest is in {there.get("dimension")}'
|
|
assert seen['horse'], f'the horse {horse["id"]} did not come to the Nether with its rider: {seen}'
|
|
assert seen['riding'] == horse['id'], f'the guest arrived on foot: {seen}'
|
|
assert math.dist(seen['horse']['pos'], seen['at']) < 3, f'the horse is not under its rider: {seen}'
|
|
host_dim = host('state').get('dimension')
|
|
assert host_dim == OVERWORLD, f'the host went to {host_dim} with the guest'
|
|
return {'left_after_ticks': left, 'riding': seen['riding'], 'horse': seen['horse']['pos'], 'guest': seen['at']}
|
|
finally:
|
|
back_to_game(guest)
|
|
guest('command', command=f'kill @e[tag={tag}]')
|
|
guest('command', command=f'{home} fill {x - 1} {y - 1} {pz} {x + 2} {y + 3} {pz} minecraft:air')
|
|
guest('command', command=f'{home} fill {x - 1} {y - 1} {pz} {x + 2} {y - 1} {pz} minecraft:stone')
|
|
guest('command', command=f'{home} tp @s {x + 0.5} {y} {z + 0.5} 0 0')
|
|
wait_dimension(guest, OVERWORLD, 600)
|
|
guest.wait(20)
|
|
|
|
|
|
CHECKS += ['portal_rider']
|
|
CHECK_FNS.update(portal_rider=check_portal_rider)
|
|
|
|
|
|
def check_cart_portal_rider(host, guest, out):
|
|
"""#6021: a player rides a minecart along rails into a Nether portal. The cart goes through at once
|
|
(Entity.handlePortal: a vehicle's transition time is 0; the passenger has no portal process of its
|
|
own) and takes its rider (Entity.teleportCrossDimension), so the player arrives in the Nether still
|
|
on the same cart, the cart under it and out of the Overworld. The host (the window of the
|
|
single-player server) and the guest each ride."""
|
|
spots = apart(host, guest)
|
|
tag = 'cart_portal_rider'
|
|
home = 'execute in minecraft:overworld run'
|
|
result = {}
|
|
for label, player in (('host', host), ('guest', guest)):
|
|
x, y, z = spots[label]
|
|
# Rails run east from the player's spot into a portal in the plane x = rx + 8 (axis z).
|
|
rx, pz = x + 1, z + 1
|
|
px = rx + 8
|
|
player('command', command='clear @s')
|
|
player('command', command='gamemode survival')
|
|
player('command', command=f'fill {rx - 1} {y - 1} {pz - 2} {px + 2} {y - 1} {pz + 3} minecraft:stone')
|
|
player('command', command=f'fill {rx - 1} {y} {pz - 2} {px + 2} {y + 4} {pz + 3} minecraft:air')
|
|
player('command', command=f'fill {rx} {y} {pz} {px - 1} {y} {pz} minecraft:powered_rail[powered=false,shape=east_west]')
|
|
player('command', command=f'setblock {rx - 1} {y} {pz} minecraft:stone')
|
|
player('command', command=f'fill {px} {y - 1} {pz - 1} {px} {y + 3} {pz + 2} minecraft:obsidian')
|
|
player('command', command=f'fill {px} {y} {pz} {px} {y + 2} {pz + 1} minecraft:nether_portal[axis=z]')
|
|
player('command', command=f'summon minecraft:minecart {rx + 0.5} {y} {pz + 0.5} {{Tags:["{tag}"]}}')
|
|
player('command', command=f'tp @s {rx + 0.5} {y} {pz - 0.5} 0 0')
|
|
player.wait(30)
|
|
try:
|
|
cart = board(player, 'minecart', (rx + 0.5, y, pz + 0.5))
|
|
# The rails come alive: a cart at rest on powered rails is pushed off the stone behind it.
|
|
player('command', command=f'setblock {rx + 1} {y} {pz + 1} minecraft:redstone_block')
|
|
left = None
|
|
for waited in range(0, 200, 5):
|
|
if player('state').get('dimension') == NETHER:
|
|
left = waited
|
|
break
|
|
player.wait(5)
|
|
carts = [o for o in player('status')['objects'] if o['kind'].endswith('minecart')]
|
|
assert left is not None, (f'the {label} on its minecart did not go through the portal in 200 ticks: '
|
|
f'riding {player("status").get("riding")}, at {pos_of(player)}, carts {carts}')
|
|
wait_dimension(player, NETHER, 600)
|
|
seen = None
|
|
for _ in range(30):
|
|
player.wait(20)
|
|
seen = {'riding': player('status').get('riding'), 'at': pos_of(player)}
|
|
if seen['riding'] == cart['id']:
|
|
break
|
|
assert seen['riding'] == cart['id'], f'the {label} arrived on foot, not on the cart: {seen}'
|
|
near = [o for o in player('status')['objects'] if o['id'] == cart['id']]
|
|
assert near, f'the cart {cart["id"]} did not come to the Nether with its rider'
|
|
assert math.dist(near[0]['position'], seen['at']) < 3, f'the cart is not under its rider: {near[0]} {seen}'
|
|
result[label] = {'left_after_ticks': left, 'riding': seen['riding'], 'cart': near[0]['position'],
|
|
'player': seen['at']}
|
|
finally:
|
|
back_to_game(player)
|
|
player('command', command=f'kill @e[tag={tag}]')
|
|
player('command', command=f'{home} fill {rx - 1} {y - 1} {pz - 2} {px + 2} {y + 4} {pz + 3} minecraft:air')
|
|
player('command', command=f'{home} fill {rx - 1} {y - 1} {pz - 2} {px + 2} {y - 1} {pz + 3} minecraft:stone')
|
|
player('command', command=f'{home} tp @s {x + 0.5} {y} {z + 0.5} 0 0')
|
|
wait_dimension(player, OVERWORLD, 600)
|
|
player.wait(20)
|
|
return result
|
|
|
|
|
|
CHECKS += ['cart_portal_rider']
|
|
CHECK_FNS.update(cart_portal_rider=check_cart_portal_rider)
|
|
|
|
|
|
def check_recipe_removal(host, guest, out):
|
|
"""ServerRecipeBook.removeRecipes sends ClientboundRecipeBookRemovePacket to the player:
|
|
the open book updates immediately, reopening keeps it empty, and later grants still arrive."""
|
|
seen = {}
|
|
for game in (host, guest):
|
|
back_to_game(game)
|
|
game('command', command='clear @s')
|
|
game('command', command='give @s minecraft:oak_log 4')
|
|
game('command', command='recipe give @s minecraft:oak_planks')
|
|
game('command', command='recipe give @s minecraft:stick')
|
|
game.wait(40)
|
|
game.raw('key', 'e', 'tap')
|
|
game.wait(5)
|
|
try:
|
|
book = game('screen').get('recipe_book')
|
|
assert book, f'{game.name}: the inventory has no recipe book'
|
|
if len(book['targets']) == 1:
|
|
game.raw('click-at', *centre(book['targets'][0]))
|
|
game.wait(5)
|
|
book = game('screen')['recipe_book']
|
|
known = book['known']
|
|
assert known > 1, f'{game.name}: recipes were not granted: {book}'
|
|
game.shot(out, 'recipe-removal-before')
|
|
|
|
game('command', command='recipe take @s *')
|
|
game.wait(20)
|
|
empty = game('screen')['recipe_book']['known']
|
|
game.shot(out, 'recipe-removal-empty')
|
|
assert empty == 0, f'{game.name}: forgotten recipes remain in the open book: {empty}'
|
|
game.raw('key', 'esc', 'tap')
|
|
game.wait(5)
|
|
game.raw('key', 'e', 'tap')
|
|
game.wait(5)
|
|
reopened = game('screen')['recipe_book']['known']
|
|
assert reopened == 0, f'{game.name}: reopening restored {reopened} forgotten recipes'
|
|
|
|
game('command', command='recipe give @s minecraft:oak_planks')
|
|
game('command', command='recipe give @s minecraft:stick')
|
|
game.wait(20)
|
|
given = game('screen')['recipe_book']['known']
|
|
assert given == 2, f'{game.name}: two later grants did not appear: {given}'
|
|
game('command', command='recipe take @s minecraft:oak_planks')
|
|
game.wait(20)
|
|
one = game('screen')['recipe_book']['known']
|
|
assert one == 1, f'{game.name}: taking one recipe left {one}, expected 1'
|
|
game('command', command='recipe take @s minecraft:stick')
|
|
game.wait(20)
|
|
assert game('screen')['recipe_book']['known'] == 0, f'{game.name}: the last recipe remains'
|
|
|
|
game('command', command='recipe give @s minecraft:oak_planks')
|
|
game.wait(20)
|
|
granted = game('screen')['recipe_book']['known']
|
|
assert granted == 1, f'{game.name}: a later grant did not appear: {granted}'
|
|
game.shot(out, 'recipe-removal-granted')
|
|
seen[game.name] = {'known': known, 'empty': empty, 'reopened': reopened,
|
|
'given': given, 'one': one, 'granted': granted}
|
|
finally:
|
|
back_to_game(game)
|
|
return seen
|
|
|
|
|
|
CHECKS += ['recipe_removal']
|
|
CHECK_FNS.update(recipe_removal=check_recipe_removal)
|
|
|
|
|
|
def check_recipe_reselect(host, guest, out):
|
|
"""RecipeBookComponent.keyPressed: Enter or Space (key repeats too) places `lastRecipe`, the recipe the
|
|
last book click placed, again, with the key's Shift; slot clicks keep it, so the grid refills after
|
|
every craft taken from the result slot."""
|
|
seen = {}
|
|
for game in (host, guest):
|
|
back_to_game(game)
|
|
game('command', command='gamemode survival')
|
|
game('command', command='clear @s')
|
|
# Picking up string unlocks more recipes: take them, so wool's is the book's only button.
|
|
game('command', command='give @s minecraft:string 12')
|
|
game.wait(20)
|
|
game('command', command='recipe take @s *')
|
|
game('command', command='recipe give @s minecraft:white_wool_from_string')
|
|
game.wait(20)
|
|
game.raw('key', 'e', 'tap')
|
|
game.wait(5)
|
|
try:
|
|
book = game('screen').get('recipe_book')
|
|
assert book, f'{game.name}: the inventory has no recipe book'
|
|
if len(book['targets']) == 1:
|
|
game.raw('click-at', *centre(book['targets'][0]))
|
|
game.wait(5)
|
|
book = game('screen')['recipe_book']
|
|
buttons = [r for r in book['targets'] if r[2:] == [25.0, 25.0]]
|
|
assert len(buttons) == 1, f'{game.name}: expected the wool button alone: {book}'
|
|
steps = []
|
|
# 12 string: Shift places 3 per grid slot, a plain press 1.
|
|
for press, shift, per_slot in (('click', True, 3), ('space repeat', True, 3), ('enter tap', False, 1)):
|
|
if press != 'click':
|
|
game('command', command='give @s minecraft:string 12')
|
|
game.wait(10)
|
|
if shift:
|
|
game.raw('key', 'shift', 'down')
|
|
if press == 'click':
|
|
game.raw('click-at', *centre(buttons[0]))
|
|
else:
|
|
game.raw('key', *press.split())
|
|
if shift:
|
|
game.raw('key', 'shift', 'up')
|
|
game.wait(10)
|
|
grid = {s['slot']: (s['stack']['item'], s['stack']['count'])
|
|
for s in game.raw('menu')['slots'] if 100 <= s['slot'] < 109}
|
|
steps.append({'press': press, 'grid': grid})
|
|
counts = sorted(c for item, c in grid.values() if item == 'minecraft:string')
|
|
assert counts == [per_slot] * 4, f'{game.name}-{press}: grid {grid}'
|
|
game.raw('click-slot', 200, 'shift')
|
|
game.wait(10)
|
|
game.shot(out, 'recipe-reselect')
|
|
game.raw('key', 'esc', 'tap')
|
|
game.wait(10)
|
|
case = {'steps': steps, 'server': {item: server_count(game, item) for item in ('string', 'white_wool')}}
|
|
seen[game.name] = case
|
|
# 36 string in, 3 + 3 + 1 wool out at 4 string each.
|
|
assert case['server'] == {'string': 8, 'white_wool': 7}, f'{game.name}: {case}'
|
|
finally:
|
|
back_to_game(game)
|
|
game('command', command='clear @s')
|
|
game('command', command='recipe give @s *')
|
|
return seen
|
|
|
|
|
|
CHECKS += ['recipe-reselect']
|
|
CHECK_FNS['recipe-reselect'] = check_recipe_reselect
|
|
|
|
|
|
def save_and_reopen(host, guest=None, world='Lan', guest_name=GUEST_NAME):
|
|
"""Save and Quit on `host`, reopen `world`, and reconnect `guest` when present."""
|
|
if guest is not None:
|
|
back_to_game(guest)
|
|
guest.raw('disconnect')
|
|
back_to_game(host)
|
|
host.raw('pause')
|
|
host.raw('click-button', 'Save and Quit to Title')
|
|
host.raw('wait-for', 'screen', 'title', '--timeout', 120)
|
|
host.raw('open', world)
|
|
host('wait_ready', seconds=400)
|
|
if guest is not None:
|
|
port = host.raw('lan', 'open', 0, 'commands')['port']
|
|
guest.raw('connect', f'127.0.0.1:{port}', guest_name)
|
|
guest.raw('wait-for', 'loaded', '--timeout', 300)
|
|
guest.wait(20)
|
|
|
|
|
|
def check_recipe_settings(host, guest, out):
|
|
"""RecipeBookComponent sends both settings to the server; each player's
|
|
own book keeps them when the authoritative world saves and reopens (#2290)."""
|
|
def inventory(game):
|
|
back_to_game(game)
|
|
game.raw('key', 'e', 'tap')
|
|
game.wait(5)
|
|
return game('screen')['recipe_book']
|
|
|
|
def flags(book):
|
|
return {'open': book['open'], 'filtering': book['filtering']}
|
|
|
|
def set_flags(game, opened, filtering):
|
|
book = inventory(game)
|
|
if not book['open']:
|
|
game.raw('click-at', *centre(book['targets'][0]))
|
|
game.wait(5)
|
|
book = game('screen')['recipe_book']
|
|
if book['filtering'] != filtering:
|
|
target = next(t for t in book['targets'] if t[2:] == [26.0, 16.0])
|
|
game.raw('click-at', *centre(target))
|
|
game.wait(5)
|
|
if not opened:
|
|
book = game('screen')['recipe_book']
|
|
game.raw('click-at', *centre(book['targets'][0]))
|
|
game.wait(5)
|
|
actual = flags(game('screen')['recipe_book'])
|
|
assert actual == {'open': opened, 'filtering': filtering}, f'{game.name}: {actual}'
|
|
game.shot(out, 'recipe-settings-before')
|
|
back_to_game(game)
|
|
return actual
|
|
|
|
# Deliberately different settings ensure neither player's book overwrites the other's.
|
|
before = {host.name: set_flags(host, True, True),
|
|
guest.name: set_flags(guest, False, True)}
|
|
host.wait(20)
|
|
save_and_reopen(host, guest)
|
|
after = {}
|
|
for game in (host, guest):
|
|
after[game.name] = flags(inventory(game))
|
|
game.shot(out, 'recipe-settings-reopened')
|
|
back_to_game(game)
|
|
assert after == before, f'saved recipe settings reset: before={before}, after={after}'
|
|
return {'before': before, 'reopened': after}
|
|
|
|
|
|
CHECKS += ['recipe_settings']
|
|
CHECK_FNS.update(recipe_settings=check_recipe_settings)
|
|
|
|
|
|
def check_reopen_player(host, guest, out):
|
|
"""Save and Quit, then reopen: the owner comes back where they stood with what
|
|
they held (the world's own player file, `players/data/<singleplayer uuid>.dat`),
|
|
and a guest likewise from their own file (#5065, #5075)."""
|
|
places = {host.name: (40.5, 120.0, -30.5), guest.name: (-25.5, 110.0, 45.5)}
|
|
items = {host.name: ('minecraft:diamond', 7), guest.name: ('minecraft:emerald', 5)}
|
|
|
|
def held(game):
|
|
item = items[game.name][0]
|
|
return game('inventory')['counts'].get(item, 0)
|
|
|
|
for game in (host, guest):
|
|
back_to_game(game)
|
|
x, y, z = places[game.name]
|
|
item, count = items[game.name]
|
|
# A glass block to stand on, so nobody is still falling when the world saves.
|
|
host('command', command=f'setblock {math.floor(x)} {int(y) - 1} {math.floor(z)} minecraft:glass')
|
|
game('command', command=f'tp @s {x} {y} {z}')
|
|
game('command', command=f'give @s {item} {count}')
|
|
host.wait(40)
|
|
before = {game.name: {'position': [round(v, 1) for v in pos_of(game)], 'held': held(game)}
|
|
for game in (host, guest)}
|
|
for game in (host, guest):
|
|
want = {'position': list(places[game.name]), 'held': items[game.name][1]}
|
|
assert before[game.name] == want, f'{game.name} not set up: {before[game.name]}'
|
|
save_and_reopen(host, guest)
|
|
after = {}
|
|
for game in (host, guest):
|
|
after[game.name] = {'position': [round(v, 1) for v in pos_of(game)], 'held': held(game)}
|
|
game.shot(out, 'reopen-player')
|
|
assert after == before, f'players reset on reopen: before={before}, after={after}'
|
|
return {'reopened': after}
|
|
|
|
|
|
CHECKS += ['reopen_player']
|
|
CHECK_FNS.update(reopen_player=check_reopen_player)
|
|
|
|
|
|
def check_leash_reload(host, guest, out):
|
|
"""A mob tied to a player stays tied across Save and Quit / reopen and can be untied by
|
|
right-clicking it afterward (#6876)."""
|
|
tag = 'leash_reload_cow'
|
|
spots = apart(host, guest)
|
|
x, y, z = spots['host']
|
|
at = (x + 0.5, y, z + 2.5)
|
|
|
|
def cow(game):
|
|
found = [m for m in game.raw('mobs', 32).get('near', []) if m['kind'] == 'minecraft:cow'
|
|
and abs(m['pos'][0] - at[0]) < 1.5 and abs(m['pos'][2] - at[2]) < 1.5]
|
|
assert len(found) == 1, f'{game.name} sees {len(found)} cows at {at}: {found}'
|
|
return found[0]
|
|
|
|
def nbt_ints(reply):
|
|
values = re.findall(r'-?\d+', reply.partition('[I;')[2].partition(']')[0])
|
|
assert len(values) == 4, f'not a UUID int array: {reply}'
|
|
return ','.join(values)
|
|
|
|
def player_uuid():
|
|
return nbt_ints(server_reply(host, 'data get entity @s UUID', '[I;'))
|
|
|
|
def cow_uuid():
|
|
return nbt_ints(server_reply(host, f'data get entity @e[type=minecraft:cow,tag={tag},limit=1] UUID', '[I;'))
|
|
|
|
def cow_leash_uuid():
|
|
return nbt_ints(server_reply(host, f'data get entity @e[type=minecraft:cow,tag={tag},limit=1] leash', '[I;'))
|
|
|
|
back_to_game(host)
|
|
back_to_game(guest)
|
|
host('command', command=f'fill {x - 1} {y - 1} {z - 1} {x + 1} {y - 1} {z + 4} minecraft:glass')
|
|
host('command', command=f'fill {x - 1} {y} {z - 1} {x + 1} {y + 3} {z + 4} minecraft:air')
|
|
host('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
host('command', command='gamemode survival')
|
|
host('command', command='clear @s')
|
|
host('command', command=f'summon minecraft:cow {at[0]} {y} {at[2]} '
|
|
f'{{NoAI:1b,Tags:["{tag}"],UUID:[I;{offline_uuid_ints(tag)}]}}')
|
|
host('command', command='item replace entity @s weapon.mainhand with minecraft:lead')
|
|
host.wait(30)
|
|
try:
|
|
owner_uuid = offline_uuid_ints(host.player)
|
|
before_player_uuid = player_uuid()
|
|
before_cow_uuid = cow_uuid()
|
|
assert before_player_uuid == owner_uuid, f'unexpected host UUID: {before_player_uuid} != {owner_uuid}'
|
|
|
|
host.raw('look-at', f'{at[0]:.2f}', f'{y + 0.7:.2f}', f'{at[2]:.2f}')
|
|
host.raw('mouse', 'right', 'click')
|
|
host.wait(20)
|
|
tied_before = {
|
|
'own': cow(host)['leash'],
|
|
'other': cow(guest)['leash'],
|
|
'nbt_holder': cow_leash_uuid(),
|
|
}
|
|
assert tied_before['own'] == 'Player' and tied_before['other'], f'cow not tied before save: {tied_before}'
|
|
host.shot(out, 'leash-reload-before')
|
|
|
|
save_and_reopen(host, guest)
|
|
back_to_game(host)
|
|
back_to_game(guest)
|
|
host.wait(20)
|
|
|
|
after_player_uuid = player_uuid()
|
|
after_cow_uuid = cow_uuid()
|
|
assert after_player_uuid == before_player_uuid, (
|
|
f'player UUID changed across reopen: {before_player_uuid} -> {after_player_uuid}'
|
|
)
|
|
assert after_cow_uuid == before_cow_uuid, (
|
|
f'cow persistent UUID changed across reopen: {before_cow_uuid} -> {after_cow_uuid}'
|
|
)
|
|
reopened_cow = cow(host)
|
|
assert reopened_cow['health'] > 0, f'cow not alive after reopen: {reopened_cow}'
|
|
tied_reopened = {
|
|
'own': reopened_cow['leash'],
|
|
'other': cow(guest)['leash'],
|
|
'nbt_holder': cow_leash_uuid(),
|
|
}
|
|
assert tied_reopened['own'] == 'Player' and tied_reopened['other'], (
|
|
f'cow lost player leash after reopen: {tied_reopened}'
|
|
)
|
|
host.shot(out, 'leash-reload-reopened')
|
|
|
|
# A closer player with an empty hand must not become the holder or untie it.
|
|
guest('command', command='clear @s')
|
|
guest('command', command=f'tp @s {at[0] + 1.0} {y} {at[2]}')
|
|
guest.raw('look-at', f'{at[0]:.2f}', f'{y + 0.7:.2f}', f'{at[2]:.2f}')
|
|
guest.raw('mouse', 'right', 'click')
|
|
host.wait(20)
|
|
bystander = {'own': cow(host)['leash'], 'other': cow(guest)['leash']}
|
|
|
|
# Right-clicking with an empty hand after reopening must untie the cow (#6876).
|
|
hand = host('state').get('held') or {}
|
|
assert hand.get('item') in (None, 'minecraft:air'), f'hand not empty after reopen: {hand}'
|
|
host.raw('look-at', f'{at[0]:.2f}', f'{y + 0.7:.2f}', f'{at[2]:.2f}')
|
|
host.raw('mouse', 'right', 'click')
|
|
host.wait(20)
|
|
untied_cow = cow(host)
|
|
assert untied_cow['health'] > 0, f'cow died when untied: {untied_cow}'
|
|
assert cow_uuid() == before_cow_uuid, 'cow persistent UUID changed after untying'
|
|
untied = {'own': untied_cow['leash'], 'other': cow(guest)['leash']}
|
|
# Keep every stage even when the regression fails, and attempt the actual
|
|
# post-reopen interaction before checking the serialized holder UUID.
|
|
evidence = {'player_uuid': before_player_uuid, 'cow_uuid': before_cow_uuid,
|
|
'before': tied_before, 'reopened': tied_reopened,
|
|
'bystander': bystander, 'untied': untied}
|
|
(out / 'leash-reload.json').write_text(json.dumps(evidence, indent=2) + '\n')
|
|
assert untied == {'own': None, 'other': None}, f'cow still leashed after right-click on reopen: {untied}'
|
|
assert bystander['own'] == 'Player' and bystander['other'].startswith('Mob('), (
|
|
f'closer bystander took or removed the leash: {bystander}'
|
|
)
|
|
assert tied_before['nbt_holder'] == owner_uuid, f'wrong leash holder UUID before save: {tied_before}'
|
|
assert tied_reopened['nbt_holder'] == owner_uuid, (
|
|
f'cow leash holder UUID changed after reopen: {tied_reopened}'
|
|
)
|
|
host.shot(out, 'leash-reload-untied')
|
|
finally:
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
host('command', command=f'kill @e[type=minecraft:item,x={at[0]},y={y},z={at[2]},distance=..6]')
|
|
host('command', command='clear @s')
|
|
return {
|
|
'player_uuid': before_player_uuid,
|
|
'cow_uuid': before_cow_uuid,
|
|
'before': tied_before,
|
|
'reopened': tied_reopened,
|
|
'bystander': bystander,
|
|
'untied': untied,
|
|
}
|
|
|
|
|
|
CHECKS += ['leash_reload']
|
|
CHECK_FNS.update(leash_reload=check_leash_reload)
|
|
|
|
|
|
def check_recipe_targets(host, guest, out):
|
|
"""RecipeCommand gives and takes the recipes of each target's own ServerRecipeBook: a
|
|
`/recipe give|take <other player>` changes that player's book and reaches their client,
|
|
not the book of the player who typed it: the guest gives the host a recipe too."""
|
|
host_name = host.raw('lan', 'status').get('host', 'Player')
|
|
books = {}
|
|
for game in (host, guest):
|
|
back_to_game(game)
|
|
game('command', command='clear @s')
|
|
game('command', command='recipe take @s *')
|
|
host.wait(20)
|
|
for game in (host, guest):
|
|
game.raw('key', 'e', 'tap')
|
|
game.wait(5)
|
|
book = game('screen').get('recipe_book')
|
|
assert book, f'{game.name}: the inventory has no recipe book'
|
|
if len(book['targets']) == 1:
|
|
game.raw('click-at', *centre(book['targets'][0]))
|
|
game.wait(5)
|
|
|
|
def known():
|
|
return {game.name: game('screen')['recipe_book']['known'] for game in (host, guest)}
|
|
|
|
try:
|
|
books['empty'] = known()
|
|
assert books['empty'] == {host.name: 0, guest.name: 0}, books
|
|
host('command', command=f'recipe give {GUEST_NAME} minecraft:oak_planks')
|
|
host('command', command=f'recipe give {GUEST_NAME} minecraft:stick')
|
|
host.wait(20)
|
|
books['host gives guest'] = known()
|
|
guest('command', command=f'recipe give {host_name} minecraft:stick')
|
|
guest.wait(20)
|
|
books['guest gives host'] = known()
|
|
host('command', command=f'recipe take {GUEST_NAME} minecraft:stick')
|
|
host.wait(20)
|
|
books['host takes from guest'] = known()
|
|
host.shot(out, 'recipe-targets-host')
|
|
guest.shot(out, 'recipe-targets-guest')
|
|
expected = {'empty': (0, 0), 'host gives guest': (0, 2), 'guest gives host': (1, 2),
|
|
'host takes from guest': (1, 1)}
|
|
for step, (in_host, in_guest) in expected.items():
|
|
assert books[step] == {host.name: in_host, guest.name: in_guest}, (step, books)
|
|
finally:
|
|
back_to_game(host)
|
|
back_to_game(guest)
|
|
return books
|
|
|
|
|
|
CHECKS += ['recipe_targets']
|
|
CHECK_FNS.update(recipe_targets=check_recipe_targets)
|
|
|
|
|
|
def check_shulker_bullets(host, guest, out):
|
|
"""ShulkerAttackGoal's bullets reach ClientPacketListener.handleAddEntity, then the existing
|
|
projectile renderer and interpolation, with distinct ids from the server's entity counter."""
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
tag = 'lan_shulker_bullets'
|
|
selector = f'@e[type=minecraft:shulker,tag={tag},limit=1]'
|
|
sound = 'minecraft:entity.shulker.shoot'
|
|
paths = {g.name: {} for g in (host, guest)}
|
|
before = {g.name: heard(g, sound) for g in (host, guest)}
|
|
shots = dict.fromkeys(before, 0)
|
|
first_shot = stopped = None
|
|
photographed = False
|
|
|
|
def bullets(status):
|
|
return [p for p in status['projectiles'] if p['kind'] == 'ShulkerBullet']
|
|
|
|
try:
|
|
host('command', command='difficulty normal')
|
|
host('command', command='gamemode creative')
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='effect give @s minecraft:resistance 120 4 true')
|
|
host('command', command=f'fill {x - 10} {y - 1} {z - 10} {x + 10} {y - 1} {z + 18} minecraft:stone')
|
|
host('command', command=f'fill {x - 10} {y} {z - 10} {x + 10} {y + 12} {z + 18} minecraft:air')
|
|
# A west wall supports the shulker above the floor, leaving room for a short flight.
|
|
host('command', command=f'fill {x - 1} {y} {z + 7} {x - 1} {y + 4} {z + 7} minecraft:stone')
|
|
host('command', command=f'tp @s {x + 4.5} {y} {z - 0.5}')
|
|
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
host('command', command=f'summon minecraft:shulker {x + 0.5} {y + 3} {z + 7.5} '
|
|
f'{{NoAI:1b,PersistenceRequired:1b,Tags:["{tag}"]}}')
|
|
for game in (host, guest):
|
|
game.raw('look-at', x + 0.5, y + 3.5, z + 7.5)
|
|
guest.wait(20)
|
|
host('command', command=f'data merge entity {selector} {{NoAI:0b}}')
|
|
for waited in range(2, 402, 2):
|
|
guest.wait(2)
|
|
for game in (host, guest):
|
|
status = game('status')
|
|
live = bullets(status)
|
|
ids = [p['id'] for p in live]
|
|
assert all(ids) and len(set(ids)) == len(ids), f'{game.name}: invalid bullet ids {ids}'
|
|
for p in live:
|
|
paths[game.name].setdefault(p['id'], []).append(p['position'])
|
|
shots[game.name] = status['heard'].get(sound, 0) - before[game.name]
|
|
if shots['host'] and first_shot is None:
|
|
first_shot = waited
|
|
if first_shot is not None and waited - first_shot >= 8 and not photographed:
|
|
host('command', command='tick freeze')
|
|
try:
|
|
for game in (host, guest):
|
|
game.raw('look-at', x + 0.5, y + 3.5, z + 7.5)
|
|
game.raw('key', 'f1', 'tap')
|
|
try:
|
|
game.shot(out, 'shulker-bullets')
|
|
finally:
|
|
game.raw('key', 'f1', 'tap')
|
|
finally:
|
|
host('command', command='tick unfreeze')
|
|
photographed = True
|
|
if shots['host'] >= 2 and stopped is None:
|
|
host('command', command=f'data merge entity {selector} {{NoAI:1b}}')
|
|
stopped = waited
|
|
if stopped is not None and waited - stopped >= 20:
|
|
break
|
|
# Keep the survival target in range after a hit applies levitation.
|
|
if waited % 20 == 0:
|
|
guest('command', command='effect clear @s minecraft:levitation')
|
|
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
lab.write_json(out / 'shulker-bullets.json', {'shots': shots, 'paths': paths})
|
|
assert shots['host'] >= 2, f'the shulker did not attack twice: {shots}'
|
|
for name, by_id in paths.items():
|
|
assert len(by_id) == shots[name], f'{name}: {shots[name]} shots but bullet ids {list(by_id)}'
|
|
for id, positions in by_id.items():
|
|
assert any(math.dist(positions[0], p) > 0.5 for p in positions), \
|
|
f'{name}: bullet {id} did not move: {positions}'
|
|
assert paths['host'].keys() == paths['guest'].keys(), f'different bullets: {paths}'
|
|
for id, positions in paths['guest'].items():
|
|
# The clients interpolate three ticks behind, and reads are not simultaneous.
|
|
assert all(min(math.dist(p, q) for q in paths['host'][id]) < 1.5 for p in positions), \
|
|
f'bullet {id} diverged from the host: {paths}'
|
|
|
|
# Put blocks in the flight volume to exercise server removal without depending on homing.
|
|
for game in (host, guest):
|
|
game('command', command='gamemode creative')
|
|
game('command', command=f'tp @s {x + 0.5} {y + 16} {z + 0.5}')
|
|
host('command', command=f'fill {x - 10} {y} {z - 10} {x + 10} {y + 12} {z + 18} minecraft:stone')
|
|
for _ in range(20):
|
|
guest.wait(2)
|
|
remaining = {g.name: bullets(g('status')) for g in (host, guest)}
|
|
if not any(remaining.values()):
|
|
break
|
|
assert not any(remaining.values()), f'bullets survived block hits: {remaining}'
|
|
finally:
|
|
host('command', command='tick unfreeze')
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
host('command', command=f'fill {x - 10} {y} {z - 10} {x + 10} {y + 12} {z + 18} minecraft:air')
|
|
for game in (host, guest):
|
|
game('command', command='effect clear @s')
|
|
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
game('command', command='gamemode survival')
|
|
face_each_other(host, guest)
|
|
return {'shots': shots, 'ids': sorted(paths['host']), 'removed': True}
|
|
|
|
|
|
CHECKS += ['shulker_bullets']
|
|
CHECK_FNS.update(shulker_bullets=check_shulker_bullets)
|
|
|
|
|
|
def check_farmland(host, guest, out):
|
|
"""FarmlandBlock.fallOn's base-block change breaks the unsupported crop (#1492):
|
|
the host and guest each land on mature wheat, get its drops, and both clients see dirt/air."""
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
y = max(hy + 12, 100)
|
|
starts = {g.name: pos_of(g) for g in (host, guest)}
|
|
seen = {}
|
|
host('command', command='gamerule random_tick_speed 0')
|
|
try:
|
|
for game, other, dz in ((host, guest, -3), (guest, host, 3)):
|
|
x, z = hx + 2, hz + dz
|
|
soil, crop, marker = (x, y, z), (x, y + 1, z), (x + 4, y + 1, z)
|
|
for g in (game, other):
|
|
g('command', command='gamemode creative')
|
|
host('command', command=f'fill {x - 4} {y} {z - 2} {x + 4} {y} {z + 2} minecraft:stone')
|
|
host('command', command=f'fill {x - 4} {y + 1} {z - 2} {x + 4} {y + 7} {z + 2} minecraft:air')
|
|
host('command', command=f'setblock {x} {y} {z} minecraft:farmland[moisture=7]')
|
|
host('command', command=f'setblock {x} {y + 1} {z} minecraft:wheat[age=7]')
|
|
other('command', command=f'tp @s {x + 3.5} {y + 1} {z + 0.5}')
|
|
game('command', command=f'tp @s {x + 2.5} {y + 1} {z + 0.5}')
|
|
game('command', command='clear @s')
|
|
game('command', command='gamemode survival')
|
|
game.wait(20)
|
|
before = {g.name: [g.raw('block', *p).get('block') for p in (soil, crop)]
|
|
for g in (host, guest)}
|
|
assert all(v == ['minecraft:farmland', 'minecraft:wheat'] for v in before.values()), \
|
|
f'{game.name}: planted wheat missing before landing: {before}'
|
|
other.raw('look-at', x + 0.5, y + 1, z + 0.5)
|
|
other.shot(out, f'farmland-{game.name}-before')
|
|
# More than 1.5 blocks of fall distance guarantees the trampling random check.
|
|
game('command', command=f'tp @s {x + 0.5} {y + 6} {z + 0.5}')
|
|
game.wait(80)
|
|
after = {g.name: [g.raw('block', *p).get('block') for p in (soil, crop)]
|
|
for g in (host, guest)}
|
|
# Test the authoritative blocks too, rather than only each client's view.
|
|
game('command', command=f'execute if block {x} {y} {z} minecraft:dirt '
|
|
f'if block {x} {y + 1} {z} minecraft:air run '
|
|
f'setblock {marker[0]} {marker[1]} {marker[2]} minecraft:gold_block')
|
|
game.wait(10)
|
|
counts = {item: server_count(game, item) for item in ('wheat', 'wheat_seeds')}
|
|
client_counts = game('inventory')['counts']
|
|
seen[game.name] = {'blocks': after, 'server': game.raw('block', *marker).get('block'),
|
|
'drops': counts,
|
|
'client_drops': {item: client_counts.get('minecraft:' + item, 0)
|
|
for item in counts}}
|
|
other.shot(out, f'farmland-{game.name}-after')
|
|
finally:
|
|
host('command', command='gamerule random_tick_speed 3')
|
|
host('command', command='kill @e[type=minecraft:item]')
|
|
for g in (host, guest):
|
|
x0, y0, z0 = starts[g.name]
|
|
g('command', command=f'tp @s {x0} {y0} {z0}')
|
|
g('command', command='gamemode survival')
|
|
for name, s in seen.items():
|
|
assert all(v == ['minecraft:dirt', 'minecraft:air'] for v in s['blocks'].values()), \
|
|
f'{name}: unsupported wheat after landing: {seen}'
|
|
assert s['server'] == 'minecraft:gold_block', f'{name}: server kept the unsupported wheat: {seen}'
|
|
assert s['drops']['wheat'] == 1 and 1 <= s['drops']['wheat_seeds'] <= 4, \
|
|
f'{name}: mature wheat drops missing: {seen}'
|
|
assert s['client_drops'] == s['drops'], f'{name}: crop drops did not synchronize: {seen}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['farmland']
|
|
CHECK_FNS.update(farmland=check_farmland)
|
|
|
|
|
|
def check_large_chest(host, guest, out):
|
|
"""Every player slot can be picked up or shift-clicked into a double chest (#1505, #1589).
|
|
Reopening checks the authoritative contents, with a single chest as the control."""
|
|
seen = {}
|
|
for game in (host, guest):
|
|
game('command', command='gamemode survival')
|
|
x, y, z = (int(v // 1) for v in pos_of(game))
|
|
chest, other = (x, y, z + 2), (x + 1, y, z + 2)
|
|
try:
|
|
for double in (False, True):
|
|
size = 54 if double else 27
|
|
# Double chest indices 54..89 include the old cutoff at 63/64 and every hotbar slot.
|
|
sources = list(range(9, 36)) + list(range(9)) if double else [19, 0, 8]
|
|
for shift in (False, True):
|
|
back_to_game(game)
|
|
for at in (chest, other):
|
|
game('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
|
|
game('command', command='kill @e[type=minecraft:item,distance=..8]')
|
|
game('command', command='clear @s')
|
|
block = 'minecraft:chest[facing=north,type=left]' if double else 'minecraft:chest'
|
|
game('command', command=f'setblock {x} {y} {z + 2} {block}')
|
|
if double:
|
|
game('command', command=f'setblock {x + 1} {y} {z + 2} minecraft:chest[facing=north,type=right]')
|
|
for slot in sources:
|
|
name = f'hotbar.{slot}' if slot < 9 else f'inventory.{slot - 9}'
|
|
game('command', command=f'item replace entity @s {name} with minecraft:diamond')
|
|
game.wait(10)
|
|
game.raw('interact', *chest)
|
|
game.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
|
game.wait(10)
|
|
assert container_items(game)[1] == size, f'{game.name}: expected {size} chest slots'
|
|
label = f'{"double" if double else "single"}-chest-{"shift" if shift else "pickup"}'
|
|
for target, slot in enumerate(sources):
|
|
if shift:
|
|
game.raw('key', 'shift', 'down')
|
|
try:
|
|
mouse(game, slot)
|
|
finally:
|
|
if shift:
|
|
game.raw('key', 'shift', 'up')
|
|
if not shift:
|
|
held = game('inventory').get('carried') or {}
|
|
if held.get('item') != 'minecraft:diamond' or held.get('count') != 1:
|
|
game.shot(out, f'{label}-slot-{slot}-failed')
|
|
raise AssertionError(f'{game.name} {label}: slot {slot} picked up {held}')
|
|
mouse(game, 300 + target)
|
|
game.raw('key', 'esc', 'tap')
|
|
game.wait(10)
|
|
game.raw('interact', *chest)
|
|
game.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
|
game.wait(10)
|
|
contents = [s for s in game('screen')['slots'] if 300 <= s['slot'] < 300 + size and s.get('item')]
|
|
inv = game('inventory')
|
|
stored = sum(s['count'] for s in contents if s['item'] == 'minecraft:diamond')
|
|
assert stored == len(sources) and not inv['counts'].get('minecraft:diamond') and not inv.get('carried'), \
|
|
f'{game.name} {label}: chest {contents}, inventory {inv}'
|
|
game.shot(out, label)
|
|
seen[f'{game.name}-{label}'] = stored
|
|
game.raw('key', 'esc', 'tap')
|
|
finally:
|
|
back_to_game(game)
|
|
for at in (chest, other):
|
|
game('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
|
|
game('command', command='kill @e[type=minecraft:item,distance=..8]')
|
|
game('command', command='clear @s')
|
|
return seen
|
|
|
|
|
|
CHECKS += ['large_chest']
|
|
CHECK_FNS.update(large_chest=check_large_chest)
|
|
|
|
|
|
def check_falling(host, guest, out):
|
|
"""ItemEntity, FallingBlockEntity and PrimedTnt tick on a client too (gravity, collisions, drag),
|
|
and their position packets snap them (Entity's NO_OP InterpolationHandler). The server sends an
|
|
item's and a falling block's position every 20 ticks and TNT's every 10, so between two updates
|
|
the guest's own tick is what moves them: sampled every 2 ticks over the first 16, all three are
|
|
lower nearly every time. The TNT's fuse (sent once, at spawn) counts down on the guest too."""
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
top = y + 30
|
|
tag = 'falling_e2e'
|
|
host('command', command=f'fill {x + 2} {y} {z - 1} {x + 4} {top + 2} {z + 1} minecraft:air')
|
|
host('command', command=f'summon minecraft:item {x + 3.5} {top} {z + 0.5} '
|
|
f'{{Tags:["{tag}"],Item:{{id:"minecraft:diamond",count:1}},PickupDelay:32767}}')
|
|
host('command', command=f'summon minecraft:falling_block {x + 2.5} {top} {z - 0.5} '
|
|
f'{{Tags:["{tag}"],BlockState:{{Name:"minecraft:sand"}},DropItem:0b}}')
|
|
# Horizon's selectors don't see primed TNT yet: its fuse ends it in the air, 19 blocks down.
|
|
host('command', command=f'summon minecraft:tnt {x + 4.5} {top} {z + 0.5} {{fuse:34s}}')
|
|
heights = {'item': [], 'falling block': [], 'primed tnt': []}
|
|
fuses = []
|
|
try:
|
|
for _ in range(8):
|
|
items = [e['pos'][1] for e in guest.raw('entities', 48, 'minecraft:item')['entities']
|
|
if abs(e['pos'][0] - x - 3.5) < 0.5 and abs(e['pos'][2] - z - 0.5) < 0.5]
|
|
blocks = [o['position'][1] for o in guest('status')['projectiles']
|
|
if o.get('kind') == 'minecraft:falling_block'
|
|
and abs(o['position'][0] - x - 2.5) < 0.5 and abs(o['position'][2] - z + 0.5) < 0.5]
|
|
tnts = [o for o in guest('status')['projectiles'] if o.get('kind') == 'minecraft:tnt'
|
|
and abs(o['position'][0] - x - 4.5) < 0.5 and abs(o['position'][2] - z - 0.5) < 0.5]
|
|
heights['item'].append(items[0] if items else None)
|
|
heights['falling block'].append(blocks[0] if blocks else None)
|
|
heights['primed tnt'].append(tnts[0]['position'][1] if tnts else None)
|
|
fuses.append(tnts[0]['fuse'] if tnts else None)
|
|
guest.wait(2)
|
|
guest.shot(out, 'falling')
|
|
finally:
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
host('command', command=f'fill {x + 2} {y} {z - 1} {x + 4} {top + 2} {z + 1} minecraft:air')
|
|
for name, ys in heights.items():
|
|
seen = [h for h in ys if h is not None]
|
|
assert len(seen) >= 6, f'the guest saw the {name} {len(seen)} times of 8: {heights}'
|
|
drops = sum(b < a for a, b in zip(seen, seen[1:]))
|
|
# A tick or two can pass between two samples with no move; never most of them.
|
|
assert drops >= len(seen) - 2, f'the {name} fell in {drops} of {len(seen) - 1} samples: {heights}'
|
|
# The server sends the TNT's fuse with each move, so each sample says how many ticks it has
|
|
# fallen from rest: snapped to the server's place and ticking on, the guest has it exactly
|
|
# there (it used to trail two ticks behind, interpolating towards each update).
|
|
fell = [(h, 34 - f) for h, f in zip(heights['primed tnt'], fuses) if f is not None]
|
|
assert fell and fell[-1][1] > fell[0][1], f'the TNT\'s fuse on the guest: {fuses}'
|
|
off = max(abs(h - tnt_fall(top, n)) for h, n in fell)
|
|
assert off < 0.01, f'the guest\'s TNT is {off:.3f} off its fall (height, ticks): {fell}'
|
|
return {**heights, 'fuse': fuses}
|
|
|
|
|
|
def tnt_fall(top, ticks):
|
|
"""PrimedTnt.tick in open air from rest: its height after `ticks` ticks."""
|
|
y, v = top, 0.0
|
|
for _ in range(ticks):
|
|
v -= 0.04
|
|
y += v
|
|
v *= 0.98
|
|
return y
|
|
|
|
|
|
CHECKS += ['falling']
|
|
CHECK_FNS.update(falling=check_falling)
|
|
|
|
|
|
def check_crystals(host, guest, out):
|
|
"""#1532: a real left click detonates a placed End crystal in survival and creative,
|
|
on the host and the guest; reach, block occlusion and passing right clicks still apply."""
|
|
spots = apart(host, guest)
|
|
seen = {}
|
|
|
|
def crystals(game, at):
|
|
return [p for p in game('status')['projectiles'] if p['kind'] == 'minecraft:end_crystal'
|
|
and math.dist(p['position'], at) < 0.01]
|
|
|
|
for label, player, other in (('host', host, guest), ('guest', guest, host)):
|
|
x, y, z = spots[label]
|
|
y += 32 # Above the terrain and its entities: EndCrystalItem requires an empty box.
|
|
at = [x + 2.5, y, z + 0.5]
|
|
other('command', command='gamemode creative')
|
|
for mode in ('creative', 'survival'):
|
|
case = f'{label}-{mode}'
|
|
try:
|
|
host('command', command=f'fill {x - 5} {y - 1} {z - 3} {x + 5} {y - 1} {z + 3} minecraft:obsidian')
|
|
host('command', command=f'fill {x - 5} {y} {z - 3} {x + 5} {y + 3} {z + 3} minecraft:air')
|
|
player('command', command=f'gamemode {mode}')
|
|
player('command', command='clear @s')
|
|
player('command', command='effect clear @s')
|
|
player('command', command='effect give @s minecraft:instant_health 1 5 true')
|
|
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
player('command', command='give @s minecraft:end_crystal 1')
|
|
player.raw('select', 1)
|
|
player.wait(10)
|
|
player.raw('look-at', at[0], y - 0.01, at[2])
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(10)
|
|
placed = crystals(player, at)
|
|
assert len(placed) == 1, f'{case}: placing the crystal: {placed}'
|
|
crystal_id = placed[0]['id']
|
|
tracked = crystals(other, at)
|
|
assert crystal_id != 0 and [p['id'] for p in tracked] == [crystal_id], f'{case}: crystal not tracked on both sides'
|
|
|
|
# The nearest face is six blocks away, beyond both modes' attack reach.
|
|
player('command', command=f'tp @s {x - 4.5} {y} {z + 0.5}')
|
|
player.wait(5)
|
|
player.raw('look-at', at[0], y + 1, at[2])
|
|
player.raw('mouse', 'left', 'click')
|
|
player.wait(10)
|
|
assert crystals(player, at), f'{case}: attacked a crystal out of reach'
|
|
|
|
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
host('command', command=f'fill {x + 1} {y} {z} {x + 1} {y + 2} {z} minecraft:obsidian')
|
|
player.wait(5)
|
|
player.raw('look-at', at[0], y + 1, at[2])
|
|
player.raw('mouse', 'left', 'click')
|
|
player.wait(10)
|
|
assert crystals(player, at), f'{case}: attacked a crystal through a block'
|
|
host('command', command=f'fill {x + 1} {y} {z} {x + 1} {y + 2} {z} minecraft:air')
|
|
player('command', command='item replace entity @s weapon.mainhand with minecraft:shield')
|
|
player.wait(10)
|
|
player.raw('look-at', at[0], y + 1, at[2])
|
|
player.raw('mouse', 'right', 'down')
|
|
player.wait(10)
|
|
assert player('state').get('using') and server_using(host, player), f'{case}: crystal consumed right click'
|
|
player.raw('mouse', 'right', 'up')
|
|
player('command', command='clear @s')
|
|
player.wait(5)
|
|
player.shot(out, f'crystals-{mode}-before')
|
|
|
|
player.raw('mouse', 'left', 'click')
|
|
player.wait(40)
|
|
remaining = {g.name: len(crystals(g, at)) for g in (player, other)}
|
|
health = server_player(host, player)['health']
|
|
screen = player('state').get('screen')
|
|
seen[case] = {'id': crystal_id, 'remaining': remaining, 'health': health, 'screen': screen}
|
|
player.shot(out, f'crystals-{mode}-after')
|
|
assert not any(remaining.values()), f'{case}: left click left the crystal intact: {seen[case]}'
|
|
assert health == (20 if mode == 'creative' else 0), f'{case}: explosion damage: {seen[case]}'
|
|
assert screen == ('playing' if mode == 'creative' else 'dead'), f'{case}: explosion death: {seen[case]}'
|
|
finally:
|
|
if player('state').get('screen') == 'dead':
|
|
respawn_when_ready(player)
|
|
player.wait(30)
|
|
else:
|
|
player.raw('mouse', 'right', 'up')
|
|
player('command', command='gamemode creative')
|
|
return seen
|
|
|
|
|
|
CHECKS += ['crystals']
|
|
CHECK_FNS.update(crystals=check_crystals)
|
|
|
|
|
|
def check_crystal_blast(host, guest, out):
|
|
"""#1533: TNT removes a crystal without a blast; a punched crystal blasts once.
|
|
Both clients must see the surviving distant and invulnerable crystals."""
|
|
spots = apart(host, guest)
|
|
seen = {}
|
|
for label, player, other in (('host', host, guest), ('guest', guest, host)):
|
|
x, y, z = spots[label]
|
|
y += 32
|
|
player('command', command='gamemode creative')
|
|
for offset, cause in enumerate(('tnt', 'melee')):
|
|
# Leave survivors in separate arenas; the scratch world is discarded.
|
|
z = spots[label][2] + (0 if label == 'host' else 80) + offset * 40
|
|
case = f'{label}-{cause}'
|
|
host('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 25} {y - 1} {z + 5} minecraft:bedrock')
|
|
host('command', command=f'fill {x - 3} {y} {z - 3} {x + 25} {y + 4} {z + 5} minecraft:air')
|
|
positions = [[x + dx, y, z + dz] for dx, dz in ((2.5, 0.5), (12.5, 0.5), (22.5, 0.5), (4.5, 3.5))]
|
|
|
|
def remaining(game):
|
|
crystals = [p for p in game('status')['projectiles'] if p['kind'] == 'minecraft:end_crystal']
|
|
return [i for i, at in enumerate(positions)
|
|
if any(math.dist(p['position'], at) < 0.01 for p in crystals)]
|
|
|
|
for i, at in enumerate(positions):
|
|
host('command', command=f'summon minecraft:end_crystal {at[0]} {at[1]} {at[2]} {{Invulnerable:{1 if i == 3 else 0}b}}')
|
|
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
other('command', command='gamemode creative')
|
|
other('command', command=f'tp @s {x + 0.5} {y} {z + 4.5}')
|
|
player('command', command='clear @s')
|
|
player.wait(20)
|
|
for game in (host, guest):
|
|
assert remaining(game) == [0, 1, 2, 3], f'{case}: crystals not tracked on {game.name}'
|
|
if cause == 'tnt':
|
|
player('command', command=f'summon minecraft:tnt {x + 0.5} {y} {z + 0.5} {{fuse:10s}}')
|
|
else:
|
|
player.raw('look-at', positions[0][0], y + 1, positions[0][2])
|
|
player.raw('mouse', 'left', 'click')
|
|
player.wait(80)
|
|
result = {game.name: remaining(game) for game in (host, guest)}
|
|
seen[case] = result
|
|
# TNT reaches the first crystal only. A direct crystal blast reaches
|
|
# the second too, but that crystal must not detonate the third.
|
|
expected = [1, 2, 3] if cause == 'tnt' else [2, 3]
|
|
assert all(ids == expected for ids in result.values()), f'{case}: expected {expected}, got {result}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['crystal_blast']
|
|
CHECK_FNS.update(crystal_blast=check_crystal_blast)
|
|
|
|
|
|
def check_boat_dismount_pose(host, guest, out):
|
|
"""Removing a boat detaches its mobs on the server and every observing client."""
|
|
x, y, z = map(math.floor, pos_of(host))
|
|
bx, bz = x + 3.5, z + 3.5
|
|
tag = 'boat_dismount_pose'
|
|
host('command', command='gamemode creative')
|
|
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 8} {y - 1} {z + 8} minecraft:stone')
|
|
host('command', command=f'fill {x - 2} {y} {z - 2} {x + 8} {y + 4} {z + 8} minecraft:air')
|
|
host('command', command=f'summon minecraft:oak_boat {bx} {y} {bz} {{Tags:["{tag}"]}}')
|
|
for _ in range(2):
|
|
host('command', command=f'summon minecraft:husk {bx} {y} {bz} {{NoAI:1b,Tags:["{tag}"]}}')
|
|
host.wait(5)
|
|
try:
|
|
host.wait(20)
|
|
boat = next(o for o in host('status')['objects']
|
|
if o['kind'].endswith('oak_boat') and math.dist(o['position'], [bx, y, bz]) < 2)
|
|
riders = [m['id'] for m in host.raw('mobs', 64)['near']
|
|
if m['kind'] == 'minecraft:husk' and m['mount'] == boat['id']]
|
|
assert len(riders) == 2, f'the boat did not collect both husks: {riders}'
|
|
for game in (host, guest):
|
|
game.wait(10)
|
|
for rider in riders:
|
|
m = mob_seen(game, rider)
|
|
assert m and m['mount'] == boat['id'] and m['passenger'], f'{game.name} did not see seated husk {rider}: {m}'
|
|
game.raw('look-at', bx, y + 1, bz)
|
|
game.shot(out, 'boat-passengers-seated')
|
|
host('command', command=f'tp @s {bx - 3} {y} {bz} 0 0')
|
|
host.wait(5)
|
|
# Aim at the near rim, below the riders, so their boxes cannot
|
|
# intercept the creative player's one hit that destroys the boat.
|
|
host.raw('look-at', bx - 0.55, y + 0.1, bz)
|
|
host.raw('mouse', 'left', 'click')
|
|
seen = {}
|
|
for game in (host, guest):
|
|
game.wait(20)
|
|
assert all(o['id'] != boat['id'] for o in game('status')['objects']), f'{game.name} still sees the boat'
|
|
seen[game.name] = [mob_seen(game, rider) for rider in riders]
|
|
game.raw('look-at', bx, y + 1, bz)
|
|
game.shot(out, 'boat-passengers-dismounted')
|
|
for m in seen[game.name]:
|
|
assert m and m['mount'] is None and not m['passenger'], f'{game.name} still draws a removed boat\'s passenger: {m}'
|
|
return {name: [{k: m[k] for k in ('id', 'mount', 'passenger')} for m in mobs] for name, mobs in seen.items()}
|
|
finally:
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
|
|
|
|
CHECKS += ['boat-dismount-pose']
|
|
CHECK_FNS['boat-dismount-pose'] = check_boat_dismount_pose
|
|
|
|
|
|
def check_sprint_fov(host, guest, out):
|
|
"""Releasing Ctrl must preserve a sprint until W is released, then reset FOV.
|
|
|
|
PlayerInput reports keys; PlayerCommand starts/stops the sprint. Confusing
|
|
them clears the client's sprint flag while leaving its speed modifier on.
|
|
"""
|
|
seen = {}
|
|
for game in (host, guest):
|
|
game('command', command='gamemode creative')
|
|
game('command', command='effect clear @s')
|
|
game.raw('options-set', 'fovEffectScale', '1.0')
|
|
x, y, z = map(math.floor, pos_of(game))
|
|
game('command', command=f'fill {x - 2} {y} {z - 2} {x + 2} {y + 3} {z + 20} minecraft:air')
|
|
game('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 2} {y - 1} {z + 20} minecraft:stone')
|
|
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
|
|
game.wait(20)
|
|
try:
|
|
game.raw('key', 'w', 'down')
|
|
game.raw('key', 'ctrl', 'down')
|
|
game.wait(10)
|
|
started = game('state')['player']
|
|
game.raw('key', 'ctrl', 'up')
|
|
game.wait(10)
|
|
released_ctrl = game('state')['player']
|
|
game.raw('key', 'w', 'up')
|
|
game.wait(20)
|
|
stopped = game('state')['player']
|
|
finally:
|
|
game.raw('key', 'ctrl', 'up')
|
|
game.raw('key', 'w', 'up')
|
|
seen[game.name] = states = {
|
|
label: {k: p[k] for k in ('sprinting', 'fov_modifier')}
|
|
for label, p in [('started', started), ('released_ctrl', released_ctrl), ('stopped', stopped)]
|
|
}
|
|
for label in ('started', 'released_ctrl'):
|
|
assert states[label]['sprinting'], f'{game.name} {label}: {states}'
|
|
assert abs(states[label]['fov_modifier'] - 1.15) < 0.002, f'{game.name} {label}: {states}'
|
|
assert not stopped['sprinting'], f'{game.name}: {states}'
|
|
assert abs(stopped['fov_modifier'] - 1.0) < 0.002, f'{game.name}: {states}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['sprint-fov']
|
|
CHECK_FNS['sprint-fov'] = check_sprint_fov
|
|
|
|
|
|
def check_critical_hits(host, guest, out):
|
|
"""#1898: a real falling melee hit emits crits on the attacker and observer.
|
|
Grounded hits and refused falling hits emit none, against mobs and players.
|
|
"""
|
|
x, y, z = map(math.floor, pos_of(host))
|
|
target = [x + 2.5, y, z + 0.5]
|
|
tag = 'critical_hit_particles'
|
|
seen = {}
|
|
host('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 6} {y - 1} {z + 6} minecraft:stone')
|
|
host('command', command=f'fill {x - 3} {y} {z - 3} {x + 6} {y + 5} {z + 6} minecraft:air')
|
|
host('command', command='time set midnight')
|
|
host('command', command='gamerule natural_health_regeneration false')
|
|
try:
|
|
for label, player, other in (('host', host, guest), ('guest', guest, host)):
|
|
for kind in ('zombie', 'player'):
|
|
for hit in ('grounded', 'critical', 'refused'):
|
|
case = f'{label}-{kind}-{hit}'
|
|
for game in (player, other):
|
|
game('command', command='gamemode survival')
|
|
game('command', command='clear @s')
|
|
game('command', command='effect clear @s')
|
|
game('command', command='effect give @s minecraft:instant_health 1 5 true')
|
|
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
other_at = target if kind == 'player' else [x + 0.5, y, z + 4.5]
|
|
other('command', command=f'tp @s {other_at[0]} {other_at[1]} {other_at[2]}')
|
|
host('command', command=f'gamerule pvp {str(hit != "refused").lower()}')
|
|
mob_id = None
|
|
if kind == 'zombie':
|
|
host('command', command=f'summon minecraft:zombie {target[0]} {y} {target[2]} '
|
|
f'{{NoAI:1b,PersistenceRequired:1b,Invulnerable:{int(hit == "refused")}b,Tags:["{tag}"]}}')
|
|
player.wait(20) # full attack charge and paired entities
|
|
if kind == 'zombie':
|
|
mob = next(m for m in player.raw('mobs', 16)['near']
|
|
if m['kind'] == 'minecraft:zombie' and math.dist(m['pos'], target) < 0.1)
|
|
mob_id = mob['id']
|
|
assert mob_seen(other, mob_id), f'{case}: observer has not paired the zombie'
|
|
health_before = mob_seen(player, mob_id)['health'] if mob_id is not None else hearts(other)[1]
|
|
sound = 'minecraft:entity.player.attack.crit'
|
|
sounds_before = {side: heard(g, sound) for side, g in (('own', player), ('other', other))}
|
|
|
|
def strike():
|
|
if hit != 'grounded':
|
|
player.raw('key', 'space', 'down')
|
|
player.wait(1)
|
|
player.raw('key', 'space', 'up')
|
|
body = None
|
|
for _ in range(15):
|
|
body = player('state')['player']
|
|
# Let the server record the fall before the attack packet.
|
|
if not body['on_ground'] and body['velocity'][1] < -0.2:
|
|
break
|
|
player.wait(1)
|
|
assert body and not body['on_ground'] and body['velocity'][1] < -0.2, \
|
|
f'{case}: jump never reached its falling phase: {body}'
|
|
player.raw('look-at', target[0], y + 1, target[2])
|
|
player.raw('mouse', 'left', 'click')
|
|
|
|
particles = sparked(player, other, ['crit'], strike)
|
|
player.shot(out, f'critical-hits-{kind}-{hit}-{label}')
|
|
player.wait(10) # finish the three-tick emitter and health replication
|
|
health_after = mob_seen(player, mob_id)['health'] if mob_id is not None else hearts(other)[1]
|
|
sounds = {side: heard(g, sound) - sounds_before[side]
|
|
for side, g in (('own', player), ('other', other))}
|
|
seen[case] = {'particles': particles, 'crit_sounds': sounds,
|
|
'health': [health_before, health_after]}
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
assert (health_after == health_before) if hit == 'refused' else (health_after < health_before), \
|
|
f'{case}: unexpected damage: {seen[case]}'
|
|
assert all(n == int(hit == 'critical') for n in sounds.values()), \
|
|
f'{case}: the server did not recognize the expected crit: {seen[case]}'
|
|
wrong = {case: result for case, result in seen.items()
|
|
if any((n <= 0 if case.endswith('-critical') else n != 0)
|
|
for n in result['particles'].values())}
|
|
assert not wrong, f'critical-hit particles: {wrong}'
|
|
return seen
|
|
finally:
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
host('command', command='gamerule pvp true')
|
|
host('command', command='gamerule natural_health_regeneration true')
|
|
host('command', command='time set day')
|
|
for game in (host, guest):
|
|
game.raw('key', 'space', 'up')
|
|
|
|
|
|
CHECKS += ['critical-hits']
|
|
CHECK_FNS['critical-hits'] = check_critical_hits
|
|
|
|
|
|
def check_sculk_container(host, guest, out):
|
|
"""A real guest's container interaction emits a server game event. Both
|
|
clients see the sensor and the non-summoning shrieker activate and reset.
|
|
The component oracle separately controls warning levels and seeded mobs.
|
|
"""
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
chest, sensor, shrieker = (x + 2, y, z), (x + 3, y, z), (x + 4, y, z)
|
|
guest('command', command='gamemode survival')
|
|
for at, block in ((chest, 'chest'), (shrieker, 'sculk_shrieker'), (sensor, 'sculk_sensor')):
|
|
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:{block}')
|
|
guest.wait(60)
|
|
seen = {}
|
|
# Keep the sensor's 30-tick active window observable while both clients
|
|
# acknowledge the container and the control socket inspects them.
|
|
host('command', command='tick rate 4')
|
|
try:
|
|
guest.raw('interact', *chest)
|
|
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
|
for game in (host, guest):
|
|
active = wait_state(game, sensor, 'sculk_sensor_phase', 'active', 40)
|
|
shrieking = wait_state(game, shrieker, 'shrieking', 'true', 40)
|
|
seen[game.name] = (active, shrieking)
|
|
assert all(s == ('active', 'true') for s in seen.values()), seen
|
|
guest.raw('key', 'esc', 'tap')
|
|
for game in (host, guest):
|
|
assert wait_state(game, sensor, 'sculk_sensor_phase', 'inactive', 160) == 'inactive', seen
|
|
assert wait_state(game, shrieker, 'shrieking', 'false', 160) == 'false', seen
|
|
finally:
|
|
host('command', command='tick rate 20')
|
|
if guest('state').get('screen') == 'container':
|
|
guest.raw('key', 'esc', 'tap')
|
|
for at in (sensor, shrieker, chest):
|
|
guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
|
|
return seen
|
|
|
|
|
|
CHECKS += ['sculk_container']
|
|
CHECK_FNS['sculk_container'] = check_sculk_container
|
|
|
|
|
|
def check_projectile_land(host, guest, out):
|
|
"""Projectile.onHit raises PROJECTILE_LAND for every projectile, so a sculk sensor next to the
|
|
landing hears a thrown trident, a splash potion and a fishing hook. The sensor
|
|
stands 10 blocks from the thrower, past its 8-block range, so the throw's own PROJECTILE_SHOOT
|
|
cannot power it; only the landing, 1-5 blocks away, can."""
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
sensor = (x + 10, y, z)
|
|
host('command', command=f'fill {x - 1} {y - 1} {z - 2} {x + 14} {y - 1} {z + 2} minecraft:stone')
|
|
host('command', command=f'fill {x - 1} {y} {z - 2} {x + 14} {y + 5} {z + 2} minecraft:air')
|
|
host('command', command=f'fill {x + 11} {y} {z - 2} {x + 11} {y + 4} {z + 2} minecraft:stone')
|
|
host('command', command=f'setblock {sensor[0]} {sensor[1]} {sensor[2]} minecraft:sculk_sensor')
|
|
host('command', command='difficulty peaceful')
|
|
host.wait(60)
|
|
# label: (item, gamemode, how it is thrown)
|
|
cases = {
|
|
'trident': ('minecraft:trident', 'survival', 'hold'),
|
|
'potion': ('minecraft:splash_potion[potion_contents={potion:"minecraft:water"}]', 'survival', 'use'),
|
|
'hook': ('minecraft:fishing_rod', 'survival', 'use'),
|
|
}
|
|
seen = {}
|
|
try:
|
|
for label, (item, mode, how) in cases.items():
|
|
assert wait_state(host, sensor, 'sculk_sensor_phase', 'inactive', 200) == 'inactive', seen
|
|
host('command', command=f'gamemode {mode}')
|
|
host('command', command='clear @s')
|
|
# Wandering mobs step, flap and get hurt next to the sensor and power it themselves.
|
|
host('command', command='kill @e[type=!minecraft:player,distance=..40]')
|
|
host('command', command=f'item replace entity @s weapon.mainhand with {item}')
|
|
host('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} -90 0')
|
|
host.wait(10)
|
|
if label == 'hook':
|
|
host.raw('look-at', f'{x + 4.5:.2f}', f'{y:.2f}', f'{z + 0.5:.2f}')
|
|
elif label == 'potion':
|
|
host.raw('look-at', f'{x + 11.0:.2f}', f'{y + 2.5:.2f}', f'{z + 0.5:.2f}')
|
|
else:
|
|
host.raw('look-at', f'{x + 11.0:.2f}', f'{y + 1.6:.2f}', f'{z + 0.5:.2f}')
|
|
host.wait(20)
|
|
assert state_of(host, sensor, 'sculk_sensor_phase') == 'inactive', f'{label}: the setup powered the sensor: {seen}'
|
|
if how == 'hold':
|
|
host.raw('use-item', '--hold', 1.2)
|
|
else:
|
|
host.raw('use-item')
|
|
seen[label] = wait_state(host, sensor, 'sculk_sensor_phase', 'active', 80)
|
|
if label == 'hook':
|
|
host.raw('use-item')
|
|
finally:
|
|
host('command', command='clear @s')
|
|
host('command', command='kill @e[type=minecraft:trident]')
|
|
host('command', command='gamemode creative')
|
|
host('command', command=f'fill {x + 11} {y} {z - 2} {x + 11} {y + 4} {z + 2} minecraft:air')
|
|
host('command', command=f'setblock {sensor[0]} {sensor[1]} {sensor[2]} minecraft:air')
|
|
assert all(v == 'active' for v in seen.values()), f'a landing the sensor did not hear: {seen}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['projectile_land']
|
|
CHECK_FNS['projectile_land'] = check_projectile_land
|
|
|
|
def check_replant(host, guest, out):
|
|
"""Instant breaks leave no server dig that can destroy a replacement (#1880).
|
|
|
|
ServerPlayerGameMode.handleBlockBreakAction's insta-mine branch finishes on
|
|
START; MultiPlayerGameMode sends no STOP or ABORT when attack is released.
|
|
Exercise actual flower placement and seed planting after a short click.
|
|
"""
|
|
spots = apart(host, guest)
|
|
seen = {}
|
|
host('command', command='gamerule random_tick_speed 0')
|
|
try:
|
|
for label, player in (('host', host), ('guest', guest)):
|
|
x, y, z = spots[label]
|
|
cell = (x, y, z + 2)
|
|
|
|
def observe(block):
|
|
# The integrated host is itself a client: wait for both views.
|
|
for _ in range(40):
|
|
blocks = [g.raw('block', *cell).get('block') for g in (host, guest)]
|
|
if blocks == [f'minecraft:{block}'] * 2:
|
|
return blocks
|
|
host.wait(2)
|
|
raise AssertionError(f'{label} expected {block} on both clients: {blocks}')
|
|
|
|
for mode, block, item, soil in (
|
|
('survival', 'dandelion', 'dandelion', 'dirt'),
|
|
('survival', 'wheat', 'wheat_seeds', 'farmland[moisture=7]'),
|
|
('survival', 'carrots', 'carrot', 'farmland[moisture=7]'),
|
|
('creative', 'dandelion', 'dandelion', 'dirt'),
|
|
):
|
|
player('command', command=f'gamemode {mode}')
|
|
player('command', command='clear @s')
|
|
player('command', command=f'item replace entity @s weapon.mainhand with minecraft:{item} 8')
|
|
player('command', command=f'setblock {x} {y - 1} {z + 2} minecraft:{soil}')
|
|
player('command', command=f'setblock {x} {y} {z + 2} minecraft:{block}')
|
|
player.wait(5)
|
|
observe(block)
|
|
# Flowers' narrow outlines have a position-dependent offset.
|
|
for dx in (0.0, -0.2, 0.2):
|
|
player.raw('look-at', x + 0.5 + dx, y + (0.3 if block == 'dandelion' else 0.1), z + 2.5)
|
|
target = player('looking_at').get('block') or {}
|
|
if target.get('pos') == list(cell):
|
|
break
|
|
assert target.get('pos') == list(cell), f'{label} does not aim at {block}: {target}'
|
|
player.raw('mouse', 'left', 'click')
|
|
player.wait(5)
|
|
observe('air')
|
|
# Keep aiming at the supporting soil and use the held flower/seeds.
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(3)
|
|
observe(block)
|
|
player.wait(20) # past the stale dig's five-tick destroy delay
|
|
key = f'{label} {mode} {block}'
|
|
seen[key] = [g.raw('block', *cell).get('block') for g in (host, guest)]
|
|
player.shot(out, f'replant-{mode}-{block}')
|
|
assert seen[key] == [f'minecraft:{block}'] * 2, f'{key}: {seen}'
|
|
player('command', command=f'setblock {x} {y} {z + 2} minecraft:air')
|
|
finally:
|
|
host('command', command='gamerule random_tick_speed 3')
|
|
return seen
|
|
|
|
|
|
CHECKS += ['replant']
|
|
CHECK_FNS['replant'] = check_replant
|
|
|
|
|
|
def check_guardian_beams(host, guest, out):
|
|
"""Guardian's synced target drives the same beam on each client (#1491).
|
|
|
|
Guardian.defineSynchedData/onSyncedDataUpdated/aiStep and
|
|
GuardianRenderer.extractRenderState: charge, hit, then target loss.
|
|
"""
|
|
x, y, z = map(math.floor, pos_of(host))
|
|
host('command', command='difficulty normal')
|
|
host('command', command='gamerule natural_health_regeneration false')
|
|
host('command', command=f'fill {x - 10} {y - 2} {z - 10} {x + 10} {y + 4} {z + 10} minecraft:water')
|
|
seen = {}
|
|
try:
|
|
for kind in ('guardian', 'elder_guardian'):
|
|
for target, observer in ((host, guest), (guest, host)):
|
|
for game in (host, guest):
|
|
game('command', command='gamemode creative')
|
|
game('command', command='effect give @s minecraft:water_breathing infinite')
|
|
target('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
|
|
observer('command', command=f'tp @s {x + 4.5} {y} {z + 0.5} 0 0')
|
|
target('command', command='effect give @s minecraft:instant_health 1 10')
|
|
target('command', command='gamemode survival')
|
|
host('command', command=f'summon minecraft:{kind} {x + 0.5} {y} {z + 6.5}')
|
|
|
|
def mob(game):
|
|
return next(m for m in game.raw('mobs', 32)['near'] if m['kind'] == f'minecraft:{kind}')
|
|
|
|
def charging():
|
|
for _ in range(60):
|
|
target.wait(2)
|
|
states = [mob(g) for g in (target, observer)]
|
|
if all(m['guardian_beam'] is not None for m in states):
|
|
return states
|
|
raise Failed(f'{kind} never charged on both clients')
|
|
|
|
states = charging()
|
|
assert states[0]['target'] == 'Player', states
|
|
# The host reads the level that ticks the guardian (the server's), where a
|
|
# targeted player is `Player`.
|
|
if observer is host:
|
|
assert states[1]['target'] == 'Player', states
|
|
else:
|
|
assert states[1]['target'].startswith('Mob('), states
|
|
first = states[0]['guardian_beam'][0]
|
|
target.wait(4)
|
|
assert mob(target)['guardian_beam'][0] > first, 'client charge clock did not advance'
|
|
for game in (target, observer):
|
|
game.raw('look-at', x + 0.5, y + 0.5, z + 6.5)
|
|
game.shot(out, f'{kind}-{target.name}-charge')
|
|
# The beam must end after the first hit (before another charge).
|
|
before = target('status')['player']['health']
|
|
for _ in range(70):
|
|
target.wait(2)
|
|
if all(mob(g)['guardian_beam'] is None for g in (target, observer)):
|
|
break
|
|
else:
|
|
raise Failed(f'{kind} beam did not end after its attack')
|
|
after = target('status')['player']['health']
|
|
assert after < before, f'{kind} beam ended without damage: {before} -> {after}'
|
|
charging()
|
|
target('command', command='gamemode creative')
|
|
target.wait(10)
|
|
assert all(mob(g)['guardian_beam'] is None for g in (target, observer)), 'beam survived target loss'
|
|
seen[f'{kind}-{target.name}'] = {
|
|
'charge': [{'target': m['target'], 'beam': m['guardian_beam']} for m in states],
|
|
'health': [before, after]}
|
|
host('command', command=f'kill @e[type=minecraft:{kind}]')
|
|
host.wait(10)
|
|
finally:
|
|
for kind in ('guardian', 'elder_guardian'):
|
|
host('command', command=f'kill @e[type=minecraft:{kind}]')
|
|
for game in (host, guest):
|
|
game('command', command='gamemode creative')
|
|
game('command', command='effect clear @s')
|
|
return seen
|
|
|
|
|
|
CHECKS += ['guardian-beams']
|
|
CHECK_FNS['guardian-beams'] = check_guardian_beams
|
|
|
|
|
|
def check_fangs(host, guest, out):
|
|
"""EvokerFangs: authoritative spawn, event 4, client animation and server removal (#1482)."""
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
tag = 'lan_evoker_fangs'
|
|
selector = f'@e[type=minecraft:evoker,tag={tag},limit=1]'
|
|
paths = {g.name: {} for g in (host, guest)}
|
|
warmup = dict.fromkeys(paths, False)
|
|
drawn = dict.fromkeys(paths, False)
|
|
stopped = False
|
|
photographed = False
|
|
|
|
def fangs(status):
|
|
return [p for p in status['projectiles'] if p['kind'] == 'minecraft:evoker_fangs']
|
|
|
|
try:
|
|
host('command', command='difficulty normal')
|
|
host('command', command='gamemode creative')
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='effect give @s minecraft:resistance 120 4 true')
|
|
host('command', command=f'fill {x - 10} {y - 1} {z - 10} {x + 10} {y - 1} {z + 18} minecraft:stone')
|
|
host('command', command=f'fill {x - 10} {y} {z - 10} {x + 10} {y + 8} {z + 18} minecraft:air')
|
|
host('command', command=f'tp @s {x + 4.5} {y} {z - 2.5}')
|
|
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
guest.wait(10)
|
|
host('command', command=f'summon minecraft:evoker {x + 0.5} {y} {z + 9.5} '
|
|
f'{{PersistenceRequired:1b,Tags:["{tag}"]}}')
|
|
for game in (host, guest):
|
|
game.raw('look-at', x + 0.5, y + 0.5, z + 5.5)
|
|
for _ in range(200):
|
|
guest.wait(2)
|
|
statuses = {g.name: g('status') for g in (host, guest)}
|
|
for name, status in statuses.items():
|
|
live = fangs(status)
|
|
ids = [f['id'] for f in live]
|
|
assert all(ids) and len(ids) == len(set(ids)), f'{name}: invalid fang ids {ids}'
|
|
warmup[name] |= any(not f['started'] for f in live)
|
|
drawn[name] |= status['fangs_vertices'] > 0
|
|
for f in live:
|
|
paths[name].setdefault(f['id'], []).append(f)
|
|
if paths['host'] and not stopped:
|
|
host('command', command=f'data merge entity {selector} {{NoAI:1b}}')
|
|
stopped = True
|
|
if (all(drawn.values()) and not photographed
|
|
and any(f['started'] and 8 <= f['life'] <= 16
|
|
for f in fangs(statuses['guest']))):
|
|
host('command', command='tick freeze')
|
|
try:
|
|
for game in (host, guest):
|
|
game.raw('key', 'f1', 'tap')
|
|
try:
|
|
game.shot(out, 'evoker-fangs')
|
|
finally:
|
|
game.raw('key', 'f1', 'tap')
|
|
finally:
|
|
host('command', command='tick unfreeze')
|
|
photographed = True
|
|
if stopped and not any(fangs(s) for s in statuses.values()):
|
|
break
|
|
lab.write_json(out / 'evoker-fangs.json', {'paths': paths, 'warmup': warmup, 'drawn': drawn})
|
|
assert all(warmup.values()), f'fang warmup not received: {warmup}'
|
|
assert all(drawn.values()), f'fang renderer produced no geometry: {drawn}'
|
|
assert paths['host'].keys() == paths['guest'].keys(), 'clients received different fang ids'
|
|
for id, samples in paths['host'].items():
|
|
other = paths['guest'][id]
|
|
assert all(f['position'] == samples[0]['position'] for f in samples + other), \
|
|
f'fang {id} is not at its authoritative position'
|
|
for name, frames in (('host', samples), ('guest', other)):
|
|
life = {f['life'] for f in frames if f['started']}
|
|
assert len(life) > 1, f'{name}: fang {id} did not animate: {frames}'
|
|
assert not any(fangs(g('status')) for g in (host, guest)), 'fangs survived server removal'
|
|
finally:
|
|
host('command', command='tick unfreeze')
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
# The evoker can summon vexes before fangs; killing it leaves those summons alive.
|
|
host('command', command=f'kill @e[type=minecraft:vex,x={x},y={y},z={z},distance=..32]')
|
|
guest('command', command='effect clear @s')
|
|
face_each_other(host, guest)
|
|
return {'fangs': len(paths['host']), 'warmup': warmup, 'drawn': drawn, 'removed': True}
|
|
|
|
|
|
CHECKS += ['fangs']
|
|
CHECK_FNS.update(fangs=check_fangs)
|
|
|
|
|
|
def check_sonic(host, guest, out):
|
|
"""A real warden's ray reaches connected clients once, with normal damage.
|
|
|
|
The creative observer sees the same ray nearby and none outside sendParticles' range.
|
|
"""
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
result = {}
|
|
guest('command', command='gamemode creative')
|
|
host('command', command='difficulty normal')
|
|
host('command', command='gamerule natural_health_regeneration false')
|
|
try:
|
|
host('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 85} {y - 1} {z + 14} minecraft:stone')
|
|
host('command', command=f'fill {x - 3} {y} {z - 3} {x + 15} {y + 4} {z + 14} minecraft:air')
|
|
host('command', command=f'fill {x - 2} {y} {z + 4} {x + 2} {y + 3} {z + 4} minecraft:stone')
|
|
for near in (True, False):
|
|
guest('command', command=f'tp @s {x + (3 if near else 80) + 0.5} {y} {z + 0.5}')
|
|
host('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
|
|
host('command', command='gamemode survival')
|
|
host('command', command='effect give @s minecraft:instant_health 1 5')
|
|
host.wait(4)
|
|
host('command', command=f'summon minecraft:warden {x + 0.5} {y} {z + 9.5} {{NoAI:1b,Tags:["sonic_test"]}}')
|
|
host('command', command='attribute @e[tag=sonic_test,limit=1] minecraft:knockback_resistance base set 1')
|
|
before = {side: made(g, 'sonic_boom') for side, g in (('host', host), ('guest', guest))}
|
|
health = host('status')['player']['health']
|
|
host('command', command='data merge entity @e[tag=sonic_test,limit=1] {NoAI:0b}')
|
|
host('command', command='attribute @e[tag=sonic_test,limit=1] minecraft:movement_speed base set 0')
|
|
host('command', command='damage @e[tag=sonic_test,limit=1] 1 minecraft:player_attack by @s')
|
|
for _ in range(400):
|
|
host.wait(2)
|
|
if host('status')['player']['health'] < health:
|
|
break
|
|
host.wait(2) # let the observer process the same packet batch
|
|
seen = {side: made(g, 'sonic_boom') - before[side] for side, g in (('host', host), ('guest', guest))}
|
|
after = host('status')['player']['health']
|
|
result['near' if near else 'far'] = {'particles': seen, 'health': [health, after]}
|
|
host.shot(out, f'sonic-{"near" if near else "far"}')
|
|
host('command', command='kill @e[tag=sonic_test]')
|
|
assert after == health - 10, result
|
|
assert seen['host'] == 15, result
|
|
assert seen['guest'] == (15 if near else 0), result
|
|
finally:
|
|
host('command', command='kill @e[tag=sonic_test]')
|
|
host('command', command='gamerule natural_health_regeneration true')
|
|
return result
|
|
|
|
|
|
CHECKS += ['sonic']
|
|
CHECK_FNS['sonic'] = check_sonic
|
|
|
|
|
|
def check_creative_tools(host, guest, out):
|
|
"""#2035: switching Survival -> Creative cannot mine with a restricted tool.
|
|
Verify both the player's prediction and the host's authoritative block;
|
|
an ordinary tool must still break the same block.
|
|
"""
|
|
seen = {}
|
|
for label, player in (('host', host), ('guest', guest)):
|
|
x, y, z = (int(v // 1) for v in pos_of(player))
|
|
cell = (x, y + 1, z + 2)
|
|
host('command', command=f'fill {x - 1} {y} {z + 1} {x + 1} {y + 3} {z + 3} minecraft:air')
|
|
player('command', command='gamemode survival')
|
|
player('command', command='item replace entity @s weapon.mainhand with minecraft:netherite_sword')
|
|
player('command', command='gamemode creative')
|
|
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:stone')
|
|
player.wait(10)
|
|
player.raw('look-at', x + 0.5, y + 1.5, z + 2.5)
|
|
player.raw('mouse', 'left', 'down')
|
|
try:
|
|
player.wait(15)
|
|
finally:
|
|
player.raw('mouse', 'left', 'up')
|
|
seen[label] = {'client': player.raw('block', *cell).get('block'),
|
|
'server': host.raw('block', *cell).get('block')}
|
|
player.shot(out, 'creative-sword')
|
|
assert seen[label]['client'] == seen[label]['server'] == 'minecraft:stone', seen
|
|
player('command', command='item replace entity @s weapon.mainhand with minecraft:diamond_pickaxe')
|
|
player.wait(6)
|
|
player.raw('mouse', 'left', 'click')
|
|
player.wait(10)
|
|
seen[label]['ordinary_tool'] = host.raw('block', *cell).get('block')
|
|
assert seen[label]['ordinary_tool'] == 'minecraft:air', seen
|
|
return seen
|
|
|
|
|
|
CHECKS += ['creative_tools']
|
|
CHECK_FNS['creative_tools'] = check_creative_tools
|
|
|
|
|
|
def check_creative_throw(host, guest, out):
|
|
"""#3684: Q over a hotbar slot on a creative item tab drops the item instead of deleting it.
|
|
CreativeModeInventoryScreen.slotClicked on an item tab: with an empty hand the menu's THROW
|
|
sends the stack (handleCreativeModeItemDrop), Control+Q the whole stack; while carrying a stack
|
|
the screen itself drops one (Control: all) from the slot and the carried stack stays; over a
|
|
display slot it drops one, or a full stack with Control, of the shown item. On the Survival
|
|
Inventory tab the screen drops from the slot itself, one or the whole stack, carrying or not."""
|
|
seen = {}
|
|
|
|
def dropped(player, item):
|
|
return sum((e.get('item') or {}).get('count', 0) for e in player.raw('entities', 8)['entities']
|
|
if e['type'] == 'minecraft:item' and (e.get('item') or {}).get('item') == item)
|
|
|
|
def clear():
|
|
player('command', command='kill @e[type=minecraft:item,distance=..16]')
|
|
player.wait(2)
|
|
|
|
def slots(player, *ids):
|
|
inventory = player('inventory')
|
|
every = {s['slot']: (s.get('item'), s.get('count')) for s in inventory['hotbar'] + inventory['main']}
|
|
return {i: every[i] for i in ids}
|
|
|
|
def throw(player, slot, ctrl=False):
|
|
# The pointer over the slot, then Q (Control+Q) as AbstractContainerScreen.keyPressed reads it.
|
|
player.raw('scroll-at', *(f'{v:.1f}' for v in slot_point(player, slot)), 0)
|
|
if ctrl:
|
|
player.raw('key', 'ctrl', 'down')
|
|
player.raw('key', 'q', 'tap')
|
|
if ctrl:
|
|
player.raw('key', 'ctrl', 'up')
|
|
player.wait(5)
|
|
|
|
def tab(player, x, y):
|
|
# A tab button, from the screen's origin (slot 0 sits at 9, 112 on every tab).
|
|
sx, sy, _, _ = next(w for w in player.raw('ui')['widgets'] if w.get('id') == 'slot-0')['bounds']
|
|
player.raw('click-at', f'{sx - 9 + x:.1f}', f'{sy - 112 + y:.1f}')
|
|
player.wait(5)
|
|
|
|
for label, player in (('host', host), ('guest', guest)):
|
|
player('command', command='gamemode creative')
|
|
player('command', command='clear @s')
|
|
clear()
|
|
for slot, item in (('hotbar.0', 'stone 10'), ('hotbar.1', 'dirt 5'), ('hotbar.2', 'gravel 6'),
|
|
('inventory.0', 'cobblestone 10'), ('inventory.1', 'sand 5')):
|
|
player('command', command=f'item replace entity @s {slot} with minecraft:{item}')
|
|
player.wait(10)
|
|
player.raw('key', 'e', 'tap')
|
|
player.raw('wait-for', 'screen', 'creative', '--timeout', 10)
|
|
player.wait(5)
|
|
widgets = {w['id']: w for w in player.raw('ui')['widgets']}
|
|
if 'slot-900' not in widgets:
|
|
raise Failed(f'{label}: the creative screen did not open on an item tab')
|
|
shown = (widgets['slot-900'].get('stack') or {}).get('item')
|
|
s = seen[label] = {}
|
|
try:
|
|
# Empty hand: Q drops one stone, Control+Q all the gravel.
|
|
throw(player, 0)
|
|
s['one'] = (slots(player, 0)[0], dropped(player, 'minecraft:stone'))
|
|
# Each step's drops go before the next, so none is picked up again (40 ticks' delay).
|
|
clear()
|
|
throw(player, 2, ctrl=True)
|
|
s['all'] = (slots(player, 2)[2], dropped(player, 'minecraft:gravel'))
|
|
clear()
|
|
# Carrying a picked display stack: Q over the dirt drops one dirt, the hand keeps its stack.
|
|
player.raw('click', 'slot-900')
|
|
player.wait(2)
|
|
carried = player('inventory').get('carried')
|
|
throw(player, 1)
|
|
s['carrying'] = (slots(player, 1)[1], dropped(player, 'minecraft:dirt'), player('inventory').get('carried'))
|
|
clear()
|
|
# Control+Q, still carrying: the rest of the dirt.
|
|
throw(player, 1, ctrl=True)
|
|
s['whole'] = (slots(player, 1)[1], dropped(player, 'minecraft:dirt'), player('inventory').get('carried'))
|
|
clear()
|
|
# The carried stack goes to the empty hotbar slot 3.
|
|
player.raw('click', 'slot-3')
|
|
player.wait(2)
|
|
# A display slot: one of the shown item, a full stack with Control; the hotbar stays.
|
|
throw(player, 900)
|
|
s['display'] = dropped(player, shown)
|
|
clear()
|
|
throw(player, 900, ctrl=True)
|
|
s['display_all'] = dropped(player, shown)
|
|
s['hotbar'] = slots(player, 0, 1, 2, 3)
|
|
clear()
|
|
player.shot(out, f'creative-throw-{label}')
|
|
# The Survival Inventory tab (the bottom row's last).
|
|
tab(player, 169 + 13, 132 + 14)
|
|
if 'slot-9' not in {w['id'] for w in player.raw('ui')['widgets']}:
|
|
raise Failed(f'{label}: the Survival Inventory tab did not open')
|
|
throw(player, 9)
|
|
s['inventory_one'] = (slots(player, 9)[9], dropped(player, 'minecraft:cobblestone'))
|
|
clear()
|
|
throw(player, 9, ctrl=True)
|
|
s['inventory_all'] = (slots(player, 9)[9], dropped(player, 'minecraft:cobblestone'))
|
|
clear()
|
|
# Carrying the sand: Q over the stone drops one stone, the hand keeps the sand.
|
|
player.raw('click', 'slot-10')
|
|
player.wait(2)
|
|
throw(player, 0)
|
|
s['inventory_carrying'] = (slots(player, 0)[0], dropped(player, 'minecraft:stone'),
|
|
player('inventory').get('carried'))
|
|
player.raw('click', 'slot-10')
|
|
player.wait(2)
|
|
clear()
|
|
player.shot(out, f'creative-throw-inventory-{label}')
|
|
# Back to the first tab, where the screen opens next time.
|
|
tab(player, 13, -28 + 14)
|
|
finally:
|
|
player.raw('key', 'esc', 'tap')
|
|
player.wait(5)
|
|
assert s['one'] == (('minecraft:stone', 9), 1), seen
|
|
assert s['all'] == ((None, None), 6), seen
|
|
assert carried and s['carrying'] == (('minecraft:dirt', 4), 1, carried), seen
|
|
assert s['whole'] == ((None, None), 4, carried), seen
|
|
assert s['display'] == 1 and s['display_all'] == 64, seen
|
|
assert s['hotbar'] == {0: ('minecraft:stone', 9), 1: (None, None), 2: (None, None),
|
|
3: (carried['item'], carried['count'])}, seen
|
|
assert s['inventory_one'] == (('minecraft:cobblestone', 9), 1), seen
|
|
assert s['inventory_all'] == ((None, None), 9), seen
|
|
assert s['inventory_carrying'] == (('minecraft:stone', 8), 1, {'count': 5, 'item': 'minecraft:sand'}), seen
|
|
clear()
|
|
return seen
|
|
|
|
|
|
CHECKS += ['creative_throw']
|
|
CHECK_FNS['creative_throw'] = check_creative_throw
|
|
|
|
|
|
def check_cushions(host, guest, out):
|
|
"""#1935: place a colored cushion, consume it, track it, sit, dismount and break it.
|
|
#3117: placing swings the arm, and a seated player sits on another cushion at once."""
|
|
seen = {}
|
|
for label, player, color in (('host', host, 'red'), ('guest', guest, 'blue')):
|
|
x, y, z = (int(v // 1) for v in pos_of(player))
|
|
at = (x + 0.5, y, z + 2.5)
|
|
other = (x + 1.5, y, z + 2.5)
|
|
host('command', command=f'fill {x - 1} {y} {z + 1} {x + 1} {y + 3} {z + 3} minecraft:air')
|
|
host('command', command=f'setblock {x} {y - 1} {z + 2} minecraft:stone')
|
|
host('command', command=f'setblock {x + 1} {y - 1} {z + 2} minecraft:stone')
|
|
player('command', command='gamemode survival')
|
|
player('command', command=f'item replace entity @s weapon.mainhand with minecraft:{color}_cushion')
|
|
player.wait(10)
|
|
player.raw('look-at', *at)
|
|
swings = player('state')['swings']
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(15)
|
|
def cushions(game, at=at):
|
|
return [o for o in game('status')['objects'] if o['kind'] == 'minecraft:cushion'
|
|
and abs(o['position'][0] - at[0]) < .1 and abs(o['position'][2] - at[2]) < .1]
|
|
server, client = cushions(host), cushions(player)
|
|
seen[label] = {'server': server, 'client': client,
|
|
'remaining': player('inventory')['counts'].get(f'minecraft:{color}_cushion', 0),
|
|
'place_swings': player('state')['swings'] - swings}
|
|
assert len(server) == len(client) == 1, seen
|
|
assert server[0]['color'] == client[0]['color'] == color, seen
|
|
assert seen[label]['remaining'] == 0, seen
|
|
# CushionItem.useOn returns SUCCESS: the hand swings.
|
|
assert seen[label]['place_swings'] == 1, seen
|
|
player.shot(out, 'cushion-placed')
|
|
player('command', command=f'item replace entity @s weapon.mainhand with minecraft:{color}_cushion')
|
|
player.wait(10)
|
|
player.raw('look-at', *other)
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(15)
|
|
second = cushions(host, other)
|
|
assert len(second) == 1, (seen, second)
|
|
player.raw('look-at', at[0], at[1] + .125, at[2])
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(10)
|
|
seen[label]['riding'] = player('status').get('riding')
|
|
assert seen[label]['riding'] == server[0]['id'], seen
|
|
# Entity.startRiding: a passenger first gets off its vehicle, so it moves to the other cushion.
|
|
player.raw('look-at', other[0], other[1] + .125, other[2])
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(10)
|
|
# A client rides what the server's SetPassengers says.
|
|
seen[label]['switched'] = player('status').get('riding')
|
|
player.shot(out, 'cushion-switched')
|
|
assert seen[label]['switched'] == second[0]['id'], seen
|
|
player.raw('key', 'shift', 'down')
|
|
player.wait(5)
|
|
player.raw('key', 'shift', 'up')
|
|
player.wait(5)
|
|
assert player('status').get('riding') is None, seen
|
|
player('command', command='gamemode creative')
|
|
player.raw('look-at', at[0], at[1] + .125, at[2])
|
|
before = {g.name: g('status').get('particles', {}).get('block', 0) for g in (host, guest)}
|
|
player.raw('mouse', 'left', 'click')
|
|
player.wait(10)
|
|
assert not cushions(host) and not cushions(player), seen
|
|
seen[label]['break_particles'] = {
|
|
g.name: g('status').get('particles', {}).get('block', 0) - before[g.name]
|
|
for g in (host, guest)
|
|
}
|
|
assert all(n == 10 for n in seen[label]['break_particles'].values()), seen
|
|
player.raw('look-at', other[0], other[1] + .125, other[2])
|
|
player.raw('mouse', 'left', 'click')
|
|
player.wait(10)
|
|
assert not cushions(host, other) and not cushions(player, other), seen
|
|
return seen
|
|
|
|
|
|
CHECKS += ['cushions']
|
|
CHECK_FNS['cushions'] = check_cushions
|
|
|
|
|
|
def check_forced_levels(host, guest, out):
|
|
"""#1525: forced block/entity ticks outlive the last player and the 300-tick idle timeout.
|
|
|
|
Also keep a resident, unforced furnace outside the forced ticket's neighbour ring: scheduling
|
|
a playerless integrated-server level must not enable the fixtures' tick-everywhere fallback.
|
|
"""
|
|
start = pos_of(guest)
|
|
results = {}
|
|
guest('command', command='gamemode creative')
|
|
try:
|
|
for dimension in (NETHER, END):
|
|
into_level(guest, dimension, 8, 100, 8)
|
|
host('command', command='tick freeze')
|
|
furnace = ('minecraft:furnace{Items:[{Slot:0b,id:"minecraft:raw_iron",count:1},'
|
|
'{Slot:1b,id:"minecraft:coal",count:1}],cooking_total_time:200,'
|
|
'cooking_time_spent:0}')
|
|
for x in (8, 56):
|
|
guest('command', command=f'setblock {x} 100 8 {furnace}')
|
|
server_reply(guest, f'execute if data block {x} 100 8 '
|
|
'Items[{Slot:0b,id:"minecraft:raw_iron"}] '
|
|
'run say forced-input-ready', 'forced-input-ready')
|
|
guest('command', command='forceload add 8 8')
|
|
guest('command', command='summon minecraft:armor_stand 8.5 104 8.5 {Tags:["forced_tick"]}')
|
|
guest('command', command=f'execute in {OVERWORLD} run tp @s {start[0]} {start[1]} {start[2]}')
|
|
wait_dimension(guest, OVERWORLD, 400)
|
|
host('command', command='tick step 450')
|
|
host.wait(500)
|
|
prefix = f'execute in {dimension} '
|
|
results[dimension] = server_reply(
|
|
host, prefix + 'if data block 8 100 8 Items[{Slot:2b,id:"minecraft:iron_ingot"}] '
|
|
'if data block 56 100 8 {cooking_time_spent:0} '
|
|
'if data block 56 100 8 Items[{Slot:0b,id:"minecraft:raw_iron"}] '
|
|
'if data entity @e[tag=forced_tick,limit=1] {Pos:[8.5d,101.0d,8.5d]} run say forced-level-pass',
|
|
'forced-level-pass')
|
|
assert dims(host, guest) == (OVERWORLD, OVERWORLD), dims(host, guest)
|
|
host('command', command=prefix + 'run forceload remove 8 8')
|
|
host('command', command=prefix + 'run kill @e[tag=forced_tick]')
|
|
host('command', command='tick unfreeze')
|
|
finally:
|
|
host('command', command='tick unfreeze')
|
|
for dimension in (NETHER, END):
|
|
host('command', command=f'execute in {dimension} run forceload remove 8 8')
|
|
guest('command', command=f'execute in {OVERWORLD} run tp @s {start[0]} {start[1]} {start[2]}')
|
|
guest('command', command='gamemode survival')
|
|
return results
|
|
|
|
|
|
CHECKS += ['forced-levels']
|
|
CHECK_FNS['forced-levels'] = check_forced_levels
|
|
|
|
|
|
def check_dig_stop(host, guest, out):
|
|
"""#1340 and #1908: a block the client breaks at once leaves the server no dig to keep.
|
|
|
|
MultiPlayerGameMode sends no STOP or ABORT for a creative break or an instant
|
|
survival break (its isDestroying stays false), so the server must forget the dig on
|
|
its own, as ServerPlayerGameMode.handleBlockBreakAction's creative and insta-mine
|
|
branches do. Otherwise the server keeps mining the old spot: the block put back
|
|
there is destroyed again (#1340) and the mining sound keeps playing at it (#1908).
|
|
"""
|
|
result = {}
|
|
spots = apart(host, guest)
|
|
for label, player in (('host', host), ('guest', guest)):
|
|
x, y, z = spots[label]
|
|
cell = (x, y + 1, z + 2)
|
|
seen = result[label] = {}
|
|
for mode, block in (('creative', 'minecraft:stone'), ('survival', 'minecraft:dead_bush')):
|
|
player('command', command=f'gamemode {mode}')
|
|
player('command', command='clear @s')
|
|
host('command', command=f'fill {x - 1} {y} {z + 1} {x + 1} {y + 3} {z + 4} minecraft:air')
|
|
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} {block}')
|
|
player.wait(10)
|
|
before = {g.name: heard(g, 'minecraft:block.stone.hit') for g in (host, guest)}
|
|
player.raw('look-at', f'{cell[0] + 0.5:.2f}', f'{cell[1] + 0.4:.2f}', f'{cell[2] + 0.5:.2f}')
|
|
# One left-click, then the attack released.
|
|
player.raw('mouse', 'left', 'click')
|
|
player.wait(40)
|
|
seen[f'{mode} broken'] = host.raw('block', *cell).get('block')
|
|
seen[f'{mode} mining'] = player('state').get('mining')
|
|
# The block put back must stay: the server has no dig left to destroy it.
|
|
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:stone')
|
|
player.wait(100)
|
|
seen[f'{mode} replacement'] = [g.raw('block', *cell).get('block') for g in (host, player)]
|
|
seen[f'{mode} hits'] = {g.name: heard(g, 'minecraft:block.stone.hit') - before[g.name]
|
|
for g in (host, guest)}
|
|
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:air')
|
|
assert seen['creative broken'] == 'minecraft:air', f'{label}: the creative break failed: {seen}'
|
|
assert seen['survival broken'] == 'minecraft:air', f'{label}: the instant break failed: {seen}'
|
|
assert seen['creative mining'] is None, f'{label}: the client still digs: {seen}'
|
|
assert seen['survival mining'] is None, f'{label}: the client still digs: {seen}'
|
|
for mode in ('creative', 'survival'):
|
|
assert seen[f'{mode} replacement'] == ['minecraft:stone'] * 2, \
|
|
f'{label} {mode}: the server kept digging: {seen}'
|
|
assert all(hits == 0 for hits in seen[f'{mode} hits'].values()), \
|
|
f'{label} {mode}: the mining sound kept playing: {seen}'
|
|
return result
|
|
|
|
|
|
CHECKS += ['dig_stop']
|
|
CHECK_FNS['dig_stop'] = check_dig_stop
|
|
|
|
|
|
def check_team_push(host, guest, out):
|
|
"""Another player's LivingEntity.pushEntities on the guest's screen: EntitySelector.pushableBy reads the
|
|
pusher's team, which for a player goes by its profile name (Player.getScoreboardName), not its UUID. The
|
|
host 0.3 east of the guest pushes it west, unless the host's team has collisionRule never."""
|
|
for g in (host, guest):
|
|
g('command', command='gamemode survival')
|
|
host_at, guest_at = pos_of(host), pos_of(guest)
|
|
x, y, z = guest_at
|
|
x, z = x // 1 + 0.5, z // 1 + 0.5
|
|
seen = {}
|
|
try:
|
|
for case in ('no team', 'never'):
|
|
if case == 'never':
|
|
host('command', command='team add parity_ghosts')
|
|
host('command', command='team modify parity_ghosts collisionRule never')
|
|
host('command', command=f'team join parity_ghosts {host.player}')
|
|
guest('command', command=f'tp @s {x} {y:.4f} {z}')
|
|
host('command', command=f'tp @s {x + 0.3} {y:.4f} {z}')
|
|
guest.wait(40)
|
|
nx, _, nz = pos_of(guest)
|
|
guest.shot(out, 'team-push-' + case.replace(' ', '-'))
|
|
seen[case] = {'dx': round(nx - x, 3), 'dz': round(nz - z, 3)}
|
|
# Apart again before the next case.
|
|
host('command', command=f'tp @s {x + 3} {y:.4f} {z}')
|
|
guest.wait(10)
|
|
finally:
|
|
host('command', command='team remove parity_ghosts')
|
|
# Back where they stood: later checks place the guest next to the host (face_each_other), and the
|
|
# host's last spot here put the guest inside the hill east of the spawn (pvp then hit the wall).
|
|
for g, (px, py, pz) in ((host, host_at), (guest, guest_at)):
|
|
g('command', command=f'tp @s {px:.4f} {py:.4f} {pz:.4f}')
|
|
guest.wait(10)
|
|
assert seen['no team']['dx'] < -0.05, f'the host did not push the guest west: {seen}'
|
|
assert abs(seen['never']['dx']) < 0.01 and abs(seen['never']['dz']) < 0.01, \
|
|
f'a host on a collisionRule never team still pushed the guest: {seen}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['team-push']
|
|
CHECK_FNS['team-push'] = check_team_push
|
|
|
|
|
|
def check_dig_sounds(host, guest, out):
|
|
"""#2277: the mining sound plays once per cadence, for everyone.
|
|
|
|
Vanilla plays the block's hit sound from the server's destroy progress
|
|
level events (2019 and 2020), so the digger and the players around hear
|
|
the same hits. Horizon's digger heard every hit twice: once from its own
|
|
client and once from the server, which is what the report called a sound
|
|
played too often.
|
|
"""
|
|
result = {}
|
|
spots = apart(host, guest)
|
|
for label, player in (('host', host), ('guest', guest)):
|
|
other = guest if label == 'host' else host
|
|
x, y, z = spots[label]
|
|
cell = (x, y + 1, z + 2)
|
|
player('command', command='gamemode survival')
|
|
player('command', command='clear @s')
|
|
host('command', command=f'fill {x - 1} {y} {z + 1} {x + 1} {y + 3} {z + 4} minecraft:air')
|
|
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:stone')
|
|
player.wait(10)
|
|
before = {g.name: heard(g, 'minecraft:block.stone.hit') for g in (host, guest)}
|
|
player.raw('look-at', f'{cell[0] + 0.5:.2f}', f'{cell[1] + 0.5:.2f}', f'{cell[2] + 0.5:.2f}')
|
|
player.raw('mouse', 'left', 'down')
|
|
player.wait(40)
|
|
player.raw('mouse', 'left', 'up')
|
|
player.wait(5)
|
|
hits = {g.name: heard(g, 'minecraft:block.stone.hit') - before[g.name] for g in (host, guest)}
|
|
result[label] = hits
|
|
assert host.raw('block', *cell).get('block') == 'minecraft:stone', f'{label}: the stone broke: {result}'
|
|
assert hits[other.name] >= 3, f'{label}: the player nearby heard no mining sound: {result}'
|
|
assert hits[player.name] <= hits[other.name] + 3, \
|
|
f'{label}: the digger heard the mining sound twice: {result}'
|
|
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:air')
|
|
return result
|
|
|
|
|
|
CHECKS += ['dig_sounds']
|
|
CHECK_FNS['dig_sounds'] = check_dig_sounds
|
|
|
|
|
|
def check_dig_clock(host, guest, out):
|
|
"""#4124: attack held on a block only the top of a jump can reach keeps one dig clock.
|
|
|
|
A diamond pickaxe on stone is 30 ticks of digging (Player.getDestroySpeed: correct tool,
|
|
speed 8 over hardness 1.5 and 30), five times slower airborne. The stone hangs seven blocks
|
|
above the guest's feet, so the standing eye, 1.62 up, is 5.38 from the block's lowest face,
|
|
past the 4.5 pick, while at the apex the eye is 2.87 up and 4.13 away, inside it: the client
|
|
digs the stone for the few ticks around each apex only, and its pick misses on the way down.
|
|
Vanilla's stopDestroyBlock returns at once for a break that is not running, so after the first
|
|
fall it is a no-op: the client's progress and dig identity survive, no START is re-sent, and
|
|
the server keeps the clock of the first START (ServerPlayerGameMode.destroyPos). The STOP that
|
|
arrives when the accumulated progress passes 1.0 is therefore measured from that first START.
|
|
The face met is the underside: the eye is below the block at the apex too.
|
|
"""
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
cell = (x, y + 7, z)
|
|
# The eye's distance to the stone's lowest face: past the 4.5 pick standing, inside it at apex.
|
|
gap = lambda lift: round(cell[1] - (y + 1.62 + lift), 2)
|
|
seen = {'cell': list(cell), 'gap': {'standing': gap(0.0), 'apex': gap(1.2522)}}
|
|
assert gap(0.0) > 4.5 > gap(1.2522), f'the stone is not out of reach standing and in it at the apex: {seen}'
|
|
mode = guest('state').get('gamemode', 'survival')
|
|
try:
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
guest('command', command='item replace entity @s weapon.mainhand with minecraft:diamond_pickaxe')
|
|
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 2} {y + 8} {z + 2} minecraft:air')
|
|
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 2} {y - 1} {z + 2} minecraft:stone')
|
|
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:stone')
|
|
# Straight up: the ray meets the stone's lowest face at every eye height.
|
|
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 -90')
|
|
guest.wait(20)
|
|
standing = guest('state')
|
|
if 'looking_at' not in standing or standing.get('player') is None:
|
|
return 'skipped (the ctl state has no crosshair target or eyes)'
|
|
seen['standing'] = {'aim': standing['looking_at']['kind'],
|
|
'eye': [round(v, 2) for v in standing['player']['eye']],
|
|
'pitch': round(standing['player']['pitch'], 1)}
|
|
assert standing['looking_at']['kind'] != 'block' \
|
|
or standing['looking_at']['block']['pos'] != list(cell), f'the stone is in reach standing: {seen}'
|
|
assert host.raw('block', *cell).get('block') == 'minecraft:stone', f'the stone did not stay: {seen}'
|
|
# Hold attack and keep jumping: each fall stops the client's dig (its progress is zeroed)
|
|
# and airborne digs five times slower, so only the server's clock can finish the stone.
|
|
before = standing['ticks']
|
|
nearest, stretch, polls, broke = None, 0, 0, False
|
|
guest.raw('mouse', 'left', 'down')
|
|
guest.raw('key', 'space', 'down')
|
|
deadline = time.time() + 15
|
|
while time.time() < deadline:
|
|
polls += 1
|
|
now = guest('state')
|
|
target = now['looking_at'].get('block')
|
|
if target and target['pos'] == list(cell):
|
|
nearest = target['distance'] if nearest is None else min(nearest, target['distance'])
|
|
if (now.get('mining') or {}).get('pos') == list(cell):
|
|
stretch = max(stretch, now['mining']['ticks'])
|
|
if polls % 3 == 0 and host.raw('block', *cell).get('block') == 'minecraft:air':
|
|
broke = True
|
|
break
|
|
guest.wait(2)
|
|
seen.update(ticks=guest('state')['ticks'] - before, polls=polls,
|
|
nearest=None if nearest is None else round(nearest, 2), longest_dig=stretch,
|
|
guest_view=guest.raw('block', *cell).get('block'))
|
|
guest.shot(out, 'dig_clock')
|
|
assert nearest is not None and nearest < 4.5, f'the guest never had the stone at an apex: {seen}'
|
|
assert broke and seen['guest_view'] == 'minecraft:air', \
|
|
f'attack held through 15 s of jumps did not break the stone: {seen}'
|
|
# One in-reach stretch is about five ticks; more than that survived a fall, which is the
|
|
# accumulation vanilla's stopDestroyBlock leaves behind and the fix restores.
|
|
assert stretch > 8, f'the dig restarted on every jump ({stretch} ticks): {seen}'
|
|
return seen
|
|
finally:
|
|
guest.raw('release')
|
|
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:air')
|
|
guest('command', command=f'gamemode {mode}')
|
|
|
|
|
|
CHECKS += ['dig_clock']
|
|
CHECK_FNS['dig_clock'] = check_dig_clock
|
|
|
|
|
|
def check_popped_break_effects(host, guest, out):
|
|
"""Block.playerWillDestroy only skips the mined block's own level event: a block that pops off
|
|
when its support is mined (a torch on the side, sugar cane above) plays its break event (sound and
|
|
particles) for the miner too, once, as it does for the players around (#3747)."""
|
|
result = {}
|
|
spots = apart(host, guest)
|
|
cases = [('torch', 'minecraft:dirt', 'minecraft:torch', (0, 1, 0), 'minecraft:block.wood.break'),
|
|
('cane', 'minecraft:sand', 'minecraft:sugar_cane', (0, 1, 0), 'minecraft:block.grass.break')]
|
|
for label, player in (('host', host), ('guest', guest)):
|
|
other = guest if label == 'host' else host
|
|
x, y, z = spots[label]
|
|
player('command', command='gamemode survival')
|
|
player('command', command='clear @s')
|
|
for name, support, popped, offset, sound in cases:
|
|
cell = (x, y + 1, z + 3)
|
|
host('command', command=f'fill {x - 1} {y} {z + 1} {x + 1} {y + 3} {z + 5} minecraft:air')
|
|
host('command', command=f'setblock {cell[0]} {cell[1] - 1} {cell[2]} minecraft:sand')
|
|
host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} {support}')
|
|
at = (cell[0] + offset[0], cell[1] + offset[1], cell[2] + offset[2])
|
|
if name == 'cane':
|
|
host('command', command=f'fill {x - 2} {cell[1] - 1} {cell[2] + 1} {x - 2} {cell[1] - 1} {cell[2] + 1} minecraft:water')
|
|
host('command', command=f'fill {cell[0] - 1} {cell[1]} {cell[2]} {cell[0] - 1} {cell[1]} {cell[2]} minecraft:water')
|
|
host('command', command=f'setblock {cell[0]} {cell[1] + 1} {cell[2]} minecraft:sugar_cane')
|
|
else:
|
|
host('command', command=f'setblock {at[0]} {at[1]} {at[2]} {popped}')
|
|
host.wait(10)
|
|
player.until(lambda: player.raw('block', *at)['block'], lambda b: b.startswith(popped.split('[')[0]))
|
|
before = {g.name: heard(g, sound) for g in (host, guest)}
|
|
player.raw('look-at', f'{cell[0] + 0.5:.2f}', f'{cell[1] + 0.5:.2f}', f'{cell[2] + 0.5:.2f}')
|
|
player.raw('mouse', 'left', 'down')
|
|
player.until(lambda: host.raw('block', *cell)['block'], lambda b: b == 'minecraft:air', ticks=120)
|
|
player.raw('mouse', 'left', 'up')
|
|
player.wait(10)
|
|
seen = {g.name: heard(g, sound) - before[g.name] for g in (host, guest)}
|
|
result[f'{label}-{name}'] = seen
|
|
assert host.raw('block', *at)['block'] == 'minecraft:air', f'{label}-{name}: the popped block stayed: {result}'
|
|
assert seen[player.name] == 1, f'{label}-{name}: the miner heard {seen[player.name]} break sounds: {result}'
|
|
assert seen[other.name] == 1, f'{label}-{name}: the other player heard {seen[other.name]}: {result}'
|
|
return result
|
|
|
|
|
|
CHECKS += ['popped_break_effects']
|
|
CHECK_FNS['popped_break_effects'] = check_popped_break_effects
|
|
|
|
|
|
def mooshroom_types(game, centre):
|
|
"""The colour (0 red, 1 brown) this side draws for each mooshroom around `centre`, by column."""
|
|
return {round(m['pos'][0] - centre[0]): m['variant'] for m in game.raw('mobs', 32).get('near', [])
|
|
if m['kind'] == 'minecraft:mooshroom' and abs(m['pos'][2] - centre[2] - 6) < 2}
|
|
|
|
|
|
def check_mooshroom(host, guest, out):
|
|
"""MushroomCow.DATA_TYPE, entry 18 after AgeableMob's DATA_BABY_ID and AGE_LOCKED, reaches both
|
|
clients (#3236): a mooshroom summoned brown is drawn brown, and a red one a bolt strikes
|
|
(MushroomCow.thunderHit) turns brown on the guest's screen too."""
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
centre = (x + 0.5, y, z + 0.5)
|
|
sides = {'host': host, 'guest': guest}
|
|
seen = {}
|
|
# 12 blocks apart, so one bolt (3 blocks of reach) strikes only the red one, on stone with
|
|
# nothing around (no fire).
|
|
host('command', command=f'fill {x - 8} {y - 1} {z + 3} {x + 8} {y - 1} {z + 9} minecraft:stone')
|
|
host('command', command=f'fill {x - 8} {y} {z + 3} {x + 8} {y + 4} {z + 9} minecraft:air')
|
|
try:
|
|
host('command', command=f'summon minecraft:mooshroom {x - 5.5} {y} {z + 6.5} {{NoAI:1b,Type:"brown"}}')
|
|
host('command', command=f'summon minecraft:mooshroom {x + 6.5} {y} {z + 6.5} {{NoAI:1b,Type:"red"}}')
|
|
host.wait(20)
|
|
for g in sides.values():
|
|
g.raw('look-at', centre[0], y + 1, z + 6.5)
|
|
seen['spawned'] = {name: mooshroom_types(g, centre) for name, g in sides.items()}
|
|
host('command', command=f'summon minecraft:lightning_bolt {x + 6.5} {y} {z + 6.5}')
|
|
guest.until(lambda: mooshroom_types(guest, centre), lambda t: t.get(6) == 1, ticks=40)
|
|
host.wait(5)
|
|
seen['struck'] = {name: mooshroom_types(g, centre) for name, g in sides.items()}
|
|
seen['server'] = host('command', command=f'data get entity @e[type=minecraft:mooshroom,x={x + 6.5},'
|
|
f'y={y},z={z + 6.5},distance=..1,limit=1] Type')['chat'][-1]
|
|
guest.shot(out, 'mooshroom')
|
|
finally:
|
|
host('command', command=f'kill @e[type=minecraft:mooshroom,x={x - 8},y={y - 1},z={z + 3},dx=16,dy=6,dz=6]')
|
|
host('command', command=f'fill {x - 8} {y} {z + 3} {x + 8} {y + 4} {z + 9} minecraft:air')
|
|
for name in sides:
|
|
assert seen['spawned'][name] == {-6: 1, 6: 0}, f'{name}: brown and red at the summon: {seen}'
|
|
assert seen['struck'][name] == {-6: 1, 6: 1}, f'{name}: the struck one turned brown: {seen}'
|
|
assert '"brown"' in seen['server'], f'the server has the struck one brown: {seen}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['mooshroom']
|
|
CHECK_FNS['mooshroom'] = check_mooshroom
|
|
|
|
|
|
def check_shulker_menu(host, guest, out):
|
|
"""A shulker box opens a ShulkerBoxMenu (ShulkerBoxBlockEntity.createMenu), whose ShulkerBoxSlots
|
|
refuse shulker boxes. The server opened it as a 9x3 chest menu, which took them."""
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
box = (x, y, z + 2)
|
|
try:
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command='clear @s')
|
|
guest('command', command='give @s minecraft:cobblestone 1')
|
|
guest('command', command='give @s minecraft:red_shulker_box 1')
|
|
guest('command', command=f'setblock {box[0]} {box[1]} {box[2]} minecraft:shulker_box')
|
|
guest.wait(10)
|
|
guest.raw('interact', *box)
|
|
guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
|
guest.wait(10)
|
|
# The cobblestone goes in; the red shulker box stays on the cursor.
|
|
mouse(guest, hotbar_slot(guest, 'minecraft:cobblestone'))
|
|
mouse(guest, 301)
|
|
mouse(guest, hotbar_slot(guest, 'minecraft:red_shulker_box'))
|
|
mouse(guest, 300)
|
|
guest.wait(10)
|
|
guest.shot(out, 'shulker-menu')
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(10)
|
|
inside, size = open_container(guest, box, out, 'shulker-menu-after')
|
|
counts = guest('inventory')['counts']
|
|
stored = host('command', command=f'data get block {box[0]} {box[1]} {box[2]} Items')
|
|
finally:
|
|
guest('command', command=f'setblock {box[0]} {box[1]} {box[2]} minecraft:air')
|
|
guest('command', command='clear @s')
|
|
assert size == 27 and inside == {1: 'minecraft:cobblestone'}, f'the shulker box holds {inside} ({size})'
|
|
assert 'red_shulker_box' not in json.dumps(stored), f'the server stored {stored}'
|
|
assert counts.get('minecraft:red_shulker_box') == 1, f'inventory after: {counts}'
|
|
return {'inside': inside, 'inventory': counts}
|
|
|
|
|
|
CHECKS += ['shulker_menu']
|
|
CHECK_FNS['shulker_menu'] = check_shulker_menu
|
|
|
|
|
|
def check_keys_released(host, guest, out):
|
|
"""Gui.setScreen -> KeyMapping.releaseAll: the screen shown after leaving a server releases every held
|
|
key, so W and Space held through the disconnect don't walk and jump on in the next world (#3246)."""
|
|
port = host.raw('lan', 'status')['port']
|
|
guest.raw('key', 'w', 'down')
|
|
guest.raw('key', 'space', 'down')
|
|
held = sorted(guest('status')['keys'])
|
|
guest.raw('disconnect')
|
|
left = sorted(guest('status')['keys'])
|
|
guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME)
|
|
guest.raw('wait-for', 'loaded', '--timeout', 300)
|
|
guest.wait(20)
|
|
rejoined = sorted(guest('status')['keys'])
|
|
start = pos_of(guest)
|
|
guest.wait(20)
|
|
end = pos_of(guest)
|
|
walked = round(math.hypot(end[0] - start[0], end[2] - start[2]), 3)
|
|
seen = {'held': held, 'left': left, 'rejoined': rejoined, 'walked': walked}
|
|
assert held == ['KeyW', 'Space'], f'the keys were not pressed: {seen}'
|
|
assert left == [] and rejoined == [], f'keys stayed down after leaving: {seen}'
|
|
assert walked < 0.1, f'the guest walked on its own after rejoining: {seen}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['keys_released']
|
|
CHECK_FNS['keys_released'] = check_keys_released
|
|
|
|
|
|
def offline_uuid_ints(name):
|
|
"""UUIDUtil.createOfflinePlayerUUID as the contents of an int-array UUID tag."""
|
|
digest = bytearray(hashlib.md5(f'OfflinePlayer:{name}'.encode()).digest())
|
|
digest[6] = digest[6] & 0x0f | 0x30
|
|
digest[8] = digest[8] & 0x3f | 0x80
|
|
return ','.join(str(int.from_bytes(digest[i:i + 4], 'big', signed=True)) for i in range(0, 16, 4))
|
|
|
|
|
|
def check_pet_death(host, guest, out):
|
|
"""TamableAnimal.die (#3514): with showDeathMessages, a tame pet's owner, and nobody else, hears how it
|
|
died (CombatTracker.getDeathMessage): the guest's wolf slain by a zombie, the host's cat killed."""
|
|
hx, hy, hz = pos_of(host)
|
|
tag = 'pet_death'
|
|
try:
|
|
host('command', command=f'summon minecraft:zombie {hx + 1.5:.2f} {hy:.2f} {hz - 2:.2f} '
|
|
f'{{NoAI:1b,Silent:1b,Tags:["{tag}"]}}')
|
|
host('command', command=f'summon minecraft:wolf {hx - 1.5:.2f} {hy:.2f} {hz - 2:.2f} '
|
|
f'{{CustomName:"Rex",NoAI:1b,Tags:["{tag}","rex"],'
|
|
f'Owner:[I;{offline_uuid_ints(GUEST_NAME)}]}}')
|
|
host('command', command=f'summon minecraft:cat {hx:.2f} {hy:.2f} {hz - 3:.2f} '
|
|
f'{{CustomName:"Tom",NoAI:1b,Tags:["{tag}","tom"],'
|
|
f'Owner:[I;{offline_uuid_ints(host.player)}]}}')
|
|
host.wait(5)
|
|
host('command', command=f'damage @e[tag=rex,limit=1] 100 minecraft:mob_attack '
|
|
f'by @e[type=minecraft:zombie,tag={tag},limit=1]')
|
|
host('command', command='kill @e[tag=tom]')
|
|
host.wait(20)
|
|
guest_chat = guest.raw('messages', 10)['chat']
|
|
host_chat = host.raw('messages', 10)['chat']
|
|
guest.shot(out, 'pet-death')
|
|
finally:
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
seen = {'guest': guest_chat, 'host': host_chat}
|
|
assert 'Rex was slain by Zombie' in guest_chat, f"the wolf's owner was not told: {seen}"
|
|
assert 'Rex was slain by Zombie' not in host_chat, f"someone else's wolf was told to the host: {seen}"
|
|
assert 'Tom was killed' in host_chat, f"the cat's owner was not told: {seen}"
|
|
assert 'Tom was killed' not in guest_chat, f"someone else's cat was told to the guest: {seen}"
|
|
return seen
|
|
|
|
|
|
CHECKS += ['pet_death']
|
|
CHECK_FNS['pet_death'] = check_pet_death
|
|
|
|
|
|
def check_data_uuid(host, guest, out):
|
|
"""EntityDataAccessor: `/data get entity @s UUID` prints the player who runs it, their own
|
|
offline UUID, for the guest and for the host (a server player too), not the world owner's."""
|
|
seen = {}
|
|
for game, name in ((guest, GUEST_NAME), (host, host.player)):
|
|
reply = server_reply(game, 'data get entity @s UUID', '[I;')
|
|
seen[name] = ','.join(re.findall(r'-?\d+', reply.partition('[I;')[2].partition(']')[0]))
|
|
for name, ints in seen.items():
|
|
assert ints == offline_uuid_ints(name), f"{name}'s UUID is not their own: {seen}"
|
|
return seen
|
|
|
|
|
|
CHECKS += ['data_uuid']
|
|
CHECK_FNS['data_uuid'] = check_data_uuid
|
|
|
|
|
|
def check_arrow_pickup(host, guest, out):
|
|
"""ClientPacketListener.handleTakeItemEntity (#4486): an arrow a player picks up
|
|
(AbstractArrow.playerTouch, once in the ground) flies into them on every client, the
|
|
collector's and the other player's, as an ItemPickupParticle, like an item does."""
|
|
face_each_other(host, guest)
|
|
seen = {}
|
|
try:
|
|
# The guest stands 3 blocks east: each arrow lands on the far side from the other player.
|
|
for game, dx in ((host, -0.9), (guest, 0.9)):
|
|
game('command', command='gamemode survival')
|
|
game('command', command='clear @s')
|
|
before = {g.name: g('status')['pickups'].get('arrow', 0) for g in (host, guest)}
|
|
game('command', command=f'summon minecraft:arrow ~{dx} ~2 ~ {{pickup:1b,Motion:[0.0,-1.0,0.0]}}')
|
|
game.wait(40)
|
|
game.shot(out, 'arrow-pickup')
|
|
seen[game.name] = {'flown': {g.name: g('status')['pickups'].get('arrow', 0) - before[g.name]
|
|
for g in (host, guest)},
|
|
'arrows': game('inventory')['counts'].get('minecraft:arrow', 0)}
|
|
finally:
|
|
for game in (host, guest):
|
|
game('command', command='clear @s')
|
|
host('command', command='kill @e[type=minecraft:arrow]')
|
|
for name, case in seen.items():
|
|
assert case['arrows'] == 1, f'{name} did not pick the arrow up: {seen}'
|
|
assert all(n == 1 for n in case['flown'].values()), f"{name}'s arrow did not fly to them: {seen}"
|
|
return seen
|
|
|
|
|
|
CHECKS += ['arrow_pickup']
|
|
CHECK_FNS['arrow_pickup'] = check_arrow_pickup
|
|
|
|
|
|
def check_trident_pickup(host, guest, out):
|
|
"""#5577: a thrown trident a player picks up (AbstractArrow.playerTouch, then Player.take) flies
|
|
into them on every client as an ItemPickupParticle, like an arrow, instead of vanishing. A
|
|
server's client keeps tridents apart from the other projectiles."""
|
|
face_each_other(host, guest)
|
|
seen = {}
|
|
try:
|
|
for game in (host, guest):
|
|
game('command', command='gamemode survival')
|
|
game('command', command='clear @s')
|
|
game('command', command='item replace entity @s weapon.mainhand with minecraft:trident')
|
|
# Straight down: it sticks at the player's feet, inside their pickup box.
|
|
game('command', command='tp @s ~ ~ ~ ~ 90')
|
|
game.wait(5)
|
|
before = {g.name: g('status')['pickups'].get('trident', 0) for g in (host, guest)}
|
|
game.raw('use-item', '--hold', 1.2)
|
|
game.wait(40)
|
|
game.shot(out, 'trident-pickup')
|
|
seen[game.name] = {'flown': {g.name: g('status')['pickups'].get('trident', 0) - before[g.name]
|
|
for g in (host, guest)},
|
|
'tridents': game('inventory')['counts'].get('minecraft:trident', 0)}
|
|
finally:
|
|
for game in (host, guest):
|
|
game.raw('mouse', 'right', 'up')
|
|
game('command', command='clear @s')
|
|
host('command', command='kill @e[type=minecraft:trident]')
|
|
face_each_other(host, guest)
|
|
for name, case in seen.items():
|
|
assert case['tridents'] == 1, f'{name} did not pick the trident up: {seen}'
|
|
assert all(n == 1 for n in case['flown'].values()), f"{name}'s trident did not fly to them: {seen}"
|
|
return seen
|
|
|
|
|
|
CHECKS += ['trident_pickup']
|
|
CHECK_FNS['trident_pickup'] = check_trident_pickup
|
|
|
|
|
|
# Mounts for mount_other_level: (kind, summon NBT, blocks in front of the player). A camel is
|
|
# always tame (Camel.isTamed); the equines need Tame:1b, and a nautilus an Owner, the player's
|
|
# offline UUID (TamableAnimal.readAdditionalSaveData tames it). A donkey, mule and llama get a
|
|
# chest for their extra slots; a camel is 1.7 wide and long, so it stands a little further off.
|
|
OTHER_LEVEL_MOUNTS = [
|
|
('horse', '{Tame:1b,NoAI:1b}', 2.5),
|
|
('camel', '{NoAI:1b}', 3.0),
|
|
('llama', '{Tame:1b,NoAI:1b,ChestedHorse:1b,Strength:3}', 2.5),
|
|
('trader_llama', '{Tame:1b,NoAI:1b,ChestedHorse:1b,Strength:3}', 2.5),
|
|
('nautilus', '{Owner:[I;OWNER],NoAI:1b}', 2.5),
|
|
('donkey', '{Tame:1b,NoAI:1b,ChestedHorse:1b}', 2.5),
|
|
('mule', '{Tame:1b,NoAI:1b,ChestedHorse:1b}', 2.5),
|
|
]
|
|
HEIGHTS.update({'nautilus': 0.95, 'mule': 1.6, 'trader_llama': 1.87})
|
|
# The second level the server ticks while the players stand in the Overworld: a chunk force-loaded
|
|
# there.
|
|
OTHER_LEVELS = ['minecraft:the_nether', 'minecraft:the_end']
|
|
|
|
|
|
def check_mount_other_level(host, guest, out):
|
|
"""#5331: ServerPlayer.tick checks the open menu's stillValid in the player's own level. With a
|
|
chunk force-loaded in the Nether, or in the End, the server ticks a second level that has none
|
|
of the Overworld's mobs; the inventory of a tamed horse, camel, llama, trader llama, nautilus,
|
|
donkey and mule, opened with shift + right click or with the inventory key while riding, and
|
|
again by shift + right click after getting off, must stay open through those ticks instead of
|
|
closing at once. The other way round too (in_nether): the player and the mount
|
|
in the Nether while the other player keeps the Overworld ticking. Each case is reported on its
|
|
own (LAN_E2E_MOUNTS=camel,llama picks mounts, LAN_E2E_LEVELS=the_end,in_nether cases).
|
|
|
|
By default only the horse and the camel, with the Nether force-loaded, plus in_nether: the
|
|
whole matrix takes about 10 minutes, past what the nightly LAN suite has left of its timeout.
|
|
LAN_E2E_MOUNTS=all and LAN_E2E_LEVELS=all run all of it."""
|
|
spots = apart(host, guest)
|
|
result = {}
|
|
failures = []
|
|
|
|
def env_list(name, default):
|
|
given = [k for k in os.environ.get(name, '').split(',') if k]
|
|
return [] if given == ['all'] else given or default
|
|
|
|
# An empty list means every one.
|
|
only = env_list('LAN_E2E_MOUNTS', ['horse', 'camel'])
|
|
picked = env_list('LAN_E2E_LEVELS', ['the_nether', 'in_nether'])
|
|
mounts = [m for m in OTHER_LEVEL_MOUNTS if not only or m[0] in only]
|
|
|
|
def try_mount(where, label, player, spot, kind, nbt, ahead, into):
|
|
"""Summon `kind` in front of `spot` and open its inventory the three ways, each reported in
|
|
`into[kind]` and, when it fails, in `failures`."""
|
|
x, y, z = spot
|
|
who = f'{where} {label} {kind}'
|
|
opened = into[kind] = {}
|
|
try:
|
|
player('command', command='gamemode survival')
|
|
player('command', command='clear @s')
|
|
owner = offline_uuid_ints(host.player if label == 'host' else GUEST_NAME)
|
|
player('command', command=f'summon minecraft:{kind} {x + 0.5} {y} {z + ahead} '
|
|
f'{nbt.replace("OWNER", owner)}')
|
|
player.wait(5)
|
|
mob = next((m for m in player.raw('mobs', 16).get('near', [])
|
|
if m['kind'] == f'minecraft:{kind}' and m['health'] > 0), None)
|
|
assert mob, f'the {label} sees no {kind}'
|
|
p = mob['pos']
|
|
aim = (f'{p[0]:.2f}', f'{p[1] + HEIGHTS[kind] / 2:.2f}', f'{p[2]:.2f}')
|
|
|
|
def shift_click():
|
|
player.raw('look-at', *aim)
|
|
player.raw('key', 'shift', 'down')
|
|
player.wait(3)
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(2)
|
|
player.raw('key', 'shift', 'up')
|
|
|
|
def stays_open(how, what):
|
|
# The screen opens and is still open 20 ticks later; each way is reported on its
|
|
# own, so one that fails doesn't hide the others.
|
|
if mount_screen(player) is None:
|
|
opened[how] = False
|
|
failures.append(f'{who}: {what} opened no {kind} inventory')
|
|
return
|
|
player.wait(20)
|
|
opened[how] = mount_screen(player, ticks=2) is not None
|
|
if not opened[how]:
|
|
failures.append(f'{who}: the {kind} inventory ({what}) closed by itself')
|
|
back_to_game(player)
|
|
player.wait(5)
|
|
|
|
shift_click()
|
|
stays_open('use', 'shift + right click')
|
|
player.raw('look-at', *aim)
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(8)
|
|
riding = player('status').get('riding')
|
|
if riding == mob['id']:
|
|
player.raw('key', 'e', 'tap')
|
|
stays_open('key', 'the inventory key')
|
|
hold(player, 'shift', 10)
|
|
player.wait(10)
|
|
if player('status').get('riding') is not None:
|
|
failures.append(f'{who}: did not get off the {kind}')
|
|
else:
|
|
failures.append(f'{who}: right-clicked the {kind}: riding {riding}')
|
|
# Off the mount and open again.
|
|
shift_click()
|
|
stays_open('reopen', 'shift + right click after getting off')
|
|
except AssertionError as error:
|
|
failures.append(f'{who}: {error}')
|
|
finally:
|
|
back_to_game(player)
|
|
player('command', command=f'kill @e[type=minecraft:{kind},x={x},y={y},z={z},distance=..16]')
|
|
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
|
|
# A killed mob lies there for 20 ticks (LivingEntity.tickDeath): the next mount's
|
|
# clicks, at the same spot, must not land on it.
|
|
for _ in range(15):
|
|
player.wait(2)
|
|
if not any(m['kind'] == f'minecraft:{kind}' for m in player.raw('mobs', 16).get('near', [])):
|
|
break
|
|
|
|
for level in OTHER_LEVELS:
|
|
if picked and level.removeprefix('minecraft:') not in picked:
|
|
continue
|
|
result[level] = {}
|
|
host('command', command=f'execute in {level} run forceload add 0 0')
|
|
try:
|
|
host.wait(10)
|
|
for label, player in (('host', host), ('guest', guest)):
|
|
result[level][label] = {}
|
|
for kind, nbt, ahead in mounts:
|
|
try_mount(level, label, player, spots[label], kind, nbt, ahead, result[level][label])
|
|
finally:
|
|
host('command', command=f'execute in {level} run forceload remove 0 0')
|
|
# The player and the mount in the Nether, the other player keeping the Overworld ticking: the
|
|
# Overworld's pass must leave the menu alone. One mount is enough: every mount's menu takes the
|
|
# same check.
|
|
if picked and 'in_nether' not in picked:
|
|
pass
|
|
else:
|
|
result['in_nether'] = {}
|
|
for label, player, spot in (('host', host, (0, 100, 0)), ('guest', guest, (12, 100, 0))):
|
|
x, y, z = spot
|
|
box = f'{x - 3} {y - 1} {z - 3} {x + 4} {y + 3} {z + 6}'
|
|
result['in_nether'][label] = {}
|
|
try:
|
|
# The Nether's mobs, fire and the fall into whatever is below until the platform
|
|
# stands can't get in the way.
|
|
player('command', command='gamemode creative')
|
|
player('command', command='effect give @s minecraft:resistance 120 4 true')
|
|
player('command', command='effect give @s minecraft:fire_resistance 120 0 true')
|
|
player('command', command=f'execute in minecraft:the_nether run tp @s {x + 0.5} {y} {z + 0.5} 0 0')
|
|
state = wait_dimension(player, 'minecraft:the_nether', 600)
|
|
if state.get('dimension') != 'minecraft:the_nether':
|
|
failures.append(f'in_nether {label}: still in {state.get("dimension")}')
|
|
continue
|
|
player('command', command=f'fill {box} minecraft:air')
|
|
player('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 4} {y - 1} {z + 6} minecraft:obsidian')
|
|
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0')
|
|
player.wait(20)
|
|
at = pos_of(player)
|
|
if abs(at[1] - y) > 0.1:
|
|
failures.append(f'in_nether {label}: not on the platform at {y}: {at}')
|
|
continue
|
|
for kind, nbt, ahead in mounts[:1]:
|
|
try_mount('in_nether', label, player, spot, kind, nbt, ahead, result['in_nether'][label])
|
|
finally:
|
|
back_to_game(player)
|
|
host('command', command=f'execute in minecraft:the_nether run fill {box} minecraft:air')
|
|
player('command', command='effect clear @s')
|
|
bx, by, bz = spots[label]
|
|
player('command', command=f'execute in minecraft:overworld run tp @s {bx + 0.5} {by} {bz + 0.5} 0 0')
|
|
wait_dimension(player, 'minecraft:overworld', 600)
|
|
player.wait(10)
|
|
assert not failures, f'{failures}: {result}'
|
|
return result
|
|
|
|
|
|
CHECKS += ['mount_other_level']
|
|
CHECK_FNS['mount_other_level'] = check_mount_other_level
|
|
|
|
def check_cushion_break_seated(host, guest, out):
|
|
"""#4571: breaking the cushion one sits on (Entity.setRemoved) puts the rider on the floor it
|
|
sat on (LivingEntity.dismountVehicle from a removed vehicle), not a block below it."""
|
|
seen = {}
|
|
for label, player in (('host', host), ('guest', guest)):
|
|
x, y, z = (int(v // 1) for v in pos_of(player))
|
|
at = (x + 0.5, y, z + 2.5)
|
|
host('command', command=f'fill {x - 1} {y} {z + 1} {x + 1} {y + 3} {z + 3} minecraft:air')
|
|
host('command', command=f'setblock {x} {y - 1} {z + 2} minecraft:stone')
|
|
player('command', command='gamemode creative')
|
|
player('command', command='item replace entity @s weapon.mainhand with minecraft:green_cushion')
|
|
player.wait(10)
|
|
player.raw('look-at', *at)
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(15)
|
|
cushion = [o for o in host('status')['objects'] if o['kind'] == 'minecraft:cushion'
|
|
and abs(o['position'][0] - at[0]) < .1 and abs(o['position'][2] - at[2]) < .1]
|
|
assert len(cushion) == 1, (label, cushion)
|
|
player('command', command='item replace entity @s weapon.mainhand with minecraft:air')
|
|
player.wait(5)
|
|
player.raw('look-at', at[0], at[1] + .125, at[2])
|
|
player.raw('mouse', 'right', 'click')
|
|
player.wait(10)
|
|
assert player('status').get('riding') == cushion[0]['id'], label
|
|
player.raw('look-at', at[0], at[1] + .125, at[2])
|
|
player.raw('mouse', 'left', 'click')
|
|
player.wait(20)
|
|
seen[label] = {'riding': player('status').get('riding'), 'position': pos_of(player)}
|
|
player.shot(out, 'cushion-broken-seated')
|
|
assert seen[label]['riding'] is None, seen
|
|
assert abs(seen[label]['position'][1] - y) < 1e-3, seen
|
|
assert abs(seen[label]['position'][0] - at[0]) < 1e-3, seen
|
|
return seen
|
|
|
|
|
|
CHECKS += ['cushion_break_seated']
|
|
CHECK_FNS['cushion_break_seated'] = check_cushion_break_seated
|
|
|
|
|
|
def check_hoglin_wall(host, guest, out):
|
|
host('command', command='gamemode creative')
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
gx, gz = x + 6, z
|
|
box = f'{gx - 2} {y} {gz - 2} {gx + 2} {y + 3} {gz + 2}'
|
|
health = lambda: server_reply(host, 'data get entity @e[type=minecraft:hoglin,limit=1] Health', 'entity data')
|
|
try:
|
|
host('command', command='kill @e[type=minecraft:hoglin]')
|
|
host('command', command=f'fill {box} minecraft:stone')
|
|
host('command', command=f'summon minecraft:hoglin {gx + 0.5} {y + 1} {gz + 0.5} {{NoAI:1b}}')
|
|
before = health()
|
|
host.wait(40)
|
|
after = health()
|
|
finally:
|
|
host('command', command='kill @e[type=minecraft:hoglin]')
|
|
host('command', command=f'fill {box} minecraft:air')
|
|
host('command', command='gamemode survival')
|
|
assert before != after, f'a hoglin inside a wall took no damage: {before} -> {after}'
|
|
return {'before': before, 'after': after}
|
|
|
|
|
|
CHECKS += ['hoglin-wall']
|
|
CHECK_FNS['hoglin-wall'] = check_hoglin_wall
|
|
|
|
|
|
def check_attack_over_slab(host, guest, out):
|
|
"""#5772: a mob whose centre a block hides from the eyes, but whose head shows over it, takes the
|
|
hit the crosshair aims at its head. The attack packet names only the entity, so the server aims
|
|
the swapped-in player itself; it aimed at the centre and dropped the hit (#5903). A bottom slab at
|
|
eye height between the player and a zombie hides the zombie's centre, not its head."""
|
|
x, y, z = map(math.floor, pos_of(host))
|
|
tag = 'attack_over_slab'
|
|
target = [x + 2.5, y, z + 0.5]
|
|
seen = {}
|
|
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 5} {y - 1} {z + 3} minecraft:stone')
|
|
host('command', command=f'fill {x - 2} {y} {z - 2} {x + 5} {y + 4} {z + 3} minecraft:air')
|
|
host('command', command=f'setblock {x + 1} {y + 1} {z} minecraft:stone_slab[type=bottom]')
|
|
try:
|
|
for label, player, other in (('host', host, guest), ('guest', guest, host)):
|
|
player('command', command='gamemode survival')
|
|
player('command', command='clear @s')
|
|
player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
|
|
other('command', command=f'tp @s {x + 0.5} {y} {z + 3.5}')
|
|
host('command', command=f'summon minecraft:zombie {target[0]} {y} {target[2]} '
|
|
f'{{NoAI:1b,PersistenceRequired:1b,Tags:["{tag}"]}}')
|
|
player.wait(20) # full attack charge and the zombie paired
|
|
mob = next(m for m in player.raw('mobs', 16)['near']
|
|
if m['kind'] == 'minecraft:zombie' and math.dist(m['pos'], target) < 0.1)
|
|
before = mob_seen(player, mob['id'])['health']
|
|
# The head, over the slab: the crosshair hits the zombie, not the slab.
|
|
player.raw('look-at', target[0], y + 1.8, target[2])
|
|
player.wait(2)
|
|
player.raw('mouse', 'left', 'click')
|
|
player.wait(10)
|
|
after = mob_seen(player, mob['id'])['health']
|
|
player.shot(out, f'attack-over-slab-{label}')
|
|
seen[label] = {'health': [before, after]}
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
player.wait(5)
|
|
assert after < before, f'{label}: the hit over the slab did no damage: {seen}'
|
|
return seen
|
|
finally:
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
host('command', command=f'setblock {x + 1} {y + 1} {z} minecraft:air')
|
|
|
|
|
|
CHECKS += ['attack_over_slab']
|
|
CHECK_FNS['attack_over_slab'] = check_attack_over_slab
|
|
|
|
|
|
def check_wind_bystanders(host, guest, out):
|
|
"""A wind charge bursting on the floor pushes the other entities it reaches (ServerExplosion.hurtEntities
|
|
-> Entity.pushFromExplosion): an experience orb, a falling block, a boat and a minecart, but never a dropped
|
|
item (ItemEntity.ignoreExplosion unless the burst affects block-like entities, which a wind charge's never
|
|
does). The scene was run on the real 26.3 server (`tick freeze`, `tick step 3` after the burst): the orb
|
|
moved (+1.45, +0.19, 0), the falling block (-1.23, +0.82, 0), the boat (0, +0.30, +0.76), the minecart
|
|
(0, +0.14, -0.39), the item not at all."""
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
x, y, z = hx + 6, hy, hz
|
|
for g in (host, guest):
|
|
g('command', command='gamemode spectator') # orbs home in on survival players
|
|
steps = [f'fill {x - 8} {y - 1} {z - 8} {x + 8} {y - 1} {z + 8} minecraft:bedrock',
|
|
f'fill {x - 8} {y} {z - 8} {x + 8} {y + 5} {z + 8} minecraft:air',
|
|
'kill @e[type=!player]', 'tick freeze',
|
|
f'summon minecraft:experience_orb {x + 1.4} {y} {z + 0.5} {{Value:1s}}',
|
|
f'summon minecraft:falling_block {x - 0.4} {y + 0.02} {z + 0.5} {{BlockState:{{Name:"minecraft:sand"}},Time:1,NoGravity:1b}}',
|
|
f'summon minecraft:oak_boat {x + 0.5} {y} {z + 1.5}',
|
|
f'summon minecraft:minecart {x + 0.5} {y} {z - 0.5}',
|
|
f'summon minecraft:item {x + 1.1} {y} {z - 0.1} {{Item:{{id:"minecraft:stone",count:1}}}}',
|
|
'tick step 20']
|
|
|
|
def scene():
|
|
near = lambda p: abs(p[0] - x) < 4 and abs(p[2] - z) < 4
|
|
found = {}
|
|
for e in host.raw('entities', 8, 'minecraft:experience_orb')['entities']:
|
|
found['orb'] = e['pos']
|
|
found['orb_velocity'] = e.get('velocity')
|
|
for e in host.raw('entities', 8, 'minecraft:item')['entities']:
|
|
if near(e['pos']):
|
|
found['item'] = e['pos']
|
|
for p in host.raw('simulation')['projectiles']:
|
|
kind = {'minecraft:falling_block': 'fall', 'minecraft:boat': 'boat', 'minecraft:minecart': 'cart'}.get(p['kind'])
|
|
if kind and near(p['position']):
|
|
found[kind] = p['position']
|
|
return found
|
|
try:
|
|
host('command', command='tick unfreeze')
|
|
for c in steps:
|
|
host('command', command=c)
|
|
host.wait(10)
|
|
before = scene()
|
|
host('command', command=f'summon minecraft:wind_charge {x + 0.5} {y + 0.6} {z + 0.5} {{Motion:[0.0,-1.0,0.0]}}')
|
|
ticks = []
|
|
for _ in range(4):
|
|
host('command', command='tick step 1')
|
|
host.wait(4)
|
|
ticks.append(scene())
|
|
after = ticks[2]
|
|
finally:
|
|
host('command', command='tick unfreeze')
|
|
host('command', command='kill @e[type=!player]')
|
|
for g in (host, guest):
|
|
g('command', command='gamemode creative')
|
|
moved = {k: [round(a - b, 3) for a, b in zip(after[k], before[k])] for k in ('orb', 'fall', 'boat', 'cart', 'item') if k in before and k in after}
|
|
assert set(moved) == {'orb', 'fall', 'boat', 'cart', 'item'}, f'missing entities: {before} {after}'
|
|
assert moved['item'] == [0, 0, 0], f'a dropped item was pushed: {moved}'
|
|
# An orb's position follows its tracker, which a frozen tick does not refresh: its velocity shows the push.
|
|
orb = after.get('orb_velocity') or [0, 0, 0]
|
|
assert orb[0] > 0.3 and orb[1] > 0.02, f'the orb was not pushed away: {orb}'
|
|
for kind, axis, sign in (('fall', 0, -1), ('boat', 2, 1), ('cart', 2, -1)):
|
|
assert moved[kind][axis] * sign > 0.2 and moved[kind][1] > 0.05, f'{kind} was not pushed away: {moved}'
|
|
return {'before': before, 'moved': moved, 'orb_velocity': orb, 'ticks': ticks}
|
|
|
|
|
|
CHECKS += ['wind-bystanders']
|
|
CHECK_FNS['wind-bystanders'] = check_wind_bystanders
|
|
|
|
|
|
def check_lightning_box(host, guest, out):
|
|
"""A lightning bolt tests the box of every entity against its strike box (3 around, 9 above),
|
|
not their feet, and destroys boats, minecarts and experience orbs (LightningBolt.tick ->
|
|
Entity.thunderHit -> VehicleEntity.hurtServer 5 x 10 > 40; ExperienceOrb health 5). Real
|
|
vanilla 26.3 (same scene): boat, cart, orb and a boat whose feet stand 3.5 out are gone, a cow
|
|
whose feet stand 3.2 out drops from 10 to 5, a boat 6 out is untouched."""
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
ox, oy, oz = x + 12, y, z
|
|
tag = 'dg_bolt'
|
|
spots = {'boat': ('minecraft:oak_boat', 1.5, 0), 'cart': ('minecraft:minecart', -1.5, 0),
|
|
'orb': ('minecraft:experience_orb', 0, -1.5), 'edgeboat': ('minecraft:oak_boat', 0, 3.5),
|
|
'edgecow': ('minecraft:cow', 3.2, 0), 'farboat': ('minecraft:oak_boat', 6.0, 0)}
|
|
markers = {name: (ox - 8 + i, oy, oz + 12) for i, name in enumerate(spots)}
|
|
|
|
def alive(name):
|
|
if name == 'orb': # @e does not list experience orbs; the state counts them by kind
|
|
return remote(host).get('entities', {}).get('minecraft:experience_orb', 0) > 0
|
|
# Marker blocks: a command the game runs, a block the control reads back.
|
|
mx, my, mz = markers[name]
|
|
host('command', command=f'setblock {mx} {my} {mz} minecraft:air')
|
|
host('command', command=f'execute if entity @e[tag={tag}_{name}] run setblock {mx} {my} {mz} minecraft:gold_block')
|
|
host.wait(2)
|
|
return host.raw('block', mx, my, mz).get('block') == 'minecraft:gold_block'
|
|
|
|
seen = {}
|
|
try:
|
|
host('command', command='gamemode creative')
|
|
host('command', command='difficulty normal')
|
|
host('command', command=f'fill {ox - 9} {oy - 1} {oz - 9} {ox + 9} {oy - 1} {oz + 14} minecraft:stone')
|
|
host('command', command=f'fill {ox - 9} {oy} {oz - 9} {ox + 9} {oy + 6} {oz + 14} minecraft:air')
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
for name, (kind, dx, dz) in spots.items():
|
|
extra = ',NoAI:1b,PersistenceRequired:1b' if kind == 'minecraft:cow' else ''
|
|
host('command', command=f'summon {kind} {ox + 0.5 + dx} {oy} {oz + 0.5 + dz} '
|
|
f'{{Tags:["{tag}","{tag}_{name}"]{extra}}}')
|
|
host.wait(10)
|
|
seen['before'] = {name: alive(name) for name in spots}
|
|
assert all(seen['before'].values()), f'the command cannot see every entity before the bolt: {seen}'
|
|
def cow():
|
|
near = [m for m in host.raw('entities', 20, 'cow')['entities']
|
|
if math.dist(m['pos'], (ox + 3.7, oy, oz + 0.5)) < 1.0]
|
|
return near[0]['health'] if near else 0.0
|
|
seen['cow_before'] = cow()
|
|
host('command', command=f'summon minecraft:lightning_bolt {ox + 0.5} {oy} {oz + 0.5}')
|
|
seen['cow_series'] = []
|
|
for _ in range(8):
|
|
host.wait(1)
|
|
seen['cow_series'].append(cow())
|
|
host.wait(2)
|
|
seen['after'] = {name: alive(name) for name in spots}
|
|
seen['cow_after'] = min(seen['cow_series'])
|
|
host.shot(out, 'lightning-box')
|
|
finally:
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
# Vanilla: everything but the far boat is gone; the cow is hurt.
|
|
# (The cow burns on after the strike, so only its health at the strike is compared.)
|
|
gone = {k: v for k, v in seen['after'].items() if k != 'edgecow'}
|
|
assert gone == {'boat': False, 'cart': False, 'orb': False, 'edgeboat': False, 'farboat': True}, \
|
|
f'who survived the bolt: {seen}'
|
|
assert seen['cow_series'][0] == 5.0, f'the cow with its feet 3.2 out was not struck for 5: {seen}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['lightning-box']
|
|
CHECK_FNS['lightning-box'] = check_lightning_box
|
|
|
|
|
|
def check_boat_submerged(host, guest, out):
|
|
"""AbstractBoat.tick ejects players after 60 consecutive underwater ticks on the server."""
|
|
hx, _, hz = pos_of(host)
|
|
x, y, z = int(hx // 16) * 16 + 8, 100, int(hz // 16) * 16 + 8
|
|
tag = 'submerged_boat'
|
|
seen = {}
|
|
try:
|
|
for player in (host, guest):
|
|
for kind in ('oak_boat', 'bamboo_raft'):
|
|
label = f'{player.name}-{kind}'
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
host('command', command=f'fill {x-4} {y} {z-4} {x+4} {y+6} {z+4} minecraft:air')
|
|
host('command', command=f'fill {x-4} {y-1} {z-4} {x+4} {y+5} {z+4} minecraft:stone hollow')
|
|
host('command', command=f'fill {x-4} {y+5} {z-4} {x+4} {y+5} {z+4} minecraft:air')
|
|
host('command', command=f'fill {x-3} {y} {z-3} {x+3} {y} {z+3} minecraft:water')
|
|
player('command', command='gamemode survival')
|
|
player('command', command=f'tp @s {x+0.5} {y+1} {z-0.5}')
|
|
player('command', command=f'summon minecraft:{kind} {x+0.5} {y+1} {z+2.5} {{Tags:["{tag}"]}}')
|
|
player.wait(30)
|
|
boat = board(player, kind, (x+0.5, y+1, z+2.5))
|
|
player.wait(80)
|
|
assert player('status').get('riding') == boat['id'], f'{label}: ejected at the surface'
|
|
water = f'fill {x-3} {y} {z-3} {x+3} {y+4} {z+3}'
|
|
# A short immersion followed by a dry hull must reset the 60-tick countdown.
|
|
host('command', command=water + ' minecraft:water')
|
|
player.wait(25)
|
|
host('command', command=water + ' minecraft:air')
|
|
player.wait(20)
|
|
assert player('status').get('riding') == boat['id'], f'{label}: ejected after a short immersion'
|
|
host('command', command=water + ' minecraft:water')
|
|
player.wait(40)
|
|
early = player('status').get('riding')
|
|
assert early == boat['id'], f'{label}: the immersion counter did not reset (riding {early}, boat {boat["id"]})'
|
|
player.shot(out, 'boat-submerged-' + label + '-before')
|
|
player.wait(40)
|
|
after = player('status').get('riding')
|
|
player.shot(out, 'boat-submerged-' + label + '-after')
|
|
seen[label] = dict(boat=boat['id'], early=early, after=after)
|
|
lab.write_json(out / 'boat-submerged.json', seen)
|
|
assert after is None, f'{label}: still riding a submerged boat: {seen[label]}'
|
|
return seen
|
|
finally:
|
|
host('command', command=f'kill @e[tag={tag}]')
|
|
host('command', command=f'fill {x-3} {y} {z-3} {x+3} {y+4} {z+3} minecraft:air')
|
|
|
|
|
|
CHECKS += ['boat_submerged']
|
|
CHECK_FNS['boat_submerged'] = check_boat_submerged
|
|
|
|
|
|
def check_shulker_lift(host, guest, out):
|
|
"""ShulkerBoxBlockEntity.tick moves the local player on each client, even with its menu open."""
|
|
hx, _, hz = pos_of(host)
|
|
x, y, z = int(hx // 16) * 16 + 8, 100, int(hz // 16) * 16 + 8
|
|
box = (x, y, z)
|
|
modes = {player: player('state')['gamemode'] for player in (host, guest)}
|
|
host('command', command=f'fill {x-4} {y-1} {z-4} {x+4} {y+5} {z+4} minecraft:air')
|
|
host('command', command=f'fill {x-4} {y-1} {z-4} {x+4} {y-1} {z+4} minecraft:stone')
|
|
host('command', command=f'setblock {x} {y} {z} minecraft:shulker_box[facing=up]')
|
|
seen = {}
|
|
try:
|
|
for player in (host, guest):
|
|
other = guest if player is host else host
|
|
other('command', command=f'tp @s {x+3.5} {y} {z+0.5}')
|
|
for mode in ('survival', 'creative'):
|
|
player('command', command=f'gamemode {mode}')
|
|
player('command', command=f'tp @s {x+0.5} {y+1} {z+0.5}')
|
|
player.wait(15)
|
|
before = pos_of(player)
|
|
assert abs(before[1] - (y+1)) < 0.01, f'not standing on the box: {before}'
|
|
label = f'shulker-lift-{player.name}-{mode}'
|
|
player.shot(out, label + '-before')
|
|
player.raw('interact', *box)
|
|
player.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
|
player.wait(20)
|
|
opened = pos_of(player)
|
|
player.shot(out, label + '-open')
|
|
# Let the player settle on the open lid: it must remain a supporting shape.
|
|
player.wait(20)
|
|
settled = pos_of(player)
|
|
player.raw('key', 'esc', 'tap')
|
|
player.wait(30)
|
|
closed = pos_of(player)
|
|
seen[label] = dict(before=before, opened=opened, settled=settled, closed=closed)
|
|
lab.write_json(out / 'shulker-lift.json', seen)
|
|
assert abs(opened[1] - (y+1.5)) < 0.02, f'opening lid did not lift {label}: {seen[label]}'
|
|
assert abs(settled[1] - (y+1.5)) < 0.02, f'open lid did not support {label}: {seen[label]}'
|
|
assert abs(closed[1] - (y+1)) < 0.02, f'closing lid did not lower {label}: {seen[label]}'
|
|
return seen
|
|
finally:
|
|
for player in (host, guest):
|
|
back_to_game(player)
|
|
player('command', command=f'gamemode {modes[player]}')
|
|
host('command', command=f'setblock {x} {y} {z} minecraft:air')
|
|
|
|
|
|
CHECKS += ['shulker_lift']
|
|
CHECK_FNS['shulker_lift'] = check_shulker_lift
|
|
|
|
|
|
def check_blast_bystanders(host, guest, out):
|
|
"""Primed TNT pushes a falling block and a boat, and breaks a boat in reach
|
|
(ServerExplosion.hurtEntities -> VehicleEntity.hurtServer, Entity.pushFromExplosion). Run on the real
|
|
26.3 server (`tick freeze`, `tick step 8` after the fuse): the falling block moved (+1.795, +1.235, 0),
|
|
the far boat (+0.071, 0, 0, tracker-lagged here); the near boat broke and dropped one item."""
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
x, y, z = hx + 6, hy, hz
|
|
for g in (host, guest):
|
|
g('command', command='gamemode spectator')
|
|
tx = x - 4.5
|
|
steps = [f'fill {x - 8} {y - 1} {z - 8} {x + 8} {y - 1} {z + 8} minecraft:bedrock',
|
|
f'fill {x - 8} {y} {z - 8} {x + 8} {y + 5} {z + 8} minecraft:air',
|
|
'kill @e[type=!player]', 'tick freeze',
|
|
f'summon minecraft:falling_block {x - 2.5} {y + 1.02} {z + 0.5} {{BlockState:{{Name:"minecraft:sand"}},Time:1,NoGravity:1b}}',
|
|
f'summon minecraft:oak_boat {tx} {y} {z + 3.5}',
|
|
f'summon minecraft:oak_boat {x + 3.0} {y} {z + 0.5}']
|
|
|
|
def scene():
|
|
found = {}
|
|
status = host('status')
|
|
# `simulation` has the server's own positions; `status` follows the trackers, which lag.
|
|
for p in host.raw('simulation')['projectiles'] + status['projectiles'] + status['objects']:
|
|
k = p['kind']
|
|
kind = {'minecraft:falling_block': 'fall', }.get(k, 'boat' if k.endswith('_boat') else None)
|
|
if kind and abs(p['position'][0] - x) < 8 and abs(p['position'][2] - z) < 8:
|
|
found.setdefault(kind, []).append(p['position'])
|
|
return found
|
|
try:
|
|
host('command', command='tick unfreeze')
|
|
for c in steps:
|
|
host('command', command=c)
|
|
host.wait(10)
|
|
before = scene()
|
|
host('command', command=f'summon minecraft:tnt {tx} {y + 1} {z + 0.5} {{fuse:5s}}')
|
|
host('command', command='tick step 8')
|
|
host.wait(10)
|
|
after = scene()
|
|
finally:
|
|
host('command', command='tick unfreeze')
|
|
host('command', command='kill @e[type=!player]')
|
|
for g in (host, guest):
|
|
g('command', command='gamemode creative')
|
|
assert len(before.get('boat', [])) == 2 and len(after.get('boat', [])) == 1, f'the near boat was not broken: {before} {after}'
|
|
moved = {k: [round(a - b, 3) for a, b in zip(after[k][0], before[k][0])] for k in ('fall',) if k in before and k in after}
|
|
assert set(moved) == {'fall'}, f'missing entities: {before} {after}'
|
|
far = [round(a - b, 3) for a, b in zip(after['boat'][0], next(p for p in before['boat'] if p[0] > x))]
|
|
for kind, want in (('fall', [1.795, 1.235, 0.0]),):
|
|
assert all(abs(a - b) < 0.05 for a, b in zip(moved[kind], want)), f'{kind} moved {moved[kind]}, vanilla {want}'
|
|
# A boat's position here follows its tracker, so only the direction is asserted (sbs.py blast_bystanders
|
|
# matches the vanilla boat to 1e-9: Pos x 3.0712677).
|
|
assert far[0] > 0.01 and abs(far[2]) < 0.02, f'the far boat was not pushed east: {far}'
|
|
return {'moved': moved, 'far_boat': far}
|
|
|
|
|
|
CHECKS += ['blast-bystanders']
|
|
CHECK_FNS['blast-bystanders'] = check_blast_bystanders
|
|
def check_wind_remaining(host, guest, out):
|
|
"""A wind burst pushes a firework rocket and a thrown trident (ServerExplosion.hurtEntities ->
|
|
Entity.pushFromExplosion; neither overrides ignoreExplosion). Real 26.3 server, wind charge into the floor,
|
|
tick freeze, tick step 1 after the burst: the rocket's Motion x -0.3174, -0.3650, -0.4197 and y 0.5971, 0.6371,
|
|
0.6771 on the next three ticks (it starts at (0, 0.08, 0), 0.8 west of the burst, 1.0 above the floor); a
|
|
trident 0.8 east gets (+0.446, +0.490) on the first tick when NoGravity holds. A summoned trident here
|
|
ignores NoGravity and falls first, so only its direction is asserted."""
|
|
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
|
x, y, z = hx + 6, hy, hz
|
|
host('command', command='gamemode spectator')
|
|
|
|
def read():
|
|
found = {}
|
|
for p in host.raw('simulation')['projectiles']:
|
|
if p['kind'] in ('minecraft:trident', 'minecraft:firework_rocket') and abs(p['position'][0] - x) < 4:
|
|
found[p['kind'].split(':')[1]] = p['velocity']
|
|
return found
|
|
detail = {}
|
|
try:
|
|
host('command', command='tick unfreeze')
|
|
for c in (f'fill {x - 8} {y - 1} {z - 8} {x + 8} {y - 1} {z + 8} minecraft:bedrock',
|
|
f'fill {x - 8} {y} {z - 8} {x + 8} {y + 5} {z + 8} minecraft:air', 'kill @e[type=!player]', 'tick freeze',
|
|
f'summon minecraft:trident {x + 1.3} {y + 1.0} {z + 0.5} {{NoGravity:1b}}',
|
|
f'summon minecraft:firework_rocket {x - 0.3} {y + 1.0} {z + 0.5} {{LifeTime:200}}', 'tick step 2'):
|
|
host('command', command=c)
|
|
host.wait(8)
|
|
detail['before'] = read()
|
|
host('command', command=f'summon minecraft:wind_charge {x + 0.5} {y + 0.6} {z + 0.5} {{Motion:[0.0,-1.0,0.0]}}')
|
|
detail['ticks'] = []
|
|
for _ in range(3):
|
|
host('command', command='tick step 1')
|
|
host.wait(4)
|
|
detail['ticks'].append(read())
|
|
finally:
|
|
host('command', command='tick unfreeze')
|
|
host('command', command='kill @e[type=!player]')
|
|
host('command', command='gamemode creative')
|
|
first = detail['ticks'][0]
|
|
assert 'firework_rocket' in first and 'trident' in first, f'no rocket listed: {detail}'
|
|
rocket = first['firework_rocket']
|
|
assert abs(rocket[0] + 0.3174) < 0.01 and abs(rocket[1] - 0.5971) < 0.01, f'the rocket was not pushed like vanilla: {detail}'
|
|
trident = first['trident']
|
|
assert trident[0] > 0.2 and trident[1] > 0.1, f'the trident was not pushed away: {detail}'
|
|
return detail
|
|
|
|
|
|
CHECKS += ['wind-remaining']
|
|
CHECK_FNS['wind-remaining'] = check_wind_remaining
|
|
|
|
|
|
def check_selector_tags(host, guest, out):
|
|
"""`@e[tag=]` and `/tag` find summoned experience orbs, arrows, thrown items, fireballs and wind
|
|
charges, and `/data get entity` reads a wind charge (vanilla 26.3, same scene: every kind is found
|
|
by its summoned Tags; sbs.py selkinds)."""
|
|
x, y, z = (int(v // 1) for v in pos_of(host))
|
|
kinds = ['experience_orb', 'arrow', 'snowball', 'egg', 'ender_pearl', 'fireball', 'small_fireball', 'wind_charge']
|
|
row = (x, y + 8, z + 4)
|
|
seen = {}
|
|
|
|
def marker(i, selector):
|
|
spot = (row[0] + i, row[1], row[2])
|
|
host('command', command=f'setblock {spot[0]} {spot[1]} {spot[2]} minecraft:air')
|
|
host('command', command=f'execute if {selector} run setblock {spot[0]} {spot[1]} {spot[2]} minecraft:gold_block')
|
|
host.wait(2)
|
|
return host.raw('block', *spot).get('block') == 'minecraft:gold_block'
|
|
|
|
try:
|
|
host('command', command='gamemode creative')
|
|
host('command', command='tick freeze')
|
|
for i, k in enumerate(kinds):
|
|
host('command', command=f'summon minecraft:{k} {x + 0.5 + i} {y + 20} {z + 0.5} '
|
|
f'{{NoGravity:1b,Tags:["sel_{k}"],Motion:[0.0d,0.0d,0.0d]}}')
|
|
host('command', command='summon minecraft:snowball ~ ~20 ~ {NoGravity:1b,Motion:[0.0d,0.0d,0.0d]}')
|
|
host('command', command='tag @e[type=minecraft:snowball,tag=!sel_snowball] add sel_late')
|
|
host.wait(4)
|
|
for i, k in enumerate(kinds):
|
|
seen[k] = marker(i, f'entity @e[tag=sel_{k}]')
|
|
seen['late'] = marker(len(kinds), 'entity @e[tag=sel_late]')
|
|
seen['data'] = marker(len(kinds) + 1, 'data entity @e[type=minecraft:wind_charge,limit=1] Pos')
|
|
finally:
|
|
host('command', command='tick unfreeze')
|
|
host('command', command='kill @e[type=!player]')
|
|
assert all(seen.values()), f'the selector cannot see these: {[k for k, v in seen.items() if not v]}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['selector-tags']
|
|
CHECK_FNS['selector-tags'] = check_selector_tags
|
|
|
|
|
|
def check_mining_drop_latency(host, guest, out):
|
|
"""An instant survival break sends its drop in the tick that handles START (#4572).
|
|
|
|
Queue the click behind the server barrier, then release it at one TPS. A
|
|
snapshot taken before tick_guests delays the drop until the next second.
|
|
Both the integrated owner and a LAN guest must see the first snapshot.
|
|
"""
|
|
hx, _, hz = pos_of(host)
|
|
x, y, z = int(hx), 100, int(hz)
|
|
seen = {}
|
|
try:
|
|
host('command', command=f'fill {x-5} {y-1} {z-5} {x+5} {y-1} {z+5} minecraft:stone')
|
|
host('command', command=f'fill {x-5} {y} {z-5} {x+5} {y+4} {z+5} minecraft:air')
|
|
for player in (host, guest):
|
|
player('command', command='clear @s')
|
|
player('command', command='gamemode survival')
|
|
for player, other in ((host, guest), (guest, host)):
|
|
host('command', command='tick rate 20')
|
|
host('command', command='kill @e[type=minecraft:item]')
|
|
player('command', command=f'tp @s {x+0.5} {y} {z-3.5}')
|
|
other('command', command=f'tp @s {x+3.5} {y} {z+0.5}')
|
|
host('command', command=f'setblock {x} {y} {z} minecraft:torch')
|
|
player.wait(20)
|
|
assert all(g.raw('block', x, y, z).get('block') == 'minecraft:torch'
|
|
for g in (host, guest)), 'torch did not reach both clients'
|
|
player.raw('look-at', x+0.5, y+0.3, z+0.5)
|
|
host('command', command='tick rate 1')
|
|
host.raw('server-hold', 'on')
|
|
try:
|
|
player.raw('mouse', 'left', 'click')
|
|
# The client's input tick also slows to one TPS. Wait for its
|
|
# prediction while transport runs and the server is still held.
|
|
deadline = time.monotonic() + 3
|
|
while player.raw('block', x, y, z).get('block') != 'minecraft:air':
|
|
assert time.monotonic() < deadline, \
|
|
f'{player.name}: client did not predict the instant break'
|
|
time.sleep(0.02)
|
|
finally:
|
|
host.raw('server-hold', 'off')
|
|
started = time.monotonic()
|
|
arrivals = {}
|
|
while time.monotonic() - started < 0.75 and len(arrivals) < 2:
|
|
for viewer in (host, guest):
|
|
if viewer.name in arrivals:
|
|
continue
|
|
entities = viewer.raw('entities', 24, 'minecraft:item')['entities']
|
|
if any(abs(e['pos'][0] - x - 0.5) < 2
|
|
and abs(e['pos'][2] - z - 0.5) < 2 for e in entities):
|
|
arrivals[viewer.name] = round(time.monotonic() - started, 3)
|
|
time.sleep(0.01)
|
|
seen[player.name] = arrivals
|
|
lab.write_json(out / 'mining-drop-latency.json', seen)
|
|
assert len(arrivals) == 2, \
|
|
f'{player.name}: drop waited for a second server tick: {seen}'
|
|
finally:
|
|
host.raw('server-hold', 'off')
|
|
host('command', command='tick rate 20')
|
|
host('command', command='kill @e[type=minecraft:item]')
|
|
for player in (host, guest):
|
|
player.raw('release')
|
|
return seen
|
|
|
|
|
|
CHECKS += ['mining-drop-latency']
|
|
CHECK_FNS['mining-drop-latency'] = check_mining_drop_latency
|
|
|
|
|
|
def check_scale_body(host, guest, out):
|
|
"""#3684: a player's `minecraft:scale` is the same entity's attribute through a change of level and
|
|
a death. ServerPlayer.teleport keeps the player; ServerPlayer.restoreFrom copies the base values to
|
|
the body after a death (assignBaseValues), then sets its health to the max they give. The eye the
|
|
client sees stays at 0.162 (1.62 * 0.1), not back at 1.62, and a max health base of 40 respawns the
|
|
player with 40."""
|
|
seen = {}
|
|
|
|
def scale_of(player):
|
|
# The eye height the player's client has: the server's scale reaches it as an attribute update.
|
|
state = player('state')['player']
|
|
return round(state['eye'][1] - state['position'][1], 3)
|
|
|
|
for label, player in (('host', host), ('guest', guest)):
|
|
s = seen[label] = {}
|
|
player('command', command='gamemode creative')
|
|
player('command', command='attribute @s minecraft:scale base set 0.1')
|
|
player('command', command='attribute @s minecraft:max_health base set 40')
|
|
player.wait(20)
|
|
s['set'] = scale_of(player)
|
|
x, z = (int(v // 1) for v in pos_of(player)[::2])
|
|
into_level(player, NETHER, x, 100, z)
|
|
s['nether'] = scale_of(player)
|
|
player('command', command=f'execute in {OVERWORLD} run tp @s {x + 0.5} 200 {z + 0.5}')
|
|
wait_dimension(player, OVERWORLD, 400)
|
|
player.wait(40)
|
|
s['overworld'] = scale_of(player)
|
|
player('command', command='kill @s')
|
|
player.wait(30)
|
|
player.raw('respawn')
|
|
player.wait(60)
|
|
back_to_game(player)
|
|
s['respawned'] = scale_of(player)
|
|
s['health'] = player('state')['player']['health']
|
|
player('command', command='attribute @s minecraft:scale base set 1')
|
|
player('command', command='attribute @s minecraft:max_health base set 20')
|
|
player.wait(20)
|
|
s['reset'] = scale_of(player)
|
|
for label, s in seen.items():
|
|
for step in ('set', 'nether', 'overworld', 'respawned'):
|
|
assert s[step] == 0.162, f'{label} after {step}: {s}'
|
|
assert s['reset'] == 1.62, f'{label} reset: {s}'
|
|
assert s['health'] == 40, f'{label} respawned health: {s}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['scale-body']
|
|
CHECK_FNS['scale-body'] = check_scale_body
|
|
|
|
|
|
def check_datapack_recipe(host, guest, out):
|
|
"""A crafting recipe from the world's data pack shows its result in the crafting table of the
|
|
host's window and of a guest (#6733). Vanilla's CraftingMenu.slotChangedCraftingGrid sets the
|
|
result on the server and ClientboundContainerSetSlotPacket sends slot 0: the window shows the
|
|
server's, not a result of its own recipes. The world reopens to load the pack."""
|
|
if not SERVER_THREAD:
|
|
return {'skipped': "the reopen is the server thread's"}
|
|
pack = host.work / 'saves' / 'Lan' / 'datapacks' / 'repro'
|
|
(pack / 'data/repro/recipe').mkdir(parents=True, exist_ok=True)
|
|
(pack / 'pack.mcmeta').write_text('{"pack":{"description":"repro","min_format":[121,0],"max_format":[121,0]}}')
|
|
(pack / 'data/repro/recipe/dirt_to_diamond.json').write_text(
|
|
'{"type":"minecraft:crafting_shapeless","ingredients":["minecraft:dirt"],"result":{"id":"minecraft:diamond"}}')
|
|
seen = {}
|
|
try:
|
|
host.raw('disconnect')
|
|
for attempt in range(60):
|
|
time.sleep(1)
|
|
try:
|
|
host.raw('open', 'Lan')
|
|
break
|
|
except Failed:
|
|
if attempt == 59:
|
|
raise
|
|
host('wait_ready', seconds=400)
|
|
port = host.raw('lan', 'open', 0, 'commands')['port']
|
|
back_to_game(guest)
|
|
guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME)
|
|
guest.raw('wait-for', 'loaded', '--timeout', 300)
|
|
guest.wait(20)
|
|
for game in (host, guest):
|
|
game('command', command='gamemode survival')
|
|
game('command', command='clear @s')
|
|
game('command', command='give @s minecraft:dirt 4')
|
|
x, y, z = (int(v // 1) for v in pos_of(game))
|
|
table = (x, y, z + 2)
|
|
game('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:crafting_table')
|
|
game.wait(10)
|
|
grab(game)
|
|
game.raw('interact', *table)
|
|
game.raw('wait-for', 'screen', 'inventory', '--timeout', 10)
|
|
game.wait(5)
|
|
slot = next(s['slot'] for s in game('inventory')['hotbar'] if s and s.get('item') == 'minecraft:dirt')
|
|
game.raw('click-slot', slot)
|
|
game.raw('click-slot', 100, 'right')
|
|
game.raw('click-slot', slot)
|
|
game.wait(10)
|
|
case = {'result': result_of(game)}
|
|
game.shot(out, f'datapack-recipe-{game.name}')
|
|
game.raw('click-slot', 200)
|
|
game.wait(10)
|
|
case['carried'] = held(game)
|
|
case['emptied'] = result_of(game)
|
|
game.raw('key', 'esc', 'tap')
|
|
game.wait(5)
|
|
game('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:air')
|
|
seen[game.name] = case
|
|
finally:
|
|
back_to_game(host)
|
|
back_to_game(guest)
|
|
shutil.rmtree(pack, ignore_errors=True)
|
|
for name, case in seen.items():
|
|
assert case['result'] == ('minecraft:diamond', 1), f'{name}: the pack recipe shows {case}'
|
|
assert case['carried'] == ('minecraft:diamond', 1), f'{name}: taking the result gave {case}'
|
|
assert case['emptied'] == (None, None), f'{name}: the result stays after the grid is used up: {case}'
|
|
return seen
|
|
|
|
|
|
CHECKS += ['datapack-recipe']
|
|
CHECK_FNS['datapack-recipe'] = check_datapack_recipe
|
|
|
|
|
|
CHECKS += ['raid-captain-fields']
|
|
CHECK_FNS['raid-captain-fields'] = check_raid_captain_fields
|
|
|
|
|
|
def check_damage_attacker(host, guest, out):
|
|
"""Connected causing players survive /damage and /execute on attacker.
|
|
|
|
Scoreboard writes observe whether a relation actually ran; selector feedback
|
|
wording and the warden's sonic behavior are outside this check.
|
|
"""
|
|
objective = 'h059_attacker'
|
|
positions = [(g, pos_of(g)) for g in (host, guest)]
|
|
seen = {}
|
|
for g in (host, guest):
|
|
g('command', command='gamemode creative')
|
|
host('command', command='forceload add 0 0')
|
|
for g in (host, guest):
|
|
g('command', command='tp @s 8.5 230 8.5')
|
|
guest.wait(20)
|
|
host('command', command='tick freeze')
|
|
host('command', command=f'scoreboard objectives add {objective} dummy')
|
|
|
|
def game_time():
|
|
line = server_reply(host, 'time query gametime', 'time is')
|
|
return int(re.search(r'time is (\d+)', line).group(1))
|
|
|
|
def relations(observer, target, serial):
|
|
result = {}
|
|
conditions = {'any': '', 'guest': f'[name={GUEST_NAME}]',
|
|
'host': f'[name={host.player}]', 'cow': '[tag=h059_cause]'}
|
|
for name, condition in conditions.items():
|
|
holder = f'#h059_{serial}_{observer.name}_{name}'
|
|
observer('command', command=f'scoreboard players set {holder} {objective} 0')
|
|
observer('command', command=f'execute as {target} on attacker if entity @s{condition} '
|
|
f'run scoreboard players set {holder} {objective} 1')
|
|
line = server_reply(observer, f'scoreboard players get {holder} {objective}', holder, objective, ' has ')
|
|
result[name] = int(re.search(r' has (-?\d+) ', line).group(1))
|
|
return result
|
|
|
|
try:
|
|
frozen = game_time()
|
|
host('command', command='summon cow 8 200 8 {NoAI:1b,NoGravity:1b,Tags:["h059_cause"]}')
|
|
cow = '@e[tag=h059_cause,limit=1]'
|
|
cases = [('none', '', 'none'), ('cow', f' by {cow}', 'cow'),
|
|
('host', f' by {host.player}', 'host'), ('guest', f' by {GUEST_NAME}', 'guest'),
|
|
('indirect_guest', f' by {cow} from {GUEST_NAME}', 'guest')]
|
|
serial = 0
|
|
for issuer in (host, guest):
|
|
for kind, health in (('pig', 9000), ('warden', 499000)):
|
|
for cause, suffix, expected in cases:
|
|
serial += 1
|
|
tag = f'h059_subject_{serial}'
|
|
target = f'@e[tag={tag},limit=1]'
|
|
host('command', command=f'summon {kind} 4 200 4 '
|
|
f'{{NoAI:1b,NoGravity:1b,Tags:["h059_subject","{tag}"]}}')
|
|
server_reply(issuer, f'damage {target} 1 minecraft:player_attack{suffix}', 'Applied')
|
|
line = server_reply(host, f'data get entity {target} Health 1000', str(health))
|
|
assert re.search(rf'\b{health}\b', line), line
|
|
want = {name: int(name == expected or name == 'any' and expected != 'none')
|
|
for name in ('any', 'guest', 'host', 'cow')}
|
|
row = {g.name: relations(g, target, serial) for g in (host, guest)}
|
|
seen[f'{issuer.name}/{kind}/{cause}'] = row
|
|
assert all(result == want for result in row.values()), (issuer.name, kind, cause, want, row)
|
|
if issuer is host and kind == 'pig' and cause == 'host':
|
|
# Refused equal damage keeps the old attacker; an accepted
|
|
# stronger hit replaces it with the other connected player.
|
|
for amount, remaining, credit in ((1, 9000, 'host'), (2, 8000, 'guest')):
|
|
host('command', command=f'damage {target} {amount} minecraft:player_attack by {GUEST_NAME}')
|
|
server_reply(host, f'data get entity {target} Health 1000', str(remaining))
|
|
changed = {g.name: relations(g, target, f'{serial}_hit{amount}') for g in (host, guest)}
|
|
expected_credit = {name: int(name in ('any', credit)) for name in want}
|
|
seen[f'host/pig/replacement{amount}'] = changed
|
|
assert all(value == expected_credit for value in changed.values()), changed
|
|
host('command', command=f'kill {target}')
|
|
assert game_time() == frozen, 'damage and attacker reads must not advance world ticks'
|
|
finally:
|
|
host('command', command='kill @e[tag=h059_subject]')
|
|
host('command', command='kill @e[tag=h059_cause]')
|
|
host('command', command=f'scoreboard objectives remove {objective}')
|
|
host('command', command='forceload remove 0 0')
|
|
for g, at in positions:
|
|
g('command', command='tp @s ' + ' '.join(str(v) for v in at))
|
|
g('command', command='gamemode survival')
|
|
host('command', command='tick unfreeze')
|
|
return seen
|
|
|
|
|
|
CHECKS += ['damage-attacker']
|
|
CHECK_FNS['damage-attacker'] = check_damage_attacker
|
|
|
|
|
|
def check_spectator_container(host, guest, out):
|
|
"""A spectator inspects a container without its lid, its sound or a lid left open (#8517).
|
|
|
|
`ChestBlockEntity.startOpen` returns at once for a spectator, and
|
|
`stopOpen` skips it the same way, so `ContainerOpenersCounter` never counts
|
|
one: the menu opens (a spectator does look inside), while the lid, its
|
|
block event and the open and close sounds stay away and nothing is left
|
|
open when the menu closes.
|
|
"""
|
|
opened_sound, closed_sound = 'minecraft:block.chest.open', 'minecraft:block.chest.close'
|
|
x, y, z = (int(v // 1) for v in pos_of(guest))
|
|
chest = (x, y, z + 2)
|
|
guest('command', command=f'setblock {chest[0]} {chest[1]} {chest[2]} minecraft:chest')
|
|
guest('command', command='gamemode spectator')
|
|
guest.wait(10)
|
|
before = {g.name: (heard(g, opened_sound), heard(g, closed_sound)) for g in (host, guest)}
|
|
try:
|
|
guest.raw('interact', *chest)
|
|
screen = guest.raw('wait-for', 'screen', 'container', '--timeout', 10)
|
|
guest.wait(20)
|
|
opened = {'guest': lid(guest, chest), 'host': lid(host, chest)}
|
|
guest.shot(out, 'spectator-container-open')
|
|
guest.raw('key', 'esc', 'tap')
|
|
guest.wait(20)
|
|
closed = {'guest': lid(guest, chest), 'host': lid(host, chest)}
|
|
finally:
|
|
guest('command', command='gamemode survival')
|
|
guest('command', command=f'setblock {chest[0]} {chest[1]} {chest[2]} minecraft:air')
|
|
after = {g.name: (heard(g, opened_sound), heard(g, closed_sound)) for g in (host, guest)}
|
|
sounds = {name: (after[name][0] - before[name][0], after[name][1] - before[name][1]) for name in after}
|
|
assert screen, 'the spectator did not get the container screen'
|
|
assert all(v < 0.1 for v in opened.values()), f'the lid while a spectator looked inside: {opened}'
|
|
assert all(v < 0.1 for v in closed.values()), f'the lid after the spectator closed it: {closed}'
|
|
assert not any(any(heard_at) for heard_at in sounds.values()), f'the sounds a spectator caused: {sounds}'
|
|
return {'opened': opened, 'closed': closed, 'sounds': sounds}
|
|
|
|
|
|
CHECKS += ['spectator-container']
|
|
CHECK_FNS['spectator-container'] = check_spectator_container
|
|
|
|
|
|
def check_trident_stack_durability(host, guest, out):
|
|
"""#8911: stack max_damage gates trident use/release and its capped wear."""
|
|
player = host
|
|
objective = 'trident_clock'
|
|
old_pos = pos_of(player)
|
|
rows, failures, component_failures = {}, [], []
|
|
|
|
def reply(text):
|
|
# The host command control request returns this command's authoritative replies.
|
|
return '\n'.join(player('command', command=text)['chat'])
|
|
|
|
def ticks(n):
|
|
def clock():
|
|
line = reply(f'scoreboard players get @s {objective}')
|
|
match = re.search(r'has (-?\d+) ', line)
|
|
assert match, f'prerequisite: no player clock: {line}'
|
|
return int(match.group(1))
|
|
start = clock()
|
|
deadline = time.monotonic() + 30
|
|
while clock() - start < n:
|
|
assert time.monotonic() < deadline, 'prerequisite: player clock stalled'
|
|
player.wait(1)
|
|
|
|
try:
|
|
for command in ['tick freeze', 'weather clear', 'difficulty normal',
|
|
'gamerule spawn_mobs false', 'forceload add 0 0 15 15',
|
|
'fill 0 199 0 8 204 8 stone strict',
|
|
f'scoreboard objectives add {objective} minecraft.custom:minecraft.play_time']:
|
|
player('command', command=command)
|
|
cases = [('small', 10, 9, True, False, True, False),
|
|
('large', 500, 249, True, False, True, True),
|
|
('default-near', None, 249, True, False, True, False),
|
|
('default', None, 0, True, False, True, True),
|
|
('dry', 500, 249, False, False, True, False),
|
|
('creative-near', 10, 9, True, True, True, False),
|
|
('throw-near', 10, 9, True, False, False, False),
|
|
('throw', 500, 249, True, False, False, True)]
|
|
for label, maximum, damage, wet, creative, riptide, admitted in cases:
|
|
player.raw('release')
|
|
player('command', command='kill @e[tag=trident_durability]')
|
|
player('command', command='kill @e[type=trident]')
|
|
# Frozen death animations still occupy the spin's collision route.
|
|
start = int(re.search(r'\d+', reply('time query gametime')).group())
|
|
player('command', command='tick step 25')
|
|
deadline = time.monotonic() + 30
|
|
while int(re.search(r'\d+', reply('time query gametime')).group()) < start + 25:
|
|
assert time.monotonic() < deadline, 'prerequisite: cleanup ticks stalled'
|
|
player.wait(1)
|
|
# A one-block column keeps the charge and first spin contact in the same loaded cell.
|
|
player('command', command='fill 3 200 3 5 204 5 stone strict')
|
|
player('command', command='fill 4 200 4 4 203 4 '+('water' if wet else 'air')+' strict')
|
|
player('command', command='gamemode '+('creative' if creative else 'survival'))
|
|
player('command', command='clear @s')
|
|
player('command', command='tp @s 4.5 200 4.5 0 0')
|
|
components = [f'damage={damage}']
|
|
if maximum is not None:
|
|
components.append(f'max_damage={maximum}')
|
|
if riptide:
|
|
components.append('enchantments={"minecraft:riptide":1}')
|
|
item = 'minecraft:trident[' + ','.join(components) + ']'
|
|
player('command', command='item replace entity @s weapon.mainhand with '+item)
|
|
player.raw('key', '1', 'tap')
|
|
ticks(24)
|
|
predicate = item.replace('damage=0,', '').replace('[damage=0]', '')
|
|
assert 'Test passed' in reply(f'execute if items entity @s weapon.mainhand {predicate}'), label
|
|
mode = 'creative' if creative else 'survival'
|
|
assert 'Test passed' in reply(f'execute if entity @s[gamemode={mode}]'), label
|
|
assert 'Test passed' in reply('execute positioned 4.5 200 4.5 if entity @s[distance=..0.75]'), label
|
|
assert 'Test passed' in reply('execute if block 4 200 4 '+('water' if wet else 'air')), label
|
|
client_components = player('state')['held'].get('components', '')
|
|
if maximum is not None and f'"minecraft:max_damage":{maximum}' not in client_components.replace(' ', ''):
|
|
component_failures.append(label)
|
|
player.raw('mouse', 'right', 'down')
|
|
ticks(20)
|
|
using = player('state').get('using')
|
|
# Charge is observed, not inferred from a wall-clock sleep.
|
|
charge = using is not None and using['ticks'] >= 10
|
|
if riptide:
|
|
player('command', command='summon zombie 4.5 200 4.5 '
|
|
f'{{NoAI:1b,NoGravity:1b,Silent:1b,Tags:["trident_durability","trident_{label}"]}}')
|
|
assert 'Test passed' in reply(f'execute if entity @e[tag=trident_{label}]'), label
|
|
player.raw('mouse', 'right', 'up')
|
|
ticks(10)
|
|
hand = reply('data get entity @s SelectedItem')
|
|
row = {'charge': charge, 'using': using, 'hand': hand, 'client_components': client_components}
|
|
if riptide:
|
|
health = reply(f'data get entity @e[tag=trident_{label},limit=1] Health 1000')
|
|
row['health'] = health
|
|
hit = 'is 12128' in health
|
|
healthy = 'is 20000' in health
|
|
checks = [charge == admitted, hit if admitted else healthy,
|
|
(f'"minecraft:damage": {damage + (2 if admitted else 0)}' in hand
|
|
if damage or admitted else '"minecraft:damage"' not in hand)]
|
|
else:
|
|
thrown = reply('execute if entity @e[type=trident]')
|
|
row['thrown'] = thrown
|
|
checks = [charge == admitted, ('Test passed' in thrown) == admitted]
|
|
rows[label] = row
|
|
failures += [f'{label}/{i}' for i, passed in enumerate(checks) if not passed]
|
|
detail = {'cases': rows, 'failures': failures, 'component_failures': component_failures}
|
|
(out / 'trident-stack-durability.json').write_text(json.dumps(detail, indent=2)+'\n')
|
|
assert not failures and not component_failures, detail
|
|
return detail
|
|
finally:
|
|
player.raw('release')
|
|
for command in ['clear @s', 'kill @e[tag=trident_durability]', 'kill @e[type=trident]',
|
|
f'scoreboard objectives remove {objective}', 'forceload remove 0 0',
|
|
'tick unfreeze', 'gamemode survival', 'tp @s '+' '.join(map(str, old_pos))]:
|
|
player('command', command=command)
|
|
|
|
|
|
CHECKS += ['trident-durability']
|
|
CHECK_FNS['trident-durability'] = check_trident_stack_durability
|
|
|
|
|
|
def check_pig_saddle_shears(host, guest, out):
|
|
"""#9124: a real click shears an unoccupied pig before the mounting route.
|
|
|
|
Fresh Java 26.3: remove/drop/wear for unoccupied shears, no change or
|
|
mounting when occupied, and ordinary mounting with an empty hand.
|
|
"""
|
|
player = guest or host
|
|
name = getattr(player, 'player', GUEST_NAME)
|
|
old_host, old_player = pos_of(host), pos_of(player)
|
|
rows = []
|
|
objective = 'pig_shear_test'
|
|
|
|
def reply(text):
|
|
return '\n'.join(host('command', command=text)['chat'])
|
|
|
|
def number(text, success=False):
|
|
mode = 'success' if success else 'result'
|
|
reply(f'scoreboard players set probe {objective} 0')
|
|
reply(f'execute store {mode} score probe {objective} run {text}')
|
|
line = reply(f'scoreboard players get probe {objective}')
|
|
found = re.search(r'has (-?\d+) ', line)
|
|
assert found, f'prerequisite: missing score reply: {line}'
|
|
return int(found.group(1))
|
|
|
|
def step(n):
|
|
start = int(re.search(r'is (-?\d+)', reply('time query gametime')).group(1))
|
|
reply(f'tick step {n}')
|
|
deadline = time.monotonic() + 30
|
|
while True:
|
|
now = int(re.search(r'is (-?\d+)', reply('time query gametime')).group(1))
|
|
if now >= start + n:
|
|
assert now == start + n, 'prerequisite: world advanced extra ticks'
|
|
return
|
|
assert time.monotonic() < deadline, 'prerequisite: world ticks stalled'
|
|
player.wait(1)
|
|
|
|
try:
|
|
for text in ['tick freeze', 'gamerule spawn_mobs false', 'difficulty normal',
|
|
'forceload add 0 0 15 15', 'fill 0 98 0 15 99 15 stone',
|
|
'fill 0 100 0 15 106 15 air', f'scoreboard objectives add {objective} dummy']:
|
|
reply(text)
|
|
if guest:
|
|
reply(f'gamemode spectator {host.player}')
|
|
reply(f'tp {host.player} 60.5 100 70.5')
|
|
for i, label in enumerate(('unoccupied', 'occupied', 'mount-control'), 1):
|
|
occupied, empty = label == 'occupied', label == 'mount-control'
|
|
player.raw('release')
|
|
reply(f'ride {name} dismount')
|
|
reply('kill @e[tag=pig_shear_subject]')
|
|
reply('kill @e[tag=pig_shear_rider]')
|
|
step(25)
|
|
reply('kill @e[type=item]')
|
|
reply(f'gamemode survival {name}')
|
|
reply(f'clear {name}')
|
|
reply(f'tp {name} 4.5 100 4.5 0 0')
|
|
if not empty:
|
|
reply(f'item replace entity {name} weapon.mainhand with shears')
|
|
uid = f'00000007-0000-0007-0000-00070000000{i}'
|
|
child = ',Passengers:[{id:"minecraft:chicken",Pos:[6.5d,100d,4.5d],NoAI:1b,Tags:["pig_shear_rider"]}]' if occupied else ''
|
|
reply(f'summon pig 6.5 100 4.5 {{UUID:[I;7,7,7,{i}],NoAI:1b,PersistenceRequired:1b,Tags:["pig_shear_subject"],'
|
|
'Health:20f,attributes:[{id:"minecraft:max_health",base:20d}],'
|
|
'equipment:{saddle:{id:"minecraft:saddle",count:1}}' + child + '}')
|
|
step(1) # publish the loaded pig to the real client before the click
|
|
player.wait(2)
|
|
saddle = 'execute if entity @e[tag=pig_shear_subject,nbt={equipment:{saddle:{id:"minecraft:saddle"}}}]'
|
|
passenger = f'execute as @e[tag=pig_shear_rider,type=chicken] on vehicle if entity @s[nbt={{UUID:[I;7,7,7,{i}]}}]'
|
|
assert number(f'execute if entity @e[tag=pig_shear_subject,type=pig,nbt={{UUID:[I;7,7,7,{i}],NoAI:1b,Pos:[6.5d,100d,4.5d]}}]', True) == 1
|
|
assert number(f'data get entity {uid} Health') == 20
|
|
assert number(saddle, True) == 1 and number(passenger, True) == int(occupied)
|
|
held = reply(f'data get entity {name} SelectedItem')
|
|
assert ('minecraft:shears' in held) == (not empty)
|
|
if not empty:
|
|
assert number(f'data get entity {name} SelectedItem.count') == 1
|
|
assert number(f'data get entity {name} SelectedItem.components."minecraft:damage"') == 0
|
|
assert number(f'execute if entity @a[name={name},gamemode=survival,nbt={{Pos:[4.5d,100d,4.5d]}}]', True) == 1
|
|
assert number('execute if entity @e[type=item,nbt={Item:{id:"minecraft:saddle"}}]') == 0
|
|
player.raw('look-at', 6.5, 100.4, 4.5)
|
|
player.wait(2)
|
|
picked = player('state')['looking_at']
|
|
assert picked['kind'] == 'entity' and picked['entity']['type'] == 'minecraft:pig', picked
|
|
player.raw('interact-entity', picked['entity']['id'])
|
|
command_says(player, 'time query gametime') # same-connection interaction barrier
|
|
step(1)
|
|
player.wait(2)
|
|
rows.append({'case': label, 'saddle': number(saddle, True),
|
|
'drops': number('execute if entity @e[type=item,nbt={Item:{id:"minecraft:saddle",count:1}}]'),
|
|
'wear': number(f'data get entity {name} SelectedItem.components."minecraft:damage"'),
|
|
'riding': player('status').get('riding') is not None,
|
|
'passenger': number(passenger, True), 'health': number(f'data get entity {uid} Health'),
|
|
'live': number(f'execute if entity {uid}', True)})
|
|
(out / 'pig-saddle-shears.json').write_text(json.dumps(rows, indent=2))
|
|
expected = [dict(case='unoccupied', saddle=0, drops=1, wear=1, riding=False, passenger=0, health=20, live=1),
|
|
dict(case='occupied', saddle=1, drops=0, wear=0, riding=False, passenger=1, health=20, live=1),
|
|
dict(case='mount-control', saddle=1, drops=0, wear=0, riding=True, passenger=0, health=20, live=1)]
|
|
assert rows == expected, rows
|
|
return rows
|
|
finally:
|
|
reply(f'ride {name} dismount')
|
|
reply('kill @e[tag=pig_shear_subject]')
|
|
reply('kill @e[tag=pig_shear_rider]')
|
|
reply('kill @e[type=item]')
|
|
reply(f'scoreboard objectives remove {objective}')
|
|
reply('forceload remove 0 0')
|
|
reply('tick unfreeze')
|
|
for actor, position in ((host.player, old_host), (name, old_player)):
|
|
reply(f'gamemode survival {actor}')
|
|
reply(f'tp {actor} '+' '.join(map(str, position)))
|
|
|
|
|
|
CHECKS += ['pig-saddle-shears']
|
|
CHECK_FNS['pig-saddle-shears'] = check_pig_saddle_shears
|
|
|
|
|
|
def prepare_recipes_hmod(*games):
|
|
"""Install the same original content package in both scratch game folders."""
|
|
fixture = ROOT / 'tools/fixtures/recipes_hmod'
|
|
package = io.BytesIO()
|
|
with zipfile.ZipFile(package, 'w', zipfile.ZIP_DEFLATED) as archive:
|
|
for path in sorted(fixture.rglob('*')):
|
|
if path.is_file():
|
|
archive.write(path, path.relative_to(fixture).as_posix())
|
|
for game in games:
|
|
mods = game.work / 'horizon-mods'
|
|
mods.mkdir()
|
|
(mods / 'recipe_pilot.hmod').write_bytes(package.getvalue())
|
|
(game.work / 'horizon-mods.txt').write_text('mod.recipe_pilot:true\nmod.recipes:true\n')
|
|
(game.work / 'options.txt').write_text('lang:en_us\n')
|
|
settings = mods / '.horizon-mod-data/recipes'
|
|
settings.mkdir(parents=True)
|
|
(settings / 'give').write_text('false')
|
|
|
|
|
|
def check_recipes_hmod(host, guest, out):
|
|
"""Recipes' real panel -> normal server menu -> custom output -> save/rejoin (#9647).
|
|
|
|
Opt in with --check recipes-hmod and --bin-dir pointing at a game compiled with the official
|
|
native Recipes mod. The fixture is ours; no Java mod, copied assets or synthetic game replies.
|
|
"""
|
|
item = 'minecraft:echo_shard'
|
|
recipe = 'recipe_pilot:flint_to_echo_shard'
|
|
seen = {}
|
|
|
|
def widget(game, predicate):
|
|
matches = [w for w in game.raw('ui')['widgets'] if predicate(w)]
|
|
assert len(matches) == 1, f'{game.name}: expected one Recipes widget, got {matches}'
|
|
return matches[0]
|
|
|
|
def search(game, text):
|
|
ui = game.raw('ui')
|
|
fields = [w for w in ui['widgets'] if w['kind'] == 'field']
|
|
if not fields:
|
|
game.shot(out, 'recipes-hmod-no-search')
|
|
(out / f'{game.name}-recipes-hmod-ui.json').write_text(json.dumps(ui, indent=2))
|
|
assert fields, f'{game.name}: no Recipes search field (mod absent or empty-search recovery failed)'
|
|
# The native panel is to the right of the vanilla recipe book's search field.
|
|
field = max(fields, key=lambda w: w['bounds'][0])
|
|
game.raw('click', field['id'])
|
|
# `key` drives key bindings; `type` drives this focused edit box, including backspace.
|
|
game.script([r"type '\b'"] * 50 + [f'type {text}'])
|
|
game.wait(5)
|
|
|
|
def page(game):
|
|
result = widget(game, lambda w: w.get('label') == 'Echo Shard'
|
|
and w['kind'] == 'button')
|
|
game.raw('click', result['id'])
|
|
game.wait(5)
|
|
return widget(game, lambda w: (w.get('label') or '').startswith('+ '))
|
|
|
|
def contents(game):
|
|
return {'flint': server_count(game, 'flint'), 'echo_shard': server_count(game, item)}
|
|
|
|
for game, supplied in ((host, 4), (guest, 5)):
|
|
game('command', command='clear @s')
|
|
server_reply(game, 'recipe take @s *', 'recipe')
|
|
x, y, z = (math.floor(v) for v in pos_of(game))
|
|
table = (x, y, z - 2)
|
|
game('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:crafting_table')
|
|
game.wait(10)
|
|
game.raw('interact', *table)
|
|
game.raw('wait-for', 'screen', 'inventory', '--timeout', 10)
|
|
assert game('screen').get('craft_size') == 3
|
|
|
|
# The output exists in vanilla; its flint recipe is original .hmod content.
|
|
search(game, 'recipe_pilot:no_such_item')
|
|
assert not any(w.get('label') == 'Echo Shard' for w in game.raw('ui')['widgets'])
|
|
search(game, item)
|
|
game.shot(out, 'recipes-hmod-search')
|
|
unknown = page(game)
|
|
assert unknown['enabled'] is False, f'{game.name}: unknown recipe can be placed: {unknown}'
|
|
game.shot(out, 'recipes-hmod-unknown')
|
|
assert not any(i for i, _ in grid_of(game, 100, 109).values())
|
|
assert not result_of(game)[0]
|
|
|
|
server_reply(game, f'recipe give @s {recipe}', 'recipe')
|
|
game.wait(10)
|
|
back = widget(game, lambda w: w.get('label') == 'Back')
|
|
game.raw('click', back['id'])
|
|
game.wait(5)
|
|
known = page(game)
|
|
assert known['enabled'] is True, f'{game.name}: learned custom recipe unavailable: {known}'
|
|
game.raw('click', known['id'])
|
|
game.wait(10)
|
|
missing = contents(game)
|
|
assert missing == {'flint': 0, 'echo_shard': 0}, missing
|
|
assert not any(i for i, _ in grid_of(game, 100, 109).values())
|
|
assert not result_of(game)[0], f'{game.name}: missing ingredients fabricated a result'
|
|
game.shot(out, 'recipes-hmod-missing')
|
|
|
|
# Supply the input through the server, then use only the panel's normal + action.
|
|
game('command', command=f'give @s minecraft:flint {supplied}')
|
|
game.wait(10)
|
|
known = widget(game, lambda w: (w.get('label') or '').startswith('+ '))
|
|
game.raw('click', known['id'])
|
|
game.until(lambda: result_of(game), lambda r: bool(r[0]))
|
|
grid = [s for s in grid_of(game, 100, 109).values() if s[0]]
|
|
result = result_of(game)
|
|
assert grid == [('minecraft:flint', 1)], f'{game.name}: filled grid {grid}'
|
|
# Independent expected yield: changing the fixture to yield one must fail here.
|
|
assert result == (item, 2), f'{game.name}: custom result {result}'
|
|
game.shot(out, 'recipes-hmod-filled')
|
|
game.raw('click-slot', 200, 'shift')
|
|
game.wait(10)
|
|
assert not result_of(game)[0], f'{game.name}: consumed input left another result'
|
|
back_to_game(game)
|
|
crafted = contents(game)
|
|
assert crafted == {'flint': supplied - 1, 'echo_shard': 2}, crafted
|
|
seen[game.name] = {'missing': missing, 'grid': grid, 'result': result, 'crafted': crafted}
|
|
game('command', command=f'setblock {table[0]} {table[1]} {table[2]} minecraft:air')
|
|
|
|
save_and_reopen(host, guest)
|
|
for game in (host, guest):
|
|
persisted = contents(game)
|
|
assert persisted == seen[game.name]['crafted'], f'{game.name}: after rejoin {persisted}'
|
|
seen[game.name]['rejoined'] = persisted
|
|
game.shot(out, 'recipes-hmod-rejoined')
|
|
(out / 'recipes-hmod.json').write_text(json.dumps(seen, indent=2) + '\n')
|
|
return seen
|
|
|
|
|
|
CHECKS += ['recipes-hmod']
|
|
CHECK_FNS['recipes-hmod'] = check_recipes_hmod
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|