Source code for fabricpy.modconfig

# 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 # # ------------------------------------------------------------------ #
[docs] def update_mod_metadata(self, path, data): """Update the fabric.mod.json file with mod metadata. Args: path (str): Path to the fabric.mod.json file to update. data (dict): Dictionary containing the metadata to update. Common keys include: - id: Mod identifier - name: Mod display name - version: Mod version string - description: Mod description - authors: List of author names Raises: FileNotFoundError: If the fabric.mod.json file doesn't exist. json.JSONDecodeError: If the existing file contains invalid JSON. Example: Updating mod metadata:: mod.update_mod_metadata( "src/main/resources/fabric.mod.json", { "id": "mymod", "name": "My Awesome Mod", "version": "1.0.0", "description": "An awesome Minecraft mod", "authors": ["Author Name"] } ) """ """Update the fabric.mod.json file with new metadata. Args: path (str): Path to the fabric.mod.json file to update. data (dict): Dictionary of metadata fields to update. Common fields include 'id', 'name', 'version', 'description', and 'authors'. Raises: FileNotFoundError: If the fabric.mod.json file doesn't exist. json.JSONDecodeError: If the existing file contains invalid JSON. Example: Updating mod metadata:: mod.update_mod_metadata("path/to/fabric.mod.json", { "id": "mymod", "name": "My Mod", "version": "1.0.0", "authors": ["Me"] }) """ if not os.path.exists(path): raise FileNotFoundError("fabric.mod.json not found") with open(path, "r", encoding="utf-8") as fh: meta = json.load(fh) meta.update(data) with open(path, "w", encoding="utf-8") as fh: json.dump(meta, fh, indent=2) print("Updated fabric.mod.json\n")
# ------------------------------------------------------------------ # # 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) ──────────────────────────────────── #
[docs] def write_block_tags(self, project_dir: str, mod_id: str) -> None: """Write mineable and mining-level block-tag JSON files. For every registered block that specifies a ``tool_type`` (and/or keys in ``mining_speeds``), the block is added to the corresponding ``data/minecraft/tags/block/mineable/<tool>.json`` tags. Blocks that specify a ``mining_level`` (``"stone"``, ``"iron"``, or ``"diamond"``) are also added to ``data/minecraft/tags/block/needs_<level>_tool.json``. Args: project_dir: Root directory of the mod project. mod_id: The mod's identifier. """ # ── collect mineable tool types ────────────────────────────── # tool_map: Dict[str, List[str]] = defaultdict(list) for blk in self.registered_blocks: tools_for_block: set[str] = set() tool = getattr(blk, "tool_type", None) if tool: tools_for_block.add(tool) mining_speeds = getattr(blk, "mining_speeds", None) if mining_speeds: tools_for_block.update(mining_speeds.keys()) for t in tools_for_block: tool_map[t].append(blk.id) for tool, block_ids in tool_map.items(): tag_dir = os.path.join( project_dir, "src", "main", "resources", "data", "minecraft", "tags", "block", "mineable", ) os.makedirs(tag_dir, exist_ok=True) tag_path = os.path.join(tag_dir, f"{tool}.json") # Merge with an existing tag file if present existing_values: List[str] = [] if os.path.exists(tag_path): with open(tag_path, "r", encoding="utf-8") as fh: existing = json.load(fh) existing_values = existing.get("values", []) merged = list(dict.fromkeys(existing_values + block_ids)) tag_data = {"replace": False, "values": merged} with open(tag_path, "w", encoding="utf-8") as fh: json.dump(tag_data, fh, indent=2) print(f" ✔ wrote block tag → {os.path.relpath(tag_path, project_dir)}") # ── collect mining-level requirements ─────────────────────── # level_map: Dict[str, List[str]] = defaultdict(list) for blk in self.registered_blocks: level = getattr(blk, "mining_level", None) if level: level_map[level].append(blk.id) for level, block_ids in level_map.items(): tag_dir = os.path.join( project_dir, "src", "main", "resources", "data", "minecraft", "tags", "block", ) os.makedirs(tag_dir, exist_ok=True) tag_path = os.path.join(tag_dir, f"needs_{level}_tool.json") existing_values = [] if os.path.exists(tag_path): with open(tag_path, "r", encoding="utf-8") as fh: existing = json.load(fh) existing_values = existing.get("values", []) merged = list(dict.fromkeys(existing_values + block_ids)) tag_data = {"replace": False, "values": merged} with open(tag_path, "w", encoding="utf-8") as fh: json.dump(tag_data, fh, indent=2) print( f" ✔ wrote mining-level tag → {os.path.relpath(tag_path, project_dir)}" )
# ================================================================== # # 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