Port loiter and harden 26.3 bed updates
This commit is contained in:
@@ -5,9 +5,9 @@ play. The connection path targets protocol 777; older Minecraft protocol
|
||||
versions are not supported.
|
||||
|
||||
The original bot included wood/sand/crop farming, villager trading, eating,
|
||||
sleeping, and threat avoidance. On the current 26.3 runtime, path following and
|
||||
crop harvest/replant are migrated; other legacy jobs remain unavailable until
|
||||
ported to protocol 777.
|
||||
sleeping, and threat avoidance. On the current 26.3 runtime, path following,
|
||||
crop harvest/replant, and loiter/threat avoidance are migrated; other legacy
|
||||
jobs remain unavailable until ported to protocol 777.
|
||||
|
||||
## Requirements and setup
|
||||
|
||||
@@ -52,9 +52,9 @@ path-following movement (`1goto x y z`), inventory draining (`1drain`),
|
||||
normal/trapped-chest caching (`1cache`, also run by crop farming at the
|
||||
inventory threshold), bed sleeping (`1sleep`, or `1sleep new` to place a bed;
|
||||
also run by crop farming at night), food use (`1eat`, also run as needed by
|
||||
crop farming), and the crop harvest/replant loop (`1farm crops`). Other
|
||||
inventory/window actions and farming/trading jobs remain disabled until their
|
||||
26.3 packets are migrated.
|
||||
crop farming), and the crop harvest/replant loop (`1farm crops`), and death-screen respawning (`1respawn`). Other
|
||||
inventory/window actions and resource-farming/trading jobs remain disabled
|
||||
until their 26.3 packets are migrated.
|
||||
The default server is `127.0.0.1`; set `SERVER` explicitly only when you
|
||||
intentionally want another server.
|
||||
|
||||
@@ -79,7 +79,8 @@ and all bots will run the command.
|
||||
The current 26.3 command path handles public chat and direct messages, replying
|
||||
in the same channel. Verified commands are `!ping`, `!pos`, `!loaded`,
|
||||
`!info x y z`, `!inv` (basic slot/count data), `1cache`, `1drain`, `1eat`,
|
||||
`1sleep`, addressed path movement such as `1goto x y z`, and `1farm crops`. The command catalog below is retained from
|
||||
`1respawn`, `1sleep`, `1loiter`, addressed path movement such as `1goto x y z`, and
|
||||
`1farm crops`. The command catalog below is retained from
|
||||
the legacy bot; other inventory actions, arbitrary block interaction, and the
|
||||
other farming/trading commands are not yet available on 26.3. Common item
|
||||
components (names, damage, enchantments, book
|
||||
|
||||
+2
-2
@@ -66,9 +66,9 @@ class Commands:
|
||||
command = 'nosquarebrackets'
|
||||
|
||||
unsupported = {
|
||||
'gather', 'loiter', 'trade', 'gapple', 'fill',
|
||||
'gather', 'trade', 'gapple', 'fill',
|
||||
'drop', 'dump', 'break', 'open', 'click', 'use',
|
||||
'interact', 'respawn',
|
||||
'interact',
|
||||
}
|
||||
if (command in unsupported or
|
||||
(command == 'farm' and not data.lower().startswith('crop'))):
|
||||
|
||||
+11
-3
@@ -24,7 +24,7 @@ from mosfet.protocol.packets import (
|
||||
|
||||
from mosfet.protocol.types import Slot
|
||||
from mosfet.protocol.modern import (
|
||||
PlayerCommandPacket, UpdateTimePacket, UseItemPacket,
|
||||
ClientCommandPacket, PlayerCommandPacket, UpdateTimePacket, UseItemPacket,
|
||||
)
|
||||
|
||||
from mosfet import utils
|
||||
@@ -711,6 +711,12 @@ class Game(LegacyGame):
|
||||
self.break_deadline = time.monotonic() + max(0.05, hardness_time)
|
||||
return True
|
||||
|
||||
def respawn(self):
|
||||
self.g.respawn_requested_at = time.monotonic()
|
||||
self.g.connection.write_packet(
|
||||
ClientCommandPacket(action_id=0), force=True)
|
||||
self.g.connection.write_packet(ClientTickEnd(), force=True)
|
||||
|
||||
def leave_bed(self):
|
||||
self._send_action(PlayerCommandPacket(
|
||||
entity_id=self.g.eid, action_id=0, jump_boost=0))
|
||||
@@ -854,11 +860,13 @@ class Game(LegacyGame):
|
||||
self.g.connection.write_packet(ClientTickEnd(), force=True)
|
||||
return True
|
||||
|
||||
def place_block(self, location, face, hand=0):
|
||||
def place_block(self, location, face, hand=0, cursor_y=None):
|
||||
location = tuple(int(value) for value in location)
|
||||
if cursor_y is None:
|
||||
cursor_y = 0.99 if int(face) == 1 else 0.5
|
||||
packet = UseItemOnBlockPacket(
|
||||
hand=hand, location=location, face=int(face),
|
||||
cursor_x=0.5, cursor_y=0.99 if int(face) == 1 else 0.5,
|
||||
cursor_x=0.5, cursor_y=cursor_y,
|
||||
cursor_z=0.5, inside_block=False, world_border_hit=False,
|
||||
sequence=self._next_sequence())
|
||||
self._send_action(packet)
|
||||
|
||||
+11
-1
@@ -265,7 +265,7 @@ class LegacyJobStates:
|
||||
|
||||
|
||||
class JobStates(LegacyJobStates):
|
||||
"""26.3 job facade; crop gathering is the currently migrated job."""
|
||||
"""26.3 facade for crop, inventory, sleeping, and loiter workflows."""
|
||||
def __init__(self, global_state):
|
||||
self.g = global_state
|
||||
self.gather_crop_states = gather_crop.GatherCropStates(global_state)
|
||||
@@ -273,6 +273,8 @@ class JobStates(LegacyJobStates):
|
||||
self.sleep_with_bed_states = sleep_with_bed.SleepWithBedStates(
|
||||
global_state)
|
||||
self.eat_food_states = eat_food.EatFoodStates(global_state)
|
||||
self.check_threats_states = check_threats.CheckThreatsStates(
|
||||
global_state)
|
||||
self.state = self.idle
|
||||
|
||||
def farm_crop(self):
|
||||
@@ -295,6 +297,11 @@ class JobStates(LegacyJobStates):
|
||||
self.sleep_with_bed_states.silent = False
|
||||
return [self.sleep_with_bed_states]
|
||||
|
||||
def loiter(self):
|
||||
self.sleep_with_bed_states.silent = True
|
||||
return [self.check_threats_states, self.sleep_with_bed_states,
|
||||
self.eat_food_states]
|
||||
|
||||
def stop(self):
|
||||
if self.g.get('breaking'):
|
||||
self.g.game.cancel_break()
|
||||
@@ -309,5 +316,8 @@ class JobStates(LegacyJobStates):
|
||||
self.sleep_with_bed_states.state = self.sleep_with_bed_states.idle
|
||||
self.sleep_with_bed_states.force_new_bed = False
|
||||
self.eat_food_states.state = self.eat_food_states.idle
|
||||
self.check_threats_states.state = self.check_threats_states.idle
|
||||
self.check_threats_states.safety = None
|
||||
self.check_threats_states.external_safety = False
|
||||
self.gather_crop_states.active_section_anchor = None
|
||||
self.state = self.idle
|
||||
|
||||
@@ -17,6 +17,10 @@ from mosfet.info import mcdata
|
||||
from mosfet.info import mobs
|
||||
|
||||
class CheckThreatsStates:
|
||||
THREAT_DISTANCE = 20
|
||||
CLEAR_DISTANCE = 30
|
||||
CHECK_INTERVAL = 2.0
|
||||
|
||||
def idle(self):
|
||||
return None
|
||||
|
||||
@@ -28,51 +32,63 @@ class CheckThreatsStates:
|
||||
w = self.g.world
|
||||
p = utils.pint(self.g.pos)
|
||||
|
||||
threats = w.find_threats(p, 40)
|
||||
threats = w.find_threats(p, self.THREAT_DISTANCE)
|
||||
|
||||
if threats:
|
||||
print('Found', len(threats), 'threats, fleeing:')
|
||||
print('Found', len(threats), 'threats, seeking safety:')
|
||||
print(threats)
|
||||
self.threats = threats
|
||||
self.state = self.find_safety
|
||||
else:
|
||||
print('Aborting, no threats')
|
||||
self.state = self.cleanup
|
||||
self.wait_time = self.CHECK_INTERVAL
|
||||
self.state = self.wait
|
||||
|
||||
def find_safety(self):
|
||||
w = self.g.world
|
||||
p = utils.pint(self.g.pos)
|
||||
|
||||
safety = w.find_blocks_indexed(p, [blocks.EMERALD_BLOCK])
|
||||
|
||||
if not safety:
|
||||
print('No emerald blocks found, aborting')
|
||||
self.state = self.cleanup
|
||||
return
|
||||
|
||||
safety.sort(key=lambda s: utils.phyp(p, s))
|
||||
print('Found emerald blocks:', safety)
|
||||
|
||||
for s in safety:
|
||||
s = utils.padd(s, path.BLOCK_ABOVE)
|
||||
navpath = w.path_to_place(p, s)
|
||||
|
||||
if navpath:
|
||||
safety_blocks = w.find_blocks_indexed(
|
||||
p, [blocks.EMERALD_BLOCK], self.THREAT_DISTANCE * 4)
|
||||
for block in safety_blocks:
|
||||
spot = utils.padd(block, path.BLOCK_ABOVE)
|
||||
if w.find_threats(spot, self.CLEAR_DISTANCE):
|
||||
continue
|
||||
navpath = w.path_to_place(p, spot)
|
||||
if navpath is not None:
|
||||
self.safety = spot
|
||||
self.external_safety = True
|
||||
self.g.path = navpath
|
||||
self.state = self.going_to_safety
|
||||
self.safety = s
|
||||
print('Going to safety', self.safety)
|
||||
print('Going to emerald safety point', self.safety)
|
||||
return
|
||||
else:
|
||||
print('Cant get to safety', self.safety)
|
||||
time.sleep(0.1)
|
||||
|
||||
print('Cant get to safety, aborting')
|
||||
self.state = self.cleanup
|
||||
# No configured emerald safe point was reachable. Retreat to a clear,
|
||||
# supported location that the modern navigator can actually reach.
|
||||
for spot in w.find_bed_areas(p, self.CLEAR_DISTANCE * 2):
|
||||
if w.find_threats(spot, self.CLEAR_DISTANCE):
|
||||
continue
|
||||
navpath = w.path_to_place(p, spot)
|
||||
if navpath is not None:
|
||||
self.safety = spot
|
||||
self.external_safety = False
|
||||
self.g.path = navpath
|
||||
self.state = self.going_to_safety
|
||||
print('Fleeing to clear area', self.safety)
|
||||
return
|
||||
|
||||
print('No reachable safe area found')
|
||||
self.wait_time = self.CHECK_INTERVAL
|
||||
self.state = self.wait
|
||||
|
||||
def going_to_safety(self):
|
||||
if utils.pint(self.g.pos) == self.safety:
|
||||
print('At safety spot, waiting to be moved')
|
||||
self.state = self.wait_for_move
|
||||
if self.external_safety:
|
||||
print('At emerald safety point, waiting for server move')
|
||||
self.state = self.wait_for_move
|
||||
else:
|
||||
print('At clear retreat spot')
|
||||
self.wait_time = 3
|
||||
self.state = self.wait
|
||||
|
||||
def wait_for_move(self):
|
||||
# wait for the server to move the bot when it's safe
|
||||
@@ -103,6 +119,8 @@ class CheckThreatsStates:
|
||||
|
||||
self.safety = None
|
||||
self.wait_time = 0
|
||||
self.threats = []
|
||||
self.external_safety = False
|
||||
|
||||
def run(self):
|
||||
self.state()
|
||||
|
||||
+106
-15
@@ -24,7 +24,11 @@ class SleepWithBedStates:
|
||||
self.my_bed = False
|
||||
self.area = None
|
||||
self.opening = None
|
||||
self.bed_head = None
|
||||
self.beds = []
|
||||
self.sleep_use_attempts = 0
|
||||
self.waiting_for_night = False
|
||||
self.waiting_for_time_update = False
|
||||
|
||||
current_time = self.g.get('time')
|
||||
if current_time is None or current_time < 12000:
|
||||
@@ -39,16 +43,55 @@ class SleepWithBedStates:
|
||||
|
||||
self.state = self.find_beds
|
||||
|
||||
@staticmethod
|
||||
def _bed_facing(state_id):
|
||||
props = blocks.PROPS.get(state_id, {})
|
||||
return {
|
||||
'north': (0, 0, -1), 'south': (0, 0, 1),
|
||||
'east': (1, 0, 0), 'west': (-1, 0, 0),
|
||||
}.get(props.get('facing'))
|
||||
|
||||
def _bed_foot(self, position):
|
||||
w = self.g.world
|
||||
state_id = w.block_at(*position)
|
||||
props = blocks.PROPS.get(state_id, {})
|
||||
if props.get('part') != 'head':
|
||||
return position
|
||||
facing = self._bed_facing(state_id)
|
||||
if facing is None:
|
||||
return position
|
||||
foot = utils.psub(position, facing)
|
||||
foot_state = w.block_at(*foot)
|
||||
if (foot_state in blocks.BED_IDS and
|
||||
blocks.PROPS.get(foot_state, {}).get('part') == 'foot'):
|
||||
return foot
|
||||
return position
|
||||
|
||||
def _bed_head(self, foot):
|
||||
state_id = self.g.world.block_at(*foot)
|
||||
facing = self._bed_facing(state_id)
|
||||
return utils.padd(foot, facing) if facing is not None else foot
|
||||
|
||||
def find_beds(self):
|
||||
print('Finding beds...')
|
||||
w = self.g.world
|
||||
p = utils.pint(self.g.pos)
|
||||
|
||||
result = ([] if self.force_new_bed else
|
||||
w.find_blocks_indexed(p, blocks.BED_IDS, 80))
|
||||
if self.force_new_bed:
|
||||
result = []
|
||||
print('Skipping existing beds; placing a new bed as requested')
|
||||
else:
|
||||
indexed = w.find_blocks_indexed(p, blocks.BED_IDS, 80)
|
||||
# Keep a tiny direct scan as a guard against stale/missing chunk
|
||||
# index entries, especially around respawn and chunk-cache changes.
|
||||
nearby = list(w.find_blocks_3d(
|
||||
p, blocks.BED_IDS, distance=8, y_limit=8))
|
||||
result = list(dict.fromkeys(nearby + indexed))
|
||||
result.sort(key=lambda bed: utils.phyp(p, bed))
|
||||
if nearby and not indexed:
|
||||
print('Found beds by local scan; chunk index had none')
|
||||
|
||||
result = list(dict.fromkeys(self._bed_foot(bed) for bed in result))
|
||||
self.beds = []
|
||||
for bed in result:
|
||||
if bed in self.bad_beds:
|
||||
@@ -78,8 +121,11 @@ class SleepWithBedStates:
|
||||
continue
|
||||
|
||||
self.area = bed
|
||||
self.bed_head = self._bed_head(bed)
|
||||
self.opening = navpath[-2] if len(navpath) > 1 else p
|
||||
self.g.path = navpath[:-1]
|
||||
self.opening_deadline = time.monotonic() + max(
|
||||
8.0, len(navpath) * 0.75)
|
||||
self.g.look_at = self.area
|
||||
self.state = self.going_to_bed
|
||||
return
|
||||
@@ -87,13 +133,23 @@ class SleepWithBedStates:
|
||||
print('No reachable beds; trying to place one')
|
||||
self.state = self.select_bed
|
||||
|
||||
def _at_opening(self):
|
||||
position = utils.pint(self.g.pos)
|
||||
return utils.phyp(position, self.opening) <= 1.25
|
||||
|
||||
def going_to_bed(self):
|
||||
if utils.pint(self.g.pos) == self.opening:
|
||||
if self._at_opening():
|
||||
print('At existing bed')
|
||||
self.g.path = []
|
||||
self.my_bed = False
|
||||
self.g.look_at = self.area
|
||||
self.wait_time = 0.5
|
||||
self.state = self.turn_to_bed
|
||||
elif time.monotonic() >= self.opening_deadline:
|
||||
print('Could not reach bed opening', self.opening,
|
||||
'; trying another bed')
|
||||
self.bad_beds.append(self.area)
|
||||
self.state = self.choose_bed
|
||||
|
||||
def turn_to_bed(self):
|
||||
self.g.look_at = self.area
|
||||
@@ -151,15 +207,22 @@ class SleepWithBedStates:
|
||||
return
|
||||
|
||||
self.g.path = navpath
|
||||
self.opening_deadline = time.monotonic() + max(
|
||||
8.0, len(navpath) * 0.75)
|
||||
self.g.look_at = self.area
|
||||
self.state = self.going_to_area
|
||||
|
||||
def going_to_area(self):
|
||||
if utils.pint(self.g.pos) == self.opening:
|
||||
if self._at_opening():
|
||||
print('At bed area')
|
||||
self.g.path = []
|
||||
self.g.look_at = self.area
|
||||
self.wait_time = 0.5
|
||||
self.state = self.turn_to_bed_area
|
||||
elif time.monotonic() >= self.opening_deadline:
|
||||
print('Could not reach bed placement opening', self.opening)
|
||||
self.bad_areas.append(self.area)
|
||||
self.state = self.cleanup
|
||||
|
||||
def turn_to_bed_area(self):
|
||||
self.g.look_at = self.area
|
||||
@@ -174,28 +237,38 @@ class SleepWithBedStates:
|
||||
support, BlockFace.TOP,
|
||||
hand=self.g.get('holding_hand', 0))
|
||||
self.my_bed = True
|
||||
self.wait_time = 0.5
|
||||
self.bed_place_deadline = time.monotonic() + 5.0
|
||||
self.state = self.wait_use
|
||||
|
||||
def wait_use(self):
|
||||
if self.wait_time > 0:
|
||||
self.wait_time -= utils.TICK
|
||||
return
|
||||
|
||||
if self.g.world.block_at(*self.area) not in blocks.BED_IDS:
|
||||
# Block updates can arrive after the use packet; poll authoritative
|
||||
# chunk state for several seconds instead of giving up after 0.5s.
|
||||
if self.g.world.block_at(*self.area) in blocks.BED_IDS:
|
||||
self.bed_head = self._bed_head(self.area)
|
||||
self.state = self.use_bed
|
||||
elif time.monotonic() >= self.bed_place_deadline:
|
||||
print('Bed placement was not confirmed at', self.area)
|
||||
self.bad_areas.append(self.area)
|
||||
self.my_bed = False
|
||||
self.state = self.cleanup
|
||||
return
|
||||
self.state = self.use_bed
|
||||
|
||||
def use_bed(self):
|
||||
w = self.g.world
|
||||
current_time = self.g.get('time')
|
||||
|
||||
if current_time is None or current_time < 12550:
|
||||
if current_time is None:
|
||||
if not getattr(self, 'waiting_for_time_update', False):
|
||||
print('Waiting for world time update before using bed')
|
||||
self.waiting_for_time_update = True
|
||||
return
|
||||
if current_time < 12550:
|
||||
if not getattr(self, 'waiting_for_night', False):
|
||||
print('Bed reached; waiting until sleep time (time={})'.format(
|
||||
current_time))
|
||||
self.waiting_for_night = True
|
||||
return
|
||||
self.waiting_for_night = False
|
||||
self.waiting_for_time_update = False
|
||||
|
||||
if w.block_at(*self.area) not in blocks.BED_IDS:
|
||||
print('Bed disappeared before use:', self.area)
|
||||
@@ -208,8 +281,13 @@ class SleepWithBedStates:
|
||||
return
|
||||
|
||||
print('Using bed')
|
||||
self.g.game.place_block(self.area, BlockFace.TOP)
|
||||
# Beds are only 9/16 block tall. Aim inside their collision shape;
|
||||
# the near-top cursor used for placing a new block misses them.
|
||||
self.g.game.place_block(
|
||||
self.area, BlockFace.TOP, cursor_y=0.5)
|
||||
self.sleep_deadline = time.monotonic() + 5.0
|
||||
self.sleep_retry_at = time.monotonic() + 1.5
|
||||
self.sleep_use_attempts = 1
|
||||
self.sleep_confirmed = False
|
||||
if not self.silent:
|
||||
self.g.chat.send('zzz')
|
||||
@@ -236,9 +314,19 @@ class SleepWithBedStates:
|
||||
elif self.g.get('correction_count', 0) and self.sleep_confirmed:
|
||||
print('Woke up by movement')
|
||||
self.state = self.break_bed
|
||||
elif (not self.sleep_confirmed and
|
||||
self.sleep_use_attempts < 2 and
|
||||
time.monotonic() >= self.sleep_retry_at):
|
||||
print('Bed use not confirmed on foot; trying head block',
|
||||
self.bed_head)
|
||||
self.g.game.place_block(
|
||||
self.bed_head, BlockFace.TOP, cursor_y=0.5)
|
||||
self.sleep_use_attempts += 1
|
||||
self.sleep_retry_at = time.monotonic() + 1.5
|
||||
elif (not self.sleep_confirmed and
|
||||
time.monotonic() >= self.sleep_deadline):
|
||||
print('Bed use was not confirmed')
|
||||
print('Bed use was not confirmed; blacklisting', self.area)
|
||||
self.bad_beds.append(self.area)
|
||||
self.state = self.cleanup
|
||||
|
||||
def break_bed(self):
|
||||
@@ -299,8 +387,11 @@ class SleepWithBedStates:
|
||||
self.bad_beds = []
|
||||
self.area = None
|
||||
self.opening = None
|
||||
self.bed_head = None
|
||||
self.bad_areas = []
|
||||
self.wait_time = 0
|
||||
self.sleep_use_attempts = 0
|
||||
self.sleep_retry_at = 0
|
||||
self.force_new_bed = False
|
||||
self.sleep_confirmed = False
|
||||
self.sleep_deadline = 0
|
||||
|
||||
@@ -48,6 +48,13 @@ class UseItemPacket(Packet):
|
||||
]
|
||||
|
||||
|
||||
class ClientCommandPacket(Packet):
|
||||
"""Client command; action 0 asks the server to respawn this player."""
|
||||
id = 0x0C
|
||||
packet_name = 'client command'
|
||||
definition = [{'action_id': VarInt}]
|
||||
|
||||
|
||||
def parse_incoming_private_message(message):
|
||||
"""Return (sender_name, body) for common Minecraft incoming-DM forms."""
|
||||
patterns = (
|
||||
|
||||
+8
-3
@@ -13,6 +13,7 @@ from mosfet.info import mobs
|
||||
class World:
|
||||
def __init__(self, global_state):
|
||||
self.g = global_state
|
||||
self._bed_occupancy_states = {}
|
||||
|
||||
def block_at(self, x, y, z):
|
||||
return self.g.chunks.get_block_at(x, y, z)
|
||||
@@ -316,11 +317,15 @@ class World:
|
||||
return result
|
||||
|
||||
def check_bed_occupied(self, bed):
|
||||
"""Return whether a loaded bed block currently has occupied=true."""
|
||||
"""Return occupancy, logging only the first observation or a change."""
|
||||
bed = tuple(bed)
|
||||
bid = self.g.chunks.get_block_at(*bed)
|
||||
occupied = blocks.PROPS.get(bid, {}).get('occupied') == 'true'
|
||||
print('Checking bed occupancy:', bed, '->',
|
||||
'occupied' if occupied else 'free')
|
||||
previous = self._bed_occupancy_states.get(bed)
|
||||
if previous is None or previous != occupied:
|
||||
print('Checking bed occupancy:', bed, '->',
|
||||
'occupied' if occupied else 'free')
|
||||
self._bed_occupancy_states[bed] = occupied
|
||||
return occupied
|
||||
|
||||
def find_cache_openings(self, area):
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import unittest
|
||||
|
||||
from munch import Munch
|
||||
from minecraft.networking.packets import PacketBuffer
|
||||
from minecraft.networking.modern import ClientTickEnd
|
||||
|
||||
from mosfet.commands import Commands
|
||||
from mosfet.game import Game
|
||||
from mosfet.protocol.modern import ClientCommandPacket
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self):
|
||||
self.packets = []
|
||||
|
||||
def write_packet(self, packet, force=False):
|
||||
self.packets.append((packet, force))
|
||||
|
||||
|
||||
class FakeChat:
|
||||
def __init__(self):
|
||||
self.handler = None
|
||||
self.messages = []
|
||||
|
||||
def set_handler(self, handler):
|
||||
self.handler = handler
|
||||
|
||||
def send(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
|
||||
class TestRespawn(unittest.TestCase):
|
||||
def test_client_command_encodes_perform_respawn(self):
|
||||
packet = ClientCommandPacket(action_id=0)
|
||||
buffer = PacketBuffer()
|
||||
packet.write_fields(buffer)
|
||||
self.assertEqual(packet.id, 0x0C)
|
||||
self.assertEqual(buffer.get_writable(), b'\x00')
|
||||
|
||||
def test_game_respawn_sends_modern_client_command_and_tick_end(self):
|
||||
connection = FakeConnection()
|
||||
game = Game.__new__(Game)
|
||||
game.g = Munch(connection=connection)
|
||||
|
||||
game.respawn()
|
||||
|
||||
packet, forced = connection.packets[0]
|
||||
self.assertIsInstance(packet, ClientCommandPacket)
|
||||
self.assertEqual(packet.action_id, 0)
|
||||
self.assertTrue(forced)
|
||||
self.assertIsInstance(connection.packets[1][0], ClientTickEnd)
|
||||
self.assertIn('respawn_requested_at', game.g)
|
||||
|
||||
def test_respawn_command_is_enabled(self):
|
||||
chat = FakeChat()
|
||||
calls = []
|
||||
game = Munch(respawn=lambda: calls.append('respawn'))
|
||||
commands = Commands(Munch(name='mosfet1', chat=chat, game=game))
|
||||
|
||||
commands.handle_chat(('SYSTEM', None, 'server: 1respawn'))
|
||||
|
||||
self.assertEqual(calls, ['respawn'])
|
||||
self.assertEqual(chat.messages, ['ok'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,5 +1,6 @@
|
||||
import io
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
|
||||
from munch import Munch
|
||||
from minecraft.networking.packets import PacketBuffer
|
||||
@@ -9,8 +10,9 @@ from mosfet.game import Game
|
||||
from mosfet.info import blocks, items
|
||||
from mosfet.protocol.types import Slot
|
||||
from mosfet.jobs.sleep_with_bed import SleepWithBedStates
|
||||
from mosfet.world import World
|
||||
from mosfet.protocol.modern import PlayerCommandPacket, UpdateTimePacket
|
||||
from minecraft.networking.modern import ClientTickEnd
|
||||
from minecraft.networking.modern import ClientTickEnd, UseItemOnBlockPacket
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
@@ -21,6 +23,14 @@ class FakeConnection:
|
||||
self.packets.append((packet, force))
|
||||
|
||||
|
||||
class FakeChunks:
|
||||
def __init__(self, block):
|
||||
self.block = block
|
||||
|
||||
def get_block_at(self, *position):
|
||||
return self.block
|
||||
|
||||
|
||||
class FakeWorld:
|
||||
def __init__(self, block=blocks.AIR):
|
||||
self.block = block
|
||||
@@ -28,16 +38,46 @@ class FakeWorld:
|
||||
def block_at(self, *position):
|
||||
return self.block
|
||||
|
||||
def check_bed_occupied(self, position):
|
||||
return False
|
||||
|
||||
|
||||
class FakeGame:
|
||||
def __init__(self):
|
||||
self.placements = []
|
||||
|
||||
def place_block(self, location, face, hand=0):
|
||||
self.placements.append((location, face, hand))
|
||||
def place_block(self, location, face, hand=0, cursor_y=None):
|
||||
self.placements.append((location, face, hand, cursor_y))
|
||||
|
||||
|
||||
class TestSleepWithBed(unittest.TestCase):
|
||||
def test_bed_occupancy_only_logs_first_observation_and_changes(self):
|
||||
bed_states = [
|
||||
state for state in blocks.BED_IDS
|
||||
if blocks.BLOCKS[state] == 'white_bed' and
|
||||
blocks.PROPS[state].get('part') == 'foot' and
|
||||
blocks.PROPS[state].get('facing') == 'north'
|
||||
]
|
||||
free = next(state for state in bed_states
|
||||
if blocks.PROPS[state].get('occupied') == 'false')
|
||||
occupied = next(state for state in bed_states
|
||||
if blocks.PROPS[state].get('occupied') == 'true')
|
||||
chunks = FakeChunks(free)
|
||||
world = World(Munch(chunks=chunks))
|
||||
output = io.StringIO()
|
||||
|
||||
with redirect_stdout(output):
|
||||
self.assertFalse(world.check_bed_occupied((1, 64, 2)))
|
||||
self.assertFalse(world.check_bed_occupied((1, 64, 2)))
|
||||
chunks.block = occupied
|
||||
self.assertTrue(world.check_bed_occupied((1, 64, 2)))
|
||||
self.assertTrue(world.check_bed_occupied((1, 64, 2)))
|
||||
|
||||
lines = output.getvalue().splitlines()
|
||||
self.assertEqual(len(lines), 2)
|
||||
self.assertIn('-> free', lines[0])
|
||||
self.assertIn('-> occupied', lines[1])
|
||||
|
||||
def test_update_time_packet_decodes_clock_updates(self):
|
||||
payload = PacketBuffer()
|
||||
Long.send(123456, payload)
|
||||
@@ -78,6 +118,20 @@ class TestSleepWithBed(unittest.TestCase):
|
||||
self.assertTrue(force)
|
||||
self.assertIsInstance(connection.packets[1][0], ClientTickEnd)
|
||||
|
||||
def test_existing_bed_use_aims_inside_the_short_bed_collision_shape(self):
|
||||
connection = FakeConnection()
|
||||
game = Game.__new__(Game)
|
||||
game.g = Munch(connection=connection)
|
||||
game.sequence = 0
|
||||
|
||||
game.place_block((-175, 70, -312), 1, cursor_y=0.5)
|
||||
|
||||
packet = connection.packets[0][0]
|
||||
self.assertIsInstance(packet, UseItemOnBlockPacket)
|
||||
self.assertEqual(packet.location, (-175, 70, -312))
|
||||
self.assertEqual(packet.cursor_y, 0.5)
|
||||
self.assertIsInstance(connection.packets[1][0], ClientTickEnd)
|
||||
|
||||
def test_bed_in_offhand_is_selectable_without_swapping_inventory(self):
|
||||
connection = FakeConnection()
|
||||
game = Game.__new__(Game)
|
||||
@@ -91,6 +145,147 @@ class TestSleepWithBed(unittest.TestCase):
|
||||
self.assertEqual(game.g.holding_hand, 1)
|
||||
self.assertIsInstance(connection.packets[0][0], ClientTickEnd)
|
||||
|
||||
def test_bed_head_position_is_normalized_to_foot(self):
|
||||
bed_name = 'white_bed'
|
||||
foot_state = next(
|
||||
state for state in blocks.BED_IDS
|
||||
if blocks.BLOCKS[state] == bed_name and
|
||||
blocks.PROPS[state].get('part') == 'foot' and
|
||||
blocks.PROPS[state].get('facing') == 'west')
|
||||
head_state = next(
|
||||
state for state in blocks.BED_IDS
|
||||
if blocks.BLOCKS[state] == bed_name and
|
||||
blocks.PROPS[state].get('part') == 'head' and
|
||||
blocks.PROPS[state].get('facing') == 'west')
|
||||
foot, head = (-175, 70, -312), (-176, 70, -312)
|
||||
|
||||
class BedWorld:
|
||||
def block_at(self, *position):
|
||||
return {foot: foot_state, head: head_state}.get(tuple(position), blocks.AIR)
|
||||
|
||||
state = SleepWithBedStates(Munch(world=BedWorld()))
|
||||
self.assertEqual(state._bed_foot(head), foot)
|
||||
self.assertEqual(state._bed_foot(foot), foot)
|
||||
|
||||
def test_bed_search_falls_back_to_loaded_blocks_if_index_is_empty(self):
|
||||
bed = (1, 64, 0)
|
||||
bed_state = next(
|
||||
state for state in blocks.BED_IDS
|
||||
if blocks.PROPS[state].get('part') == 'foot')
|
||||
|
||||
class IndexedWorld:
|
||||
def find_blocks_indexed(self, center, block_ids, distance=0):
|
||||
return []
|
||||
|
||||
def find_blocks_3d(self, center, block_ids, distance, y_limit):
|
||||
self.assert_search = (center, block_ids, distance, y_limit)
|
||||
return iter([bed])
|
||||
|
||||
def block_at(self, *position):
|
||||
return bed_state if tuple(position) == bed else blocks.AIR
|
||||
|
||||
def check_bed_occupied(self, position):
|
||||
return False
|
||||
|
||||
world = IndexedWorld()
|
||||
state = SleepWithBedStates(Munch(
|
||||
world=world, pos=(0, 64, 0), time=13000,
|
||||
dimension='overworld'))
|
||||
|
||||
state.find_beds()
|
||||
|
||||
self.assertEqual(state.beds, [bed])
|
||||
self.assertEqual(state.state, state.choose_bed)
|
||||
|
||||
def test_force_new_bed_skips_existing_bed_scan(self):
|
||||
class IndexedWorld:
|
||||
def find_blocks_indexed(self, *args, **kwargs):
|
||||
raise AssertionError('forced placement must skip bed search')
|
||||
|
||||
def find_blocks_3d(self, *args, **kwargs):
|
||||
raise AssertionError('forced placement must skip local scan')
|
||||
|
||||
state = SleepWithBedStates(Munch(
|
||||
world=IndexedWorld(), pos=(0, 64, 0), time=13000,
|
||||
dimension='overworld'))
|
||||
state.force_new_bed = True
|
||||
|
||||
state.find_beds()
|
||||
|
||||
self.assertEqual(state.beds, [])
|
||||
self.assertEqual(state.state, state.choose_bed)
|
||||
|
||||
def test_arrival_accepts_completed_path_with_small_position_error(self):
|
||||
state = SleepWithBedStates(Munch(
|
||||
pos=(0, 64, 0), path=[(1, 64, 0)], look_at=None,
|
||||
game=FakeGame(), world=FakeWorld()))
|
||||
state.opening = (1, 64, 0)
|
||||
state.opening_deadline = 9999999999
|
||||
|
||||
state.going_to_bed()
|
||||
|
||||
self.assertEqual(state.state, state.turn_to_bed)
|
||||
self.assertEqual(state.g.path, [])
|
||||
|
||||
def test_unconfirmed_sleep_retries_using_head_block(self):
|
||||
foot = (0, 64, 0)
|
||||
head = (0, 64, -1)
|
||||
bed_id = next(
|
||||
state for state in blocks.BED_IDS
|
||||
if blocks.PROPS[state].get('part') == 'foot')
|
||||
|
||||
class ThreatWorld(FakeWorld):
|
||||
def find_threats(self, center, distance):
|
||||
return []
|
||||
|
||||
game = FakeGame()
|
||||
state = SleepWithBedStates(Munch(
|
||||
time=13000, pos=(1, 64, 0), world=ThreatWorld(block=bed_id),
|
||||
game=game, path=[], look_at=None, correction_count=0))
|
||||
state.area = foot
|
||||
state.bed_head = head
|
||||
state.sleep_use_attempts = 1
|
||||
state.sleep_retry_at = 0
|
||||
state.sleep_deadline = 9999999999
|
||||
|
||||
state.sleep_bed()
|
||||
|
||||
self.assertEqual(game.placements, [(head, 1, 0, 0.5)])
|
||||
self.assertEqual(state.sleep_use_attempts, 2)
|
||||
|
||||
def test_failed_existing_bed_use_is_blacklisted(self):
|
||||
bed_id = next(iter(blocks.BED_IDS))
|
||||
|
||||
class ThreatWorld(FakeWorld):
|
||||
def find_threats(self, center, distance):
|
||||
return []
|
||||
|
||||
state = SleepWithBedStates(Munch(
|
||||
time=13000, pos=(1, 64, 0), world=ThreatWorld(block=bed_id),
|
||||
game=FakeGame(), path=[], look_at=None, correction_count=0))
|
||||
state.area = (0, 64, 0)
|
||||
state.sleep_use_attempts = 2
|
||||
state.sleep_retry_at = 9999999999
|
||||
state.sleep_deadline = 0
|
||||
|
||||
state.sleep_bed()
|
||||
|
||||
self.assertIn(state.area, state.bad_beds)
|
||||
self.assertEqual(state.state, state.cleanup)
|
||||
|
||||
def test_bed_waits_with_status_until_sleep_time(self):
|
||||
bed_id = next(iter(blocks.BED_IDS))
|
||||
state = SleepWithBedStates(Munch(
|
||||
time=12400, world=FakeWorld(block=bed_id), game=FakeGame(),
|
||||
path=[], look_at=None))
|
||||
state.area = (0, 64, 0)
|
||||
state.state = state.use_bed
|
||||
|
||||
state.use_bed()
|
||||
|
||||
self.assertEqual(state.state, state.use_bed)
|
||||
self.assertTrue(state.waiting_for_night)
|
||||
|
||||
def test_daytime_sleep_aborts_cleanly(self):
|
||||
state = SleepWithBedStates(Munch(time=1000, dimension='overworld'))
|
||||
state.init()
|
||||
@@ -106,20 +301,40 @@ class TestSleepWithBed(unittest.TestCase):
|
||||
state.place_bed()
|
||||
|
||||
self.assertEqual(len(game.placements), 1)
|
||||
location, face, hand = game.placements[0]
|
||||
location, face, hand, cursor_y = game.placements[0]
|
||||
self.assertEqual(location, (4, 69, 5))
|
||||
self.assertEqual(int(face), 1)
|
||||
self.assertEqual(hand, 1)
|
||||
self.assertIsNone(cursor_y)
|
||||
self.assertTrue(state.my_bed)
|
||||
self.assertEqual(state.state, state.wait_use)
|
||||
|
||||
def test_wait_use_aborts_if_server_rejects_bed_placement(self):
|
||||
def test_wait_use_waits_for_delayed_chunk_update(self):
|
||||
bed_id = next(iter(blocks.BED_IDS))
|
||||
world = FakeWorld(block=blocks.AIR)
|
||||
state = SleepWithBedStates(Munch(
|
||||
game=FakeGame(), world=world, path=[], look_at=None))
|
||||
state.area = (4, 70, 5)
|
||||
state.my_bed = True
|
||||
state.bed_place_deadline = 9999999999
|
||||
state.state = state.wait_use
|
||||
|
||||
state.wait_use()
|
||||
self.assertEqual(state.state, state.wait_use)
|
||||
self.assertTrue(state.my_bed)
|
||||
|
||||
world.block = bed_id
|
||||
state.wait_use()
|
||||
self.assertEqual(state.state, state.use_bed)
|
||||
self.assertIsNotNone(state.bed_head)
|
||||
|
||||
def test_wait_use_aborts_after_update_timeout(self):
|
||||
state = SleepWithBedStates(Munch(
|
||||
game=FakeGame(), world=FakeWorld(block=blocks.AIR),
|
||||
path=[], look_at=None))
|
||||
state.area = (4, 70, 5)
|
||||
state.my_bed = True
|
||||
state.wait_time = 0
|
||||
state.bed_place_deadline = 0
|
||||
|
||||
state.wait_use()
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import unittest
|
||||
|
||||
from munch import Munch
|
||||
|
||||
from mosfet.commands import Commands
|
||||
from mosfet.info import blocks, mobs
|
||||
from mosfet.job import JobStates
|
||||
from mosfet.jobs.check_threats import CheckThreatsStates
|
||||
from mosfet.world import World
|
||||
|
||||
|
||||
class FakeChunks:
|
||||
def __init__(self, solid=()):
|
||||
self.solid = set(solid)
|
||||
|
||||
def get_block_at(self, x, y, z):
|
||||
return blocks.STONE if (x, y, z) in self.solid else blocks.AIR
|
||||
|
||||
|
||||
class FakeChat:
|
||||
def __init__(self):
|
||||
self.handler = None
|
||||
self.messages = []
|
||||
|
||||
def set_handler(self, handler):
|
||||
self.handler = handler
|
||||
|
||||
def send(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
|
||||
class TestThreatDetection(unittest.TestCase):
|
||||
def test_find_threats_filters_passive_and_distant_entities(self):
|
||||
zombie = mobs.MOBS['minecraft:zombie']['protocol_id']
|
||||
villager = mobs.MOBS['minecraft:villager']['protocol_id']
|
||||
world = World(Munch(mobs={
|
||||
1: Munch(entity_id=1, type=zombie, x=3.2, y=64, z=0),
|
||||
2: Munch(entity_id=2, type=villager, x=2, y=64, z=0),
|
||||
3: Munch(entity_id=3, type=zombie, x=30, y=64, z=0),
|
||||
}))
|
||||
world.check_air_column = lambda pos, distance: True
|
||||
|
||||
result = world.find_threats((0, 64, 0), 12)
|
||||
|
||||
self.assertEqual([threat.entity_id for threat in result], [1])
|
||||
|
||||
def test_air_column_excludes_mobs_under_a_solid_roof(self):
|
||||
world = World(Munch(chunks=FakeChunks(solid={(0, 67, 0)})))
|
||||
self.assertFalse(world.check_air_column((0, 64, 0), 5))
|
||||
self.assertTrue(world.check_air_column((2, 64, 0), 5))
|
||||
|
||||
def test_threat_state_falls_back_to_reachable_clear_area(self):
|
||||
spot = (8, 64, 0)
|
||||
|
||||
class SafeWorld:
|
||||
def find_blocks_indexed(self, center, block_ids, distance=0):
|
||||
return []
|
||||
|
||||
def find_bed_areas(self, center, distance):
|
||||
return [spot]
|
||||
|
||||
def find_threats(self, center, distance):
|
||||
return []
|
||||
|
||||
def path_to_place(self, start, target):
|
||||
return [start, target]
|
||||
|
||||
g = Munch(world=SafeWorld(), pos=(0, 64, 0), path=[], look_at=None)
|
||||
state = CheckThreatsStates(g)
|
||||
state.state = state.find_safety
|
||||
state.threats = [Munch(entity_id=7)]
|
||||
|
||||
state.find_safety()
|
||||
|
||||
self.assertEqual(state.state, state.going_to_safety)
|
||||
self.assertEqual(state.safety, spot)
|
||||
self.assertFalse(state.external_safety)
|
||||
self.assertEqual(g.path[-1], spot)
|
||||
|
||||
g.pos = spot
|
||||
state.going_to_safety()
|
||||
self.assertEqual(state.state, state.wait)
|
||||
self.assertEqual(state.wait_time, 3)
|
||||
|
||||
def test_loiter_is_enabled_as_a_job(self):
|
||||
check = object()
|
||||
sleep = Munch(silent=False)
|
||||
eat = object()
|
||||
job = JobStates.__new__(JobStates)
|
||||
job.check_threats_states = check
|
||||
job.sleep_with_bed_states = sleep
|
||||
job.eat_food_states = eat
|
||||
|
||||
self.assertEqual(JobStates.loiter(job), [check, sleep, eat])
|
||||
self.assertTrue(sleep.silent)
|
||||
|
||||
def test_loiter_command_is_no_longer_rejected(self):
|
||||
def loiter():
|
||||
return []
|
||||
|
||||
chat = FakeChat()
|
||||
job = Munch(loiter=loiter, state=None)
|
||||
commands = Commands(Munch(name='mosfet1', chat=chat, job=job))
|
||||
commands.handle_chat(('CHAT', 'player-uuid', 'tanner: 1loiter'))
|
||||
|
||||
self.assertIs(job.state, loiter)
|
||||
self.assertEqual(chat.messages, ['ok'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,66 @@
|
||||
import io
|
||||
import unittest
|
||||
|
||||
from minecraft.networking.modern import (
|
||||
PaletteSection, SectionBlocksUpdatePacket,
|
||||
)
|
||||
from minecraft.networking.packets import PacketBuffer
|
||||
from minecraft.networking.types import Long, VarInt, VarLong
|
||||
|
||||
from mosfet.info import blocks
|
||||
from mosfet.protocol.managers import ChunkColumn, WorldState
|
||||
|
||||
|
||||
def encode_section_update(section_x, section_y, section_z, changes):
|
||||
"""Encode Prismarine's section-position and packed x:z:y records."""
|
||||
buffer = PacketBuffer()
|
||||
packed_section = (
|
||||
((section_x & 0x3FFFFF) << 42) |
|
||||
((section_z & 0x3FFFFF) << 20) |
|
||||
(section_y & 0xFFFFF))
|
||||
if packed_section >= (1 << 63):
|
||||
packed_section -= 1 << 64
|
||||
Long.send(packed_section, buffer)
|
||||
VarInt.send(len(changes), buffer)
|
||||
for x, y, z, block_id in changes:
|
||||
local = ((x & 0xF) << 8) | ((z & 0xF) << 4) | (y & 0xF)
|
||||
VarLong.send((block_id << 12) | local, buffer)
|
||||
packet = SectionBlocksUpdatePacket()
|
||||
packet.read(io.BytesIO(buffer.get_writable()))
|
||||
return packet
|
||||
|
||||
|
||||
class TestSectionBlockUpdates(unittest.TestCase):
|
||||
def test_negative_section_coordinates_and_local_yz_decode(self):
|
||||
bed_id = next(iter(blocks.BED_IDS))
|
||||
packet = encode_section_update(
|
||||
-11, 4, -20, [(3, 6, 9, bed_id)])
|
||||
|
||||
self.assertEqual(packet.id, 0x56)
|
||||
self.assertEqual(packet.changes, [(-173, 70, -311, bed_id)])
|
||||
|
||||
def test_section_update_replaces_old_block_and_index_entry(self):
|
||||
section_x, section_y, section_z = -11, 4, -20
|
||||
x, y, z = -173, 70, -311
|
||||
bed_id = next(iter(blocks.BED_IDS))
|
||||
world = WorldState(min_y=section_y * 16)
|
||||
world.chunks[(section_x, section_z)] = ChunkColumn(
|
||||
section_x, section_z,
|
||||
[{'blocks': PaletteSection(0, [blocks.AIR], [], 4096)}],
|
||||
section_y)
|
||||
|
||||
placed = encode_section_update(
|
||||
section_x, section_y, section_z, [(x, y, z, bed_id)])
|
||||
world.handle_section_update(placed)
|
||||
self.assertEqual(world.get_block_at(x, y, z), bed_id)
|
||||
self.assertIn((x, y, z), world.index[bed_id])
|
||||
|
||||
broken = encode_section_update(
|
||||
section_x, section_y, section_z, [(x, y, z, blocks.AIR)])
|
||||
world.handle_section_update(broken)
|
||||
self.assertEqual(world.get_block_at(x, y, z), blocks.AIR)
|
||||
self.assertNotIn((x, y, z), world.index.get(bed_id, set()))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user