# fabricpy/modconfig.py
"""
Generates a ready-to-build Fabric mod project on disk.
* clones (or re-uses) the Fabric example-mod template repository
* rewrites fabric.mod.json with your metadata
* generates Java for:
– items & food items
– **custom ItemGroups** (creative-inventory tabs)
– blocks (with BlockItems)
* patches ExampleMod.java so those registries run at game-init
* copies textures & writes model / blockstate JSON files
* writes / merges language (en_us.json) entries for items, blocks & tabs
* **NEW:** writes any Recipe JSON attached to an Item, FoodItem or Block
* **NEW:** supports `build()` to produce the mod JAR, and `run()` to launch
a development client via Gradle.
Tested against **Minecraft 1.21.11 + Fabric-API 0.141.3**.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
from collections import defaultdict
from typing import Dict, List, Set
from .block import _normalize_hook
from .fooditem import FoodItem
from .itemgroup import ItemGroup
from .loottable import LootTable
from .recipejson import RecipeJson
from .toolitem import ToolItem
# --------------------------------------------------------------------- #
# ModConfig #
# --------------------------------------------------------------------- #
[docs]
class ModConfig:
"""Main configuration class for generating Fabric mod projects.
This class handles the entire process of creating a Minecraft Fabric mod from
Python definitions. It manages mod metadata, item/block registration, Java code
generation, texture processing, and project compilation.
The class supports:
- Cloning and configuring the Fabric example-mod template
- Registering items, food items, and blocks with custom properties
- Generating Java source files for all registered components
- Processing textures and generating model/blockstate JSON files
- Creating recipe JSON files from RecipeJson objects
- Setting up Fabric testing framework with unit and game tests
- Building and running the mod in development mode
Attributes:
mod_id (str): Unique identifier for the mod.
name (str): Display name of the mod.
description (str): Description of what the mod does.
version (str): Version string for the mod.
authors (List[str]): List of mod authors.
project_dir (str): Directory where the mod project will be generated.
template_repo (str): Git repository URL for the Fabric template.
enable_testing (bool): Whether to set up Fabric testing framework.
generate_unit_tests (bool): Whether to generate unit tests.
generate_game_tests (bool): Whether to generate game tests.
registered_items (List): List of registered Item and FoodItem objects.
registered_blocks (List): List of registered Block objects.
Example:
Basic mod setup::
mod = ModConfig(
mod_id="mymod",
name="My Awesome Mod",
version="1.0.0",
description="Adds cool items to Minecraft",
authors=["Your Name"]
)
# Register an item
item = Item(id="mymod:cool_item", name="Cool Item")
mod.registerItem(item)
# Compile and run
mod.compile()
mod.run()
"""
# ------------------------------------------------------------------ #
# constructor / registration #
# ------------------------------------------------------------------ #
[docs]
def __init__(
self,
mod_id: str,
name: str,
description: str,
version: str,
authors: List[str] | tuple[str, ...],
project_dir: str = "my-fabric-mod",
template_repo: str = "https://github.com/FabricMC/fabric-example-mod.git",
enable_testing: bool = True, # NEW: Enable Fabric testing integration
generate_unit_tests: bool = True, # NEW: Generate unit tests
generate_game_tests: bool = False, # NEW: Generate game tests
):
"""Initialize a new ModConfig instance.
Args:
mod_id (str): Unique identifier for the mod. Must be valid for Minecraft
namespaces (lowercase, no spaces, alphanumeric + underscore/hyphen).
name (str): Human-readable display name for the mod.
description (str): Brief description of what the mod does.
version (str): Version string for the mod (e.g., "1.0.0").
authors (List[str] | tuple[str, ...]): List or tuple of author names.
project_dir (str, optional): Directory name for the generated mod project.
Defaults to "my-fabric-mod".
template_repo (str, optional): Git repository URL for the Fabric template.
Defaults to the official Fabric example-mod repository.
enable_testing (bool, optional): Whether to set up Fabric testing framework.
Defaults to True.
generate_unit_tests (bool, optional): Whether to generate unit tests for
registered items and blocks. Defaults to True.
generate_game_tests (bool, optional): Whether to generate Fabric game tests
that run in a Minecraft environment. Defaults to False to prevent
build compilation issues.
Example:
Creating a mod configuration::
config = ModConfig(
mod_id="awesome_mod",
name="Awesome Mod",
description="Makes Minecraft more awesome",
version="1.2.3",
authors=["Alice", "Bob"],
project_dir="my-awesome-mod"
)
"""
self.mod_id = mod_id
# Java identifiers cannot contain hyphens; sanitise for package paths.
self._java_mod_id = re.sub(r"[^a-z0-9_]", "_", mod_id)
self.name = name
self.description = description
self.version = version
self.authors = list(authors)
self.project_dir = project_dir
self.template_repo = template_repo
self.enable_testing = enable_testing
self.generate_unit_tests = generate_unit_tests
self.generate_game_tests = generate_game_tests
self.registered_items: List = [] # Item or FoodItem
self.registered_blocks: List = [] # Block
self.registered_loot_tables: Dict[str, "LootTable"] = {} # name → LootTable
# public helpers --------------------------------------------------- #
[docs]
def registerItem(self, item): # noqa: N802
"""Register an Item instance with this mod.
Args:
item (Item): The Item instance to register. This can be a basic Item
or any subclass such as FoodItem.
Example:
Registering a basic item::
item = Item(id="mymod:stone_sword", name="Stone Sword")
mod.registerItem(item)
"""
self.registered_items.append(item)
[docs]
def registerFoodItem(self, food_item: FoodItem): # noqa: N802
"""Register a FoodItem instance with this mod.
Args:
food_item (FoodItem): The FoodItem instance to register. This is a
convenience method that's equivalent to registerItem() for food items.
Example:
Registering a food item::
apple = FoodItem(
id="mymod:golden_apple",
name="Golden Apple",
nutrition=6,
saturation=0.8
)
mod.registerFoodItem(apple)
"""
self.registered_items.append(food_item)
[docs]
def registerBlock(self, block): # noqa: N802
"""Register a Block instance with this mod.
Args:
block (Block): The Block instance to register. This will generate both
the block itself and its corresponding BlockItem.
Example:
Registering a block::
block = Block(
id="mymod:diamond_block",
name="Diamond Block",
block_texture_path="textures/diamond_block.png"
)
mod.registerBlock(block)
"""
self.registered_blocks.append(block)
[docs]
def registerLootTable(self, name: str, loot_table) -> None: # noqa: N802
"""Register a standalone loot table (entity / chest / custom).
Block loot tables should be attached directly to the Block via its
``loot_table`` attribute. Use this method for loot tables that are
**not** tied to a specific block, such as entity drops, chest loot,
or gameplay loot tables.
Args:
name (str): Filename stem for the loot table (e.g. ``"zombie"``).
The file will be written to
``data/<mod_id>/loot_table/<category>/<name>.json``.
loot_table: A :class:`~fabricpy.loottable.LootTable` instance.
Example:
Registering an entity loot table::
from fabricpy import LootTable, LootPool
lt = LootTable.entity([
LootPool().rolls(1).entry("mymod:fang")
])
mod.registerLootTable("custom_zombie", lt)
"""
self.registered_loot_tables[name] = loot_table
# ------------------------------------------------------------------ #
# helper for creating valid Java identifiers #
# ------------------------------------------------------------------ #
def _to_java_constant(self, id_string: str) -> str:
"""Convert an item/block/group ID to a valid Java constant name.
Replaces invalid characters (like : - .) with underscores and converts to uppercase.
Ensures the result is a valid Java identifier.
Args:
id_string (str): The ID string to convert (e.g., "mymod:example_item").
Returns:
str: A valid Java constant name (e.g., "MYMOD_EXAMPLE_ITEM").
Example:
Converting various ID formats::
config._to_java_constant("mymod:cool_item") # "MYMOD_COOL_ITEM"
config._to_java_constant("my-special.item") # "MY_SPECIAL_ITEM"
config._to_java_constant("123invalid") # "_123INVALID"
"""
# Replace common invalid characters with underscores
valid_name = re.sub(r"[:\-\.\s]+", "_", id_string)
# Remove any remaining non-alphanumeric characters except underscores
valid_name = re.sub(r"[^a-zA-Z0-9_]", "", valid_name)
# Ensure it doesn't start with a digit
if valid_name and valid_name[0].isdigit():
valid_name = "_" + valid_name
return valid_name.upper()
# ------------------------------------------------------------------ #
# main compile routine #
# ------------------------------------------------------------------ #
[docs]
def compile(self):
"""Compile the mod project from registered components.
This is the main method that orchestrates the entire mod generation process:
1. Clones the Fabric example-mod template repository
2. Updates fabric.mod.json with mod metadata
3. Generates Java source files for items and item groups
4. Generates Java source files for blocks (if any)
5. Copies textures and generates model/blockstate JSON files
6. Writes recipe JSON files for items/blocks with recipes
7. Updates language files with item/block/group translations
8. Sets up Fabric testing framework (if enabled)
9. Generates unit and game tests (if enabled)
The generated project will be a complete, buildable Fabric mod.
Raises:
subprocess.CalledProcessError: If git clone fails.
FileNotFoundError: If the fabric.mod.json template file is missing.
OSError: If there are issues with file I/O operations.
Example:
Compiling a mod::
mod.registerItem(Item(id="mymod:test", name="Test"))
mod.compile() # Creates complete mod project
Note:
This method must be called before build() or run().
"""
# 1) clone example-mod template ---------------------------------
if not os.path.exists(self.project_dir):
self.clone_repository(self.template_repo, self.project_dir)
else:
print(f"Directory `{self.project_dir}` already exists – skipping clone.")
# 2) patch fabric.mod.json --------------------------------------
meta_path = os.path.join(
self.project_dir, "src", "main", "resources", "fabric.mod.json"
)
self.update_mod_metadata(
meta_path,
{
"id": self.mod_id,
"name": self.name,
"version": self.version,
"description": self.description,
"authors": self.authors,
"depends": {
"fabricloader": ">=0.16.0",
"fabric-api": "*",
"minecraft": ">=1.21 <1.22",
},
},
)
# 3) items / tabs ------------------------------------------------
item_pkg = f"com.example.{self._java_mod_id}.items"
self.create_item_files(self.project_dir, item_pkg)
self.create_item_group_files(self.project_dir, item_pkg)
self.update_mod_initializer(self.project_dir, item_pkg)
self.update_mod_initializer_itemgroups(self.project_dir, item_pkg)
self.copy_texture_and_generate_models(self.project_dir, self.mod_id)
self.update_item_lang_file(self.project_dir, self.mod_id)
self.update_item_group_lang_entries(self.project_dir, self.mod_id)
# 3b) recipe JSONs ----------------------------------------------
self.write_recipe_files(self.project_dir, self.mod_id)
# 4) blocks ------------------------------------------------------
if self.registered_blocks:
block_pkg = f"com.example.{self._java_mod_id}.blocks"
self.create_block_files(self.project_dir, block_pkg)
self.update_mod_initializer_blocks(self.project_dir, block_pkg)
self.copy_block_textures_and_generate_models(self.project_dir, self.mod_id)
self.update_block_lang_file(self.project_dir, self.mod_id)
# 4b) loot-table JSONs -------------------------------------------
self.write_loot_table_files(self.project_dir, self.mod_id)
# 4c) mineable / tool tags ----------------------------------------
if self.registered_blocks:
self.write_block_tags(self.project_dir, self.mod_id)
# 5) Fabric testing integration ---------------------------------
if self.enable_testing:
self.setup_fabric_testing(self.project_dir)
if self.generate_unit_tests:
self.generate_fabric_unit_tests(self.project_dir)
if self.generate_game_tests:
self.generate_fabric_game_tests(self.project_dir)
print("\n🎉 Mod project compilation complete.")
if self.enable_testing:
print("🧪 Fabric testing integration added.")
print(" - Run tests with: ./gradlew test")
if self.generate_game_tests:
print(" - Run game tests with: ./gradlew runGametest")
# ------------------------------------------------------------------ #
# git helper #
# ------------------------------------------------------------------ #
[docs]
def clone_repository(self, repo_url, dst):
"""Clone a Git repository to the specified destination.
Args:
repo_url (str): The URL of the Git repository to clone.
dst (str): The destination directory path where the repository will be cloned.
Raises:
subprocess.CalledProcessError: If the git clone command fails.
Example:
Cloning the Fabric example mod template::
mod.clone_repository(
"https://github.com/FabricMC/fabric-example-mod.git",
"/path/to/my-mod"
)
"""
print(f"Cloning template into `{dst}` …")
subprocess.check_call(["git", "clone", repo_url, dst])
print("Template cloned.\n")
# ------------------------------------------------------------------ #
# fabric.mod.json helper #
# ------------------------------------------------------------------ #
# ------------------------------------------------------------------ #
# NEW – RECIPE FILES #
# ------------------------------------------------------------------ #
[docs]
def write_recipe_files(self, project_dir: str, mod_id: str) -> None:
"""Write recipe JSON files for items and blocks that have recipes.
Scans all registered items and blocks for attached RecipeJson objects and
writes them as JSON files in the mod's data/recipes directory. Each recipe
file is named based on the result item ID.
Args:
project_dir (str): Root directory of the mod project.
mod_id (str): The mod's identifier, used in the data directory path.
Note:
- Only items and blocks with non-None recipe attributes are processed
- Recipe files are written to data/{mod_id}/recipe/
- File names are derived from the recipe's result_id or the item/block ID
- Existing recipe files will be overwritten
Example:
Writing recipes after registering items with recipes::
# Item with recipe
item = Item(
id="mymod:diamond_sword",
name="Diamond Sword",
recipe=RecipeJson({...})
)
mod.registerItem(item)
# This will create the recipe file
mod.write_recipe_files(project_dir, "mymod")
"""
"""Write recipe JSON files for registered items and blocks.
Searches all registered items and blocks for attached RecipeJson objects
and writes them to the mod's recipe data directory. Recipe files are placed
in `data/<mod_id>/recipe/` following Minecraft's data pack structure.
Args:
project_dir (str): The root directory of the mod project.
mod_id (str): The mod's identifier, used for the data path namespace.
Note:
The filename is derived from the recipe's result ID. If the result ID
is namespaced (e.g., "testmod:poison_apple"), only the path part is
used for the filename (e.g., "poison_apple.json").
Example:
Writing recipe files::
# Recipes are automatically written during compile()
# But can be called manually if needed
mod.write_recipe_files("my-mod-project", "mymod")
"""
objs = [
*[i for i in self.registered_items if getattr(i, "recipe", None)],
*[b for b in self.registered_blocks if getattr(b, "recipe", None)],
]
if not objs:
return
base = os.path.join(
project_dir, "src", "main", "resources", "data", mod_id, "recipe"
)
os.makedirs(base, exist_ok=True)
for obj in objs:
r: RecipeJson = obj.recipe # type: ignore[attr-defined]
identifier = r.result_id or obj.id
filename = identifier.split(":", 1)[-1] + ".json"
path = os.path.join(base, filename)
with open(path, "w", encoding="utf-8") as fh:
fh.write(r.text)
print(f" ✔ wrote recipe → {os.path.relpath(path, project_dir)}")
# ------------------------------------------------------------------ #
# loot-table JSON writer #
# ------------------------------------------------------------------ #
[docs]
def write_loot_table_files(self, project_dir: str, mod_id: str) -> None:
"""Write loot-table JSON files for blocks and standalone registrations.
Scans all registered blocks for attached ``loot_table`` attributes **and**
all entries added via :meth:`registerLootTable`, then writes them as JSON
files under ``data/<mod_id>/loot_table/<category>/``.
For blocks the filename is derived from the block ID. For standalone
tables the filename is the *name* passed to :meth:`registerLootTable`.
Args:
project_dir (str): Root directory of the mod project.
mod_id (str): The mod's identifier, used in the data directory path.
Note:
This method is called automatically during :meth:`compile`.
Example:
Writing loot tables manually (usually not needed)::
mod.write_loot_table_files("my-mod-project", "mymod")
"""
# collect (name, LootTable, category) triples
entries: List[tuple] = []
for block in self.registered_blocks:
lt = getattr(block, "loot_table", None)
if lt is None:
# Default to dropping self when no loot table is specified
lt = LootTable.drops_self(block.id)
block_name = block.id.split(":", 1)[-1] if ":" in block.id else block.id
entries.append((block_name, lt))
for name, lt in self.registered_loot_tables.items():
entries.append((name, lt))
if not entries:
return
for name, lt in entries:
category = getattr(lt, "category", "blocks")
base = os.path.join(
project_dir,
"src",
"main",
"resources",
"data",
mod_id,
"loot_table",
category,
)
os.makedirs(base, exist_ok=True)
filename = name + ".json"
path = os.path.join(base, filename)
with open(path, "w", encoding="utf-8") as fh:
fh.write(lt.text)
print(f" ✔ wrote loot table → {os.path.relpath(path, project_dir)}")
# ── block tags (mineable / tool) ──────────────────────────────────── #
# ================================================================== #
# ITEMS & FOOD #
# ================================================================== #
# ---------- Java source generation -------------------------------- #
[docs]
def create_item_files(self, project_dir, package_path):
"""Generate Java source files for item registration and management.
Creates TutorialItems.java and CustomItem.java files in the specified package.
TutorialItems contains registration code for all items, while CustomItem
provides a base class for custom item behavior.
Args:
project_dir (str): Root directory of the mod project.
package_path (str): Java package path for the generated files
(e.g., "com.example.mymod.items").
Note:
Generated files include:
- TutorialItems.java: Static registration for all mod items
- CustomItem.java: Base class for items with custom behavior
Example:
Creating item files::
mod.create_item_files(
"/path/to/mod",
"com.example.mymod.items"
)
"""
"""Generate Java source files for registered items.
Creates the TutorialItems.java and CustomItem.java files containing
the Java code for all registered items. These files handle item
registration, properties, and integration with vanilla item groups.
Args:
project_dir (str): The root directory of the mod project.
package_path (str): The Java package path for the item classes
(e.g., "com.example.mymod.items").
Note:
This method is called automatically during compile() and generates:
- TutorialItems.java: Registry and initialization code for all items
- CustomItem.java: Base custom item class with example behavior
"""
java_src = os.path.join(project_dir, "src", "main", "java")
pkg_dir = os.path.join(java_src, *package_path.split("."))
os.makedirs(pkg_dir, exist_ok=True)
with open(
os.path.join(pkg_dir, "TutorialItems.java"), "w", encoding="utf-8"
) as fh:
fh.write(self._tutorial_items_src(package_path))
with open(
os.path.join(pkg_dir, "CustomItem.java"), "w", encoding="utf-8"
) as fh:
fh.write(self._custom_item_src(package_path))
# Generate CustomToolItem.java if any ToolItem is registered
if any(isinstance(i, ToolItem) for i in self.registered_items):
with open(
os.path.join(pkg_dir, "CustomToolItem.java"), "w", encoding="utf-8"
) as fh:
fh.write(self._custom_tool_item_src(package_path))
def _tutorial_items_src(self, pkg: str) -> str:
"""Generate Java source code for the TutorialItems class.
Creates a complete Java class that registers all mod items, including
proper imports, constant declarations, registration logic, and vanilla
item group integration.
Args:
pkg (str): The Java package name for the generated class.
Returns:
str: Complete Java source code for the TutorialItems class.
Example:
Creating item files::
mod.create_item_files(
"/path/to/mod",
"com.example.mymod.items"
)
"""
has_food = any(isinstance(i, FoodItem) for i in self.registered_items)
has_tool = any(isinstance(i, ToolItem) for i in self.registered_items)
has_vanila = any(
isinstance(getattr(i, "item_group", None), str)
for i in self.registered_items
)
L: List[str] = []
L.append(f"package {pkg};\n")
L.append("import net.minecraft.world.item.Item;")
if has_food:
L.append("import net.minecraft.world.food.FoodProperties;")
L.append("import net.minecraft.resources.Identifier;")
L.append("import net.minecraft.core.Registry;")
L.append("import net.minecraft.resources.ResourceKey;")
L.append("import net.minecraft.core.registries.Registries;")
L.append("import net.minecraft.core.registries.BuiltInRegistries;")
if has_tool:
L.append(f"import {pkg}.CustomToolItem;")
if has_vanila:
L.append("import net.fabricmc.fabric.api.itemgroup.v1.ItemGroupEvents;")
L.append("import net.minecraft.world.item.CreativeModeTab;")
L.append("import java.util.function.Function;\n")
L.append("public final class TutorialItems {")
L.append(" private TutorialItems() {}\n")
for itm in self.registered_items:
const = self._to_java_constant(itm.id)
if isinstance(itm, FoodItem):
b = [
f".nutrition({itm.nutrition})",
f".saturationModifier({itm.saturation}f)",
]
if itm.always_edible:
b.append(".alwaysEdible()")
settings = (
"new Item.Properties()"
f".food(new FoodProperties.Builder(){''.join(b)}.build())"
f".stacksTo({itm.max_stack_size})"
)
factory = "Item::new"
elif isinstance(itm, ToolItem):
settings = "new Item.Properties()"
repair = (
"null"
if itm.repair_ingredient is None
else f'"{itm.repair_ingredient}"'
)
factory = (
"s -> new CustomToolItem("
f"{itm.durability}, {itm.mining_speed_multiplier}f, "
f"{itm.attack_damage}f, {itm.mining_level}, {itm.enchantability}, "
f"{repair}, {itm.max_stack_size}, s)"
)
else:
settings = f"new Item.Properties().stacksTo({itm.max_stack_size})"
factory = "CustomItem::new"
# Extract just the path part if the ID is namespaced
item_path = itm.id.split(":", 1)[-1]
L.append(
f' public static final Item {const} = register("{item_path}", '
f"{factory}, {settings});"
)
L.append("")
L.append(
" private static Item register(String path, "
"Function<Item.Properties, Item> factory, Item.Properties settings) {"
)
L.append(
f' ResourceKey<Item> key = ResourceKey.create(Registries.ITEM, Identifier.fromNamespaceAndPath("{self.mod_id}", path));'
)
L.append(" settings = settings.setId(key);")
L.append(
f" return Registry.register(BuiltInRegistries.ITEM, key, factory.apply(settings));"
)
L.append(" }\n")
L.append(" public static void initialize() {")
if has_vanila:
groups: Dict[str, List[str]] = defaultdict(list)
for itm in self.registered_items:
if isinstance(itm.item_group, str):
groups[itm.item_group].append(self._to_java_constant(itm.id))
for g, consts in groups.items():
L.append(
f' ItemGroupEvents.modifyEntriesEvent(ResourceKey.create(Registries.CREATIVE_MODE_TAB, Identifier.fromNamespaceAndPath("minecraft", "{g}"))).register(e -> {{'
)
for c in consts:
L.append(f" e.accept({c});")
L.append(" });")
L.append(" }")
L.append("}")
return "\n".join(L)
def _custom_item_src(self, pkg: str) -> str:
"""Generate Java source code for the CustomItem class.
Creates a simple custom item class that extends Minecraft's Item class
with example behavior (playing a sound when used).
Args:
pkg (str): The Java package name for the generated class.
Returns:
str: Complete Java source code for the CustomItem class.
"""
return f"""package {pkg};
import net.minecraft.world.item.Item;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.player.Player;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.level.Level;
public class CustomItem extends Item {{
public CustomItem(Properties settings) {{ super(settings); }}
@Override
public InteractionResult use(Level level, Player user, InteractionHand hand) {{
if (!level.isClientSide()) {{
level.playSound(null, user.blockPosition(),
SoundEvents.WOOL_BREAK, SoundSource.PLAYERS, 1F, 1F);
}}
return InteractionResult.SUCCESS;
}}
}}
"""
def _custom_tool_item_src(self, pkg: str) -> str:
"""Generate Java source code for the CustomToolItem class.
The generated class exposes tool-specific properties like durability,
mining speed, attack damage, enchantability and repair handling.
Args:
pkg (str): The Java package name for the generated class.
Returns:
str: Complete Java source for the CustomToolItem class.
"""
return f"""package {pkg};
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.item.component.ItemAttributeModifiers;
import net.minecraft.world.entity.EquipmentSlotGroup;
import net.minecraft.world.entity.ai.attributes.Attribute;
import net.minecraft.world.entity.ai.attributes.AttributeModifier;
import net.minecraft.world.entity.ai.attributes.Attributes;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
public class CustomToolItem extends Item {{
private static final Identifier ATTACK_DAMAGE_MODIFIER_ID = Identifier.fromNamespaceAndPath("fabricpy", "tool_damage");
private final float miningSpeedMultiplier;
private final int miningLevel;
public CustomToolItem(int durability, float miningSpeedMultiplier, float attackDamage,
int miningLevel, int enchantability, String repairIngredientId,
int maxCount, Properties settings) {{
super((repairIngredientId == null ? settings
: settings.repairable(BuiltInRegistries.ITEM.getValue(Identifier.parse(repairIngredientId))))
.stacksTo(maxCount)
.durability(durability)
.enchantable(enchantability)
.attributes(ItemAttributeModifiers.builder()
.add(Attributes.ATTACK_DAMAGE,
new AttributeModifier(ATTACK_DAMAGE_MODIFIER_ID, attackDamage, AttributeModifier.Operation.ADD_VALUE),
EquipmentSlotGroup.MAINHAND)
.build()));
this.miningSpeedMultiplier = miningSpeedMultiplier;
this.miningLevel = miningLevel;
}}
@Override
public float getDestroySpeed(ItemStack stack, BlockState state) {{
return this.miningSpeedMultiplier;
}}
}}
"""
# ================================================================== #
# CUSTOM ITEM GROUPS #
# ================================================================== #
@property
def _custom_groups(self) -> Set[ItemGroup]:
"""Get all custom ItemGroup objects used by registered items and blocks.
Scans through all registered items and blocks to find custom ItemGroup
instances (as opposed to vanilla string constants). This is used
internally to determine what custom creative tabs need to be generated.
Returns:
Set[ItemGroup]: A set of unique custom ItemGroup objects found
across all registered items and blocks.
Note:
This property is used internally by the compilation process to
determine which custom item groups need Java code generation.
Only ItemGroup instances are included, not string constants
referencing vanilla item groups.
"""
groups: Set[ItemGroup] = set()
for itm in self.registered_items:
if isinstance(itm.item_group, ItemGroup):
groups.add(itm.item_group)
for blk in self.registered_blocks:
if isinstance(blk.item_group, ItemGroup):
groups.add(blk.item_group)
return groups
[docs]
def create_item_group_files(self, project_dir, package_path):
"""Generate Java source files for custom item groups (creative tabs).
Creates the TutorialItemGroups.java file containing Java code for all
custom ItemGroup objects. This handles creative tab registration,
icon assignment, and adding items/blocks to the custom tabs.
Args:
project_dir (str): The root directory of the mod project.
package_path (str): The Java package path for the item group classes
(e.g., "com.example.mymod.items").
Note:
This method is called automatically during compile() when custom
ItemGroup objects are detected. If no custom groups exist, no
files are generated.
"""
if not self._custom_groups:
return
java_src = os.path.join(project_dir, "src", "main", "java")
pkg_dir = os.path.join(java_src, *package_path.split("."))
os.makedirs(pkg_dir, exist_ok=True)
with open(
os.path.join(pkg_dir, "TutorialItemGroups.java"), "w", encoding="utf-8"
) as fh:
fh.write(self._tutorial_itemgroups_src(package_path))
def _tutorial_itemgroups_src(self, pkg: str) -> str:
"""Generate Java source code for the TutorialItemGroups class.
Creates a complete Java class that registers all custom item groups
(creative tabs) defined in the mod, including proper registry keys,
icons, display names, and item additions.
Args:
pkg (str): The Java package name for the generated class.
Returns:
str: Complete Java source code for the TutorialItemGroups class.
"""
blocks_referenced = any(
isinstance(blk.item_group, ItemGroup) for blk in self.registered_blocks
)
L: List[str] = []
L.append(f"package {pkg};\n")
L.append("import net.fabricmc.fabric.api.itemgroup.v1.FabricItemGroup;")
L.append("import net.fabricmc.fabric.api.itemgroup.v1.ItemGroupEvents;")
L.append("import net.minecraft.world.item.CreativeModeTab;")
L.append("import net.minecraft.world.item.ItemStack;")
L.append("import net.minecraft.core.Registry;")
L.append("import net.minecraft.resources.ResourceKey;")
L.append("import net.minecraft.core.registries.Registries;")
L.append("import net.minecraft.core.registries.BuiltInRegistries;")
L.append("import net.minecraft.resources.Identifier;")
L.append("import net.minecraft.network.chat.Component;")
if blocks_referenced:
L.append(f"import com.example.{self._java_mod_id}.blocks.TutorialBlocks;")
L.append("\npublic final class TutorialItemGroups {")
L.append(" private TutorialItemGroups() {}\n")
group_entries: Dict[ItemGroup, List[str]] = defaultdict(list)
for itm in self.registered_items:
if isinstance(itm.item_group, ItemGroup):
group_entries[itm.item_group].append(
f"TutorialItems.{self._to_java_constant(itm.id)}"
)
for blk in self.registered_blocks:
if isinstance(blk.item_group, ItemGroup):
group_entries[blk.item_group].append(
f"TutorialBlocks.{self._to_java_constant(blk.id)}.asItem()"
)
for grp in self._custom_groups:
const = self._to_java_constant(grp.id)
L.append(
f" public static final ResourceKey<CreativeModeTab> {const}_KEY = "
f'ResourceKey.create(Registries.CREATIVE_MODE_TAB, Identifier.fromNamespaceAndPath("{self.mod_id}", "{grp.id}"));'
)
icon_expr = (
f"TutorialItems.{self._to_java_constant(grp.icon_item_id)}"
if grp.icon_item_id
else group_entries[grp][0]
)
L.append(
f" public static final CreativeModeTab {const} = FabricItemGroup.builder()\n"
f" .icon(() -> new ItemStack({icon_expr}))\n"
f' .title(Component.translatable("itemGroup.{self.mod_id}.{grp.id}"))\n'
" .build();\n"
)
L.append(" public static void initialize() {")
for grp in self._custom_groups:
const = self._to_java_constant(grp.id)
L.append(
f" Registry.register(BuiltInRegistries.CREATIVE_MODE_TAB, {const}_KEY, {const});"
)
L.append(
f" ItemGroupEvents.modifyEntriesEvent({const}_KEY).register(e -> {{"
)
for expr in group_entries[grp]:
L.append(f" e.accept({expr});")
L.append(" });")
L.append(" }\n}")
return "\n".join(L)
# ================================================================== #
# INITIALIZER PATCHES #
# ================================================================== #
[docs]
def update_mod_initializer(self, project_dir, pkg):
"""Add item initialization code to the mod's main initializer.
Args:
project_dir (str): The root directory of the mod project.
pkg (str): The Java package name containing the TutorialItems class.
"""
self._patch_initializer(project_dir, f"{pkg}.TutorialItems.initialize();")
[docs]
def update_mod_initializer_itemgroups(self, project_dir, pkg):
"""Add item group initialization code to the mod's main initializer.
Args:
project_dir (str): The root directory of the mod project.
pkg (str): The Java package name containing the TutorialItemGroups class.
"""
if self._custom_groups:
self._patch_initializer(
project_dir, f"{pkg}.TutorialItemGroups.initialize();"
)
[docs]
def update_mod_initializer_blocks(self, project_dir, pkg):
"""Add block initialization code to the mod's main initializer.
Args:
project_dir (str): The root directory of the mod project.
pkg (str): The Java package name containing the TutorialBlocks class.
"""
self._patch_initializer(project_dir, f"{pkg}.TutorialBlocks.initialize();")
def _patch_initializer(self, project_dir, line: str):
paths = [
os.path.join(
project_dir, "src", "main", "java", "com", "example", "ExampleMod.java"
),
os.path.join(
project_dir,
"src",
"main",
"resources",
"java",
"com",
"example",
"ExampleMod.java",
),
]
init = next((p for p in paths if os.path.exists(p)), None)
if not init:
print("WARNING: ExampleMod.java not found – cannot patch initializer.")
return
with open(init, "r", encoding="utf-8") as fh:
txt = fh.read()
if line in txt:
return
patched, n = re.subn(
r"(public\s+void\s+onInitialize\s*\(\s*\)\s*\{)",
r"\1\n " + line,
txt,
1,
)
if n:
with open(init, "w", encoding="utf-8") as fh:
fh.write(patched)
print(f"Patched ExampleMod.java – added `{line.strip()}`.")
# ================================================================== #
# COPY TEXTURES / MODELS / LANG (ITEMS & GROUP TRANSLATIONS) #
# ================================================================== #
[docs]
def copy_texture_and_generate_models(self, project_dir, mod_id):
"""Copy item textures and generate model/item definition JSON files.
Processes all registered items by copying their texture files to the
mod's assets directory and generating the corresponding model and item
definition JSON files required by Minecraft's resource pack system.
Args:
project_dir (str): The root directory of the mod project.
mod_id (str): The mod's identifier, used for asset paths.
Note:
For each item, this method:
- Copies the texture PNG file to assets/<mod_id>/textures/item/
- Generates a model JSON file in assets/<mod_id>/models/item/
- Generates an item definition JSON file in assets/<mod_id>/items/
Items without valid texture paths are skipped with a warning.
"""
assets = os.path.join(project_dir, "src", "main", "resources", "assets", mod_id)
tex_dir = os.path.join(assets, "textures", "item")
mdl_dir = os.path.join(assets, "models", "item")
idef_dir = os.path.join(assets, "items")
for d in (tex_dir, mdl_dir, idef_dir):
os.makedirs(d, exist_ok=True)
for itm in self.registered_items:
if not itm.texture_path or not os.path.exists(itm.texture_path):
print(f"SKIP texture for `{itm.id}`")
continue
# Extract just the path part if the ID is namespaced
item_path = itm.id.split(":", 1)[-1]
shutil.copy(itm.texture_path, os.path.join(tex_dir, f"{item_path}.png"))
with open(
os.path.join(mdl_dir, f"{item_path}.json"), "w", encoding="utf-8"
) as fh:
json.dump(
{
"parent": "minecraft:item/generated",
"textures": {"layer0": f"{mod_id}:item/{item_path}"},
},
fh,
indent=2,
)
with open(
os.path.join(idef_dir, f"{item_path}.json"), "w", encoding="utf-8"
) as fh:
json.dump(
{
"model": {
"type": "minecraft:model",
"model": f"{mod_id}:item/{item_path}",
}
},
fh,
indent=2,
)
[docs]
def update_item_lang_file(self, project_dir, mod_id):
"""Update the English language file with item translations.
Adds or updates translation entries for all registered items in the
mod's en_us.json language file.
Args:
project_dir (str): The root directory of the mod project.
mod_id (str): The mod's identifier for namespacing translations.
"""
"""Update the language file with item translations.
Adds or updates entries in the mod's en_us.json language file for all
registered items. This provides the display names shown to players
in the game interface.
Args:
project_dir (str): The root directory of the mod project.
mod_id (str): The mod's identifier, used for translation keys.
Note:
The language file is located at assets/<mod_id>/lang/en_us.json.
Translation keys follow the format "item.<mod_id>.<item_path>".
If the file doesn't exist, it will be created. Existing entries
are preserved and only item entries are added/updated.
"""
lang_dir = os.path.join(
project_dir, "src", "main", "resources", "assets", mod_id, "lang"
)
os.makedirs(lang_dir, exist_ok=True)
path = os.path.join(lang_dir, "en_us.json")
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
except Exception:
data = {}
for itm in self.registered_items:
# Extract just the path part if the ID is namespaced
item_path = itm.id.split(":", 1)[-1]
data[f"item.{mod_id}.{item_path}"] = itm.name
with open(path, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2)
[docs]
def update_item_group_lang_entries(self, project_dir, mod_id):
"""Update the English language file with item group translations.
Adds translation entries for all custom item groups defined in the mod.
Args:
project_dir (str): The root directory of the mod project.
mod_id (str): The mod's identifier for namespacing translations.
"""
"""Update the language file with custom item group translations.
Adds translation entries for custom ItemGroup objects to the mod's
en_us.json language file. This provides the display names shown
for custom creative tabs in the creative inventory.
Args:
project_dir (str): The root directory of the mod project.
mod_id (str): The mod's identifier, used for translation keys.
Note:
Only processes custom ItemGroup objects (not vanilla groups).
Translation keys follow the format "itemGroup.<mod_id>.<group_id>".
If no custom groups exist, this method returns early without
making any changes.
"""
if not self._custom_groups:
return
lang_dir = os.path.join(
project_dir, "src", "main", "resources", "assets", mod_id, "lang"
)
os.makedirs(lang_dir, exist_ok=True)
path = os.path.join(lang_dir, "en_us.json")
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
except Exception:
data = {}
for grp in self._custom_groups:
data[f"itemGroup.{mod_id}.{grp.id}"] = grp.name
with open(path, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2)
# ================================================================== #
# BLOCKS #
# ================================================================== #
# ---------- Java source generation -------------------------------- #
[docs]
def create_block_files(self, project_dir, package_path):
"""Generate Java source files for all registered blocks.
Creates the TutorialBlocks.java and CustomBlock.java files containing
block registration and implementation logic.
Args:
project_dir (str): The root directory of the mod project.
package_path (str): The Java package path for the block classes.
"""
"""Generate Java source files for registered blocks.
Creates the TutorialBlocks.java and CustomBlock.java files containing
the Java code for all registered blocks. These files handle block
registration, properties, and automatic BlockItem creation.
Args:
project_dir (str): The root directory of the mod project.
package_path (str): The Java package path for the block classes
(e.g., "com.example.mymod.blocks").
Note:
This method is called automatically during compile() when blocks
are registered and generates:
- TutorialBlocks.java: Registry and initialization code for all blocks
- CustomBlock.java: Base custom block class extending Minecraft's Block
"""
java_src = os.path.join(project_dir, "src", "main", "java")
pkg_dir = os.path.join(java_src, *package_path.split("."))
os.makedirs(pkg_dir, exist_ok=True)
with open(
os.path.join(pkg_dir, "TutorialBlocks.java"), "w", encoding="utf-8"
) as fh:
fh.write(self._tutorial_blocks_src(package_path))
with open(
os.path.join(pkg_dir, "CustomBlock.java"), "w", encoding="utf-8"
) as fh:
fh.write(self._custom_block_src(package_path))
# Generate CustomMiningBlock.java if any block uses mining_speeds
if any(getattr(blk, "mining_speeds", None) for blk in self.registered_blocks):
with open(
os.path.join(pkg_dir, "CustomMiningBlock.java"), "w", encoding="utf-8"
) as fh:
fh.write(self._custom_mining_block_src(package_path))
def _tutorial_blocks_src(self, pkg: str) -> str:
"""Generate Java source code for the TutorialBlocks class.
Creates a complete Java class that registers all mod blocks, including
proper imports, constant declarations, registration logic, and vanilla
item group integration.
Args:
pkg (str): The Java package name for the generated class.
Returns:
str: Complete Java source code for the TutorialBlocks class.
Example:
Creating block files::
mod.create_block_files(
"/path/to/mod",
"com.example.mymod.blocks"
)
"""
has_vanila = any(
isinstance(getattr(b, "item_group", None), str)
for b in self.registered_blocks
)
left_handlers = {
blk: _normalize_hook(blk.on_left_click()) for blk in self.registered_blocks
}
right_handlers = {
blk: _normalize_hook(blk.on_right_click()) for blk in self.registered_blocks
}
break_handlers = {
blk: _normalize_hook(blk.on_break()) for blk in self.registered_blocks
}
has_left_click = any(left_handlers.values())
has_right_click = any(right_handlers.values())
has_break = any(break_handlers.values())
# Collect all event handler code for import detection
_all_event_code = "\n".join(
ev
for ev in list(left_handlers.values())
+ list(right_handlers.values())
+ list(break_handlers.values())
if ev
)
needs_text = "Component.literal" in _all_event_code
L: List[str] = []
L.append(f"package {pkg};\n")
L.append("import net.minecraft.world.level.block.Block;")
L.append("import net.minecraft.world.level.block.state.BlockBehaviour;")
L.append("import net.minecraft.world.level.block.Blocks;")
L.append("import net.minecraft.world.item.BlockItem;")
L.append("import net.minecraft.world.item.Item;")
L.append("import net.minecraft.resources.Identifier;")
L.append("import net.minecraft.core.Registry;")
L.append("import net.minecraft.resources.ResourceKey;")
L.append("import net.minecraft.core.registries.Registries;")
L.append("import net.minecraft.core.registries.BuiltInRegistries;")
if needs_text:
L.append("import net.minecraft.network.chat.Component;")
if has_vanila:
L.append("import net.fabricmc.fabric.api.itemgroup.v1.ItemGroupEvents;")
L.append("import net.minecraft.world.item.CreativeModeTab;")
if has_left_click:
L.append("import net.fabricmc.fabric.api.event.player.AttackBlockCallback;")
if has_right_click:
L.append("import net.fabricmc.fabric.api.event.player.UseBlockCallback;")
if has_break:
L.append(
"import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;"
)
if has_left_click or has_right_click:
L.append("import net.minecraft.world.InteractionResult;")
# ── action-specific imports (detected from event handler code) ── #
_ACTION_IMPORTS = [
(
"MobEffectInstance(",
"import net.minecraft.world.effect.MobEffectInstance;",
),
("MobEffects.", "import net.minecraft.world.effect.MobEffects;"),
("SoundEvents.", "import net.minecraft.sounds.SoundEvents;"),
("SoundSource.", "import net.minecraft.sounds.SoundSource;"),
("ServerLevel", "import net.minecraft.server.level.ServerLevel;"),
("LightningBolt", "import net.minecraft.world.entity.LightningBolt;"),
("EntityType.", "import net.minecraft.world.entity.EntityType;"),
("new ItemStack(", "import net.minecraft.world.item.ItemStack;"),
("Items.", "import net.minecraft.world.item.Items;"),
("LivingEntity.class", "import net.minecraft.world.entity.LivingEntity;"),
("new AABB(", "import net.minecraft.world.phys.AABB;"),
(
"ServerTickEvents.",
"import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;",
),
("GameEvent.", "import net.minecraft.world.level.gameevent.GameEvent;"),
]
for _pattern, _import_stmt in _ACTION_IMPORTS:
if _pattern in _all_event_code:
L.append(_import_stmt)
if has_right_click or "BlockPos " in _all_event_code:
L.append("import net.minecraft.core.BlockPos;")
has_mining_speeds = any(
getattr(b, "mining_speeds", None) for b in self.registered_blocks
)
if has_mining_speeds:
L.append("import java.util.Map;")
L.append("import java.util.function.Function;\n")
L.append("public final class TutorialBlocks {")
L.append(" private TutorialBlocks() {}\n")
for blk in self.registered_blocks:
const = self._to_java_constant(blk.id)
# Extract just the path part if the ID is namespaced
block_path = blk.id.split(":", 1)[-1]
# ── block properties ────────────────────────────────── #
hardness = getattr(blk, "hardness", None)
resistance = getattr(blk, "resistance", None)
blk_requires_tool = getattr(blk, "requires_tool", False)
blk_mining_speeds = getattr(blk, "mining_speeds", None)
if hardness is not None or resistance is not None:
h = hardness if hardness is not None else 1.5
r = resistance if resistance is not None else 6.0
props = f"BlockBehaviour.Properties.of().strength({h}f, {r}f)"
else:
props = "BlockBehaviour.Properties.ofFullCopy(Blocks.STONE)"
if blk_requires_tool:
props += ".requiresCorrectToolForDrops()"
# ── factory function ────────────────────────────────── #
if blk_mining_speeds:
entries = ", ".join(
f'"{k}", {v}f' for k, v in blk_mining_speeds.items()
)
factory = f"s -> new CustomMiningBlock(s, Map.of({entries}))"
else:
factory = "CustomBlock::new"
L.append(
f' public static final Block {const} = register("{block_path}", '
f"{factory}, {props}, true);"
)
L.append("")
L.append(
" private static Block register(String p, Function<BlockBehaviour.Properties, Block> f, "
"BlockBehaviour.Properties s, boolean makeItem) {"
)
L.append(
f' ResourceKey<Block> bKey = ResourceKey.create(Registries.BLOCK, Identifier.fromNamespaceAndPath("{self.mod_id}", p));'
)
L.append(" s = s.setId(bKey);")
L.append(
f" Block b = Registry.register(BuiltInRegistries.BLOCK, bKey, f.apply(s));"
)
L.append(" if (makeItem) {")
L.append(
f' ResourceKey<Item> itemKey = ResourceKey.create(Registries.ITEM, Identifier.fromNamespaceAndPath("{self.mod_id}", p));'
)
L.append(
" Registry.register(BuiltInRegistries.ITEM, itemKey, "
"new BlockItem(b, new Item.Properties().setId(itemKey)));"
)
L.append(" }")
L.append(" return b;")
L.append(" }\n")
L.append(" public static void initialize() {")
if has_vanila:
groups: Dict[str, List[str]] = defaultdict(list)
for blk in self.registered_blocks:
if isinstance(blk.item_group, str):
groups[blk.item_group].append(self._to_java_constant(blk.id))
for g, consts in groups.items():
L.append(
f' ItemGroupEvents.modifyEntriesEvent(ResourceKey.create(Registries.CREATIVE_MODE_TAB, Identifier.fromNamespaceAndPath("minecraft", "{g}"))).register(e -> {{'
)
for c in consts:
L.append(f" e.accept({c}.asItem());")
L.append(" });")
if has_left_click:
L.append(
" AttackBlockCallback.EVENT.register((player, world, hand, pos, direction) -> {"
)
for blk, code in left_handlers.items():
if code:
const = self._to_java_constant(blk.id)
L.append(
f" if (world.getBlockState(pos).getBlock() == {const}) {{",
)
for line in code.splitlines():
L.append(f" {line}")
L.append(" return InteractionResult.SUCCESS;")
L.append(" }")
L.append(" return InteractionResult.PASS;")
L.append(" });")
if has_right_click:
L.append(
" UseBlockCallback.EVENT.register((player, world, hand, hitResult) -> {"
)
L.append(" BlockPos pos = hitResult.getBlockPos();")
for blk, code in right_handlers.items():
if code:
const = self._to_java_constant(blk.id)
L.append(
f" if (world.getBlockState(pos).getBlock() == {const}) {{",
)
for line in code.splitlines():
L.append(f" {line}")
L.append(" return InteractionResult.SUCCESS;")
L.append(" }")
L.append(" return InteractionResult.PASS;")
L.append(" });")
if has_break:
L.append(
" PlayerBlockBreakEvents.AFTER.register((world, player, pos, state, entity) -> {"
)
for blk, code in break_handlers.items():
if code:
const = self._to_java_constant(blk.id)
L.append(
f" if (state.getBlock() == {const}) {{",
)
for line in code.splitlines():
L.append(f" {line}")
L.append(" }")
L.append(" });")
L.append(" }")
L.append("}")
return "\n".join(L)
def _custom_block_src(self, pkg: str) -> str:
"""Generate Java source code for the CustomBlock class.
Creates a simple custom block class that extends Minecraft's Block class.
Args:
pkg (str): The Java package name for the generated class.
Returns:
str: Complete Java source code for the CustomBlock class.
"""
return f"""package {pkg};
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockBehaviour;
public class CustomBlock extends Block {{
public CustomBlock(BlockBehaviour.Properties s) {{ super(s); }}
}}
"""
def _custom_mining_block_src(self, pkg: str) -> str:
"""Generate Java source for the ``CustomMiningBlock`` class.
``CustomMiningBlock`` extends ``Block`` with a per-tool-type speed
override. The constructor accepts a ``Map<String, Float>`` whose keys
are tool names (``"pickaxe"``, ``"axe"``, ``"shovel"``, ``"hoe"``,
``"sword"``) and whose values are the custom mining speed multiplier
applied when the player holds a tool of that type.
The class overrides ``getDestroyProgress`` to check the held item
against Minecraft's item tags (``ItemTags.PICKAXES``, etc.) and
replaces the normal speed calculation with the custom value.
Args:
pkg: The Java package name for the generated class.
Returns:
Complete Java source for ``CustomMiningBlock.java``.
"""
return f"""package {pkg};
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.core.BlockPos;
import net.minecraft.tags.ItemTags;
import java.util.Map;
/**
* A custom block that supports per-tool-type mining speed overrides.
*
* <p>Pass a {{@code Map<String, Float>}} of tool type names to speed
* multipliers in the constructor. When a player mines this block while
* holding a matching tool type, the custom speed is used instead of
* the default tool speed.</p>
*/
public class CustomMiningBlock extends Block {{
private final Map<String, Float> toolSpeeds;
/**
* @param settings block properties (hardness, resistance, etc.)
* @param toolSpeeds mapping from tool type name to speed multiplier
*/
public CustomMiningBlock(BlockBehaviour.Properties settings,
Map<String, Float> toolSpeeds) {{
super(settings);
this.toolSpeeds = toolSpeeds;
}}
@Override
public float getDestroyProgress(BlockState state, Player player,
BlockGetter level, BlockPos pos) {{
float destroyTime = state.getDestroySpeed(level, pos);
if (destroyTime == -1.0F) {{
return 0.0F;
}}
ItemStack held = player.getMainHandItem();
// Start with the default speed from the player/tool combination.
float speed = player.getDestroySpeed(state);
// Override with configured per-tool-type speed when applicable.
if (held.is(ItemTags.PICKAXES) && toolSpeeds.containsKey("pickaxe")) {{
speed = toolSpeeds.get("pickaxe");
}} else if (held.is(ItemTags.AXES) && toolSpeeds.containsKey("axe")) {{
speed = toolSpeeds.get("axe");
}} else if (held.is(ItemTags.SHOVELS) && toolSpeeds.containsKey("shovel")) {{
speed = toolSpeeds.get("shovel");
}} else if (held.is(ItemTags.HOES) && toolSpeeds.containsKey("hoe")) {{
speed = toolSpeeds.get("hoe");
}} else if (held.is(ItemTags.SWORDS) && toolSpeeds.containsKey("sword")) {{
speed = toolSpeeds.get("sword");
}}
int modifier = player.hasCorrectToolForDrops(state) ? 30 : 100;
return speed / destroyTime / (float) modifier;
}}
}}
"""
# ---------- textures / model JSON / lang (blocks) ------------------ #
[docs]
def copy_block_textures_and_generate_models(self, project_dir, mod_id):
"""Copy block textures and generate model/blockstate JSON files.
Processes all registered blocks by copying their texture files and
generating the corresponding model, blockstate, and item definition
JSON files required by Minecraft's resource pack system.
Args:
project_dir (str): The root directory of the mod project.
mod_id (str): The mod's identifier for namespacing resources.
"""
"""Copy block textures and generate model/blockstate JSON files.
Processes all registered blocks by copying their texture files and
generating the corresponding model, blockstate, and item definition
JSON files required for both world rendering and inventory display.
Args:
project_dir (str): The root directory of the mod project.
mod_id (str): The mod's identifier, used for asset paths.
Note:
For each block, this method:
- Copies the block texture to assets/<mod_id>/textures/block/
- Generates a block model JSON file in assets/<mod_id>/models/block/
- Generates a blockstate JSON file in assets/<mod_id>/blockstates/
- Copies the inventory texture to assets/<mod_id>/textures/item/
- Generates an item model and definition for the BlockItem
Blocks without valid texture paths are skipped with a warning.
"""
assets = os.path.join(project_dir, "src", "main", "resources", "assets", mod_id)
blk_tex_dir = os.path.join(assets, "textures", "block")
blk_mdl_dir = os.path.join(assets, "models", "block")
blkstate_dir = os.path.join(assets, "blockstates")
itm_tex_dir = os.path.join(assets, "textures", "item")
itm_mdl_dir = os.path.join(assets, "models", "item")
itm_def_dir = os.path.join(assets, "items")
for d in (
blk_tex_dir,
blk_mdl_dir,
blkstate_dir,
itm_tex_dir,
itm_mdl_dir,
itm_def_dir,
):
os.makedirs(d, exist_ok=True)
for blk in self.registered_blocks:
if not blk.block_texture_path or not os.path.exists(blk.block_texture_path):
print(f"SKIP block `{blk.id}` – missing texture")
continue
# Extract just the path part if the ID is namespaced
block_path = blk.id.split(":", 1)[-1]
shutil.copy(
blk.block_texture_path, os.path.join(blk_tex_dir, f"{block_path}.png")
)
with open(
os.path.join(blk_mdl_dir, f"{block_path}.json"), "w", encoding="utf-8"
) as fh:
json.dump(
{
"parent": "minecraft:block/cube_all",
"textures": {"all": f"{mod_id}:block/{block_path}"},
},
fh,
indent=2,
)
with open(
os.path.join(blkstate_dir, f"{block_path}.json"), "w", encoding="utf-8"
) as fh:
json.dump(
{"variants": {"": {"model": f"{mod_id}:block/{block_path}"}}},
fh,
indent=2,
)
inv_src = (
blk.inventory_texture_path
if blk.inventory_texture_path
and os.path.exists(blk.inventory_texture_path)
else blk.block_texture_path
)
shutil.copy(inv_src, os.path.join(itm_tex_dir, f"{block_path}.png"))
with open(
os.path.join(itm_mdl_dir, f"{block_path}.json"), "w", encoding="utf-8"
) as fh:
json.dump(
{
"parent": "minecraft:item/generated",
"textures": {"layer0": f"{mod_id}:item/{block_path}"},
},
fh,
indent=2,
)
with open(
os.path.join(itm_def_dir, f"{block_path}.json"), "w", encoding="utf-8"
) as fh:
json.dump(
{
"model": {
"type": "minecraft:model",
"model": f"{mod_id}:item/{block_path}",
}
},
fh,
indent=2,
)
[docs]
def update_block_lang_file(self, project_dir, mod_id):
"""Update the English language file with block translations.
Adds or updates translation entries for all registered blocks in the
mod's en_us.json language file. Creates entries for both the block
and its corresponding item form.
Args:
project_dir (str): The root directory of the mod project.
mod_id (str): The mod's identifier for namespacing translations.
"""
"""Update the language file with block translations.
Adds or updates entries in the mod's en_us.json language file for all
registered blocks. This provides display names for both the block
itself and its corresponding BlockItem.
Args:
project_dir (str): The root directory of the mod project.
mod_id (str): The mod's identifier, used for translation keys.
Note:
The language file is located at assets/<mod_id>/lang/en_us.json.
For each block, two translation keys are added:
- "block.<mod_id>.<block_path>": For the block in the world
- "item.<mod_id>.<block_path>": For the BlockItem in inventory
Both use the same display name from the Block object.
"""
lang_dir = os.path.join(
project_dir, "src", "main", "resources", "assets", mod_id, "lang"
)
os.makedirs(lang_dir, exist_ok=True)
path = os.path.join(lang_dir, "en_us.json")
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
except Exception:
data = {}
for blk in self.registered_blocks:
# Extract just the path part if the ID is namespaced
block_path = blk.id.split(":", 1)[-1]
data[f"block.{mod_id}.{block_path}"] = blk.name
data[f"item.{mod_id}.{block_path}"] = blk.name
with open(path, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2)
# ------------------------------------------------------------------ #
# new build / run helpers #
# ------------------------------------------------------------------ #
[docs]
def build(self):
"""Build the mod JAR file using Gradle.
Requires compile() to have been called first. Enters the mod project
directory and runs `./gradlew build` to produce the distributable JAR file.
The built JAR will be located in the `build/libs/` directory of the project.
Raises:
RuntimeError: If the project directory doesn't exist (compile() not called).
subprocess.CalledProcessError: If the Gradle build fails.
Example:
Building the mod::
mod.compile() # Must be called first
mod.build() # Creates JAR in build/libs/
"""
if not os.path.isdir(self.project_dir):
raise RuntimeError("Project directory not found – call compile() first.")
# Ensure gradle.properties has required properties
self._ensure_gradle_properties(self.project_dir)
print("🔨 Building mod JAR …")
subprocess.check_call(["./gradlew", "build"], cwd=self.project_dir)
print("✔ Build complete – JAR written to build/libs/")
[docs]
def run(self):
"""Run the mod in development mode using Fabric Loader.
Launches a Minecraft client with the mod loaded for testing and development.
This uses Gradle's `runClient` task which sets up a development environment.
Raises:
FileNotFoundError: If the project directory doesn't exist (compile() not called).
subprocess.CalledProcessError: If the Gradle runClient task fails.
Example:
Running the mod for testing::
mod.compile() # Must be called first
mod.run() # Launches Minecraft with the mod
"""
if not os.path.exists(self.project_dir):
raise FileNotFoundError(
f"Project directory '{self.project_dir}' does not exist. Run compile() first."
)
# Ensure gradle.properties has required properties
self._ensure_gradle_properties(self.project_dir)
print(f"Running mod '{self.name}' in development mode...")
original_cwd = os.getcwd()
try:
os.chdir(self.project_dir)
subprocess.check_call(["./gradlew", "runClient"])
finally:
os.chdir(original_cwd)
# ================================================================== #
# FABRIC TESTING INTEGRATION #
# ================================================================== #
[docs]
def setup_fabric_testing(self, project_dir: str):
"""Set up Fabric testing framework in the project.
Configures the mod project to support Fabric's testing capabilities
by enhancing build.gradle with testing dependencies and configuration,
and ensuring gradle.properties has the necessary testing properties.
Args:
project_dir (str): The root directory of the mod project.
Note:
This method is called automatically during compile() when
enable_testing is True. It sets up the foundation for both
unit tests and game tests but doesn't generate the test files
themselves (see generate_fabric_unit_tests and generate_fabric_game_tests).
"""
print("Setting up Fabric testing framework...")
# Enhance build.gradle with testing dependencies and configuration
self._enhance_build_gradle_for_testing(project_dir)
# Create gradle.properties if needed
self._ensure_gradle_properties(project_dir)
print("Fabric testing framework setup complete.")
def _enhance_build_gradle_for_testing(self, project_dir: str):
"""Add Fabric testing configuration to build.gradle.
Conditionally adds game testing dependencies based on:
1. Whether generate_game_tests is enabled, or
2. Whether existing game test files are detected in the project
"""
build_gradle_path = os.path.join(project_dir, "build.gradle")
if not os.path.exists(build_gradle_path):
return
with open(build_gradle_path, "r", encoding="utf-8") as f:
content = f.read()
# Check if testing is already configured
if "fabric-loader-junit" in content:
return
# Check if we should add game testing dependencies
should_add_game_tests = self.generate_game_tests or self._has_game_tests(
project_dir
)
# Base testing configuration (always added)
testing_config = """
// Fabric Testing Configuration Added by fabricpy
dependencies {
testImplementation "net.fabricmc:fabric-loader-junit:${project.loader_version}"
testImplementation "org.junit.jupiter:junit-jupiter:5.9.2"
}
test {
useJUnitPlatform()
testLogging {
events "passed", "skipped", "failed"
exceptionFormat "full"
showCauses true
showExceptions true
showStackTraces true
}
maxHeapSize = "2g"
systemProperty "fabric.development", "true"
}
"""
# Add game testing configuration only if needed
if should_add_game_tests:
testing_config += """
fabricApi {
configureTests {
createSourceSet = true
modId = "${project.mod_id}-test"
eula = true
}
}
"""
# Add unit test task (always added)
testing_config += """
// Task to run only unit tests
task unitTest(type: Test) {
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
include '**/*Test.class'
exclude '**/*GameTest.class'
}
"""
with open(build_gradle_path, "a", encoding="utf-8") as f:
f.write(testing_config)
def _ensure_gradle_properties(self, project_dir: str):
"""Ensure gradle.properties has the proper Fabric mod structure and configuration."""
gradle_props_path = os.path.join(project_dir, "gradle.properties")
# Create gradle.properties with standard Fabric format
# This overwrites any existing file to ensure consistency
gradle_props_content = f"""# Done to increase the memory available to gradle.
org.gradle.jvmargs=-Xmx1G
org.gradle.parallel=true
# IntelliJ IDEA is not yet fully compatible with configuration cache, see:
# https://github.com/FabricMC/fabric-loom/issues/1349
org.gradle.configuration-cache=false
# Fabric Properties
# check these on https://fabricmc.net/develop
minecraft_version=1.21.11
loader_version=0.18.4
loom_version=1.15-SNAPSHOT
# Mod Properties
mod_version={self.version}
maven_group=com.example
archives_base_name={self.mod_id}
mod_id={self.mod_id}
# Dependencies
fabric_version=0.141.3+1.21.11
"""
with open(gradle_props_path, "w", encoding="utf-8") as f:
f.write(gradle_props_content)
[docs]
def generate_fabric_unit_tests(self, project_dir: str):
"""Generate Fabric unit tests for the mod.
Creates comprehensive unit tests that validate item registration,
recipe functionality, and mod integration. Tests are generated
based on the registered items, blocks, and their properties.
Args:
project_dir (str): The root directory of the mod project.
Note:
Generated tests include:
- Item registration verification
- Food item property validation
- Recipe validation and result ID checking
- Complete mod integration testing
Test files are placed in src/test/java/ following standard conventions.
"""
print("Generating Fabric unit tests...")
test_dir = os.path.join(
project_dir,
"src",
"test",
"java",
"com",
"example",
self.mod_id,
"test",
)
os.makedirs(test_dir, exist_ok=True)
# Generate comprehensive unit tests
self._generate_item_registration_test(test_dir)
self._generate_recipe_validation_test(test_dir)
self._generate_mod_integration_test(test_dir)
print("Unit tests generated.")
def _generate_item_registration_test(self, test_dir: str):
"""Generate unit test for item registration."""
package_name = f"com.example.{self._java_mod_id}.test"
test_content = f"""package {package_name};
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.core.component.DataComponents;
import net.minecraft.world.food.FoodProperties;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import com.example.{self._java_mod_id}.items.TutorialItems;
/**
* Unit tests for item registration and properties.
* Generated by fabricpy library.
*/
public class ItemRegistrationTest {{
@BeforeAll
static void beforeAll() {{
// Initialize Minecraft registries for testing
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
// Initialize our mod items
TutorialItems.initialize();
}}
@Test
@DisplayName("Test all mod items are properly registered")
void testItemsAreRegistered() {{
"""
# Add tests for each registered item
for item in self.registered_items:
item_id = item.id
if ":" in item_id:
namespace, path = item_id.split(":", 1)
safe_name = path.replace("-", "_").replace(".", "_")
test_content += f'''
// Test {item.name}
Item {safe_name} = BuiltInRegistries.ITEM.getValue(Identifier.fromNamespaceAndPath("{namespace}", "{path}"));
Assertions.assertNotNull({safe_name}, "{item.name} should be registered");
ItemStack {safe_name}_stack = new ItemStack({safe_name}, 1);
Assertions.assertFalse({safe_name}_stack.isEmpty(), "{item.name} ItemStack should not be empty");
'''
test_content += """
}
@Test
@DisplayName("Test vanilla items are accessible (registry working)")
void testVanillaItemsAccessible() {
ItemStack diamondStack = new ItemStack(Items.DIAMOND, 1);
Assertions.assertTrue(diamondStack.is(Items.DIAMOND));
Assertions.assertEquals(1, diamondStack.getCount());
}
@Test
@DisplayName("Test food item properties")
void testFoodItemProperties() {
"""
# Add food-specific tests — each food item is wrapped in its own
# scoped block { ... } so that local variables like foodComponent
# do not collide when there are multiple food items.
for item in self.registered_items:
if hasattr(item, "nutrition") and item.nutrition is not None:
item_id = item.id
if ":" in item_id:
namespace, path = item_id.split(":", 1)
safe_name = path.replace("-", "_").replace(".", "_")
test_content += f'''
{{ // scope for {safe_name}
Item {safe_name} = BuiltInRegistries.ITEM.getValue(Identifier.fromNamespaceAndPath("{namespace}", "{path}"));
ItemStack {safe_name}_stack = new ItemStack({safe_name});
FoodProperties foodComponent = {safe_name}_stack.get(DataComponents.FOOD);
Assertions.assertNotNull(foodComponent, "{item.name} should have food component");
Assertions.assertEquals({item.nutrition}, foodComponent.nutrition(),
"{item.name} should have nutrition value of {item.nutrition}");
'''
if hasattr(item, "saturation") and item.saturation is not None:
test_content += f'''
Assertions.assertEquals({item.saturation}f, foodComponent.saturation(), 0.001f,
"{item.name} should have saturation value of {item.saturation}");
'''
test_content += """
}
"""
test_content += """
Assertions.assertTrue(true, "Food item property tests completed");
}
}
"""
with open(
os.path.join(test_dir, "ItemRegistrationTest.java"), "w", encoding="utf-8"
) as f:
f.write(test_content)
def _generate_recipe_validation_test(self, test_dir: str):
"""Generate unit test for recipe validation.
Creates a comprehensive test that validates all recipes associated with
registered items and blocks, ensuring they can be properly loaded and
processed by Minecraft's recipe system.
Args:
test_dir (str): Directory where the test file should be generated.
"""
package_name = f"com.example.{self._java_mod_id}.test"
test_content = f"""package {package_name};
import net.minecraft.world.item.crafting.RecipeType;
import net.minecraft.resources.Identifier;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
/**
* Unit tests for recipe validation.
* Generated by fabricpy library.
*/
public class RecipeValidationTest {{
@BeforeAll
static void beforeAll() {{
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}}
@Test
@DisplayName("Test recipe types are valid")
void testRecipeTypes() {{
"""
# Collect recipe types used
recipe_types_used = set()
items_with_recipes = []
for item in self.registered_items:
if hasattr(item, "recipe") and item.recipe and hasattr(item.recipe, "data"):
recipe_type = item.recipe.data.get("type")
if recipe_type:
recipe_types_used.add(recipe_type)
items_with_recipes.append((item, recipe_type))
for block in self.registered_blocks:
if (
hasattr(block, "recipe")
and block.recipe
and hasattr(block.recipe, "data")
):
recipe_type = block.recipe.data.get("type")
if recipe_type:
recipe_types_used.add(recipe_type)
if recipe_types_used:
test_content += """
// Test that all recipe types used in our mod are valid
"""
for recipe_type in recipe_types_used:
test_content += f'''
// Recipe type: {recipe_type}
Assertions.assertDoesNotThrow(() -> {{
// Basic validation that recipe system works
RecipeType.CRAFTING.toString();
}}, "{recipe_type} should be a valid recipe type");
'''
test_content += """
Assertions.assertTrue(true, "Recipe type validation completed");
}
@Test
@DisplayName("Test recipe result IDs match item IDs")
void testRecipeResultIds() {
"""
# Test recipe results
for item, recipe_type in items_with_recipes:
if hasattr(item.recipe, "get_result_id"):
result_id = item.recipe.get_result_id()
if result_id:
test_content += f'''
// Recipe for {item.name} should have valid result ID
Assertions.assertEquals("{item.id}", "{result_id}",
"Recipe result ID should match item ID for {item.name}");
'''
test_content += """
Assertions.assertTrue(true, "Recipe result ID validation completed");
}
}
"""
with open(
os.path.join(test_dir, "RecipeValidationTest.java"), "w", encoding="utf-8"
) as f:
f.write(test_content)
def _generate_mod_integration_test(self, test_dir: str):
"""Generate integration test for complete mod functionality.
Creates a comprehensive integration test that verifies all mod components
work together correctly, including item registration, block registration,
and cross-component interactions.
Args:
test_dir (str): Directory where the test file should be generated.
"""
package_name = f"com.example.{self._java_mod_id}.test"
test_content = f"""package {package_name};
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import com.example.{self._java_mod_id}.items.TutorialItems;
/**
* Integration tests for complete mod functionality.
* Generated by fabricpy library.
*/
public class ModIntegrationTest {{
@BeforeAll
static void beforeAll() {{
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}}
@Test
@DisplayName("Test complete mod initialization")
void testCompleteModInitialization() {{
Assertions.assertDoesNotThrow(() -> {{
TutorialItems.initialize();
}}, "Mod initialization should not throw exceptions");
}}
@Test
@DisplayName("Test all mod items are in registry")
void testItemRegistryIntegration() {{
"""
# Test each item is in registry
for item in self.registered_items:
item_id = item.id
if ":" in item_id:
namespace, path = item_id.split(":", 1)
test_content += f'''
Assertions.assertTrue(BuiltInRegistries.ITEM.containsKey(Identifier.fromNamespaceAndPath("{namespace}", "{path}")),
"{item.name} should be registered in item registry");
'''
test_content += """
}
@Test
@DisplayName("Test all mod blocks are in registry")
void testBlockRegistryIntegration() {
"""
# Test each block is in registry
for block in self.registered_blocks:
block_id = block.id
if ":" in block_id:
namespace, path = block_id.split(":", 1)
test_content += f'''
Assertions.assertTrue(BuiltInRegistries.BLOCK.containsKey(Identifier.fromNamespaceAndPath("{namespace}", "{path}")),
"{block.name} should be registered in block registry");
'''
test_content += """
}
}
"""
with open(
os.path.join(test_dir, "ModIntegrationTest.java"), "w", encoding="utf-8"
) as f:
f.write(test_content)
[docs]
def generate_fabric_game_tests(self, project_dir: str):
"""Generate Fabric game tests for the mod.
Creates game tests that run within a Minecraft environment to validate
mod functionality in actual gameplay conditions. These tests can
interact with the world, place blocks, and test item behavior.
Args:
project_dir (str): The root directory of the mod project.
Note:
Generated game tests include:
- Server-side item and block functionality testing
- Client-side interaction and rendering validation
- Block placement and world interaction tests
Game test files are placed in src/gametest/java/ and require
a separate fabric.mod.json for the test environment.
"""
print("Generating Fabric game tests...")
# Create game test directory structure
gametest_dir = os.path.join(
project_dir,
"src",
"gametest",
"java",
"com",
"example",
self.mod_id,
)
os.makedirs(gametest_dir, exist_ok=True)
# Create gametest fabric.mod.json
self._create_gametest_fabric_mod_json(project_dir)
# Generate server and client game tests
self._generate_server_game_test(gametest_dir)
self._generate_client_game_test(gametest_dir)
print("Game tests generated.")
def _create_gametest_fabric_mod_json(self, project_dir: str):
"""Create fabric.mod.json for game tests."""
gametest_resources = os.path.join(project_dir, "src", "gametest", "resources")
os.makedirs(gametest_resources, exist_ok=True)
package_name = f"com.example.{self._java_mod_id}"
fabric_mod_json = {
"schemaVersion": 1,
"id": f"{self.mod_id}-test",
"version": self.version,
"name": f"{self.name} Game Tests",
"description": f"Game tests for {self.name} generated by fabricpy",
"icon": "assets/examplemod/icon.png",
"environment": "*",
"entrypoints": {
"fabric-gametest": [
f"{package_name}.{self.mod_id.replace('-', '_').title()}ServerTest"
],
"fabric-client-gametest": [
f"{package_name}.{self.mod_id.replace('-', '_').title()}ClientTest"
],
},
"depends": {
"fabricloader": ">=0.15.0",
"fabric-api": "*",
"minecraft": "~1.21.0",
},
}
with open(
os.path.join(gametest_resources, "fabric.mod.json"), "w", encoding="utf-8"
) as f:
json.dump(fabric_mod_json, f, indent=2)
def _generate_server_game_test(self, gametest_dir: str):
"""Generate server-side game tests."""
package_name = f"com.example.{self._java_mod_id}"
class_name = f"{self.mod_id.replace('-', '_').title()}ServerTest"
server_test_content = f"""package {package_name};
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.Identifier;
import net.minecraft.core.BlockPos;
import net.fabricmc.fabric.api.gametest.v1.FabricGameTest;
/**
* Server-side game tests for {self.name}.
* Generated by fabricpy library.
*/
public class {class_name} implements FabricGameTest {{
@GameTest(template = EMPTY_STRUCTURE, timeoutTicks = 200)
public void testItemFunctionality(GameTestHelper context) {{
// Test that all mod items work in game context
"""
# Add tests for each item
for item in self.registered_items:
item_id = item.id
if ":" in item_id:
namespace, path = item_id.split(":", 1)
safe_name = path.replace("-", "_").replace(".", "_")
server_test_content += f'''
// Test {item.name}
ItemStack {safe_name}_stack = new ItemStack(
BuiltInRegistries.ITEM.getValue(Identifier.fromNamespaceAndPath("{namespace}", "{path}")), 1
);
context.assertTrue(!{safe_name}_stack.isEmpty(), "{item.name} should create valid ItemStack");
'''
# Add food-specific tests
if hasattr(item, "nutrition"):
server_test_content += f'''
context.assertTrue({safe_name}_stack.has(net.minecraft.core.component.DataComponents.FOOD), "{item.name} should be edible");
'''
server_test_content += """
context.complete();
}
@GameTest(template = EMPTY_STRUCTURE, timeoutTicks = 300)
public void testBlockPlacement(GameTestHelper context) {
// Test block placement and interaction
BlockPos testPos = new BlockPos(1, 1, 1);
// Start with air
context.expectBlock(Blocks.AIR, testPos);
"""
# Add block tests
for block in self.registered_blocks:
block_id = block.id
if ":" in block_id:
namespace, path = block_id.split(":", 1)
server_test_content += f'''
// Test {block.name}
context.setBlockState(testPos,
BuiltInRegistries.BLOCK.getValue(Identifier.fromNamespaceAndPath("{namespace}", "{path}")).defaultBlockState()
);
context.expectBlock(
BuiltInRegistries.BLOCK.getValue(Identifier.fromNamespaceAndPath("{namespace}", "{path}")),
testPos
);
// Test block can be broken
context.setBlockState(testPos, Blocks.AIR.defaultBlockState());
context.expectBlock(Blocks.AIR, testPos);
'''
server_test_content += """
context.complete();
}
}
"""
with open(
os.path.join(gametest_dir, f"{class_name}.java"), "w", encoding="utf-8"
) as f:
f.write(server_test_content)
def _generate_client_game_test(self, gametest_dir: str):
"""Generate client-side game tests."""
package_name = f"com.example.{self._java_mod_id}"
class_name = f"{self.mod_id.replace('-', '_').title()}ClientTest"
client_test_content = f'''package {package_name};
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.fabricmc.fabric.api.client.gametest.v1.context.TestSingleplayerContext;
/**
* Client-side game tests for {self.name}.
* Generated by fabricpy library.
*/
@SuppressWarnings("UnstableApiUsage")
public class {class_name} implements FabricClientGameTest {{
@Override
public void runTest(ClientGameTestContext context) {{
try (TestSingleplayerContext singleplayer = context.worldBuilder().create()) {{
// Wait for world to load
singleplayer.getClientWorld().waitForChunksRender();
// Test client-side functionality
testClientRendering(context, singleplayer);
// Take screenshot for verification
context.takeScreenshot("{self.mod_id}-client-test");
}}
}}
private void testClientRendering(ClientGameTestContext context, TestSingleplayerContext singleplayer) {{
// Test that items render properly on client
context.assertTrue(true, "Client rendering test for {self.name}");
// Additional client-side tests would go here
// For example: testing GUIs, client-side rendering, etc.
}}
}}
'''
with open(
os.path.join(gametest_dir, f"{class_name}.java"), "w", encoding="utf-8"
) as f:
f.write(client_test_content)
def _has_game_tests(self, project_dir: str) -> bool:
"""Check if game test files exist in the project.
Args:
project_dir (str): The root directory of the mod project.
Returns:
bool: True if game test files are found, False otherwise.
"""
gametest_java_dir = os.path.join(project_dir, "src", "gametest", "java")
if not os.path.exists(gametest_java_dir):
return False
# Check for any .java files in the gametest directory
for root, dirs, files in os.walk(gametest_java_dir):
for file in files:
if file.endswith(".java"):
return True
return False