Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/23.breaking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The `load_common_data` and `load_version` functions now returns custom pydantic objects instead of `dict`. This means support for `__getitem__` based accessing of the data (e.g. `load_common_data()["legacy"]["blocks"]`) will no longer work, in favor of attribute-based accessing (e.g. `load_common_data().legacy.blocks`). Additionally, to make sure we follow python's best practices, any camelCase attributes were converted into snake_case.
74 changes: 56 additions & 18 deletions minebase/__init__.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
from __future__ import annotations

import json
from enum import Enum
from pathlib import Path
from typing import Any, cast
from typing import Any, Literal, cast, overload

from minebase.edition import Edition as Edition # re-export
from minebase.types._base import MinecraftValidationContext
from minebase.types.common_data import CommonData
from minebase.types.data_paths import DataPaths
from minebase.types.mcdata import BedrockMinecraftData, PcMinecraftData

DATA_SUBMODULE_PATH = Path(__file__).parent / "data"
DATA_PATH = DATA_SUBMODULE_PATH / "data"


class Edition(Enum):
"""Available minecraft-data editions."""

PC = "pc"
BEDROCK = "bedrock"


def _validate_data() -> None:
"""Validate that the minecraft-data submodule is present."""
if not DATA_SUBMODULE_PATH.is_dir():
Expand All @@ -35,13 +33,15 @@ def _load_data_paths() -> DataPaths:
raise ValueError(f"minecraft-data submodule didn't contain data paths manifest (missing {file})")

with file.open("rb") as fp:
return cast("DataPaths", json.load(fp))
data = cast("DataPaths", json.load(fp))

return DataPaths.model_validate(data)

def _load_version_manifest(version: str, edition: Edition = Edition.PC) -> "dict[str, str]":

def _load_version_manifest(version: str, edition: Edition = Edition.PC) -> dict[str, str]:
"""Load the data paths manifest for given version (if it exists)."""
manifest = _load_data_paths()
edition_info = manifest[edition.value]
edition_info = manifest.pc if edition is Edition.PC else manifest.bedrock
try:
return edition_info[version]
except KeyError as exc:
Expand All @@ -50,12 +50,45 @@ def _load_version_manifest(version: str, edition: Edition = Edition.PC) -> "dict

def supported_versions(edition: Edition = Edition.PC) -> list[str]:
"""Get a list of all supported minecraft versions."""
# We prefer versions from common data, as they're in a list, guaranteed to be
# ordered as they were released
data = load_common_data(edition)
versions = data.versions

# This is just for a sanity check
manifest = _load_data_paths()
edition_info = manifest[edition.value]
return list(edition_info.keys())
edition_info = getattr(manifest, edition.value)
manifest_versions = set(edition_info.keys())

# These versions are present in the PC edition manifest, but aren't in the common data versions.
# I have no idea why, they're perfectly loadable. We can't just naively insert them as we want
# the versions list to be ordered. For now, as a hack, we remove these to pass the check below,
# trying to load these would work, but they won't be listed as supported from this function.
# TODO: Remove this once upstream addresses this issue
# https://github.com/PrismarineJS/minecraft-data/issues/1064
if edition is Edition.PC:
manifest_versions.remove("1.16.5")
manifest_versions.remove("1.21")
manifest_versions.remove("1.21.6")

if set(versions) != set(manifest_versions) or len(versions) != len(manifest_versions):
raise ValueError(
f"Data integrity error: common versions don't match manifest versions: "
f"{versions=} != {manifest_versions=}",
)

return versions

def load_version(version: str, edition: Edition = Edition.PC) -> dict[str, Any]:

@overload
def load_version(version: str, edition: Literal[Edition.PC] = Edition.PC) -> PcMinecraftData: ...


@overload
def load_version(version: str, edition: Literal[Edition.BEDROCK]) -> BedrockMinecraftData: ...


def load_version(version: str, edition: Edition = Edition.PC) -> PcMinecraftData | BedrockMinecraftData:
"""Load minecraft-data for given `version` and `edition`."""
_validate_data()
version_data = _load_version_manifest(version, edition)
Expand All @@ -76,10 +109,15 @@ def load_version(version: str, edition: Edition = Edition.PC) -> dict[str, Any]:
with file.open("rb") as fp:
data[field] = json.load(fp)

return data
validation_context = MinecraftValidationContext(version=version, edition=edition, versions=supported_versions())

if edition is Edition.PC:
return PcMinecraftData.model_validate(data, context=validation_context)

return BedrockMinecraftData.model_validate(data, context=validation_context)


def load_common_data(edition: Edition = Edition.PC) -> dict[str, Any]:
def load_common_data(edition: Edition = Edition.PC) -> CommonData:
"""Load the common data from minecraft-data for given `edition`."""
_validate_data()
common_dir = DATA_PATH / edition.value / "common"
Expand All @@ -94,4 +132,4 @@ def load_common_data(edition: Edition = Edition.PC) -> dict[str, Any]:
with file.open("rb") as fp:
data[file.stem] = json.load(fp)

return data
return CommonData.model_validate(data)
8 changes: 8 additions & 0 deletions minebase/edition.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from enum import Enum


class Edition(Enum):
"""Available minecraft-data editions."""

PC = "pc"
BEDROCK = "bedrock"
44 changes: 44 additions & 0 deletions minebase/types/_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from __future__ import annotations

from typing import TYPE_CHECKING, TypedDict

from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel

if TYPE_CHECKING:
from minebase.edition import Edition

__all__ = ["MinecraftDataModel", "_merge_base_config"]


class MinecraftValidationContext(TypedDict):
"""Context information used during pydantic validation."""

edition: Edition
version: str
versions: list[str]


class MinecraftDataModel(BaseModel):
"""Base type for a pydantic based class holding Minecraft-Data.

This type is reserved for internal use, and it is not a guaranteed base class
for all minecraft-data models. It is a helper class that includes pre-configured
model config for automatic field conversion from camelCase to snakeCase and to
prevent unexpected extra attributes or class population without using the camelCase
aliases.
"""

model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=False, # only allow population by alias names
from_attributes=True,
extra="forbid",
)


def _merge_base_config(conf: ConfigDict) -> ConfigDict:
"""A function to override specific keys in the pydantic config of the `MinecraftDataModel`."""
new = MinecraftDataModel.model_config.copy()
new.update(conf)
return new
49 changes: 49 additions & 0 deletions minebase/types/attributes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from __future__ import annotations

from typing import final

from pydantic import model_validator

from minebase.types._base import MinecraftDataModel


@final
class MinecraftAttributeData(MinecraftDataModel):
"""Minecraft-Data for an attribute.

Attributes in Minecraft define various character, entity, or item properties
that can influence gameplay mechanics, such as health, attack damage, movement
speed, armor, and more. Each attribute has a defined range, a default value,
and a canonical resource identifier used internally by the game.

Examples of attributes include:
- "minecraft:generic.max_health" with default 20.0, min 1.0, max 1024.0
- "minecraft:attack_damage" with default 2.0, min 0.0, max 2048.0
- "minecraft:generic.armor_toughness" with default 0.0, min 0.0, max 20.0
- "minecraft:movement_speed" with default 0.7, min 0.0, max 1024.0

Attributes:
name:
The common or friendly name of the attribute (e.g., "attackDamage", "maxHealth").
resource:
The official Mojang resource name identifying the attribute in data files and the game code.

Typically formatted as `minecraft:generic.[name]` or `generic.[name]` for legacy identifiers.
default: The default value for this attribute
min: The minimum permissible value for this attribute. Enforced by the game.
max: The maximum permissible value for this attribute. Enforced by the game.
"""

name: str
resource: str
default: float
min: float
max: float

@model_validator(mode="after")
def valid_default(self) -> MinecraftAttributeData:
"""Enforce that the default value is within the expected min-max bounds."""
if self.min <= self.default <= self.max:
return self

raise ValueError("The default value is outside of the min-max bounds")
89 changes: 89 additions & 0 deletions minebase/types/biomes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from __future__ import annotations

from typing import Literal, final

from pydantic import Field, model_validator

from minebase.types._base import MinecraftDataModel


@final
class BiomeClimateData(MinecraftDataModel):
"""Minecraft-Data about the climate in a Biome.

This controls the ideal parameter ranges for Minecraft's multi-noise biome generation system.

Attributes:
temperature: Controls hot/cold climate preference (-1.0 to 1.0)
humidity: Controls dry/wet climate preference (-1.0 to 1.0)
altitude: Controls low/high terrain preference (affects hills/valleys)
weirdness: Controls terrain "strangeness" (also known as "ridges", -1.0 to 1.0)
offset: Fine-tuning parameter for biome selection priority/weight
"""

temperature: float = Field(ge=-1, le=1)
humidity: float = Field(ge=-1, le=1)
altitude: Literal[0] # not sure what the constraints here should be, minecraft-data only uses 0
weirdness: float = Field(ge=-1, le=1)
offset: float


@final
class BiomeData(MinecraftDataModel):
"""Minecraft-Data about a Biome.

Attributes:
id: The unique identifier for a biome
name: The name of a biome
category: Category to which this biome belongs to (e.g. "forest", "ocean", ...)
temperature: The base temperature in a biome.
precipitation: The type of precipitation (none, rain or snow) [before 1.19.4]
has_precipitation: True if a biome has any precipitation (rain or snow) [1.19.4+]
dimension: The dimension of a biome: overworld, nether or end (or the_end on bedrock)
display_name: The display name of a biome
color: The color in a biome
rainfall: How much rain there is in a biome [before 1.19.4]
depth: Depth corresponds approximately to the terrain height.
climates: Climate data for the biome
name_legacy: Legacy name of the biome used in older versions.
parent: The name of the parent biome
child: ID of a variant biome
"""

id: int
name: str
category: str
temperature: float = Field(ge=-1, le=2)
precipitation: Literal["none", "rain", "snow"] | None = None
# For some reason, this field actually uses snake_case, not camelCase
has_precipitation: bool | None = Field(alias="has_precipitation", default=None)
dimension: Literal["overworld", "nether", "end", "the_end"]
display_name: str
color: int
rainfall: float | None = Field(ge=0, le=1, default=None)
depth: float | None = Field(default=None)
climates: list[BiomeClimateData] | None = Field(min_length=1, default=None)
name_legacy: str | None = Field(alias="name_legacy", default=None) # also uses snake_case for some reason
parent: str | None = None
child: int | None = Field(ge=0, default=None)

@model_validator(mode="before")
@classmethod
def rename_has_percipitation(cls, data: dict[str, object]) -> dict[str, object]:
"""Rename the typo field has_percipitation to has_precipitation.

This is a mistake in the minecraft-data dataset which is only present for a single
minecraft version (bedrock 1.21.60), this function renames it back to standardize
our data models.

This will get addressed with: https://github.com/PrismarineJS/minecraft-data/issues/1048
after which this method can be removed.
"""
if "has_percipitation" not in data:
return data

if "has_precipitation" in data:
raise ValueError("Found biome with both has_percipitation and has_precipitation fields")

data["has_precipitation"] = data.pop("has_percipitation")
return data
Loading
Loading