Skip to content

Commit 0d3251b

Browse files
committed
wip: Add pydantic models to represent minecraft-data
1 parent 6bafecc commit 0d3251b

File tree

13 files changed

+711
-4
lines changed

13 files changed

+711
-4
lines changed

minebase/__init__.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
import json
44
from enum import Enum
55
from pathlib import Path
6-
from typing import Any, cast
6+
from typing import Any, Literal, cast, overload
77

88
from minebase.types.common_data import CommonData
99
from minebase.types.data_paths import DataPaths
10+
from minebase.types.mcdata import BedrockMinecraftData, PcMinecraftData
1011

1112
DATA_SUBMODULE_PATH = Path(__file__).parent / "data"
1213
DATA_PATH = DATA_SUBMODULE_PATH / "data"
@@ -60,7 +61,15 @@ def supported_versions(edition: Edition = Edition.PC) -> list[str]:
6061
return list(edition_info.keys())
6162

6263

63-
def load_version(version: str, edition: Edition = Edition.PC) -> dict[str, Any]:
64+
@overload
65+
def load_version(version: str, edition: Literal[Edition.PC] = Edition.PC) -> PcMinecraftData: ...
66+
67+
68+
@overload
69+
def load_version(version: str, edition: Literal[Edition.BEDROCK]) -> BedrockMinecraftData: ...
70+
71+
72+
def load_version(version: str, edition: Edition = Edition.PC) -> PcMinecraftData | BedrockMinecraftData:
6473
"""Load minecraft-data for given `version` and `edition`."""
6574
_validate_data()
6675
version_data = _load_version_manifest(version, edition)
@@ -81,7 +90,10 @@ def load_version(version: str, edition: Edition = Edition.PC) -> dict[str, Any]:
8190
with file.open("rb") as fp:
8291
data[field] = json.load(fp)
8392

84-
return data
93+
if edition is Edition.PC:
94+
return PcMinecraftData.model_validate(data)
95+
96+
return BedrockMinecraftData.model_validate(data)
8597

8698

8799
def load_common_data(edition: Edition = Edition.PC) -> CommonData:

minebase/types/attributes.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from __future__ import annotations
2+
3+
from typing import final
4+
5+
from pydantic import model_validator
6+
7+
from minebase.types.base import MinecraftDataModel
8+
9+
10+
@final
11+
class MinecraftAttributeData(MinecraftDataModel):
12+
"""Minecraft-Data for an attribute.
13+
14+
Attributes:
15+
name: The name of this attribute
16+
resource: The Mojang name of an attribute (usually generic.[name] or minecraft:generic.[name]
17+
min: The minimum value of an attribute
18+
max: The maximum value of an attribute
19+
default: The default value of an attribute
20+
"""
21+
22+
name: str
23+
resource: str
24+
default: float
25+
min: float
26+
max: float
27+
28+
@model_validator(mode="after")
29+
def valid_default(self) -> MinecraftAttributeData:
30+
"""Enforce that the default value is within the expected min-max bounds."""
31+
if self.min <= self.default <= self.max:
32+
return self
33+
34+
raise ValueError("The default value is outside of the min-max bounds")

minebase/types/biomes.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from __future__ import annotations
2+
3+
from typing import Literal, final
4+
5+
from pydantic import Field, model_validator
6+
7+
from minebase.types.base import MinecraftDataModel
8+
9+
10+
@final
11+
class BiomeClimateData(MinecraftDataModel):
12+
"""Minecraft-Data about the climate in a Biome.
13+
14+
This controls the ideal parameter ranges for Minecraft's multi-noise biome generation system.
15+
16+
Attributes:
17+
temperature: Controls hot/cold climate preference (-1.0 to 1.0)
18+
humidity: Controls dry/wet climate preference (-1.0 to 1.0)
19+
altitude: Controls low/high terrain preference (affects hills/valleys)
20+
weirdness: Controls terrain "strangeness" (also known as "ridges", -1.0 to 1.0)
21+
offset: Fine-tuning parameter for biome selection priority/weight
22+
"""
23+
24+
temperature: float = Field(ge=-1, le=1)
25+
humidity: float = Field(ge=-1, le=1)
26+
altitude: Literal[0] # not sure what the constraints here should be, minecraft-data only uses 0
27+
weirdness: float = Field(ge=-1, le=1)
28+
offset: float
29+
30+
31+
@final
32+
class BiomeData(MinecraftDataModel):
33+
"""Minecraft-Data about a Biome.
34+
35+
Attributes:
36+
id: The unique identifier for a biome
37+
name: The name of a biome
38+
category: Category to which this biome belongs to (e.g. "forest", "ocean", ...)
39+
temperature: The base temperature in a biome.
40+
precipitation: The type of precipitation (none, rain or snow) [before 1.19.4]
41+
has_precipitation: True if a biome has any precipitation (rain or snow) [1.19.4+]
42+
dimension: The dimension of a biome: overworld, nether or end (or the_end on bedrock)
43+
display_name: The display name of a biome
44+
color: The color in a biome
45+
rainfall: How much rain there is in a biome [before 1.19.4]
46+
depth: Depth corresponds approximately to the terrain height.
47+
climates: Climate data for the biome
48+
name_legacy: Legacy name of the biome used in older versions.
49+
parent: The name of the parent biome
50+
child: ID of a variant biome
51+
"""
52+
53+
id: int
54+
name: str
55+
category: str
56+
temperature: float = Field(ge=-1, le=2)
57+
precipitation: Literal["none", "rain", "snow"] | None = None
58+
# For some reason, this field actually uses snake_case, not camelCase
59+
has_precipitation: bool | None = Field(alias="has_precipitation", default=None)
60+
dimension: Literal["overworld", "nether", "end", "the_end"]
61+
display_name: str
62+
color: int
63+
rainfall: float | None = Field(ge=0, le=1, default=None)
64+
depth: float | None = Field(default=None)
65+
climates: list[BiomeClimateData] | None = Field(min_length=1, default=None)
66+
name_legacy: str | None = Field(alias="name_legacy", default=None) # also uses snake_case for some reason
67+
parent: str | None = None
68+
child: int | None = Field(ge=0, default=None)
69+
70+
@model_validator(mode="before")
71+
@classmethod
72+
def rename_has_percipitation(cls, data: dict[str, object]) -> dict[str, object]:
73+
"""Rename the typo field has_percipitation to has_precipitation.
74+
75+
This is a mistake in the minecraft-data dataset which is only present for a single
76+
minecraft version (bedrock 1.21.60), this function renames it back to standardize
77+
our data models.
78+
79+
This will get addressed with: https://github.com/PrismarineJS/minecraft-data/issues/1048
80+
after which this method can be removed.
81+
"""
82+
if "has_percipitation" not in data:
83+
return data
84+
85+
if "has_precipitation" in data:
86+
raise ValueError("Found biome with both has_percipitation and has_precipitation fields")
87+
88+
data["has_precipitation"] = data.pop("has_percipitation")
89+
return data
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Sequence
4+
from typing import final
5+
6+
from pydantic import Field, field_validator, model_validator
7+
8+
from minebase.types.base import MinecraftDataModel
9+
10+
11+
@final
12+
class CollisionBoxData(MinecraftDataModel):
13+
"""Minecraft-Data for a collision shape.
14+
15+
Note that the meaning of the 6 numbers in this collision box is unclear, with the minecraft-data JSON schema
16+
only stating that it means: "The min/max x/y/z corner coordinates of this box.", For this reason, this model
17+
might very well be incorrect in interpreting it as (min_x, min_y, min_z, max_x, max_y, max_z).
18+
19+
A request for clarification was submitted, this model should be updated once it is answered:
20+
https://github.com/PrismarineJS/minecraft-data/issues/1054
21+
"""
22+
23+
min_x: float = Field(ge=-0.25, le=1.5)
24+
min_y: float = Field(ge=-0.25, le=1.5)
25+
min_z: float = Field(ge=-0.25, le=1.5)
26+
max_x: float = Field(ge=-0.25, le=1.5)
27+
max_y: float = Field(ge=-0.25, le=1.5)
28+
max_z: float = Field(ge=-0.25, le=1.5)
29+
30+
@model_validator(mode="before")
31+
@classmethod
32+
def from_sequence(cls, value: object) -> dict[str, object]:
33+
"""Ensure the value is a 6 element tuple, then convert it into a dict for pydantic."""
34+
if not isinstance(value, Sequence):
35+
raise TypeError(f"CollisionBoxData must be initialized from a sequence, got {type(value).__qualname__}")
36+
37+
if len(value) != 6:
38+
raise ValueError("CollisionBoxData must have exactly 6 elements")
39+
40+
# Use camelCase here since MinecraftDataModel expects it
41+
return {
42+
"minX": value[0],
43+
"minY": value[1],
44+
"minZ": value[2],
45+
"maxX": value[3],
46+
"maxY": value[4],
47+
"maxZ": value[5],
48+
}
49+
50+
@model_validator(mode="after")
51+
def validate_min_less_than_max(self) -> CollisionBoxData:
52+
"""Validate that the min coordinate is always smaller (or equal) than the corresponding max coordinate."""
53+
try:
54+
if self.min_x > self.max_x:
55+
raise ValueError(f"min_x ({self.min_x}) must be less than max_x ({self.max_x})") # noqa: TRY301
56+
if self.min_y > self.max_y:
57+
raise ValueError(f"min_y ({self.min_y}) must be less than max_y ({self.max_y})") # noqa: TRY301
58+
if self.min_z > self.max_z:
59+
raise ValueError(f"min_z ({self.min_z}) must be less than max_z ({self.max_z})") # noqa: TRY301
60+
except ValueError:
61+
# This is stupid, I'm aware, the above is essentially dead code
62+
# The reason for this is that the above is currently failing, likely
63+
# due to misunderstanding in what the 6 numbers in this data mean.
64+
# Once that will become known, this function should be re-enabled.
65+
# See:
66+
# https://github.com/PrismarineJS/minecraft-data/issues/1054
67+
return self
68+
69+
return self
70+
71+
72+
@final
73+
class BlockCollisionShapeData(MinecraftDataModel):
74+
"""Minecraft-Data for a block collision model.
75+
76+
blocks:
77+
Mapping of block name -> collision shape ID(s).
78+
79+
The value can either be a single number: collision ID shared by all block states of this block,
80+
or a list of numbers: Shape IDs of each block state of this block.
81+
82+
shapes: Collision shapes by ID, each shape being composed of a list of collision boxes.
83+
"""
84+
85+
blocks: dict[str, int | list[int]]
86+
shapes: dict[int, list[CollisionBoxData]]
87+
88+
@field_validator("blocks")
89+
@classmethod
90+
def validate_block_ids(cls, v: dict[str, int | list[int]]) -> dict[str, int | list[int]]:
91+
"""Ensure all specified shape IDs for the blocks are non-negative."""
92+
for key, val in v.items():
93+
if isinstance(val, int):
94+
if val < 0:
95+
raise ValueError(f"Got an unexpected collision ID value for block {key!r} (must be at least 0)")
96+
continue
97+
98+
for idx, collision_id in enumerate(val):
99+
if collision_id < 0:
100+
raise ValueError(
101+
f"Got an unexpected collision ID value for block {key!r}. ID #{idx} must be at least 0",
102+
)
103+
104+
return v
105+
106+
@field_validator("shapes")
107+
@classmethod
108+
def validate_shape_ids(cls, v: dict[int, list[CollisionBoxData]]) -> dict[int, list[CollisionBoxData]]:
109+
"""Ensure all shape IDs are non-negative."""
110+
for key in v:
111+
if key < 0:
112+
raise ValueError(f"Collision IDs can't be negative (but found: {key})")
113+
return v
114+
115+
@model_validator(mode="after")
116+
def validate_shape_references(self) -> BlockCollisionShapeData:
117+
"""Validate that all collision IDs specified for blocks have corresponding shape(s)."""
118+
for block_name, shape_ids in self.blocks.items():
119+
if isinstance(shape_ids, int):
120+
shape_ids = [shape_ids] # noqa: PLW2901
121+
122+
for shape_id in shape_ids:
123+
if shape_id not in self.shapes:
124+
raise ValueError(
125+
f"Block {block_name!r} has a collision shape ID {shape_id}, without corresponding shape data",
126+
)
127+
128+
return self
129+
130+
def shape_for(self, block_name: str) -> list[CollisionBoxData] | list[list[CollisionBoxData]]:
131+
"""Get the collision shape for given block name.
132+
133+
This is a convenience helper-function to skip having to look up the collision shape ID(s)
134+
for the block and then having to look up the corresponding collision shape(s) based on that.
135+
136+
Return:
137+
Either a:
138+
139+
- List of collision shape IDs (for all block states)
140+
- List of lists of collision shape IDs (representing different collision shapes for different block states)
141+
"""
142+
shape_ids = self.blocks[block_name]
143+
if isinstance(shape_ids, int):
144+
return self.shapes[shape_ids]
145+
146+
return [self.shapes[shape_id] for shape_id in shape_ids]

minebase/types/enchantments.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from __future__ import annotations
2+
3+
from typing import final
4+
5+
from pydantic import Field, field_validator
6+
7+
from minebase.types.base import MinecraftDataModel
8+
9+
10+
@final
11+
class EnchantmentCostCoefficients(MinecraftDataModel):
12+
"""Minecraft-Data for the cost equation's coefficients a * level + b."""
13+
14+
a: int
15+
b: int
16+
17+
18+
@final
19+
class EnchantmentData(MinecraftDataModel):
20+
"""Minecraft-Data for an enchantment.
21+
22+
Attributes:
23+
id: The unique identifier for an enchantment
24+
name: The name of an enchantment (guaranteed unique)
25+
display_name: The name of an enchantment, as displayed in the GUI
26+
max_level: Max cost equation's coefficients a * level + b.
27+
min_level: Min cost equation's coefficients a * level + b.
28+
category: The category of enchantable items
29+
weight: Weight of the rarity of the enchantment
30+
treasure_only: Can only be found in a treasure, not created
31+
curse: Is a curse, not an enchantment
32+
tradable: Can this enchantment be traded
33+
discoverable: Can this enchantment be discovered
34+
exclude: List on enchantments (names) not compatible
35+
"""
36+
37+
id: int = Field(ge=0)
38+
name: str
39+
display_name: str
40+
max_level: int = Field(ge=1, le=5)
41+
min_cost: EnchantmentCostCoefficients
42+
max_cost: EnchantmentCostCoefficients
43+
category: str
44+
weight: int = Field(ge=1, le=10)
45+
treasure_only: bool
46+
curse: bool
47+
tradeable: bool
48+
discoverable: bool
49+
exclude: list[str] | None = None
50+
51+
@field_validator("exclude")
52+
@classmethod
53+
def ensure_unique_items(cls, v: list[str]) -> list[str]:
54+
if len(v) != len(set(v)):
55+
raise ValueError("List items must be unique")
56+
return v

0 commit comments

Comments
 (0)