Port loiter and harden 26.3 bed updates

This commit is contained in:
Tanner
2026-11-05 01:05:52 +00:00
parent b9d43c7c37
commit efa2140a91
12 changed files with 664 additions and 65 deletions
+67
View File
@@ -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()
+221 -6
View File
@@ -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()
+111
View File
@@ -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()
+66
View File
@@ -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()