Ejemplo n.º 1
0
    def _unpack_blocks(
        translation_manager: "TranslationManager",
        version_identifier: VersionIdentifierType,
        chunk: Chunk,
        block_palette: AnyNDArray,
    ):
        """
        Unpacks an object array of block data into a numpy object array containing Block objects.
        :param translation_manager:
        :param version_identifier:
        :param chunk:
        :param block_palette:
        :type block_palette: numpy.ndarray[
            Tuple[
                Union[
                    Tuple[None, Tuple[int, int]],
                    Tuple[None, Block],
                    Tuple[int, Block]
                ], ...
            ]
        ]
        :return:
        """
        palette_ = BlockManager()
        for palette_index, entry in enumerate(block_palette):
            entry: BedrockInterfaceBlockType
            block = None
            for version_number, b in entry:
                version_number: Optional[int]
                if isinstance(b, tuple):
                    version = translation_manager.get_version(
                        version_identifier[0], version_number or 17563649)
                    b = version.block.ints_to_block(*b)
                elif isinstance(b, Block):
                    if version_number is not None:
                        properties = b.properties
                        properties["__version__"] = amulet_nbt.TAG_Int(
                            version_number)
                        b = Block(b.namespace, b.base_name, properties,
                                  b.extra_blocks)
                else:
                    raise Exception(f"Unsupported type {b}")
                if block is None:
                    block = b
                else:
                    block += b
            if block is None:
                raise Exception(f"Empty tuple")

            palette_.get_add_block(block)
        chunk._block_palette = palette_
Ejemplo n.º 2
0
class BlockManagerTestCase(unittest.TestCase):
    def setUp(self):
        self.manager = BlockManager()

        initial_dirt = Block.from_string_blockstate("minecraft:dirt")
        initial_stone = Block.from_string_blockstate("minecraft:stone")
        initial_granite = Block.from_string_blockstate("minecraft:granite")

        initial_dirt_water = initial_dirt + Block.from_string_blockstate(
            "minecraft:water"
        )

        # Partially populate the manager
        self.manager.get_add_block(initial_dirt)
        self.manager.get_add_block(initial_stone)
        self.manager.get_add_block(initial_granite)
        self.manager.get_add_block(initial_dirt_water)

    def test_get_index_from_manager(self):
        dirt = Block.from_string_blockstate("minecraft:dirt")
        stone = Block.from_string_blockstate("minecraft:stone")
        granite = Block.from_string_blockstate("minecraft:granite")

        self.assertEqual(0, self.manager[dirt])
        self.assertEqual(1, self.manager[stone])
        self.assertEqual(2, self.manager[granite])

        water = Block.from_string_blockstate("minecraft:water")

        dirt_water = dirt + water

        self.assertNotEqual(dirt, dirt_water)
        self.assertIsNot(dirt, dirt_water)
        self.assertEqual(3, self.manager[dirt_water])

        with self.assertRaises(KeyError):
            random_block = self.manager[10000]

    def test_get_block_from_manager(self):
        dirt = Block.from_string_blockstate("minecraft:dirt")
        stone = Block.from_string_blockstate("minecraft:stone")
        granite = Block.from_string_blockstate("minecraft:granite")
        water = Block.from_string_blockstate("minecraft:water")
        dirt_water = dirt + water

        self.assertEqual(dirt, self.manager[0])
        self.assertEqual(stone, self.manager[1])
        self.assertEqual(granite, self.manager[2])
        self.assertEqual(dirt_water, self.manager[3])

        with self.assertRaises(KeyError):
            brain_coral = Block.from_string_blockstate("minecraft:brain_coral")
            internal_id = self.manager[brain_coral]
Ejemplo n.º 3
0
    def block_palette(self, new_block_palette: BlockManager):
        """Change the block block_palette for the chunk.
        This will copy over all block states from the old block_palette and remap the block indexes to use the new block_palette."""
        assert isinstance(new_block_palette, BlockManager)
        if new_block_palette is not self._block_palette:
            # if current block block_palette and the new block block_palette are not the same object
            if self._block_palette:
                # if there are blocks in the current block block_palette remap the data
                block_lut = numpy.array(
                    [
                        new_block_palette.get_add_block(block)
                        for block in self._block_palette.blocks()
                    ],
                    dtype=numpy.uint,
                )
                for cy in self.blocks.sub_chunks:
                    self.blocks.add_sub_chunk(
                        cy, block_lut[self.blocks.get_sub_chunk(cy)])

            self.__block_palette = new_block_palette
Ejemplo n.º 4
0
    def _translate(
        chunk: Chunk,
        get_chunk_callback: Optional[GetChunkCallback],
        translate_block: TranslateBlockCallback,
        translate_entity: TranslateEntityCallback,
        full_translate: bool,
    ):
        if full_translate:
            todo = []
            output_block_entities = []
            output_entities = []
            finished = BlockManager()
            palette_mappings = {}

            # translate each block without using the callback
            for i, input_block in enumerate(chunk.block_palette):
                input_block: BlockType
                (
                    output_block,
                    output_block_entity,
                    output_entity,
                    extra,
                ) = translate_block(input_block, None, (0, 0, 0))
                if extra and get_chunk_callback:
                    todo.append(i)
                elif output_block is not None:
                    palette_mappings[i] = finished.get_add_block(output_block)
                    if output_block_entity is not None:
                        for cy in chunk.blocks.sub_chunks:
                            for x, y, z in zip(
                                *numpy.where(chunk.blocks.get_sub_chunk(cy) == i)
                            ):
                                output_block_entities.append(
                                    output_block_entity.new_at_location(
                                        x + chunk.cx * 16,
                                        y + cy * 16,
                                        z + chunk.cz * 16,
                                    )
                                )
                else:
                    # TODO: this should only happen if the object is an entity, set the block to air
                    pass

                if output_entity and entity_support:
                    for cy in chunk.blocks.sub_chunks:
                        for x, y, z in zip(
                            *numpy.where(chunk.blocks.get_sub_chunk(cy) == i)
                        ):
                            x += chunk.cx * 16
                            y += cy * 16
                            z += chunk.cz * 16
                            for entity in output_entity:
                                e = copy.deepcopy(entity)
                                e.location += (x, y, z)
                                output_entities.append(e)

            # re-translate the blocks that require extra information
            block_mappings = {}
            for index in todo:
                for cy in chunk.blocks.sub_chunks:
                    for x, y, z in zip(
                        *numpy.where(chunk.blocks.get_sub_chunk(cy) == index)
                    ):
                        y += cy * 16

                        def get_block_at(
                            pos: Tuple[int, int, int]
                        ) -> Tuple[Block, Optional[BlockEntity]]:
                            """Get a block at a location relative to the current block"""
                            nonlocal x, y, z, chunk, cy

                            # calculate position relative to chunk base
                            dx, dy, dz = pos
                            dx += x
                            dy += y
                            dz += z

                            abs_x = dx + chunk.cx * 16
                            abs_y = dy
                            abs_z = dz + chunk.cz * 16

                            # calculate relative chunk position
                            cx = dx // 16
                            cz = dz // 16
                            if cx == 0 and cz == 0:
                                # if it is the current chunk
                                block = chunk.block_palette[chunk.blocks[dx, dy, dz]]
                                return (
                                    block,
                                    chunk.block_entities.get((abs_x, abs_y, abs_z)),
                                )

                            # if it is in a different chunk
                            local_chunk = get_chunk_callback(cx, cz)
                            block = local_chunk.block_palette[
                                local_chunk.blocks[dx % 16, dy, dz % 16]
                            ]
                            return (
                                block,
                                local_chunk.block_entities.get((abs_x, abs_y, abs_z)),
                            )

                        input_block = chunk.block_palette[chunk.blocks[x, y, z]]
                        (
                            output_block,
                            output_block_entity,
                            output_entity,
                            _,
                        ) = translate_block(
                            input_block,
                            get_block_at,
                            (x + chunk.cx * 16, y, z + chunk.cz * 16),
                        )
                        if output_block is not None:
                            block_mappings[(x, y, z)] = finished.get_add_block(
                                output_block
                            )
                            if output_block_entity is not None:
                                output_block_entities.append(
                                    output_block_entity.new_at_location(
                                        x + chunk.cx * 16, y, z + chunk.cz * 16
                                    )
                                )
                        else:
                            # TODO: set the block to air
                            pass

                        if output_entity and entity_support:
                            for entity in output_entity:
                                e = copy.deepcopy(entity)
                                e.location += (x, y, z)
                                output_entities.append(e)

            if entity_support:
                for entity in chunk.entities:
                    output_block, output_block_entity, output_entity = translate_entity(
                        entity
                    )
                    if output_block is not None:
                        block_location = (
                            int(math.floor(entity.x)),
                            int(math.floor(entity.y)),
                            int(math.floor(entity.z)),
                        )
                        block_mappings[block_location] = output_block
                        if output_block_entity:
                            output_block_entities.append(
                                output_block_entity.new_at_location(*block_location)
                            )
                    if output_entity:
                        for e in output_entity:
                            e.location = entity.location
                            output_entities.append(e)

            for cy in chunk.blocks.sub_chunks:
                old_blocks = chunk.blocks.get_sub_chunk(cy)
                new_blocks = numpy.zeros(old_blocks.shape, dtype=old_blocks.dtype)
                for old, new in palette_mappings.items():
                    new_blocks[old_blocks == old] = new
                chunk.blocks.add_sub_chunk(cy, new_blocks)
            for (x, y, z), new in block_mappings.items():
                chunk.blocks[x, y, z] = new
            chunk.block_entities = output_block_entities
            chunk.entities = output_entities
            chunk._block_palette = finished
Ejemplo n.º 5
0
class BaseLevel:
    """
    BaseLevel is a base class for all world-like data.

    It exposes chunk data and other data using a history system to track and enable undoing changes.
    """
    def __init__(self, path: str, format_wrapper: api_wrapper.FormatWrapper):
        """
        Construct a :class:`BaseLevel` object from the given data.

        This should not be used directly. You should instead use :func:`amulet.load_level`.

        :param path: The path to the data being loaded. May be a file or directory. If blank there is no data on disk associated with this.
        :param format_wrapper: The :class:`FormatWrapper` instance that the level will wrap around.
        """
        self._path = path
        self._prefix = str(hash((self._path, time.time())))

        self._level_wrapper = format_wrapper
        self.level_wrapper.open()

        self._block_palette = BlockManager()
        self._block_palette.get_add_block(
            UniversalAirBlock)  # ensure that index 0 is always air

        self._biome_palette = BiomeManager()
        self._biome_palette.get_add_biome("universal_minecraft:plains")

        self._history_manager = MetaHistoryManager()

        self._chunks: ChunkManager = ChunkManager(self._prefix, self)
        self._players = PlayerManager(self)

        self.history_manager.register(self._chunks, True)
        self.history_manager.register(self._players, True)

    @property
    def level_wrapper(self) -> api_wrapper.FormatWrapper:
        """A class to access data directly from the level."""
        return self._level_wrapper

    @property
    def sub_chunk_size(self) -> int:
        """The normal dimensions of the chunk."""
        return self.level_wrapper.sub_chunk_size

    @property
    def level_path(self) -> str:
        """
        The system path where the level is located.

        This may be a directory, file or an emtpy string depending on the level that is loaded.
        """
        return self._path

    @property
    def translation_manager(self) -> PyMCTranslate.TranslationManager:
        """An instance of the translation class for use with this level."""
        return self.level_wrapper.translation_manager

    @property
    def block_palette(self) -> BlockManager:
        """The manager for the universal blocks in this level. New blocks must be registered here before adding to the level."""
        return self._block_palette

    @property
    def biome_palette(self) -> BiomeManager:
        """The manager for the universal blocks in this level. New biomes must be registered here before adding to the level."""
        return self._biome_palette

    @property
    def selection_bounds(self) -> SelectionGroup:
        """The selection(s) that all chunk data must fit within. Usually +/-30M for worlds. The selection for structures."""
        warnings.warn(
            "BaseLevel.selection_bounds is depreciated and will be removed in the future. Please use BaseLevel.bounds(dimension) instead",
            DeprecationWarning,
        )
        return self.bounds(self.dimensions[0])

    def bounds(self, dimension: Dimension) -> SelectionGroup:
        """
        The selection(s) that all chunk data must fit within.
        This specifies the volume that can be built in.
        Worlds will have a single cuboid volume.
        Structures may have one or more cuboid volumes.

        :param dimension: The dimension to get the bounds of.
        :return: The build volume for the dimension.
        """
        return self.level_wrapper.bounds(dimension)

    @property
    def dimensions(self) -> Tuple[Dimension, ...]:
        """The dimensions strings that are valid for this level."""
        return tuple(self.level_wrapper.dimensions)

    def get_block(self, x: int, y: int, z: int, dimension: Dimension) -> Block:
        """
        Gets the universal Block object at the specified coordinates.

        To get the block in a given format use :meth:`get_version_block`

        :param x: The X coordinate of the desired block
        :param y: The Y coordinate of the desired block
        :param z: The Z coordinate of the desired block
        :param dimension: The dimension of the desired block
        :return: The universal Block object representation of the block at that location
        :raises:
            :class:`~amulet.api.errors.ChunkDoesNotExist`: If the chunk does not exist (was deleted or never created)

            :class:`~amulet.api.errors.ChunkLoadError`: If the chunk was not able to be loaded. Eg. If the chunk is corrupt or some error occurred when loading.
        """
        cx, cz = block_coords_to_chunk_coords(
            x, z, sub_chunk_size=self.sub_chunk_size)
        offset_x, offset_z = x - 16 * cx, z - 16 * cz

        return self.get_chunk(cx, cz,
                              dimension).get_block(offset_x, y, offset_z)

    def _chunk_box(self,
                   cx: int,
                   cz: int,
                   sub_chunk_size: Optional[int] = None):
        """Get a SelectionBox containing the whole of a given chunk"""
        if sub_chunk_size is None:
            sub_chunk_size = self.sub_chunk_size
        return SelectionBox.create_chunk_box(cx, cz, sub_chunk_size)

    def _sanitise_selection(self, selection: Union[SelectionGroup,
                                                   SelectionBox, None],
                            dimension: Dimension) -> SelectionGroup:
        if isinstance(selection, SelectionBox):
            return SelectionGroup(selection)
        elif isinstance(selection, SelectionGroup):
            return selection
        elif selection is None:
            return self.bounds(dimension)
        else:
            raise ValueError(
                f"Expected SelectionBox, SelectionGroup or None. Got {selection}"
            )

    def get_coord_box(
        self,
        dimension: Dimension,
        selection: Union[SelectionGroup, SelectionBox, None] = None,
        yield_missing_chunks=False,
    ) -> Generator[Tuple[ChunkCoordinates, SelectionBox], None, None]:
        """
        Given a selection will yield chunk coordinates and :class:`SelectionBox` instances into that chunk

        If not given a selection will use the bounds of the object.

        :param dimension: The dimension to take effect in.
        :param selection: SelectionGroup or SelectionBox into the level. If None will use :meth:`bounds` for the dimension.
        :param yield_missing_chunks: If a chunk does not exist an empty one will be created (defaults to false). Use this with care.
        """
        selection = self._sanitise_selection(selection, dimension)
        if yield_missing_chunks or selection.footprint_area < 1_000_000:
            if yield_missing_chunks:
                for coord, box in selection.chunk_boxes(self.sub_chunk_size):
                    yield coord, box
            else:
                for (cx,
                     cz), box in selection.chunk_boxes(self.sub_chunk_size):
                    if self.has_chunk(cx, cz, dimension):
                        yield (cx, cz), box

        else:
            # if the selection gets very large iterating over the whole selection and accessing chunks can get slow
            # instead we are going to iterate over the chunks and get the intersection of the selection
            for cx, cz in self.all_chunk_coords(dimension):
                box = SelectionGroup(
                    SelectionBox.create_chunk_box(cx, cz, self.sub_chunk_size))

                if selection.intersects(box):
                    chunk_selection = selection.intersection(box)
                    for sub_box in chunk_selection.selection_boxes:
                        yield (cx, cz), sub_box

    def get_chunk_boxes(
        self,
        dimension: Dimension,
        selection: Union[SelectionGroup, SelectionBox, None] = None,
        create_missing_chunks=False,
    ) -> Generator[Tuple[Chunk, SelectionBox], None, None]:
        """
        Given a selection will yield :class:`Chunk` and :class:`SelectionBox` instances into that chunk

        If not given a selection will use the bounds of the object.

        :param dimension: The dimension to take effect in.
        :param selection: SelectionGroup or SelectionBox into the level. If None will use :meth:`bounds` for the dimension.
        :param create_missing_chunks: If a chunk does not exist an empty one will be created (defaults to false). Use this with care.
        """
        for (cx, cz), box in self.get_coord_box(dimension, selection,
                                                create_missing_chunks):
            try:
                chunk = self.get_chunk(cx, cz, dimension)
            except ChunkDoesNotExist:
                if create_missing_chunks:
                    yield self.create_chunk(cx, cz, dimension), box
            except ChunkLoadError:
                log.error(f"Error loading chunk\n{traceback.format_exc()}")
            else:
                yield chunk, box

    def get_chunk_slice_box(
        self,
        dimension: Dimension,
        selection: Union[SelectionGroup, SelectionBox] = None,
        create_missing_chunks=False,
    ) -> Generator[Tuple[Chunk, Tuple[slice, slice, slice], SelectionBox],
                   None, None]:
        """
        Given a selection will yield :class:`Chunk`, slices, :class:`SelectionBox` for the contents of the selection.

        :param dimension: The dimension to take effect in.
        :param selection: SelectionGroup or SelectionBox into the level. If None will use :meth:`bounds` for the dimension.
        :param create_missing_chunks: If a chunk does not exist an empty one will be created (defaults to false)

        >>> for chunk, slices, box in level.get_chunk_slice_box(selection):
        >>>     chunk.blocks[slice] = ...
        """
        for chunk, box in self.get_chunk_boxes(dimension, selection,
                                               create_missing_chunks):
            slices = box.chunk_slice(chunk.cx, chunk.cz, self.sub_chunk_size)
            yield chunk, slices, box

    def get_moved_coord_slice_box(
        self,
        dimension: Dimension,
        destination_origin: BlockCoordinates,
        selection: Optional[Union[SelectionGroup, SelectionBox]] = None,
        destination_sub_chunk_shape: Optional[int] = None,
        yield_missing_chunks: bool = False,
    ) -> Generator[Tuple[ChunkCoordinates, Tuple[
            slice, slice, slice], SelectionBox, ChunkCoordinates, Tuple[
                slice, slice, slice], SelectionBox, ], None, None, ]:
        """
        Iterate over a selection and return slices into the source object and destination object
        given the origin of the destination. When copying a selection to a new area the slices will
        only be equal if the offset is a multiple of the chunk size. This will rarely be the case
        so the slices need to be split up into parts that intersect a chunk in the source and destination.

        :param dimension: The dimension to iterate over.
        :param destination_origin: The location where the minimum point of the selection will end up
        :param selection: An optional selection. The overlap of this and the dimensions bounds will be used
        :param destination_sub_chunk_shape: the chunk shape of the destination object (defaults to self.sub_chunk_size)
        :param yield_missing_chunks: Generate empty chunks if the chunk does not exist.
        :return:
        """
        if destination_sub_chunk_shape is None:
            destination_sub_chunk_shape = self.sub_chunk_size

        if selection is None:
            selection = self.bounds(dimension)
        else:
            selection = self.bounds(dimension).intersection(selection)
        # the offset from self.selection to the destination location
        offset = numpy.subtract(destination_origin,
                                self.bounds(dimension).min,
                                dtype=int)
        for (src_cx, src_cz), box in self.get_coord_box(
                dimension, selection,
                yield_missing_chunks=yield_missing_chunks):
            dst_full_box = SelectionBox(offset + box.min, offset + box.max)

            first_chunk = block_coords_to_chunk_coords(
                dst_full_box.min_x,
                dst_full_box.min_z,
                sub_chunk_size=destination_sub_chunk_shape,
            )
            last_chunk = block_coords_to_chunk_coords(
                dst_full_box.max_x - 1,
                dst_full_box.max_z - 1,
                sub_chunk_size=destination_sub_chunk_shape,
            )
            for dst_cx, dst_cz in itertools.product(
                    range(first_chunk[0], last_chunk[0] + 1),
                    range(first_chunk[1], last_chunk[1] + 1),
            ):
                chunk_box = self._chunk_box(dst_cx, dst_cz,
                                            destination_sub_chunk_shape)
                dst_box = chunk_box.intersection(dst_full_box)
                src_box = SelectionBox(-offset + dst_box.min,
                                       -offset + dst_box.max)
                src_slices = src_box.chunk_slice(src_cx, src_cz,
                                                 self.sub_chunk_size)
                dst_slices = dst_box.chunk_slice(dst_cx, dst_cz,
                                                 self.sub_chunk_size)
                yield (src_cx, src_cz), src_slices, src_box, (
                    dst_cx,
                    dst_cz,
                ), dst_slices, dst_box

    def get_moved_chunk_slice_box(
        self,
        dimension: Dimension,
        destination_origin: BlockCoordinates,
        selection: Optional[Union[SelectionGroup, SelectionBox]] = None,
        destination_sub_chunk_shape: Optional[int] = None,
        create_missing_chunks: bool = False,
    ) -> Generator[Tuple[Chunk, Tuple[
            slice, slice, slice], SelectionBox, ChunkCoordinates, Tuple[
                slice, slice, slice], SelectionBox, ], None, None, ]:
        """
        Iterate over a selection and return slices into the source object and destination object
        given the origin of the destination. When copying a selection to a new area the slices will
        only be equal if the offset is a multiple of the chunk size. This will rarely be the case
        so the slices need to be split up into parts that intersect a chunk in the source and destination.

        :param dimension: The dimension to iterate over.
        :param destination_origin: The location where the minimum point of self.selection will end up
        :param selection: An optional selection. The overlap of this and self.selection will be used
        :param destination_sub_chunk_shape: the chunk shape of the destination object (defaults to self.sub_chunk_size)
        :param create_missing_chunks: Generate empty chunks if the chunk does not exist.
        :return:
        """
        for (
            (src_cx, src_cz),
                src_slices,
                src_box,
            (dst_cx, dst_cz),
                dst_slices,
                dst_box,
        ) in self.get_moved_coord_slice_box(
                dimension,
                destination_origin,
                selection,
                destination_sub_chunk_shape,
                create_missing_chunks,
        ):
            try:
                chunk = self.get_chunk(src_cx, src_cz, dimension)
            except ChunkDoesNotExist:
                chunk = self.create_chunk(dst_cx, dst_cz, dimension)
            except ChunkLoadError:
                log.error(f"Error loading chunk\n{traceback.format_exc()}")
                continue
            yield chunk, src_slices, src_box, (dst_cx,
                                               dst_cz), dst_slices, dst_box

    def pre_save_operation(self) -> Generator[float, None, bool]:
        """
        Logic to run before saving. Eg recalculating height maps or lighting.
        Is a generator yielding progress from 0 to 1 and returning a bool saying if changes have been made.

        :return: Have any modifications been made.
        """
        return self.level_wrapper.pre_save_operation(self)

    def save(
        self,
        wrapper: api_wrapper.FormatWrapper = None,
        progress_callback: Callable[[int, int], None] = None,
    ):
        """
        Save the level to the given :class:`FormatWrapper`.

        :param wrapper: If specified will save the data to this wrapper instead of self.level_wrapper
        :param progress_callback: Optional progress callback to let the calling program know the progress. Input format chunk_index, chunk_count
        :return:
        """
        for chunk_index, chunk_count in self.save_iter(wrapper):
            if progress_callback is not None:
                progress_callback(chunk_index, chunk_count)

    def save_iter(
        self,
        wrapper: api_wrapper.FormatWrapper = None
    ) -> Generator[Tuple[int, int], None, None]:
        """
        Save the level to the given :class:`FormatWrapper`.

        This will yield the progress which can be used to update a UI.

        :param wrapper: If specified will save the data to this wrapper instead of self.level_wrapper
        :return: A generator of the number of chunks completed and the total number of chunks
        """
        # TODO change the yield type to match OperationReturnType

        chunk_index = 0

        changed_chunks = list(self._chunks.changed_chunks())
        chunk_count = len(changed_chunks)

        if wrapper is None:
            wrapper = self.level_wrapper

        output_dimension_map = wrapper.dimensions

        # perhaps make this check if the directory is the same rather than if the class is the same
        save_as = wrapper is not self.level_wrapper
        if save_as:
            # The input wrapper is not the same as the loading wrapper (save-as)
            # iterate through every chunk in the input level and save them to the wrapper
            log.info(
                f"Converting level {self.level_wrapper.path} to level {wrapper.path}"
            )
            wrapper.translation_manager = (
                self.level_wrapper.translation_manager
            )  # TODO: this might cause issues in the future
            for dimension in self.level_wrapper.dimensions:
                chunk_count += len(
                    list(self.level_wrapper.all_chunk_coords(dimension)))

            for dimension in self.level_wrapper.dimensions:
                try:
                    if dimension not in output_dimension_map:
                        continue
                    for cx, cz in self.level_wrapper.all_chunk_coords(
                            dimension):
                        log.info(f"Converting chunk {dimension} {cx}, {cz}")
                        try:
                            chunk = self.level_wrapper.load_chunk(
                                cx, cz, dimension)
                            wrapper.commit_chunk(chunk, dimension)
                        except ChunkLoadError:
                            log.info(f"Error loading chunk {cx} {cz}",
                                     exc_info=True)
                        chunk_index += 1
                        yield chunk_index, chunk_count
                        if not chunk_index % 10000:
                            wrapper.save()
                            self.level_wrapper.unload()
                            wrapper.unload()
                except DimensionDoesNotExist:
                    continue

        for dimension, cx, cz in changed_chunks:
            if dimension not in output_dimension_map:
                continue
            try:
                chunk = self.get_chunk(cx, cz, dimension)
            except ChunkDoesNotExist:
                wrapper.delete_chunk(cx, cz, dimension)
            except ChunkLoadError:
                pass
            else:
                wrapper.commit_chunk(chunk, dimension)
                chunk.changed = False
            chunk_index += 1
            yield chunk_index, chunk_count
            if not chunk_index % 10000:
                wrapper.save()
                wrapper.unload()

        self.history_manager.mark_saved()
        log.info(f"Saving changes to level {wrapper.path}")
        wrapper.save()
        log.info(f"Finished saving changes to level {wrapper.path}")

    def purge(self):
        """
        Unload all loaded and cached data.

        This is functionally the same as closing and reopening the world without creating a new class.
        """
        self.unload()
        self.history_manager.purge()

    def close(self):
        """
        Close the attached level and remove temporary files.

        Use changed method to check if there are any changes that should be saved before closing.
        """
        self.level_wrapper.close()

    def unload(self,
               safe_area: Optional[Tuple[Dimension, int, int, int,
                                         int]] = None):
        """
        Unload all chunk data not in the safe area.

        :param safe_area: The area that should not be unloaded [dimension, min_chunk_x, min_chunk_z, max_chunk_x, max_chunk_z]. If None will unload all chunk data.
        """
        self._chunks.unload(safe_area)
        self.level_wrapper.unload()

    def unload_unchanged(self):
        """Unload all data that has not been marked as changed."""
        self._chunks.unload_unchanged()

    @property
    def chunks(self) -> ChunkManager:
        """
        The chunk container.

        Most methods from :class:`ChunkManager` also exists in the level class.
        """
        return self._chunks

    def all_chunk_coords(self, dimension: Dimension) -> Set[Tuple[int, int]]:
        """
        The coordinates of every chunk in this dimension of the level.

        This is the combination of chunks saved to the level and chunks yet to be saved.
        """
        return self._chunks.all_chunk_coords(dimension)

    def has_chunk(self, cx: int, cz: int, dimension: Dimension) -> bool:
        """
        Does the chunk exist. This is a quick way to check if the chunk exists without loading it.

        :param cx: The x coordinate of the chunk.
        :param cz: The z coordinate of the chunk.
        :param dimension: The dimension to load the chunk from.
        :return: True if the chunk exists. Calling get_chunk on this chunk may still throw ChunkLoadError
        """
        return self._chunks.has_chunk(dimension, cx, cz)

    def get_chunk(self, cx: int, cz: int, dimension: Dimension) -> Chunk:
        """
        Gets a :class:`Chunk` class containing the data for the requested chunk.

        :param cx: The X coordinate of the desired chunk
        :param cz: The Z coordinate of the desired chunk
        :param dimension: The dimension to get the chunk from
        :return: A Chunk object containing the data for the chunk
        :raises:
            :class:`~amulet.api.errors.ChunkDoesNotExist`: If the chunk does not exist (was deleted or never created)

            :class:`~amulet.api.errors.ChunkLoadError`: If the chunk was not able to be loaded. Eg. If the chunk is corrupt or some error occurred when loading.
        """
        return self._chunks.get_chunk(dimension, cx, cz)

    def create_chunk(self, cx: int, cz: int, dimension: Dimension) -> Chunk:
        """
        Create an empty chunk and put it at the given location.

        If a chunk exists at the given location it will be overwritten.

        :param cx: The X coordinate of the chunk
        :param cz: The Z coordinate of the chunk
        :param dimension: The dimension to put the chunk in.
        :return: The newly created :class:`Chunk`.
        """
        chunk = Chunk(cx, cz)
        self.put_chunk(chunk, dimension)
        return chunk

    def put_chunk(self, chunk: Chunk, dimension: Dimension):
        """
        Add a given chunk to the level.

        :param chunk: The :class:`Chunk` to add to the level. It will be added at the location stored in :attr:`Chunk.coordinates`
        :param dimension: The dimension to add the chunk to.
        """
        self._chunks.put_chunk(chunk, dimension)

    def delete_chunk(self, cx: int, cz: int, dimension: Dimension):
        """
        Delete a chunk from the level.

        :param cx: The X coordinate of the chunk
        :param cz: The Z coordinate of the chunk
        :param dimension: The dimension to delete the chunk from.
        """
        self._chunks.delete_chunk(dimension, cx, cz)

    def extract_structure(
            self, selection: SelectionGroup,
            dimension: Dimension) -> api_level.ImmutableStructure:
        """
        Extract the region of the dimension specified by ``selection`` to an :class:`~api_level.ImmutableStructure` class.

        :param selection: The selection to extract.
        :param dimension: The dimension to extract the selection from.
        :return: The :class:`~api_level.ImmutableStructure` containing the extracted region.
        """
        return api_level.ImmutableStructure.from_level(self, selection,
                                                       dimension)

    def extract_structure_iter(
        self, selection: SelectionGroup, dimension: Dimension
    ) -> Generator[float, None, api_level.ImmutableStructure]:
        """
        Extract the region of the dimension specified by ``selection`` to an :class:`~api_level.ImmutableStructure` class.

        Also yields the progress as a float from 0-1

        :param selection: The selection to extract.
        :param dimension: The dimension to extract the selection from.
        :return: The :class:`~api_level.ImmutableStructure` containing the extracted region.
        """
        immutable_level = yield from api_level.ImmutableStructure.from_level_iter(
            self, selection, dimension)
        return immutable_level

    def paste(
        self,
        src_structure: "BaseLevel",
        src_dimension: Dimension,
        src_selection: SelectionGroup,
        dst_dimension: Dimension,
        location: BlockCoordinates,
        scale: FloatTriplet = (1.0, 1.0, 1.0),
        rotation: FloatTriplet = (0.0, 0.0, 0.0),
        include_blocks: bool = True,
        include_entities: bool = True,
        skip_blocks: Tuple[Block, ...] = (),
        copy_chunk_not_exist: bool = False,
    ):
        """Paste a level into this level at the given location.
        Note this command may change in the future.

        :param src_structure: The structure to paste into this structure.
        :param src_dimension: The dimension of the source structure to copy from.
        :param src_selection: The selection to copy from the source structure.
        :param dst_dimension: The dimension to paste the structure into.
        :param location: The location where the centre of the structure will be in the level
        :param scale: The scale in the x, y and z axis. These can be negative to mirror.
        :param rotation: The rotation in degrees around each of the axis.
        :param include_blocks: Include blocks when pasting the structure.
        :param include_entities: Include entities when pasting the structure.
        :param skip_blocks: If a block matches a block in this list it will not be copied.
        :param copy_chunk_not_exist: If a chunk does not exist in the source should it be copied over as air. Always False where level is a World.
        :return:
        """
        return generator_unpacker(
            self.paste_iter(
                src_structure,
                src_dimension,
                src_selection,
                dst_dimension,
                location,
                scale,
                rotation,
                include_blocks,
                include_entities,
                skip_blocks,
                copy_chunk_not_exist,
            ))

    def paste_iter(
        self,
        src_structure: "BaseLevel",
        src_dimension: Dimension,
        src_selection: SelectionGroup,
        dst_dimension: Dimension,
        location: BlockCoordinates,
        scale: FloatTriplet = (1.0, 1.0, 1.0),
        rotation: FloatTriplet = (0.0, 0.0, 0.0),
        include_blocks: bool = True,
        include_entities: bool = True,
        skip_blocks: Tuple[Block, ...] = (),
        copy_chunk_not_exist: bool = False,
    ) -> Generator[float, None, None]:
        """Paste a structure into this structure at the given location.
        Note this command may change in the future.

        :param src_structure: The structure to paste into this structure.
        :param src_dimension: The dimension of the source structure to copy from.
        :param src_selection: The selection to copy from the source structure.
        :param dst_dimension: The dimension to paste the structure into.
        :param location: The location where the centre of the structure will be in the level
        :param scale: The scale in the x, y and z axis. These can be negative to mirror.
        :param rotation: The rotation in degrees around each of the axis.
        :param include_blocks: Include blocks when pasting the structure.
        :param include_entities: Include entities when pasting the structure.
        :param skip_blocks: If a block matches a block in this list it will not be copied.
        :param copy_chunk_not_exist: If a chunk does not exist in the source should it be copied over as air. Always False where level is a World.
        :return: A generator of floats from 0 to 1 with the progress of the paste operation.
        """
        yield from clone(
            src_structure,
            src_dimension,
            src_selection,
            self,
            dst_dimension,
            self.bounds(dst_dimension),
            location,
            scale,
            rotation,
            include_blocks,
            include_entities,
            skip_blocks,
            copy_chunk_not_exist,
        )

    def get_version_block(
        self,
        x: int,
        y: int,
        z: int,
        dimension: Dimension,
        version: VersionIdentifierType,
    ) -> Union[Tuple[Block, BlockEntity], Tuple[Entity, None]]:
        """
        Get a block at the specified location and convert it to the format of the version specified

        Note the odd return format. In most cases this will return (Block, None) or (Block, BlockEntity) if a block entity is present.

        In select cases (like item frames) it may return (Entity, None)

        :param x: The X coordinate of the desired block
        :param y: The Y coordinate of the desired block
        :param z: The Z coordinate of the desired block
        :param dimension: The dimension of the desired block
        :param version: The version to get the block converted to.

            >>> ("java", (1, 16, 2))  # Java 1.16.2 format
            >>> ("java", 2578)  # Java 1.16.2 format (using the data version)
            >>> ("bedrock", (1, 16, 210))  # Bedrock 1.16.210 format
        :return: The block at the given location converted to the `version` format. Note the odd return format.
        :raises:
            :class:`~amulet.api.errors.ChunkDoesNotExist`: If the chunk does not exist (was deleted or never created)

            :class:`~amulet.api.errors.ChunkLoadError`: If the chunk was not able to be loaded. Eg. If the chunk is corrupt or some error occurred when loading.
        """
        cx, cz = block_coords_to_chunk_coords(
            x, z, sub_chunk_size=self.sub_chunk_size)
        chunk = self.get_chunk(cx, cz, dimension)
        offset_x, offset_z = x - 16 * cx, z - 16 * cz

        output, extra_output, _ = self.translation_manager.get_version(
            *version).block.from_universal(
                chunk.get_block(offset_x, y, offset_z),
                chunk.block_entities.get((x, y, z)))
        return output, extra_output

    def set_version_block(
        self,
        x: int,
        y: int,
        z: int,
        dimension: Dimension,
        version: VersionIdentifierType,
        block: Block,
        block_entity: BlockEntity = None,
    ):
        """
        Convert the block and block_entity from the given version format to the universal format and set at the location.

        :param x: The X coordinate of the desired block.
        :param y: The Y coordinate of the desired block.
        :param z: The Z coordinate of the desired block.
        :param dimension: The dimension of the desired block.
        :param version: The version the given ``block`` and ``block_entity`` come from.

            >>> ("java", (1, 16, 2))  # Java 1.16.2 format
            >>> ("java", 2578)  # Java 1.16.2 format (using the data version)
            >>> ("bedrock", (1, 16, 210))  # Bedrock 1.16.210 format
        :param block: The block to set. Must be valid in the specified version.
        :param block_entity: The block entity to set. Must be valid in the specified version.
        :return: The block at the given location converted to the `version` format. Note the odd return format.
        :raises:
            ChunkLoadError: If the chunk was not able to be loaded. Eg. If the chunk is corrupt or some error occurred when loading.
        """
        cx, cz = block_coords_to_chunk_coords(
            x, z, sub_chunk_size=self.sub_chunk_size)
        try:
            chunk = self.get_chunk(cx, cz, dimension)
        except ChunkDoesNotExist:
            chunk = self.create_chunk(cx, cz, dimension)
        offset_x, offset_z = x - 16 * cx, z - 16 * cz

        (
            universal_block,
            universal_block_entity,
            _,
        ) = self.translation_manager.get_version(*version).block.to_universal(
            block, block_entity)
        chunk.set_block(offset_x, y, offset_z, universal_block),
        if isinstance(universal_block_entity, BlockEntity):
            chunk.block_entities[(x, y, z)] = universal_block_entity
        elif (x, y, z) in chunk.block_entities:
            del chunk.block_entities[(x, y, z)]
        chunk.changed = True

    # def get_entities_in_box(
    #     self, box: "SelectionGroup"
    # ) -> Generator[Tuple[Coordinates, List[object]], None, None]:
    #     # TODO: some of this logic can probably be moved the chunk class and have this method call that
    #     # TODO: update this to use the newer entity API
    #     out_of_place_entities = []
    #     entity_map: Dict[Tuple[int, int], List[List[object]]] = {}
    #     for chunk, subbox in self.get_chunk_boxes(box):
    #         entities = chunk.entities
    #         in_box = list(filter(lambda e: e.location in subbox, entities))
    #         not_in_box = filter(lambda e: e.location not in subbox, entities)
    #
    #         in_box_copy = deepcopy(in_box)
    #
    #         entity_map[chunk.coordinates] = [
    #             not_in_box,
    #             in_box,
    #         ]  # First index is the list of entities not in the box, the second is for ones that are
    #
    #         yield chunk.coordinates, in_box_copy
    #
    #         if (
    #             in_box != in_box_copy
    #         ):  # If an entity has been changed, update the dictionary entry
    #             entity_map[chunk.coordinates][1] = in_box_copy
    #         else:  # Delete the entry otherwise
    #             del entity_map[chunk.coordinates]
    #
    #     for chunk_coords, entity_list_list in entity_map.items():
    #         chunk = self.get_chunk(*chunk_coords)
    #         in_place_entities = list(
    #             filter(
    #                 lambda e: chunk_coords
    #                 == entity_position_to_chunk_coordinates(e.location),
    #                 entity_list_list[1],
    #             )
    #         )
    #         out_of_place = filter(
    #             lambda e: chunk_coords
    #             != entity_position_to_chunk_coordinates(e.location),
    #             entity_list_list[1],
    #         )
    #
    #         chunk.entities = in_place_entities + list(entity_list_list[0])
    #
    #         if out_of_place:
    #             out_of_place_entities.extend(out_of_place)
    #
    #     if out_of_place_entities:
    #         self.add_entities(out_of_place_entities)
    #
    # def add_entities(self, entities):
    #     proper_entity_chunks = map(
    #         lambda e: (entity_position_to_chunk_coordinates(e.location), e,), entities,
    #     )
    #     accumulated_entities: Dict[Tuple[int, int], List[object]] = {}
    #
    #     for chunk_coord, ent in proper_entity_chunks:
    #         if chunk_coord in accumulated_entities:
    #             accumulated_entities[chunk_coord].append(ent)
    #         else:
    #             accumulated_entities[chunk_coord] = [ent]
    #
    #     for chunk_coord, ents in accumulated_entities.items():
    #         chunk = self.get_chunk(*chunk_coord)
    #
    #         chunk.entities += ents
    #
    # def delete_entities(self, entities):
    #     chunk_entity_pairs = map(
    #         lambda e: (entity_position_to_chunk_coordinates(e.location), e,), entities,
    #     )
    #
    #     for chunk_coord, ent in chunk_entity_pairs:
    #         chunk = self.get_chunk(*chunk_coord)
    #         entities = chunk.entities
    #         entities.remove(ent)
    #         chunk.entities = entities

    @property
    def history_manager(self) -> MetaHistoryManager:
        """The class that manages undoing and redoing changes."""
        return self._history_manager

    def create_undo_point(self, world=True, non_world=True) -> bool:
        """
        Create a restore point for all the data that has changed.

        :param world: If True the restore point will include world based data.
        :param non_world: If True the restore point will include data not related to the world.
        :return: If True a restore point was created. If nothing changed no restore point will be created.
        """
        return self.history_manager.create_undo_point(world, non_world)

    def create_undo_point_iter(self,
                               world=True,
                               non_world=True) -> Generator[float, None, bool]:
        """
        Create a restore point for all the data that has changed.

        Also yields progress from 0-1

        :param world: If True the restore point will include world based data.
        :param non_world: If True the restore point will include data not related to the world.
        :return: If True a restore point was created. If nothing changed no restore point will be created.
        """
        return self.history_manager.create_undo_point_iter(world, non_world)

    @property
    def changed(self) -> bool:
        """Has any data been modified but not saved to disk"""
        return self.history_manager.changed or self.level_wrapper.changed

    def undo(self):
        """Undoes the last set of changes to the level."""
        self.history_manager.undo()

    def redo(self):
        """Redoes the last set of changes to the level."""
        self.history_manager.redo()

    def restore_last_undo_point(self):
        """
        Restore the level to the state it was when self.create_undo_point was last called.

        If an operation errors there may be modifications made that did not get tracked.

        This will revert those changes.
        """
        self.history_manager.restore_last_undo_point()

    @property
    def players(self) -> PlayerManager:
        """
        The player container.

        Most methods from :class:`PlayerManager` also exists in the level class.
        """
        return self._players

    def all_player_ids(self) -> Set[str]:
        """
        Returns a set of all player ids that are present in the level.
        """
        return self.players.all_player_ids()

    def has_player(self, player_id: str) -> bool:
        """
        Is the given player id present in the level

        :param player_id: The player id to check
        :return: True if the player id is present, False otherwise
        """
        return self.players.has_player(player_id)

    def get_player(self, player_id: str) -> Player:
        """
        Gets the :class:`Player` object that belongs to the specified player id

        If no parameter is supplied, the data of the local player will be returned

        :param player_id: The desired player id
        :return: A Player instance
        """
        return self.players.get_player(player_id)
Ejemplo n.º 6
0
class World(BaseStructure):
    """
    Class that handles world editing of any world format via an separate and flexible data format
    """

    def __init__(
        self, directory: str, world_wrapper: "WorldFormatWrapper", temp_dir: str = None
    ):
        self._directory = directory
        if temp_dir is None:
            self._temp_directory = get_temp_dir(self._directory)
        else:
            self._temp_directory = temp_dir

        self._world_wrapper = world_wrapper
        self._world_wrapper.open()

        self._block_palette = BlockManager()
        self._block_palette.get_add_block(
            Block(namespace="universal_minecraft", base_name="air")
        )  # ensure that index 0 is always air

        self._biome_palette = BiomeManager()

        self._chunk_cache: ChunkCache = {}
        shutil.rmtree(self._temp_directory, ignore_errors=True)
        self._chunk_history_manager = ChunkHistoryManager(
            os.path.join(self._temp_directory, "chunks")
        )
        self._needs_undo_point: bool = False

    @property
    def world_path(self) -> str:
        """The directory where the world is located"""
        return self._directory

    @property
    def changed(self) -> bool:
        """Has any data been modified but not saved to disk"""
        return self._world_wrapper.changed or any(
            chunk is None or chunk.changed for chunk in self._chunk_cache.values()
        )

    @property
    def chunk_history_manager(self) -> ChunkHistoryManager:
        """A class storing previous versions of chunks to roll back to as required."""
        return self._chunk_history_manager

    def create_undo_point(self):
        """Create a restore point for all chunks that have changed."""
        self._chunk_history_manager.create_undo_point(self._chunk_cache)

    @property
    def sub_chunk_size(self) -> int:
        """The normal dimensions of the chunk"""
        return self._world_wrapper.sub_chunk_size

    @property
    def chunk_size(self) -> Tuple[int, Union[int, None], int]:
        """The normal dimensions of the chunk"""
        return self._world_wrapper.chunk_size

    @property
    def translation_manager(self) -> "TranslationManager":
        """An instance of the translation class for use with this world."""
        return self._world_wrapper.translation_manager

    @property
    def world_wrapper(self) -> "WorldFormatWrapper":
        """A class to access data directly from the world."""
        return self._world_wrapper

    @property
    def palette(self) -> BlockManager:
        """The manager for the universal blocks in this world. New blocks must be registered here before adding to the world."""
        return self._block_palette

    @property
    def block_palette(self) -> BlockManager:
        """The manager for the universal blocks in this world. New blocks must be registered here before adding to the world."""
        return self._block_palette

    @property
    def biome_palette(self) -> BiomeManager:
        """The manager for the universal blocks in this world. New blocks must be registered here before adding to the world."""
        return self._biome_palette

    def save(
        self,
        wrapper: "WorldFormatWrapper" = None,
        progress_callback: Callable[[int, int], None] = None,
    ):
        """Save the world using the given wrapper.
        Leave as None to save back to the input wrapper.
        Optional progress callback to let the calling program know the progress. Input format chunk_index, chunk_count"""
        for chunk_index, chunk_count in self.save_iter(wrapper):
            if progress_callback is not None:
                progress_callback(chunk_index, chunk_count)

    def save_iter(
        self, wrapper: "WorldFormatWrapper" = None
    ) -> Generator[Tuple[int, int], None, None]:
        """Save the world using the given wrapper.
        Leave as None to save back to the input wrapper."""
        chunk_index = 0
        if self._needs_undo_point or any(
            chunk is not None and chunk.changed for chunk in self._chunk_cache.values()
        ):
            self.create_undo_point()
            self._needs_undo_point = False

        changed_chunks = list(self._chunk_history_manager.changed_chunks())
        chunk_count = len(changed_chunks)

        if wrapper is None:
            wrapper = self._world_wrapper

        output_dimension_map = wrapper.dimensions

        # perhaps make this check if the directory is the same rather than if the class is the same
        save_as = wrapper is not self._world_wrapper
        if save_as:
            # The input wrapper is not the same as the loading wrapper (save-as)
            # iterate through every chunk in the input world and save them to the wrapper
            log.info(
                f"Converting world {self._world_wrapper.path} to world {wrapper.path}"
            )
            wrapper.translation_manager = (
                self._world_wrapper.translation_manager
            )  # TODO: this might cause issues in the future
            for dimension in self._world_wrapper.dimensions:
                chunk_count += len(
                    list(self._world_wrapper.all_chunk_coords(dimension))
                )

            for dimension in self._world_wrapper.dimensions:
                try:
                    if dimension not in output_dimension_map:
                        continue
                    for cx, cz in self._world_wrapper.all_chunk_coords(dimension):
                        log.info(f"Converting chunk {dimension} {cx}, {cz}")
                        try:
                            chunk = self._world_wrapper.load_chunk(cx, cz, dimension)
                            wrapper.commit_chunk(chunk, dimension)
                        except ChunkLoadError:
                            log.info(f"Error loading chunk {cx} {cz}", exc_info=True)
                        chunk_index += 1
                        yield chunk_index, chunk_count
                        if not chunk_index % 10000:
                            wrapper.save()
                            self._world_wrapper.unload()
                            wrapper.unload()
                except LevelDoesNotExist:
                    continue

        for dimension, cx, cz in changed_chunks:
            if dimension not in output_dimension_map:
                continue
            chunk = self._chunk_history_manager.get_current(
                dimension, cx, cz, self._block_palette, self._biome_palette
            )
            if chunk is None:
                wrapper.delete_chunk(cx, cz, dimension)
            else:
                wrapper.commit_chunk(chunk, dimension)
            chunk_index += 1
            yield chunk_index, chunk_count
            if not chunk_index % 10000:
                wrapper.save()
                wrapper.unload()

        self._chunk_history_manager.mark_saved()
        log.info(f"Saving changes to world {wrapper.path}")
        wrapper.save()
        log.info(f"Finished saving changes to world {wrapper.path}")

    def close(self):
        """Close the attached world and remove temporary files
        Use changed method to check if there are any changes that should be saved before closing."""
        # TODO: add "unsaved changes" check before exit
        shutil.rmtree(self._temp_directory, ignore_errors=True)
        self._world_wrapper.close()

    def unload(self, safe_area: Optional[Tuple[Dimension, int, int, int, int]] = None):
        """Unload all chunks not in the safe area
        Safe area format: dimension, min chunk X|Z, max chunk X|Z"""
        unload_chunks = []
        if safe_area is None:
            unload_chunks = list(self._chunk_cache.keys())
        else:
            dimension, minx, minz, maxx, maxz = safe_area
            for (cd, cx, cz), chunk in self._chunk_cache.items():
                if not (cd == dimension and minx <= cx <= maxx and minz <= cz <= maxz):
                    unload_chunks.append((cd, cx, cz))
        for chunk_key in unload_chunks:
            del self._chunk_cache[chunk_key]
        self._world_wrapper.unload()

    def get_chunk(self, cx: int, cz: int, dimension: Dimension) -> Chunk:
        """
        Gets the chunk data of the specified chunk coordinates.
        If the chunk does not exist ChunkDoesNotExist is raised.
        If some other error occurs then ChunkLoadError is raised (this error will also catch ChunkDoesNotExist)

        :param cx: The X coordinate of the desired chunk
        :param cz: The Z coordinate of the desired chunk
        :param dimension: The dimension to get the chunk from
        :return: The blocks, entities, and tile entities in the chunk
        """
        chunk_key = (dimension, cx, cz)
        if chunk_key in self._chunk_cache:
            chunk = self._chunk_cache[(dimension, cx, cz)]
        elif chunk_key in self._chunk_history_manager:
            chunk = self._chunk_cache[
                (dimension, cx, cz)
            ] = self._chunk_history_manager.get_current(
                dimension, cx, cz, self._block_palette, self._biome_palette
            )
        else:
            try:
                chunk = self._world_wrapper.load_chunk(cx, cz, dimension)
                chunk.block_palette = self._block_palette
                chunk.biome_palette = self._biome_palette
                self._chunk_cache[(dimension, cx, cz)] = chunk
            except ChunkDoesNotExist:
                chunk = self._chunk_cache[(dimension, cx, cz)] = None
            except ChunkLoadError as e:
                raise e
            self._chunk_history_manager.add_original_chunk(dimension, cx, cz, chunk)

        if chunk is None:
            raise ChunkDoesNotExist(f"Chunk ({cx},{cz}) does not exist")

        return chunk

    def create_chunk(self, cx: int, cz: int, dimension: Dimension) -> Chunk:
        chunk = Chunk(cx, cz)
        self.put_chunk(chunk, dimension)
        return chunk

    def put_chunk(self, chunk: Chunk, dimension: Dimension):
        """Add a chunk to the universal world database"""
        chunk.changed = True
        chunk.block_palette = self._block_palette
        chunk.biome_palette = self._biome_palette
        self._chunk_cache[(dimension, chunk.cx, chunk.cz)] = chunk

    def delete_chunk(self, cx: int, cz: int, dimension: Dimension):
        """Delete a chunk from the universal world database"""
        self._needs_undo_point = True
        self._chunk_cache[(dimension, cx, cz)] = None

    def get_block(self, x: int, y: int, z: int, dimension: Dimension) -> Block:
        """
        Gets the universal Block object at the specified coordinates

        :param x: The X coordinate of the desired block
        :param y: The Y coordinate of the desired block
        :param z: The Z coordinate of the desired block
        :param dimension: The dimension of the desired block
        :return: The universal Block object representation of the block at that location
        :raise: Raises ChunkDoesNotExist or ChunkLoadError if the chunk was not loaded.
        """
        cx, cz = block_coords_to_chunk_coords(x, z, chunk_size=self.chunk_size[0])
        offset_x, offset_z = x - 16 * cx, z - 16 * cz

        return self.get_chunk(cx, cz, dimension).get_block(offset_x, y, offset_z)

    def get_version_block(
        self,
        x: int,
        y: int,
        z: int,
        dimension: Dimension,
        version: VersionIdentifierType,
    ) -> Tuple[Union[Block, Entity], Optional[BlockEntity]]:
        """
        Get a block at the specified location and convert it to the format of the version specified
        Note the odd return format. In most cases this will return (Block, None) or (Block, BlockEntity)
        but in select cases like item frames may return (Entity, None)

        :param x: The X coordinate of the desired block
        :param y: The Y coordinate of the desired block
        :param z: The Z coordinate of the desired block
        :param dimension: The dimension of the desired block
        :param version: The version to get the block converted to.
        :return: The block at the given location converted to the `version` format. Note the odd return format.
        :raise: Raises ChunkDoesNotExist or ChunkLoadError if the chunk was not loaded.
        """
        cx, cz = block_coords_to_chunk_coords(x, z, chunk_size=self.sub_chunk_size)
        chunk = self.get_chunk(cx, cz, dimension)
        offset_x, offset_z = x - 16 * cx, z - 16 * cz

        output, extra_output, _ = self.translation_manager.get_version(
            *version
        ).block.from_universal(
            chunk.get_block(offset_x, y, offset_z), chunk.block_entities.get((x, y, z))
        )
        return output, extra_output

    def set_version_block(
        self,
        x: int,
        y: int,
        z: int,
        dimension: Dimension,
        version: VersionIdentifierType,
        block: Block,
        block_entity: BlockEntity,
    ):
        """
        Convert the block and block_entity from the given version format to the universal format and set at the location

        :param x: The X coordinate of the desired block
        :param y: The Y coordinate of the desired block
        :param z: The Z coordinate of the desired block
        :param dimension: The dimension of the desired block
        :param version: The version to get the block converted to.
        :param block:
        :param block_entity:
        :return: The block at the given location converted to the `version` format. Note the odd return format.
        :raise: Raises ChunkLoadError if the chunk was not loaded correctly.
        """
        cx, cz = block_coords_to_chunk_coords(x, z, chunk_size=self.sub_chunk_size)
        try:
            chunk = self.get_chunk(cx, cz, dimension)
        except ChunkDoesNotExist:
            chunk = self.create_chunk(cx, cz, dimension)
        offset_x, offset_z = x - 16 * cx, z - 16 * cz

        (
            universal_block,
            universal_block_entity,
            _,
        ) = self.translation_manager.get_version(*version).block.to_universal(
            block, block_entity
        )
        chunk.set_block(offset_x, y, offset_z, block),
        chunk.block_entities[(x, y, z)] = block_entity

    def get_chunk_boxes(
        self,
        selection: Union[SelectionGroup, SelectionBox],
        dimension: Dimension,
        create_missing_chunks=False,
    ) -> Generator[Tuple[Chunk, SelectionBox], None, None]:
        """Given a selection will yield chunks and SubSelectionBoxes into that chunk

        :param selection: SelectionGroup or SelectionBox into the world
        :param dimension: The dimension to take effect in (defaults to overworld)
        :param create_missing_chunks: If a chunk does not exist an empty one will be created (defaults to false)
        """

        if isinstance(selection, SelectionBox):
            selection = SelectionGroup([selection])
        selection: SelectionGroup
        for (cx, cz), box in selection.sub_sections(self.sub_chunk_size):
            try:
                chunk = self.get_chunk(cx, cz, dimension)
            except ChunkDoesNotExist:
                if create_missing_chunks:
                    chunk = Chunk(cx, cz)
                    self.put_chunk(chunk, dimension)
                else:
                    continue
            except ChunkLoadError:
                continue

            yield chunk, box

    def get_chunk_slices(
        self,
        selection: Union[SelectionGroup, SelectionBox],
        dimension: Dimension,
        create_missing_chunks=False,
    ) -> Generator[Tuple[Chunk, Tuple[slice, slice, slice], SelectionBox], None, None]:
        """Given a selection will yield chunks, slices into that chunk and the corresponding box

        :param selection: SelectionGroup or SelectionBox into the world
        :param dimension: The dimension to take effect in (defaults to overworld)
        :param create_missing_chunks: If a chunk does not exist an empty one will be created (defaults to false)
        Usage:
        for chunk, slice, box in world.get_chunk_slices(selection):
            chunk.blocks[slice] = ...
        """
        for chunk, box in self.get_chunk_boxes(
            selection, dimension, create_missing_chunks
        ):
            slices = box.chunk_slice(chunk.cx, chunk.cz, self.sub_chunk_size)
            yield chunk, slices, box

    # def get_entities_in_box(
    #     self, box: "SelectionGroup"
    # ) -> Generator[Tuple[Coordinates, List[object]], None, None]:
    #     # TODO: some of this logic can probably be moved the chunk class and have this method call that
    #     # TODO: update this to use the newer entity API
    #     out_of_place_entities = []
    #     entity_map: Dict[Tuple[int, int], List[List[object]]] = {}
    #     for chunk, subbox in self.get_chunk_boxes(box):
    #         entities = chunk.entities
    #         in_box = list(filter(lambda e: e.location in subbox, entities))
    #         not_in_box = filter(lambda e: e.location not in subbox, entities)
    #
    #         in_box_copy = deepcopy(in_box)
    #
    #         entity_map[chunk.coordinates] = [
    #             not_in_box,
    #             in_box,
    #         ]  # First index is the list of entities not in the box, the second is for ones that are
    #
    #         yield chunk.coordinates, in_box_copy
    #
    #         if (
    #             in_box != in_box_copy
    #         ):  # If an entity has been changed, update the dictionary entry
    #             entity_map[chunk.coordinates][1] = in_box_copy
    #         else:  # Delete the entry otherwise
    #             del entity_map[chunk.coordinates]
    #
    #     for chunk_coords, entity_list_list in entity_map.items():
    #         chunk = self.get_chunk(*chunk_coords)
    #         in_place_entities = list(
    #             filter(
    #                 lambda e: chunk_coords
    #                 == entity_position_to_chunk_coordinates(e.location),
    #                 entity_list_list[1],
    #             )
    #         )
    #         out_of_place = filter(
    #             lambda e: chunk_coords
    #             != entity_position_to_chunk_coordinates(e.location),
    #             entity_list_list[1],
    #         )
    #
    #         chunk.entities = in_place_entities + list(entity_list_list[0])
    #
    #         if out_of_place:
    #             out_of_place_entities.extend(out_of_place)
    #
    #     if out_of_place_entities:
    #         self.add_entities(out_of_place_entities)
    #
    # def add_entities(self, entities):
    #     proper_entity_chunks = map(
    #         lambda e: (entity_position_to_chunk_coordinates(e.location), e,), entities,
    #     )
    #     accumulated_entities: Dict[Tuple[int, int], List[object]] = {}
    #
    #     for chunk_coord, ent in proper_entity_chunks:
    #         if chunk_coord in accumulated_entities:
    #             accumulated_entities[chunk_coord].append(ent)
    #         else:
    #             accumulated_entities[chunk_coord] = [ent]
    #
    #     for chunk_coord, ents in accumulated_entities.items():
    #         chunk = self.get_chunk(*chunk_coord)
    #
    #         chunk.entities += ents
    #
    # def delete_entities(self, entities):
    #     chunk_entity_pairs = map(
    #         lambda e: (entity_position_to_chunk_coordinates(e.location), e,), entities,
    #     )
    #
    #     for chunk_coord, ent in chunk_entity_pairs:
    #         chunk = self.get_chunk(*chunk_coord)
    #         entities = chunk.entities
    #         entities.remove(ent)
    #         chunk.entities = entities

    def run_operation(
        self, operation: OperationType, dimension: Dimension, *args, create_undo=True
    ) -> Any:
        try:
            out = operation(self, dimension, *args)
            if isinstance(out, GeneratorType):
                try:
                    while True:
                        next(out)
                except StopIteration as e:
                    out = e.value
        except Exception as e:
            self.restore_last_undo_point()
            raise e
        if create_undo:
            self.create_undo_point()
        return out

    def undo(self):
        """
        Undoes the last set of changes to the world
        """
        self._chunk_history_manager.undo(
            self._chunk_cache, self._block_palette, self._biome_palette
        )

    def redo(self):
        """
        Redoes the last set of changes to the world
        """
        self._chunk_history_manager.redo(
            self._chunk_cache, self._block_palette, self._biome_palette
        )

    def restore_last_undo_point(self):
        """Restore the world to the state it was when self.create_undo_point was called.
        If an operation errors there may be modifications made that did not get tracked.
        This will revert those changes."""
        self.unload()  # clear the loaded chunks and they will get populated by the last version in the history manager
Ejemplo n.º 7
0
class BaseLevel:
    """
    BaseLevel handles chunk editing of any world or structure format via an separate and flexible data format.
    """
    def __init__(self,
                 directory: str,
                 format_wrapper: "FormatWrapper",
                 temp_dir: str = None):
        self._directory = directory
        if temp_dir is None:
            self._temp_directory = get_temp_dir(self._directory)
        else:
            self._temp_directory = temp_dir

        self._level_wrapper = format_wrapper
        self.level_wrapper.open()

        self._block_palette = BlockManager()
        self._block_palette.get_add_block(
            UniversalAirBlock)  # ensure that index 0 is always air

        self._biome_palette = BiomeManager()
        self._biome_palette.get_add_biome("universal_minecraft:plains")

        self._history_manager = MetaHistoryManager()

        self._chunks: ChunkManager = ChunkManager(
            os.path.join(self._temp_directory, "chunks"), self)

        self.history_manager.register(self._chunks, True)

    @property
    def level_wrapper(self) -> "FormatWrapper":
        """A class to access data directly from the level."""
        return self._level_wrapper

    @property
    def world_wrapper(self) -> "FormatWrapper":
        """A class to access data directly from the world."""
        warnings.warn(
            "BaseLevel.world_wrapper is depreciated and will be removed in the future. Please use BaseLevel.level_wrapper instead.",
            DeprecationWarning,
        )
        return self.level_wrapper

    @property
    def sub_chunk_size(self) -> int:
        """The normal dimensions of the chunk"""
        return self.level_wrapper.sub_chunk_size

    @property
    def level_path(self) -> str:
        """The system path where the level is located.
        This may be a directory or file depending on the level that is loaded."""
        return self._directory

    @property
    def world_path(self) -> str:
        """The directory where the world is located"""
        warnings.warn(
            "BaseLevel.world_path is depreciated and will be removed in the future. Please use BaseLevel.level_path instead.",
            DeprecationWarning,
        )
        return self._directory

    @property
    def translation_manager(self) -> "TranslationManager":
        """An instance of the translation class for use with this level."""
        return self.level_wrapper.translation_manager

    @property
    def palette(self) -> BlockManager:
        """The manager for the universal blocks in this level. New blocks must be registered here before adding to the level."""
        warnings.warn(
            "World.palette is depreciated and will be removed in the future. Please use BaseLevel.block_palette instead",
            DeprecationWarning,
        )
        return self.block_palette

    @property
    def block_palette(self) -> BlockManager:
        """The manager for the universal blocks in this level. New blocks must be registered here before adding to the level."""
        return self._block_palette

    @property
    def biome_palette(self) -> BiomeManager:
        """The manager for the universal blocks in this level. New biomes must be registered here before adding to the level."""
        return self._biome_palette

    @property
    def selection_bounds(self) -> SelectionGroup:
        """The selection(s) that all chunk data must fit within. Usually +/-30M for worlds. The selection for structures."""
        return self.level_wrapper.selection

    @property
    def dimensions(self) -> Tuple[Dimension, ...]:
        return tuple(self.level_wrapper.dimensions)

    def all_chunk_coords(self, dimension: Dimension) -> Set[Tuple[int, int]]:
        """The coordinates of every chunk in this dimension of the level.
        This is the combination of chunks saved to the level and chunks yet to be saved."""
        return self._chunks.all_chunk_coords(dimension)

    def has_chunk(self, cx: int, cz: int, dimension: Dimension) -> bool:
        """Does the chunk exist. This is a quick way to check if the chunk exists without loading it.

        :param cx: The x coordinate of the chunk.
        :param cz: The z coordinate of the chunk.
        :param dimension: The dimension to load the chunk from.
        :return: True if the chunk exists. Calling get_chunk on this chunk may still throw ChunkLoadError
        """
        return self._chunks.has_chunk(dimension, cx, cz)

    def get_chunk(self, cx: int, cz: int, dimension: Dimension) -> Chunk:
        """
        Gets the chunk data of the specified chunk coordinates.
        If the chunk does not exist ChunkDoesNotExist is raised.
        If some other error occurs then ChunkLoadError is raised (this error will also catch ChunkDoesNotExist)

        :param cx: The X coordinate of the desired chunk
        :param cz: The Z coordinate of the desired chunk
        :param dimension: The dimension to get the chunk from
        :return: A Chunk object containing the data for the chunk
        :raises: `amulet.api.errors.ChunkDoesNotExist` if the chunk does not exist or `amulet.api.errors.ChunkLoadError` if the chunk failed to load for some reason. (This also includes it not existing)
        """
        return self._chunks.get_chunk(dimension, cx, cz)

    def get_block(self, x: int, y: int, z: int, dimension: Dimension) -> Block:
        """
        Gets the universal Block object at the specified coordinates

        :param x: The X coordinate of the desired block
        :param y: The Y coordinate of the desired block
        :param z: The Z coordinate of the desired block
        :param dimension: The dimension of the desired block
        :return: The universal Block object representation of the block at that location
        :raise: Raises ChunkDoesNotExist or ChunkLoadError if the chunk was not loaded.
        """
        cx, cz = block_coords_to_chunk_coords(
            x, z, sub_chunk_size=self.sub_chunk_size)
        offset_x, offset_z = x - 16 * cx, z - 16 * cz

        return self.get_chunk(cx, cz,
                              dimension).get_block(offset_x, y, offset_z)

    def _chunk_box(
        self,
        cx: int,
        cz: int,
        sub_chunk_size: Optional[int] = None,
    ):
        """Get a SelectionBox containing the whole of a given chunk"""
        if sub_chunk_size is None:
            sub_chunk_size = self.sub_chunk_size
        return SelectionBox.create_chunk_box(cx, cz, sub_chunk_size)

    def get_coord_box(
        self,
        dimension: Dimension,
        selection: Union[SelectionGroup, SelectionBox, None] = None,
        yield_missing_chunks=False,
    ) -> Generator[Tuple[ChunkCoordinates, SelectionBox], None, None]:
        """Given a selection will yield chunk coordinates and `SelectionBox`es into that chunk
        If not given a selection will use the bounds of the object.

        :param selection: SelectionGroup or SelectionBox into the level
        :param dimension: The dimension to take effect in
        :param yield_missing_chunks: If a chunk does not exist an empty one will be created (defaults to false). Use this with care.
        """
        if isinstance(selection, SelectionBox):
            selection = SelectionGroup(selection)
        elif selection is None:
            selection = self.selection_bounds
        elif not isinstance(selection, SelectionGroup):
            raise TypeError(
                f"Expected a SelectionGroup but got {type(selection)}")

        selection: SelectionGroup
        if yield_missing_chunks or selection.footprint_area < 1_000_000:
            if yield_missing_chunks:
                for coord, box in selection.chunk_boxes(self.sub_chunk_size):
                    yield coord, box
            else:
                for (cx,
                     cz), box in selection.chunk_boxes(self.sub_chunk_size):
                    if self.has_chunk(cx, cz, dimension):
                        yield (cx, cz), box

        else:
            # if the selection gets very large iterating over the whole selection and accessing chunks can get slow
            # instead we are going to iterate over the chunks and get the intersection of the selection
            for cx, cz in self.all_chunk_coords(dimension):
                box = SelectionGroup(
                    SelectionBox.create_chunk_box(cx, cz, self.sub_chunk_size))

                if selection.intersects(box):
                    chunk_selection = selection.intersection(box)
                    for sub_box in chunk_selection.selection_boxes:
                        yield (cx, cz), sub_box

    def get_chunk_boxes(
        self,
        dimension: Dimension,
        selection: Union[SelectionGroup, SelectionBox, None] = None,
        create_missing_chunks=False,
    ) -> Generator[Tuple[Chunk, SelectionBox], None, None]:
        """Given a selection will yield chunks and `SelectionBox`es into that chunk
        If not given a selection will use the bounds of the object.

        :param selection: SelectionGroup or SelectionBox into the level
        :param dimension: The dimension to take effect in
        :param create_missing_chunks: If a chunk does not exist an empty one will be created (defaults to false). Use this with care.
        """
        for (cx, cz), box in self.get_coord_box(dimension, selection,
                                                create_missing_chunks):
            try:
                chunk = self.get_chunk(cx, cz, dimension)
            except ChunkDoesNotExist:
                if create_missing_chunks:
                    yield self.create_chunk(cx, cz, dimension), box
            except ChunkLoadError:
                log.error(f"Error loading chunk\n{traceback.format_exc()}")
            else:
                yield chunk, box

    def get_chunk_slice_box(
        self,
        dimension: Dimension,
        selection: Union[SelectionGroup, SelectionBox],
        create_missing_chunks=False,
    ) -> Generator[Tuple[Chunk, Tuple[slice, slice, slice], SelectionBox],
                   None, None]:
        """Given a selection will yield chunks, slices into that chunk and the corresponding box

        :param selection: SelectionGroup or SelectionBox into the level
        :param dimension: The dimension to take effect in
        :param create_missing_chunks: If a chunk does not exist an empty one will be created (defaults to false)
        Usage:
        for chunk, slice, box in level.get_chunk_slices(selection):
            chunk.blocks[slice] = ...
        """
        for chunk, box in self.get_chunk_boxes(dimension, selection,
                                               create_missing_chunks):
            slices = box.chunk_slice(chunk.cx, chunk.cz, self.sub_chunk_size)
            yield chunk, slices, box

    def get_moved_coord_slice_box(
        self,
        dimension: Dimension,
        destination_origin: Tuple[int, int, int],
        selection: Optional[Union[SelectionGroup, SelectionBox]] = None,
        destination_sub_chunk_shape: Optional[int] = None,
        yield_missing_chunks: bool = False,
    ) -> Generator[Tuple[ChunkCoordinates, Tuple[
            slice, slice, slice], SelectionBox, ChunkCoordinates, Tuple[
                slice, slice, slice], SelectionBox, ], None, None, ]:
        """Iterate over a selection and return slices into the source object and destination object
        given the origin of the destination. When copying a selection to a new area the slices will
        only be equal if the offset is a multiple of the chunk size. This will rarely be the case
        so the slices need to be split up into parts that intersect a chunk in the source and destination.
        :param dimension: The dimension to iterate over.
        :param destination_origin: The location where the minimum point of self.selection_bounds will end up
        :param selection: An optional selection. The overlap of this and self.selection_bounds will be used
        :param destination_sub_chunk_shape: the chunk shape of the destination object (defaults to self.sub_chunk_size)
        :param yield_missing_chunks: Generate empty chunks if the chunk does not exist.
        :return:
        """
        if destination_sub_chunk_shape is None:
            destination_sub_chunk_shape = self.sub_chunk_size

        if selection is None:
            selection = self.selection_bounds
        else:
            selection = self.selection_bounds.intersection(selection)
        # the offset from self.selection to the destination location
        offset = numpy.subtract(destination_origin,
                                self.selection_bounds.min,
                                dtype=numpy.int)
        for (src_cx, src_cz), box in self.get_coord_box(
                dimension, selection,
                yield_missing_chunks=yield_missing_chunks):
            dst_full_box = SelectionBox(
                offset + box.min,
                offset + box.max,
            )

            first_chunk = block_coords_to_chunk_coords(
                dst_full_box.min_x,
                dst_full_box.min_z,
                sub_chunk_size=destination_sub_chunk_shape,
            )
            last_chunk = block_coords_to_chunk_coords(
                dst_full_box.max_x - 1,
                dst_full_box.max_z - 1,
                sub_chunk_size=destination_sub_chunk_shape,
            )
            for dst_cx, dst_cz in itertools.product(
                    range(first_chunk[0], last_chunk[0] + 1),
                    range(first_chunk[1], last_chunk[1] + 1),
            ):
                chunk_box = self._chunk_box(dst_cx, dst_cz,
                                            destination_sub_chunk_shape)
                dst_box = chunk_box.intersection(dst_full_box)
                src_box = SelectionBox(-offset + dst_box.min,
                                       -offset + dst_box.max)
                src_slices = src_box.chunk_slice(src_cx, src_cz,
                                                 self.sub_chunk_size)
                dst_slices = dst_box.chunk_slice(dst_cx, dst_cz,
                                                 self.sub_chunk_size)
                yield (src_cx, src_cz), src_slices, src_box, (
                    dst_cx,
                    dst_cz,
                ), dst_slices, dst_box

    def get_moved_chunk_slice_box(
        self,
        dimension: Dimension,
        destination_origin: Tuple[int, int, int],
        selection: Optional[Union[SelectionGroup, SelectionBox]] = None,
        destination_sub_chunk_shape: Optional[int] = None,
        create_missing_chunks: bool = False,
    ) -> Generator[Tuple[Chunk, Tuple[
            slice, slice, slice], SelectionBox, ChunkCoordinates, Tuple[
                slice, slice, slice], SelectionBox, ], None, None, ]:
        """Iterate over a selection and return slices into the source object and destination object
        given the origin of the destination. When copying a selection to a new area the slices will
        only be equal if the offset is a multiple of the chunk size. This will rarely be the case
        so the slices need to be split up into parts that intersect a chunk in the source and destination.
        :param dimension: The dimension to iterate over.
        :param destination_origin: The location where the minimum point of self.selection will end up
        :param selection: An optional selection. The overlap of this and self.selection will be used
        :param destination_sub_chunk_shape: the chunk shape of the destination object (defaults to self.sub_chunk_size)
        :param create_missing_chunks: Generate empty chunks if the chunk does not exist.
        :return:
        """
        for (
            (src_cx, src_cz),
                src_slices,
                src_box,
            (dst_cx, dst_cz),
                dst_slices,
                dst_box,
        ) in self.get_moved_coord_slice_box(
                dimension,
                destination_origin,
                selection,
                destination_sub_chunk_shape,
                create_missing_chunks,
        ):
            try:
                chunk = self.get_chunk(src_cx, src_cz, dimension)
            except ChunkDoesNotExist:
                chunk = self.create_chunk(dst_cx, dst_cz, dimension)
            except ChunkLoadError:
                log.error(f"Error loading chunk\n{traceback.format_exc()}")
                continue
            yield chunk, src_slices, src_box, (dst_cx,
                                               dst_cz), dst_slices, dst_box

    def save(
        self,
        wrapper: "FormatWrapper" = None,
        progress_callback: Callable[[int, int], None] = None,
    ):
        """Save the level using the given wrapper.
        Leave as None to save back to the input wrapper.
        Optional progress callback to let the calling program know the progress. Input format chunk_index, chunk_count"""
        for chunk_index, chunk_count in self.save_iter(wrapper):
            if progress_callback is not None:
                progress_callback(chunk_index, chunk_count)

    def save_iter(
        self,
        wrapper: "FormatWrapper" = None
    ) -> Generator[Tuple[int, int], None, None]:
        """Save the level using the given wrapper.
        Leave as None to save back to the input wrapper."""
        chunk_index = 0

        changed_chunks = list(self._chunks.changed_chunks())
        chunk_count = len(changed_chunks)

        if wrapper is None:
            wrapper = self.level_wrapper

        output_dimension_map = wrapper.dimensions

        # perhaps make this check if the directory is the same rather than if the class is the same
        save_as = wrapper is not self.level_wrapper
        if save_as:
            # The input wrapper is not the same as the loading wrapper (save-as)
            # iterate through every chunk in the input level and save them to the wrapper
            log.info(
                f"Converting level {self.level_wrapper.path} to level {wrapper.path}"
            )
            wrapper.translation_manager = (
                self.level_wrapper.translation_manager
            )  # TODO: this might cause issues in the future
            for dimension in self.level_wrapper.dimensions:
                chunk_count += len(
                    list(self.level_wrapper.all_chunk_coords(dimension)))

            for dimension in self.level_wrapper.dimensions:
                try:
                    if dimension not in output_dimension_map:
                        continue
                    for cx, cz in self.level_wrapper.all_chunk_coords(
                            dimension):
                        log.info(f"Converting chunk {dimension} {cx}, {cz}")
                        try:
                            chunk = self.level_wrapper.load_chunk(
                                cx, cz, dimension)
                            wrapper.commit_chunk(chunk, dimension)
                        except ChunkLoadError:
                            log.info(f"Error loading chunk {cx} {cz}",
                                     exc_info=True)
                        chunk_index += 1
                        yield chunk_index, chunk_count
                        if not chunk_index % 10000:
                            wrapper.save()
                            self.level_wrapper.unload()
                            wrapper.unload()
                except LevelDoesNotExist:
                    continue

        for dimension, cx, cz in changed_chunks:
            if dimension not in output_dimension_map:
                continue
            try:
                chunk = self.get_chunk(cx, cz, dimension)
            except ChunkDoesNotExist:
                wrapper.delete_chunk(cx, cz, dimension)
            except ChunkLoadError:
                pass
            else:
                wrapper.commit_chunk(chunk, dimension)
                chunk.changed = False
            chunk_index += 1
            yield chunk_index, chunk_count
            if not chunk_index % 10000:
                wrapper.save()
                wrapper.unload()

        self.history_manager.mark_saved()
        log.info(f"Saving changes to level {wrapper.path}")
        wrapper.save()
        log.info(f"Finished saving changes to level {wrapper.path}")

    def close(self):
        """Close the attached level and remove temporary files
        Use changed method to check if there are any changes that should be saved before closing."""
        # TODO: add "unsaved changes" check before exit
        shutil.rmtree(self._temp_directory, ignore_errors=True)
        self.level_wrapper.close()

    def unload(self,
               safe_area: Optional[Tuple[Dimension, int, int, int,
                                         int]] = None):
        """Unload all chunks not in the safe area
        Safe area format: dimension, min chunk X|Z, max chunk X|Z"""
        self._chunks.unload(safe_area)
        self.level_wrapper.unload()

    def unload_unchanged(self):
        """Unload all data that has not been marked as changed."""
        self._chunks.unload_unchanged()

    def create_chunk(self, cx: int, cz: int, dimension: Dimension) -> Chunk:
        chunk = Chunk(cx, cz)
        self.put_chunk(chunk, dimension)
        return chunk

    def put_chunk(self, chunk: Chunk, dimension: Dimension):
        """Add a chunk to the universal level database"""
        self._chunks.put_chunk(chunk, dimension)

    def delete_chunk(self, cx: int, cz: int, dimension: Dimension):
        """Delete a chunk from the universal level database"""
        self._chunks.delete_chunk(dimension, cx, cz)

    def extract_structure(
            self, selection: SelectionGroup,
            dimension: Dimension) -> amulet.api.level.ImmutableStructure:
        """Extract the area in the SelectionGroup from the level as a new structure"""
        return amulet.api.level.ImmutableStructure.from_level(
            self, selection, dimension)

    def extract_structure_iter(
        self, selection: SelectionGroup, dimension: Dimension
    ) -> Generator[float, None, amulet.api.level.ImmutableStructure]:
        """Extract the area in the SelectionGroup from the level as a new structure"""
        level = yield from amulet.api.level.ImmutableStructure.from_level_iter(
            self, selection, dimension)
        return level

    def paste(
        self,
        src_structure: "BaseLevel",
        src_dimension: Dimension,
        src_selection: SelectionGroup,
        dst_dimension: Dimension,
        location: BlockCoordinates,
        scale: FloatTriplet = (1.0, 1.0, 1.0),
        rotation: FloatTriplet = (0.0, 0.0, 0.0),
        include_blocks: bool = True,
        include_entities: bool = True,
        skip_blocks: Tuple[Block, ...] = (),
        copy_chunk_not_exist: bool = False,
    ):
        """Paste a structure into this structure at the given location.
        Note this command may change in the future.
        :param src_structure: The structure to paste into this structure.
        :param src_dimension: The dimension of the source structure to copy from.
        :param src_selection: The selection to copy from the source structure.
        :param dst_dimension: The dimension to paste the structure into.
        :param location: The location where the centre of the structure will be in the level
        :param scale: The scale in the x, y and z axis. These can be negative to mirror.
        :param rotation: The rotation in degrees around each of the axis.
        :param include_blocks: Include blocks when pasting the structure.
        :param include_entities: Include entities when pasting the structure.
        :param skip_blocks: If a block matches a block in this list it will not be copied.
        :param copy_chunk_not_exist: If a chunk does not exist in the source should it be copied over as air. Always False where level is a World.
        :return:
        """
        return generator_unpacker(
            self.paste_iter(
                src_structure,
                src_dimension,
                src_selection,
                dst_dimension,
                location,
                scale,
                rotation,
                include_blocks,
                include_entities,
                skip_blocks,
                copy_chunk_not_exist,
            ))

    def paste_iter(
        self,
        src_structure: "BaseLevel",
        src_dimension: Dimension,
        src_selection: SelectionGroup,
        dst_dimension: Dimension,
        location: BlockCoordinates,
        scale: FloatTriplet = (1.0, 1.0, 1.0),
        rotation: FloatTriplet = (0.0, 0.0, 0.0),
        include_blocks: bool = True,
        include_entities: bool = True,
        skip_blocks: Tuple[Block, ...] = (),
        copy_chunk_not_exist: bool = False,
    ) -> Generator[float, None, None]:
        """Paste a structure into this structure at the given location.
        Note this command may change in the future.
        :param src_structure: The structure to paste into this structure.
        :param src_dimension: The dimension of the source structure to copy from.
        :param src_selection: The selection to copy from the source structure.
        :param dst_dimension: The dimension to paste the structure into.
        :param location: The location where the centre of the structure will be in the level
        :param scale: The scale in the x, y and z axis. These can be negative to mirror.
        :param rotation: The rotation in degrees around each of the axis.
        :param include_blocks: Include blocks when pasting the structure.
        :param include_entities: Include entities when pasting the structure.
        :param skip_blocks: If a block matches a block in this list it will not be copied.
        :param copy_chunk_not_exist: If a chunk does not exist in the source should it be copied over as air. Always False where level is a World.
        :return: A generator of floats from 0 to 1 with the progress of the paste operation.
        """
        yield from clone(
            src_structure,
            src_dimension,
            src_selection,
            self,
            dst_dimension,
            self.selection_bounds,
            location,
            scale,
            rotation,
            include_blocks,
            include_entities,
            skip_blocks,
            copy_chunk_not_exist,
        )

    def get_version_block(
        self,
        x: int,
        y: int,
        z: int,
        dimension: Dimension,
        version: VersionIdentifierType,
    ) -> Union[Tuple[Block, BlockEntity], Tuple[Entity, None]]:
        """
        Get a block at the specified location and convert it to the format of the version specified
        Note the odd return format. In most cases this will return (Block, None) or (Block, BlockEntity)
        but in select cases like item frames may return (Entity, None)
        :param x: The X coordinate of the desired block
        :param y: The Y coordinate of the desired block
        :param z: The Z coordinate of the desired block
        :param dimension: The dimension of the desired block
        :param version: The version to get the block converted to.
        :return: The block at the given location converted to the `version` format. Note the odd return format.
        :raise: Raises ChunkDoesNotExist or ChunkLoadError if the chunk was not loaded.
        """
        cx, cz = block_coords_to_chunk_coords(
            x, z, sub_chunk_size=self.sub_chunk_size)
        chunk = self.get_chunk(cx, cz, dimension)
        offset_x, offset_z = x - 16 * cx, z - 16 * cz

        output, extra_output, _ = self.translation_manager.get_version(
            *version).block.from_universal(
                chunk.get_block(offset_x, y, offset_z),
                chunk.block_entities.get((x, y, z)))
        return output, extra_output

    def set_version_block(
        self,
        x: int,
        y: int,
        z: int,
        dimension: Dimension,
        version: VersionIdentifierType,
        block: Block,
        block_entity: BlockEntity = None,
    ):
        """
        Convert the block and block_entity from the given version format to the universal format and set at the location
        :param x: The X coordinate of the desired block
        :param y: The Y coordinate of the desired block
        :param z: The Z coordinate of the desired block
        :param dimension: The dimension of the desired block
        :param version: The version to get the block converted from.
        :param block:
        :param block_entity:
        :return: The block at the given location converted to the `version` format. Note the odd return format.
        :raise: Raises ChunkLoadError if the chunk was not loaded correctly.
        """
        cx, cz = block_coords_to_chunk_coords(
            x, z, sub_chunk_size=self.sub_chunk_size)
        try:
            chunk = self.get_chunk(cx, cz, dimension)
        except ChunkDoesNotExist:
            chunk = self.create_chunk(cx, cz, dimension)
        offset_x, offset_z = x - 16 * cx, z - 16 * cz

        (
            universal_block,
            universal_block_entity,
            _,
        ) = self.translation_manager.get_version(*version).block.to_universal(
            block, block_entity)
        chunk.set_block(offset_x, y, offset_z, universal_block),
        if isinstance(universal_block_entity, BlockEntity):
            chunk.block_entities[(x, y, z)] = universal_block_entity
        elif (x, y, z) in chunk.block_entities:
            del chunk.block_entities[(x, y, z)]

    # def get_entities_in_box(
    #     self, box: "SelectionGroup"
    # ) -> Generator[Tuple[Coordinates, List[object]], None, None]:
    #     # TODO: some of this logic can probably be moved the chunk class and have this method call that
    #     # TODO: update this to use the newer entity API
    #     out_of_place_entities = []
    #     entity_map: Dict[Tuple[int, int], List[List[object]]] = {}
    #     for chunk, subbox in self.get_chunk_boxes(box):
    #         entities = chunk.entities
    #         in_box = list(filter(lambda e: e.location in subbox, entities))
    #         not_in_box = filter(lambda e: e.location not in subbox, entities)
    #
    #         in_box_copy = deepcopy(in_box)
    #
    #         entity_map[chunk.coordinates] = [
    #             not_in_box,
    #             in_box,
    #         ]  # First index is the list of entities not in the box, the second is for ones that are
    #
    #         yield chunk.coordinates, in_box_copy
    #
    #         if (
    #             in_box != in_box_copy
    #         ):  # If an entity has been changed, update the dictionary entry
    #             entity_map[chunk.coordinates][1] = in_box_copy
    #         else:  # Delete the entry otherwise
    #             del entity_map[chunk.coordinates]
    #
    #     for chunk_coords, entity_list_list in entity_map.items():
    #         chunk = self.get_chunk(*chunk_coords)
    #         in_place_entities = list(
    #             filter(
    #                 lambda e: chunk_coords
    #                 == entity_position_to_chunk_coordinates(e.location),
    #                 entity_list_list[1],
    #             )
    #         )
    #         out_of_place = filter(
    #             lambda e: chunk_coords
    #             != entity_position_to_chunk_coordinates(e.location),
    #             entity_list_list[1],
    #         )
    #
    #         chunk.entities = in_place_entities + list(entity_list_list[0])
    #
    #         if out_of_place:
    #             out_of_place_entities.extend(out_of_place)
    #
    #     if out_of_place_entities:
    #         self.add_entities(out_of_place_entities)
    #
    # def add_entities(self, entities):
    #     proper_entity_chunks = map(
    #         lambda e: (entity_position_to_chunk_coordinates(e.location), e,), entities,
    #     )
    #     accumulated_entities: Dict[Tuple[int, int], List[object]] = {}
    #
    #     for chunk_coord, ent in proper_entity_chunks:
    #         if chunk_coord in accumulated_entities:
    #             accumulated_entities[chunk_coord].append(ent)
    #         else:
    #             accumulated_entities[chunk_coord] = [ent]
    #
    #     for chunk_coord, ents in accumulated_entities.items():
    #         chunk = self.get_chunk(*chunk_coord)
    #
    #         chunk.entities += ents
    #
    # def delete_entities(self, entities):
    #     chunk_entity_pairs = map(
    #         lambda e: (entity_position_to_chunk_coordinates(e.location), e,), entities,
    #     )
    #
    #     for chunk_coord, ent in chunk_entity_pairs:
    #         chunk = self.get_chunk(*chunk_coord)
    #         entities = chunk.entities
    #         entities.remove(ent)
    #         chunk.entities = entities

    def run_operation(self,
                      operation: OperationType,
                      dimension: Dimension,
                      *args,
                      create_undo=True) -> Any:
        try:
            out = operation(self, dimension, *args)
            if inspect.isgenerator(out):
                out: Generator
                out = generator_unpacker(out)
        except Exception as e:
            self.restore_last_undo_point()
            raise e
        if create_undo:
            self.create_undo_point()
        return out

    @property
    def history_manager(self) -> MetaHistoryManager:
        """The class that manages undoing and redoing changes."""
        return self._history_manager

    def create_undo_point(self):
        """Create a restore point for all chunks that have changed."""
        self.history_manager.create_undo_point()

    @property
    def changed(self) -> bool:
        """Has any data been modified but not saved to disk"""
        return self.history_manager.changed or self.level_wrapper.changed

    def undo(self):
        """Undoes the last set of changes to the level"""
        self.history_manager.undo()

    def redo(self):
        """Redoes the last set of changes to the level"""
        self.history_manager.redo()

    def restore_last_undo_point(self):
        """Restore the level to the state it was when self.create_undo_point was called.
        If an operation errors there may be modifications made that did not get tracked.
        This will revert those changes."""
        self.history_manager.restore_last_undo_point()