diff --git a/src/main/java/com/ldtteam/structurize/Structurize.java b/src/main/java/com/ldtteam/structurize/Structurize.java index e88cddd40..49424bcdf 100644 --- a/src/main/java/com/ldtteam/structurize/Structurize.java +++ b/src/main/java/com/ldtteam/structurize/Structurize.java @@ -14,6 +14,7 @@ import com.ldtteam.structurize.event.ClientLifecycleSubscriber; import com.ldtteam.structurize.event.EventSubscriber; import com.ldtteam.structurize.event.LifecycleSubscriber; +import com.ldtteam.structurize.index.packtypes.PackTypesRegistry; import com.ldtteam.structurize.items.ModItemGroups; import com.ldtteam.structurize.items.ModItems; import com.ldtteam.structurize.blockentities.ModBlockEntities; @@ -58,6 +59,7 @@ public Structurize(final FMLModContainer modContainer, final Dist dist) ModItems.ITEMS.register(modBus); ModBlockEntities.BLOCK_ENTITIES.register(modBus); ModItemGroups.TAB_REG.register(modBus); + PackTypesRegistry.DEFERRED_REGISTER.register(modBus); modBus.register(LifecycleSubscriber.class); forgeBus.register(EventSubscriber.class); diff --git a/src/main/java/com/ldtteam/structurize/api/Registries.java b/src/main/java/com/ldtteam/structurize/api/Registries.java new file mode 100644 index 000000000..c1a7397fc --- /dev/null +++ b/src/main/java/com/ldtteam/structurize/api/Registries.java @@ -0,0 +1,13 @@ +package com.ldtteam.structurize.api; + +import com.ldtteam.structurize.api.constants.Constants; +import com.ldtteam.structurize.index.packtypes.models.PackType; +import net.minecraft.core.Registry; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.ResourceLocation; + +public class Registries +{ + public static final ResourceKey> SCHEMATIC_INDEX_PACK_TYPES = + ResourceKey.createRegistryKey(ResourceLocation.fromNamespaceAndPath(Constants.MOD_ID, ("schematic_index_pack_types"))); +} diff --git a/src/main/java/com/ldtteam/structurize/api/constants/TranslationConstants.java b/src/main/java/com/ldtteam/structurize/api/constants/TranslationConstants.java index 864805469..eb9dcc803 100644 --- a/src/main/java/com/ldtteam/structurize/api/constants/TranslationConstants.java +++ b/src/main/java/com/ldtteam/structurize/api/constants/TranslationConstants.java @@ -14,10 +14,25 @@ public final class TranslationConstants public static final String ANCHOR_POS_OUTSIDE_SCHEMATIC = "item.sceptersteel.badanchorpos"; @NonNls - public static final String GUI_SWITCH_PACK_AUTHORS = "com.ldtteam.structurize.gui.switchpack.authors"; + public static final String GUI_SWITCH_PACK_AUTHORS = "com.ldtteam.structurize.gui.switchpack.authors"; @NonNls public static final String GUI_SWITCH_PACK_DISABLED_TEXT = "com.ldtteam.structurize.gui.switchpack.pack_disabled.hover_text"; + @NonNls + public static final String PACK_TYPE_VALIDATION_REQUIRED_SCHEMATIC = "com.ldtteam.structurize.pack_type.validation.missing_required_schematic"; + @NonNls + public static final String PACK_TYPE_VALIDATION_OPTIONAL_SCHEMATIC = "com.ldtteam.structurize.pack_type.validation.missing_optional_schematic"; + @NonNls + public static final String PACK_TYPE_VALIDATION_REQUIREMENT_PART_NAME = "com.ldtteam.structurize.pack_type.validation.requirement_part_name"; + @NonNls + public static final String PACK_TYPE_VALIDATION_REQUIREMENT_PART_PATH = "com.ldtteam.structurize.pack_type.validation.requirement_part_path"; + @NonNls + public static final String PACK_TYPE_VALIDATION_REQUIREMENT_PART_ANCHOR = "com.ldtteam.structurize.pack_type.validation.requirement_part_anchor"; + @NonNls + public static final String PACK_TYPE_VALIDATION_REQUIREMENT_PART_AND = "com.ldtteam.structurize.pack_type.validation.requirement_part_and"; + @NonNls + public static final String PACK_TYPE_VALIDATION_MISSING_LEVEL = "com.ldtteam.structurize.pack_type.validation.missing_level"; + private TranslationConstants() { //empty default diff --git a/src/main/java/com/ldtteam/structurize/blueprints/v1/IBlueprintDetails.java b/src/main/java/com/ldtteam/structurize/blueprints/v1/IBlueprintDetails.java new file mode 100644 index 000000000..467448f1f --- /dev/null +++ b/src/main/java/com/ldtteam/structurize/blueprints/v1/IBlueprintDetails.java @@ -0,0 +1,74 @@ +package com.ldtteam.structurize.blueprints.v1; + +import net.minecraft.core.BlockPos; +import org.jetbrains.annotations.Nullable; + +/** + * Provides the details required to create a {@link Blueprint} from a world region + * via {@link BlueprintUtil#createBlueprint(IBlueprintDetails, net.minecraft.server.level.ServerLevel)}. + * + *

Implemented by both {@link com.ldtteam.structurize.index.models.PackSchematic} (for validation + * of stored schematics) and scan GUI providers (for live scans via {@link ScanUtil}). + */ +public interface IBlueprintDetails +{ + /** + * Returns one corner of the bounding box. + * + * @return the first corner position + */ + BlockPos getPos1(); + + /** + * Returns the opposite corner of the bounding box. + * + * @return the second corner position + */ + BlockPos getPos2(); + + /** + * Returns the relative folder path of the schematic, empty for root-level schematics. + * + * @return the schematic folder path, never {@code null} + */ + String getSchematicPath(); + + /** + * Returns the schematic file name without extension. + * + * @return the schematic name, never {@code null} + */ + String getSchematicName(); + + /** + * Returns the building level this schematic represents, or {@code null} if no level suffix + * should be appended to the file name. + * + * @return the schematic level, or {@code null} + */ + @Nullable + Integer getSchematicLevel(); + + /** + * Returns the full schematic path including the file name and optional level digit (without extension). + * For example: {@code "huts/miner2"} for level 2, or {@code "huts/miner"} when level is {@code null}. + * + * @return the full schematic path + */ + default String getFullSchematicPath() + { + final String path = getSchematicPath(); + final Integer level = getSchematicLevel(); + final String nameWithLevel = level != null ? getSchematicName() + level : getSchematicName(); + return path.isEmpty() ? nameWithLevel : path + "/" + nameWithLevel; + } + + /** + * Returns the world position of the anchor block, or {@code null} if none is set. + * + * @return the anchor position, or {@code null} + */ + @Nullable + BlockPos getAnchor(); + +} diff --git a/src/main/java/com/ldtteam/structurize/commands/EntryPoint.java b/src/main/java/com/ldtteam/structurize/commands/EntryPoint.java index bdc56490e..49c4a8013 100644 --- a/src/main/java/com/ldtteam/structurize/commands/EntryPoint.java +++ b/src/main/java/com/ldtteam/structurize/commands/EntryPoint.java @@ -44,9 +44,11 @@ public static void register(final CommandDispatcher dispatch .addNode(UpdateSchematicsCommand::build, () -> CommandSelection.INTEGRATED) .addNode(ScanCommand::build, AbstractCommand::getEnvironmentType) .addNode(PasteCommand::build, AbstractCommand::getEnvironmentType) - .addNode(PasteFolderCommand::build, AbstractCommand::getEnvironmentType) + .addNode(PasteFolderCommand::build, AbstractCommand::getEnvironmentType) .addNode(UpgradeCommand.ToDO::build, () -> CommandSelection.ALL) - .addNode(UpdateSchematicPackCommand::build, () -> CommandSelection.ALL); + .addNode(UpdateSchematicPackCommand::build, () -> CommandSelection.ALL) + // TODO: Remove before publication — debug command only + .addNode(PackIndexCommand::build, PackIndexCommand::getEnvironmentType); structurizeRoot.register(dispatcher, environment); } diff --git a/src/main/java/com/ldtteam/structurize/commands/PackIndexCommand.java b/src/main/java/com/ldtteam/structurize/commands/PackIndexCommand.java new file mode 100644 index 000000000..7f47f7345 --- /dev/null +++ b/src/main/java/com/ldtteam/structurize/commands/PackIndexCommand.java @@ -0,0 +1,86 @@ +// TODO: Remove before publication — debug command only +package com.ldtteam.structurize.commands; + +import com.ldtteam.structurize.index.PackManager; +import com.ldtteam.structurize.index.models.PackSchematic; +import com.ldtteam.structurize.index.models.PackSchematicValidationState; +import com.ldtteam.structurize.index.packtypes.PackTypesRegistry; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands.CommandSelection; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public class PackIndexCommand extends AbstractCommand +{ + private static final Logger LOGGER = LogManager.getLogger(); + + public static final String NAME = "packindex"; + + protected static CommandSelection getEnvironmentType() + { + return CommandSelection.ALL; + } + + protected static LiteralArgumentBuilder build() + { + return newLiteral(NAME) + .then(newLiteral("add") + .then(newLiteral("pack") + .then(newArgument("packName", StringArgumentType.string()) + .executes(ctx -> addPack(ctx.getSource(), StringArgumentType.getString(ctx, "packName"), ctx.getSource().getLevel())))) + .then(newLiteral("schematic") + .then(newArgument("packId", StringArgumentType.string()) + .then(newArgument("schematicName", StringArgumentType.string()) + .then(newArgument("path", StringArgumentType.string()) + .then(newArgument("level", IntegerArgumentType.integer(0)) + .executes(ctx -> addSchematic( + ctx.getSource(), + StringArgumentType.getString(ctx, "packId"), + StringArgumentType.getString(ctx, "schematicName"), + StringArgumentType.getString(ctx, "path"), + IntegerArgumentType.getInteger(ctx, "level"))))))))); + } + + private static int addPack(final CommandSourceStack source, final String packName, final ServerLevel level) + { + final String newId = PackManager.addPack(packName, PackTypesRegistry.DEFAULT_PACK_TYPE, level); + + if (newId != null) + { + LOGGER.info("[PackIndex] Added pack '{}'", packName); + source.sendSuccess(() -> net.minecraft.network.chat.Component.literal("Added pack: " + packName), false); + } + else + { + LOGGER.info("[PackIndex] Pack not added: '{}', duplicate name", packName); + source.sendSuccess(() -> net.minecraft.network.chat.Component.literal("Couldn't add pack: " + packName + ", duplicate name"), false); + } + return 1; + } + + private static int addSchematic( + final CommandSourceStack source, + final String packId, + final String schematicName, + final String path, + final int level) + { + if (PackManager.getPack(packId) == null) + { + source.sendFailure(net.minecraft.network.chat.Component.literal("Pack not found: " + packId)); + return 0; + } + + final PackSchematic schematic = new PackSchematic(path, schematicName, level, BlockPos.ZERO, BlockPos.ZERO, null, new PackSchematicValidationState()); + PackManager.addSchematic(packId, schematic); + + LOGGER.info("[PackIndex] Added schematic '{}' to pack '{}'", schematicName, packId); + source.sendSuccess(() -> net.minecraft.network.chat.Component.literal("Added schematic '" + schematicName + "' to pack '" + packId + "'"), false); + return 1; + } +} diff --git a/src/main/java/com/ldtteam/structurize/event/EventSubscriber.java b/src/main/java/com/ldtteam/structurize/event/EventSubscriber.java index ef97526ec..bd1479fea 100644 --- a/src/main/java/com/ldtteam/structurize/event/EventSubscriber.java +++ b/src/main/java/com/ldtteam/structurize/event/EventSubscriber.java @@ -1,12 +1,15 @@ package com.ldtteam.structurize.event; import com.ldtteam.structurize.commands.EntryPoint; +import com.ldtteam.structurize.index.PackManager; import com.ldtteam.structurize.management.Manager; import com.ldtteam.structurize.util.BlockUtils; import com.ldtteam.structurize.util.IOPool; import net.minecraft.server.level.ServerLevel; import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.neoforge.event.RegisterCommandsEvent; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; +import net.neoforged.neoforge.event.level.LevelEvent; import net.neoforged.neoforge.event.server.ServerStoppingEvent; import net.neoforged.neoforge.event.tick.LevelTickEvent; import org.jetbrains.annotations.NotNull; @@ -31,6 +34,24 @@ private EventSubscriber() * * @param event event */ + @SubscribeEvent + public static void onLevelLoad(final LevelEvent.Load event) + { + PackManager.onLevelLoad(event); + } + + @SubscribeEvent + public static void onLevelUnload(final LevelEvent.Unload event) + { + PackManager.onLevelUnload(event); + } + + @SubscribeEvent + public static void onPlayerJoin(final PlayerEvent.PlayerLoggedInEvent event) + { + PackManager.onPlayerJoin(event); + } + @SubscribeEvent public static void onRegisterCommands(final RegisterCommandsEvent event) { diff --git a/src/main/java/com/ldtteam/structurize/event/LifecycleSubscriber.java b/src/main/java/com/ldtteam/structurize/event/LifecycleSubscriber.java index 5b0793421..661055ea3 100644 --- a/src/main/java/com/ldtteam/structurize/event/LifecycleSubscriber.java +++ b/src/main/java/com/ldtteam/structurize/event/LifecycleSubscriber.java @@ -2,6 +2,7 @@ import com.ldtteam.common.language.LanguageHandler; import com.ldtteam.structurize.api.constants.Constants; +import com.ldtteam.structurize.network.messages.SyncPackManagerMessage; import com.ldtteam.structurize.datagen.BlockEntityTagProvider; import com.ldtteam.structurize.datagen.BlockTagProvider; import com.ldtteam.structurize.datagen.EntityTagProvider; @@ -16,8 +17,12 @@ import net.neoforged.neoforge.data.event.GatherDataEvent; import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; import net.neoforged.neoforge.network.registration.PayloadRegistrar; +import net.neoforged.neoforge.registries.NewRegistryEvent; +import net.neoforged.neoforge.registries.RegistryBuilder; import org.jetbrains.annotations.NotNull; +import static com.ldtteam.structurize.api.Registries.SCHEMATIC_INDEX_PACK_TYPES; + public class LifecycleSubscriber { @SubscribeEvent @@ -44,6 +49,7 @@ public static void onNetworkRegistry(final RegisterPayloadHandlersEvent event) ScanToolTeleportMessage.TYPE.register(registry); SetTagInTool.TYPE.register(registry); ShowScanMessage.TYPE.register(registry); + SyncPackManagerMessage.TYPE.register(registry); SyncPreviewCacheToClient.TYPE.register(registry); SyncPreviewCacheToServer.TYPE.register(registry); SyncSettingsToServer.TYPE.register(registry); @@ -78,4 +84,10 @@ public static void onDatagen(@NotNull final GatherDataEvent event) generator.addProvider(event.includeServer(), new BlockTagProvider(event.getGenerator().getPackOutput(), Registries.BLOCK, event.getLookupProvider(), event.getExistingFileHelper())); generator.addProvider(event.includeClient(), new EntityTagProvider(event.getGenerator().getPackOutput(), Registries.ENTITY_TYPE, event.getLookupProvider(), event.getExistingFileHelper())); } + + @SubscribeEvent + public static void registerNewRegistries(final NewRegistryEvent event) + { + event.create(new RegistryBuilder<>(SCHEMATIC_INDEX_PACK_TYPES).sync(true)); + } } diff --git a/src/main/java/com/ldtteam/structurize/index/LevelPackData.java b/src/main/java/com/ldtteam/structurize/index/LevelPackData.java new file mode 100644 index 000000000..f73971bf1 --- /dev/null +++ b/src/main/java/com/ldtteam/structurize/index/LevelPackData.java @@ -0,0 +1,114 @@ +package com.ldtteam.structurize.index; + +import com.ldtteam.structurize.index.models.Pack; +import com.ldtteam.structurize.network.messages.SyncPackManagerMessage; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.saveddata.SavedData; +import net.neoforged.neoforge.network.PacketDistributor; +import org.jetbrains.annotations.NotNull; + +import java.util.HashMap; +import java.util.Map; + +/** + * Per-level SavedData storing the packs that belong to a specific level's data folder. + * One instance is loaded per {@link net.minecraft.server.level.ServerLevel}; the merged view is managed by {@link PackManager}. + */ +public class LevelPackData extends SavedData +{ + public static final String DATA_NAME = "structurize_pack_manager"; + + private static final String NBT_PACKS = "packs"; + + private final ResourceKey dimension; + + private final Map ownedPacks = new HashMap<>(); + + private LevelPackData(final ResourceKey dimension) + { + this.dimension = dimension; + } + + /** + * Returns a factory for the given dimension. Must be constructed per call-site since the + * dimension key is needed for the empty-constructor supplier. + */ + public static SavedData.Factory factoryFor(final ResourceKey dimension) + { + return new SavedData.Factory<>(() -> new LevelPackData(dimension), (tag, provider) -> deserialize(tag, provider, dimension)); + } + + private static LevelPackData deserialize(final @NotNull CompoundTag compound, final @NotNull HolderLookup.Provider provider, final ResourceKey dimension) + { + final LevelPackData data = new LevelPackData(dimension); + final ListTag list = compound.getList(NBT_PACKS, Tag.TAG_COMPOUND); + for (int i = 0; i < list.size(); i++) + { + final Pack pack = Pack.load(list.getCompound(i), provider); + data.ownedPacks.put(pack.id(), pack); + } + return data; + } + + @Override + @NotNull + public CompoundTag save(final @NotNull CompoundTag tag, final @NotNull HolderLookup.Provider provider) + { + final ListTag list = new ListTag(); + for (final Pack pack : ownedPacks.values()) + { + list.add(pack.save(provider)); + } + tag.put(NBT_PACKS, list); + return tag; + } + + /** + * Marks this data as dirty and, if dirtied, invalidates the merged pack cache and broadcasts + * an updated {@link SyncPackManagerMessage} to all connected players. + * + * @param value {@code true} to mark dirty and trigger a sync; {@code false} to clear the flag + */ + @Override + public void setDirty(final boolean value) + { + super.setDirty(value); + if (value) + { + PackManager.invalidateServerPacksSorted(); + PacketDistributor.sendToAllPlayers(new SyncPackManagerMessage(PackManager.getServerPacksMap())); + } + } + + /** + * Returns the dimension key that identifies the {@link net.minecraft.server.level.ServerLevel} this data belongs to. + * + * @return the dimension key + */ + public ResourceKey getDimension() + { + return dimension; + } + + /** + * Read-only view of packs owned by this level, used during load merging. + */ + public Map getOwnedPacks() + { + return ownedPacks; + } + + /** + * Mutable access for PackManager to insert newly created packs into this level's owned set. + * Package-private intentionally. + */ + Map getOwnedPacksMutable() + { + return ownedPacks; + } +} diff --git a/src/main/java/com/ldtteam/structurize/index/PackManager.java b/src/main/java/com/ldtteam/structurize/index/PackManager.java new file mode 100644 index 000000000..fbf0210b5 --- /dev/null +++ b/src/main/java/com/ldtteam/structurize/index/PackManager.java @@ -0,0 +1,232 @@ +package com.ldtteam.structurize.index; + +import com.ldtteam.structurize.index.models.Pack; +import com.ldtteam.structurize.index.models.PackSchematic; +import com.ldtteam.structurize.index.packtypes.models.PackType; +import com.ldtteam.structurize.network.messages.SyncPackManagerMessage; +import net.minecraft.core.Holder; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.Level; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; +import net.neoforged.neoforge.event.level.LevelEvent; +import net.neoforged.neoforge.network.PacketDistributor; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.*; + +/** + * Static utility managing the global merged pack list across all loaded levels. + * Each {@link ServerLevel} owns a {@link LevelPackData} that loads/saves its packs independently; + * this class merges them into a single in-memory view and handles client synchronisation. + */ +public final class PackManager +{ + /** + * Tracks which dimension each pack was loaded from, for cleanup on level unload. + */ + private static final Map> packOwnerDimension = new HashMap<>(); + + /** + * Live {@link LevelPackData} instances, one per currently loaded {@link ServerLevel}. + */ + private static final Map, LevelPackData> loadedLevelData = new HashMap<>(); + + /** + * Cached sorted view of the server-side merged pack map, invalidated whenever packs are added or levels load/unload. + */ + private static List serverPacksSorted = List.of(); + + /** + * Client-side pack list, populated via {@link SyncPackManagerMessage}. + */ + private static final Map clientPacks = new HashMap<>(); + + /** + * Cached sorted view of {@link #clientPacks}, invalidated whenever the map changes. + */ + private static List clientPacksSorted = List.of(); + + private PackManager() {} + + /** + * Loads each level's packs into the global map when the level loads. + */ + public static void onLevelLoad(final LevelEvent.Load event) + { + if (!(event.getLevel() instanceof final ServerLevel serverLevel)) + { + return; + } + + final ResourceKey dimension = serverLevel.dimension(); + final LevelPackData data = serverLevel.getDataStorage().computeIfAbsent(LevelPackData.factoryFor(dimension), LevelPackData.DATA_NAME); + + loadedLevelData.put(dimension, data); + + for (final String packId : data.getOwnedPacks().keySet()) + { + packOwnerDimension.put(packId, dimension); + } + + invalidateServerPacksSorted(); + } + + /** + * Removes the unloaded level's packs from the global map. + */ + public static void onLevelUnload(final LevelEvent.Unload event) + { + if (!(event.getLevel() instanceof final ServerLevel serverLevel)) + { + return; + } + + final ResourceKey dimension = serverLevel.dimension(); + loadedLevelData.remove(dimension); + packOwnerDimension.entrySet().removeIf(entry -> entry.getValue().equals(dimension)); + + invalidateServerPacksSorted(); + + if (loadedLevelData.isEmpty()) + { + clientPacks.clear(); + clientPacksSorted = List.of(); + } + } + + /** + * Syncs the pack list to a player when they join. + */ + public static void onPlayerJoin(final PlayerEvent.PlayerLoggedInEvent event) + { + if (!(event.getEntity() instanceof final ServerPlayer serverPlayer)) + { + return; + } + + PacketDistributor.sendToPlayer(serverPlayer, new SyncPackManagerMessage(getServerPacksMap())); + } + + /** + * Returns the server-sided pack list, sorted by name. + */ + @NotNull + public static List getServerPacks() + { + return serverPacksSorted; + } + + /** + * Rebuilds the cached sorted server pack list from the current merged map. + * Called whenever packs are added or levels load/unload. + */ + static void invalidateServerPacksSorted() + { + serverPacksSorted = getServerPacksMap().values().stream().sorted(Comparator.comparing(Pack::name)).toList(); + } + + /** + * Returns an unmodifiable merged view of all loaded levels' packs. + * Used by {@link LevelPackData#setDirty} to populate sync messages. + */ + @NotNull + static Map getServerPacksMap() + { + final Map merged = new LinkedHashMap<>(); + for (final LevelPackData data : loadedLevelData.values()) + { + merged.putAll(data.getOwnedPacks()); + } + return Collections.unmodifiableMap(merged); + } + + /** + * Returns the client-sided pack list, sorted by name. + */ + @NotNull + public static List getClientPacks() + { + return clientPacksSorted; + } + + /** + * Called by {@link SyncPackManagerMessage} on the client to store the synced pack list. + */ + public static void onClientSync(final @NotNull Map packs) + { + clientPacks.clear(); + clientPacks.putAll(packs); + clientPacksSorted = clientPacks.values().stream().sorted(Comparator.comparing(Pack::name)).toList(); + } + + /** + * Adds a new pack, owned by the given level's data file. + * + * @return the new pack's ID, or {@code null} if a pack with that name already exists or the level is not loaded. + */ + @Nullable + public static String addPack(final @NotNull String name, final @NotNull Holder packType, final ServerLevel level) + { + final String id = name.toLowerCase(Locale.ROOT).replace(" ", "_"); + + final LevelPackData target = loadedLevelData.get(level.dimension()); + if (target == null) + { + return null; + } + + if (target.getOwnedPacks().containsKey(id)) + { + return null; + } + + final Pack pack = new Pack(id, name, packType); + packOwnerDimension.put(id, level.dimension()); + target.getOwnedPacksMutable().put(id, pack); + + target.setDirty(); + return id; + } + + /** + * Returns the pack with the given ID, or {@code null} if not loaded. + */ + @Nullable + public static Pack getPack(final @NotNull String packId) + { + final LevelPackData data = getLevelPackData(packId); + return data != null ? data.getOwnedPacks().get(packId) : null; + } + + /** + * Adds or replaces a schematic in the given pack and marks that pack's owning level dirty. + * If a schematic with the same path, name, and level already exists it is replaced. + */ + public static void addSchematic(final @NotNull String packId, final @NotNull PackSchematic schematic) + { + final LevelPackData data = getLevelPackData(packId); + if (data == null) + { + return; + } + + final Pack pack = data.getOwnedPacks().get(packId); + if (pack == null) + { + return; + } + + data.getOwnedPacksMutable().put(packId, pack.withSchematic(schematic)); + data.setDirty(); + } + + @Nullable + private static LevelPackData getLevelPackData(final @NotNull String packId) + { + final ResourceKey owner = packOwnerDimension.get(packId); + return owner != null ? loadedLevelData.get(owner) : null; + } +} diff --git a/src/main/java/com/ldtteam/structurize/index/PackSchematicValidationCollector.java b/src/main/java/com/ldtteam/structurize/index/PackSchematicValidationCollector.java new file mode 100644 index 000000000..50fa00ccf --- /dev/null +++ b/src/main/java/com/ldtteam/structurize/index/PackSchematicValidationCollector.java @@ -0,0 +1,45 @@ +package com.ldtteam.structurize.index; + +import com.ldtteam.structurize.index.packtypes.models.PackTypeSchematicRequirementSeverity; +import net.minecraft.network.chat.Component; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +/** + * A temporary collector that accumulates per-schematic validation issues and can commit them + * to a {@link com.ldtteam.structurize.index.models.PackSchematicValidationState}. + * + *

The constructor is package-private, so only classes within {@code com.ldtteam.structurize.index} + * can create instances. This prevents external code from getting write access to a + * {@link com.ldtteam.structurize.index.models.PackSchematicValidationState}. + */ +public final class PackSchematicValidationCollector +{ + private final Map> issues = new EnumMap<>(PackTypeSchematicRequirementSeverity.class); + + PackSchematicValidationCollector() {} + + /** + * Appends an issue message at the given severity level. + * + * @param severity the severity of the issue + * @param message the issue message + */ + public void addIssue(final PackTypeSchematicRequirementSeverity severity, final Component message) + { + issues.computeIfAbsent(severity, k -> new ArrayList<>()).add(message); + } + + /** + * Returns the accumulated issues map. + * + * @return the issues map + */ + public Map> getIssues() + { + return issues; + } +} diff --git a/src/main/java/com/ldtteam/structurize/index/models/Pack.java b/src/main/java/com/ldtteam/structurize/index/models/Pack.java new file mode 100644 index 000000000..8a063ac31 --- /dev/null +++ b/src/main/java/com/ldtteam/structurize/index/models/Pack.java @@ -0,0 +1,115 @@ +package com.ldtteam.structurize.index.models; + +import com.ldtteam.structurize.api.Registries; +import com.ldtteam.structurize.index.packtypes.models.PackType; +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.NbtOps; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.resources.RegistryFixedCodec; +import org.jetbrains.annotations.NotNull; + +import java.util.*; + +/** + * Immutable record representing a schematic pack — a named, typed collection of {@link PackSchematic}s + * together with a cached {@link PackValidationState}. + * + *

Packs are owned by a specific {@link net.minecraft.server.level.ServerLevel} via + * {@link com.ldtteam.structurize.index.LevelPackData} and are merged into a global view by + * {@link com.ldtteam.structurize.index.PackManager}. + * + *

Equality and hashing are based solely on {@link #id} so that a pack can be located in sets + * and maps without caring about its mutable validation state. + */ +public record Pack( + @NotNull String id, + @NotNull String name, + @NotNull Holder type, + @NotNull Set schematics, + @NotNull PackValidationState validationState) +{ + private static final Codec> SCHEMATICS_CODEC = PackSchematic.CODEC.listOf().xmap(HashSet::new, ArrayList::new); + + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group(Codec.STRING.fieldOf("id").forGetter(Pack::id), + Codec.STRING.fieldOf("name").forGetter(Pack::name), + RegistryFixedCodec.create(Registries.SCHEMATIC_INDEX_PACK_TYPES).fieldOf("type").forGetter(Pack::type), + SCHEMATICS_CODEC.fieldOf("schematics").forGetter(Pack::schematics), + PackValidationState.CODEC.fieldOf("validation-state").forGetter(Pack::validationState)).apply(instance, Pack::new)); + + public static final StreamCodec STREAM_CODEC = ByteBufCodecs.fromCodecWithRegistries(CODEC); + + public static final StreamCodec> MAP_STREAM_CODEC = ByteBufCodecs.map(HashMap::new, ByteBufCodecs.STRING_UTF8, STREAM_CODEC); + + /** + * Convenience constructor for a new, empty pack with no schematics and a fresh validation state. + * + * @param id the unique pack identifier (typically a lowercase, underscore-separated string) + * @param name the human-readable display name + * @param type the {@link PackType} governing which schematics and requirements apply to this pack + */ + public Pack(@NotNull final String id, @NotNull final String name, @NotNull final Holder type) + { + this(id, name, type, Collections.emptySet(), new PackValidationState()); + } + + public Pack + { + schematics = Set.copyOf(schematics); + } + + /** + * Deserializes a {@link Pack} from the given NBT compound using the provided registry context. + * + * @param compound the NBT compound to read from + * @param provider the registry lookup provider for holder resolution + * @return the deserialized pack + */ + public static Pack load(final @NotNull CompoundTag compound, final @NotNull HolderLookup.Provider provider) + { + return CODEC.parse(provider.createSerializationContext(NbtOps.INSTANCE), compound).getOrThrow(); + } + + /** + * Serializes this pack to an NBT compound using the provided registry context. + * + * @param provider the registry lookup provider for holder serialization + * @return the serialized NBT compound + */ + public CompoundTag save(final @NotNull HolderLookup.Provider provider) + { + return (CompoundTag) CODEC.encodeStart(provider.createSerializationContext(NbtOps.INSTANCE), this).getOrThrow(); + } + + /** + * Returns a new {@link Pack} with the given schematic added, replacing any existing schematic with the same identity. + */ + public Pack withSchematic(final @NotNull PackSchematic schematic) + { + final Set updated = new HashSet<>(schematics); + updated.remove(schematic); + updated.add(schematic); + return new Pack(id, name, type, updated, validationState); + } + + @Override + public boolean equals(final Object o) + { + if (!(o instanceof Pack other)) + { + return false; + } + return id.equals(other.id); + } + + @Override + public int hashCode() + { + return id.hashCode(); + } +} diff --git a/src/main/java/com/ldtteam/structurize/index/models/PackSchematic.java b/src/main/java/com/ldtteam/structurize/index/models/PackSchematic.java new file mode 100644 index 000000000..865b4c731 --- /dev/null +++ b/src/main/java/com/ldtteam/structurize/index/models/PackSchematic.java @@ -0,0 +1,95 @@ +package com.ldtteam.structurize.index.models; + +import com.ldtteam.structurize.blueprints.v1.IBlueprintDetails; +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.core.BlockPos; + +import java.util.Objects; +import java.util.Optional; + +/** + * Immutable record representing a single schematic entry within a {@link Pack}. + * + *

Equality and hashing are based on {@link #path}, {@link #name}, and {@link #level} so that + * replacing a schematic (e.g. after rescanning) correctly overwrites the old entry in a set. + * + * @param path the relative folder path of the schematic file, may be empty for root-level schematics + * @param name the schematic file name (without extension) + * @param level the building level this schematic represents (1-based); defaults to 1 when omitted from storage + * @param pos1 one corner of the bounding box in world space + * @param pos2 the opposite corner of the bounding box in world space + * @param anchor the world position of the anchor block, or {@code null} if not set + * @param validationState the most recent per-schematic validation result + */ +public record PackSchematic( + String path, + String name, + int level, + BlockPos pos1, + BlockPos pos2, + Optional anchor, + PackSchematicValidationState validationState) implements IBlueprintDetails +{ + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.STRING.fieldOf("path").forGetter(PackSchematic::path), + Codec.STRING.fieldOf("name").forGetter(PackSchematic::name), + Codec.INT.optionalFieldOf("level", 1).forGetter(PackSchematic::level), + BlockPos.CODEC.fieldOf("pos1").forGetter(PackSchematic::pos1), + BlockPos.CODEC.fieldOf("pos2").forGetter(PackSchematic::pos2), + BlockPos.CODEC.optionalFieldOf("anchor").forGetter(PackSchematic::anchor), + PackSchematicValidationState.CODEC.fieldOf("validation-state").forGetter(PackSchematic::validationState) + ).apply(instance, PackSchematic::new)); + + @Override + public BlockPos getPos1() + { + return pos1; + } + + @Override + public BlockPos getPos2() + { + return pos2; + } + + @Override + public String getSchematicPath() + { + return path; + } + + @Override + public String getSchematicName() + { + return name; + } + + @Override + public Integer getSchematicLevel() + { + return level; + } + + @Override + public BlockPos getAnchor() + { + return anchor.orElse(null); + } + + @Override + public boolean equals(final Object o) + { + if (!(o instanceof PackSchematic other)) + { + return false; + } + return level == other.level && path.equals(other.path) && name.equals(other.name); + } + + @Override + public int hashCode() + { + return Objects.hash(path, name, level); + } +} diff --git a/src/main/java/com/ldtteam/structurize/index/models/PackSchematicValidationState.java b/src/main/java/com/ldtteam/structurize/index/models/PackSchematicValidationState.java new file mode 100644 index 000000000..ce305d41e --- /dev/null +++ b/src/main/java/com/ldtteam/structurize/index/models/PackSchematicValidationState.java @@ -0,0 +1,93 @@ +package com.ldtteam.structurize.index.models; + +import com.ldtteam.structurize.index.PackSchematicValidationCollector; +import com.ldtteam.structurize.index.packtypes.models.PackTypeSchematicRequirement; +import com.ldtteam.structurize.index.packtypes.models.PackTypeSchematicRequirementSeverity; +import com.mojang.serialization.Codec; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.ComponentSerialization; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Holds the per-schematic result of a validation run, keyed by + * {@link PackTypeSchematicRequirementSeverity}. + * + *

Unlike {@link PackValidationState} (which holds pack-level warnings and errors), + * this class captures issues found against individual schematics by + * {@link PackTypeSchematicRequirement}s. + * + *

Instances are written to via {@link #apply(PackSchematicValidationCollector)}, which requires + * a {@link PackSchematicValidationCollector} that can only be constructed within + * {@code com.ldtteam.structurize.index}. + */ +public class PackSchematicValidationState +{ + private static final Codec>> ISSUES_CODEC = + Codec.unboundedMap( + Codec.STRING.xmap(PackTypeSchematicRequirementSeverity::valueOf, Enum::name), + ComponentSerialization.CODEC.listOf() + ); + + public static final Codec CODEC = ISSUES_CODEC.xmap(issues -> { + final PackSchematicValidationState state = new PackSchematicValidationState(); + state.issues = Map.copyOf(issues); + return state; + }, s -> s.issues); + + private Map> issues = Collections.emptyMap(); + + /** + * Commits the issues accumulated in the given {@link PackSchematicValidationCollector} into + * this state, replacing any previously stored results. + * + *

Only classes within {@code com.ldtteam.structurize.index} can create a + * {@link PackSchematicValidationCollector}, so this method cannot be meaningfully called by + * external code. + * + * @param collector the collector holding the accumulated issues + */ + public void apply(final PackSchematicValidationCollector collector) + { + this.issues = Map.copyOf(collector.getIssues()); + } + + /** + * Returns the full issues map, keyed by severity. + * + * @return an unmodifiable map of severity to issue messages + */ + public Map> getIssues() + { + return issues; + } + + /** + * Returns all issue messages for the given severity, or an empty list if none exist. + * + * @param severity the severity to query + * @return the list of issue messages for the given severity, never {@code null} + */ + public List getIssues(final PackTypeSchematicRequirementSeverity severity) + { + return issues.getOrDefault(severity, Collections.emptyList()); + } + + @Override + public boolean equals(final Object o) + { + if (!(o instanceof PackSchematicValidationState other)) + { + return false; + } + return issues.equals(other.issues); + } + + @Override + public int hashCode() + { + return issues.hashCode(); + } +} diff --git a/src/main/java/com/ldtteam/structurize/index/models/PackValidationState.java b/src/main/java/com/ldtteam/structurize/index/models/PackValidationState.java new file mode 100644 index 000000000..40dc372be --- /dev/null +++ b/src/main/java/com/ldtteam/structurize/index/models/PackValidationState.java @@ -0,0 +1,75 @@ +package com.ldtteam.structurize.index.models; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.ComponentSerialization; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Holds the result of a validation run for a {@link Pack}. + */ +public class PackValidationState +{ + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + ComponentSerialization.CODEC.listOf().optionalFieldOf("optional-warnings", List.of()).forGetter(s -> s.optionalWarnings), + ComponentSerialization.CODEC.listOf().optionalFieldOf("required-errors", List.of()).forGetter(s -> s.requiredErrors) + ).apply(instance, (optional, required) -> { + final PackValidationState state = new PackValidationState(); + state.setOptionalWarnings(optional); + state.setRequiredErrors(required); + return state; + })); + + @NotNull + private List optionalWarnings = new ArrayList<>(); + + @NotNull + private List requiredErrors = new ArrayList<>(); + + /** + * Replaces the list of optional (non-blocking) warnings with the given list. + * + * @param warnings the new optional warning messages + */ + public void setOptionalWarnings(final @NotNull List warnings) + { + this.optionalWarnings = new ArrayList<>(warnings); + } + + /** + * Replaces the list of required (blocking) errors with the given list. + * + * @param errors the new required error messages + */ + public void setRequiredErrors(final @NotNull List errors) + { + this.requiredErrors = new ArrayList<>(errors); + } + + /** + * Returns an unmodifiable view of the optional warning messages produced by the last validation run. + * + * @return the optional warnings, never {@code null} + */ + @NotNull + public List getOptionalWarnings() + { + return Collections.unmodifiableList(optionalWarnings); + } + + /** + * Returns an unmodifiable view of the required error messages produced by the last validation run. + * + * @return the required errors, never {@code null} + */ + @NotNull + public List getRequiredErrors() + { + return Collections.unmodifiableList(requiredErrors); + } +} diff --git a/src/main/java/com/ldtteam/structurize/index/packtypes/PackTypesRegistry.java b/src/main/java/com/ldtteam/structurize/index/packtypes/PackTypesRegistry.java new file mode 100644 index 000000000..1e48c7fb1 --- /dev/null +++ b/src/main/java/com/ldtteam/structurize/index/packtypes/PackTypesRegistry.java @@ -0,0 +1,50 @@ +package com.ldtteam.structurize.index.packtypes; + +import com.ldtteam.structurize.api.constants.Constants; +import com.ldtteam.structurize.index.packtypes.models.PackType; +import com.ldtteam.structurize.index.packtypes.models.PackTypeRequirement; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.neoforged.neoforge.registries.DeferredHolder; +import net.neoforged.neoforge.registries.DeferredRegister; + +import java.util.function.Function; + +import static com.ldtteam.structurize.api.Registries.SCHEMATIC_INDEX_PACK_TYPES; + +/** + * Holds the deferred register and built-in {@link PackType} registrations for the schematic index system. + * + *

Third-party mods can register additional pack types by calling + * {@link #register(ResourceLocation, Function)} after getting a reference to this class, + * provided they do so before the registry freezes. + */ +public class PackTypesRegistry +{ + public static final DeferredRegister DEFERRED_REGISTER = DeferredRegister.create(SCHEMATIC_INDEX_PACK_TYPES, Constants.MOD_ID); + + /** + * The built-in default pack type, used as a fallback and for testing. + * Requires a schematic named {@code "test"} at levels 1 and 2, and optionally one named {@code "flex"} at level 1. + */ + public static final DeferredHolder DEFAULT_PACK_TYPE = + register(ResourceLocation.fromNamespaceAndPath(Constants.MOD_ID, "default"), + builder -> builder.withName(Component.literal("Default")) + .addPackRequirement(new PackTypeRequirement.Builder().requiresName("test").requiresLevelCount(2)) + .addPackRequirement(new PackTypeRequirement.Builder().requiresName("flex").requiresLevelCount(1).optional())); + + /** + * Registers a new {@link PackType} with the given ID, configured via the provided function. + * + * @param packId the resource location used as the registry key + * @param configure a function that receives a pre-initialized {@link PackType.Builder} and returns the configured builder + * @return the deferred holder for the registered pack type + */ + public static DeferredHolder register( + final ResourceLocation packId, + final Function configure) + { + final PackType.Builder builder = configure.apply(new PackType.Builder(packId)); + return DEFERRED_REGISTER.register(packId.getPath(), builder::build); + } +} diff --git a/src/main/java/com/ldtteam/structurize/index/packtypes/models/PackType.java b/src/main/java/com/ldtteam/structurize/index/packtypes/models/PackType.java new file mode 100644 index 000000000..17fff9a48 --- /dev/null +++ b/src/main/java/com/ldtteam/structurize/index/packtypes/models/PackType.java @@ -0,0 +1,151 @@ +package com.ldtteam.structurize.index.packtypes.models; + +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; + +import java.util.ArrayList; +import java.util.List; + +/** + * Defines the type of schematic pack, including its display name and the requirements that packs + * and individual schematics of this type must satisfy. + * + *

Instances are registered via NeoForge's registry using {@link com.ldtteam.structurize.index.packtypes.PackTypesRegistry}. + * Use {@link Builder} to construct instances. + */ +public class PackType +{ + private final ResourceLocation id; + + private final Component name; + + private final List packRequirements; + + private final List schematicRequirements; + + private PackType( + final ResourceLocation id, + final Component name, + final List packRequirements, + final List schematicRequirements) + { + this.id = id; + this.name = name; + this.packRequirements = packRequirements; + this.schematicRequirements = schematicRequirements; + } + + /** + * Returns the registry ID of this pack type. + * + * @return the resource location identifier + */ + public ResourceLocation getId() + { + return id; + } + + /** + * Returns the localized display name of this pack type shown in the UI. + * + * @return the display name component + */ + public Component getName() + { + return name; + } + + /** + * Returns the pack-level requirements that a {@link com.ldtteam.structurize.index.models.Pack} of this type must satisfy. + * These are checked against the set of schematics as a whole (e.g., required schematic names and level counts). + * + * @return an unmodifiable list of pack requirements + */ + public List getPackRequirements() + { + return packRequirements; + } + + /** + * Returns the per-schematic requirements evaluated against individual schematics when a full validation is run. + * + * @return an unmodifiable list of schematic requirements + */ + public List getSchematicRequirements() + { + return schematicRequirements; + } + + /** + * Builder for {@link PackType}. + */ + public static class Builder + { + private final ResourceLocation id; + + private Component name; + + private final List packRequirements = new ArrayList<>(); + + private final List schematicRequirements = new ArrayList<>(List.of( + PackTypeSchematicRequirement.ANCHOR_OUTSIDE_BOUNDS, + PackTypeSchematicRequirement.EXCEEDS_BLOCK_LIMIT + )); + + /** + * Creates a builder for a pack type with the given registry ID. + * + * @param id the resource location that will be used to register this pack type + */ + public Builder(final ResourceLocation id) + { + this.id = id; + } + + /** + * Sets the localized display name shown in the UI. + * + * @param name the display name component + * @return this builder + */ + public Builder withName(final Component name) + { + this.name = name; + return this; + } + + /** + * Adds a pack-level requirement using the given builder. + * + * @param requirement the requirement builder to build and add + * @return this builder + */ + public Builder addPackRequirement(final PackTypeRequirement.Builder requirement) + { + this.packRequirements.add(requirement.build()); + return this; + } + + /** + * Adds a per-schematic requirement using the given builder. + * + * @param requirement the requirement builder to build and add + * @return this builder + */ + public Builder addSchematicRequirement(final PackTypeSchematicRequirement.Builder requirement) + { + this.schematicRequirements.add(requirement.build()); + return this; + } + + /** + * Builds the {@link PackType}. + * + * @return the constructed pack type + */ + public PackType build() + { + return new PackType(this.id, this.name, this.packRequirements, this.schematicRequirements); + } + } +} diff --git a/src/main/java/com/ldtteam/structurize/index/packtypes/models/PackTypeRequirement.java b/src/main/java/com/ldtteam/structurize/index/packtypes/models/PackTypeRequirement.java new file mode 100644 index 000000000..279c89cb6 --- /dev/null +++ b/src/main/java/com/ldtteam/structurize/index/packtypes/models/PackTypeRequirement.java @@ -0,0 +1,255 @@ +package com.ldtteam.structurize.index.packtypes.models; + +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.world.level.block.state.BlockState; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +import static com.ldtteam.structurize.api.constants.TranslationConstants.*; + +/** + * A requirement that the pack as a whole must satisfy. + * + *

Requirements assert that one or more schematics exist in the pack matching all the + * specified criteria: + *

+ * + *

If {@link #requiredLevelCount} is set, matching schematics must collectively cover each + * level from 1 through N. For example, a count of 3 requires level 1, 2, and 3 to each be + * present among matching schematics. If not set, at least one matching schematic at any level + * is sufficient. + * + *

By default, requirements are mandatory. Use {@link Builder#optional()} to mark a requirement + * as optional — it will still appear in the UI but will not be flagged as a hard error. + */ +public class PackTypeRequirement +{ + /** + * If non-null, a schematic with this name must exist in the pack. + */ + @Nullable + private final String requiredName; + + /** + * If non-null, a schematic at this path must exist in the pack. + */ + @Nullable + private final String requiredPath; + + /** + * If non-null, matching schematics must collectively cover each level from 1 through this value. + * If null, any single matching schematic at any level satisfies the requirement. + */ + @Nullable + private final Integer requiredLevelCount; + + /** + * If non-null, a schematic with this anchor block type must exist in the pack. + */ + @Nullable + private final BlockState requiredAnchor; + + /** + * Whether this requirement is optional. Optional requirements are shown in the UI but not flagged as mandatory. + */ + private final boolean optional; + + private PackTypeRequirement( + final @Nullable String requiredName, + final @Nullable String requiredPath, + final @Nullable Integer requiredLevelCount, + final @Nullable BlockState requiredAnchor, + final boolean optional) + { + this.requiredName = requiredName; + this.requiredPath = requiredPath; + this.requiredLevelCount = requiredLevelCount; + this.requiredAnchor = requiredAnchor; + this.optional = optional; + } + + @Nullable + public String getRequiredName() + { + return requiredName; + } + + @Nullable + public String getRequiredPath() + { + return requiredPath; + } + + @Nullable + public Integer getRequiredLevelCount() + { + return requiredLevelCount; + } + + @Nullable + public BlockState getRequiredAnchor() + { + return requiredAnchor; + } + + public boolean isOptional() + { + return optional; + } + + /** + * Builds a {@link Component} describing this requirement's failure when no matching schematic + * exists at all, including whether it was required or optional. + * + * @return the failure message component + */ + public Component getFailureDescription() + { + return Component.translatable(optional ? PACK_TYPE_VALIDATION_OPTIONAL_SCHEMATIC : PACK_TYPE_VALIDATION_REQUIRED_SCHEMATIC, buildCriteria()); + } + + /** + * Builds a {@link Component} describing a missing level for a requirement that is otherwise + * partially satisfied — i.e. some matching schematics exist, but the given level is absent. + * + * @param missingLevel the level number that is missing + * @return the missing-level message component + */ + public Component getMissingLevelDescription(final int missingLevel) + { + return Component.translatable(PACK_TYPE_VALIDATION_MISSING_LEVEL, missingLevel, buildCriteria()); + } + + private Component buildCriteria() + { + final List parts = new ArrayList<>(); + if (requiredName != null) + { + parts.add(Component.translatable(PACK_TYPE_VALIDATION_REQUIREMENT_PART_NAME, requiredName)); + } + if (requiredPath != null) + { + parts.add(Component.translatable(PACK_TYPE_VALIDATION_REQUIREMENT_PART_PATH, requiredPath)); + } + if (requiredAnchor != null) + { + parts.add(Component.translatable(PACK_TYPE_VALIDATION_REQUIREMENT_PART_ANCHOR, requiredAnchor)); + } + + final Component and = Component.translatable(PACK_TYPE_VALIDATION_REQUIREMENT_PART_AND); + return parts.stream().reduce((a, b) -> a.append(" ").append(and).append(" ").append(b)).orElse(Component.empty()); + } + + /** + * Builder for {@link PackTypeRequirement}. + */ + public static class Builder + { + /** + * @see PackTypeRequirement#requiredName + */ + @Nullable + private String requiredName; + + /** + * @see PackTypeRequirement#requiredPath + */ + @Nullable + private String requiredPath; + + /** + * @see PackTypeRequirement#requiredLevelCount + */ + @Nullable + private Integer requiredLevelCount; + + /** + * @see PackTypeRequirement#requiredAnchor + */ + @Nullable + private BlockState requiredAnchor; + + /** + * @see PackTypeRequirement#optional + */ + private boolean optional = false; + + /** + * Requires a schematic with the given name to exist in the pack. + * + * @param name the required schematic name + * @return this builder + */ + public Builder requiresName(final String name) + { + this.requiredName = name; + return this; + } + + /** + * Requires a schematic at the given path to exist in the pack. + * + * @param path the required schematic path + * @return this builder + */ + public Builder requiresPath(final String path) + { + this.requiredPath = path; + return this; + } + + /** + * Requires that matching schematics cover each level from 1 through {@code count}. + * For example, a count of 3 means level 1, 2, and 3 must each be present among matching + * schematics. If not called, any single matching schematic at any level is sufficient. + * + * @param count the number of consecutive levels required, starting from 1 + * @return this builder + */ + public Builder requiresLevelCount(final int count) + { + this.requiredLevelCount = count; + return this; + } + + /** + * Requires a schematic with the given anchor block type to exist in the pack. + * + * @param anchor the required anchor block state + * @return this builder + */ + public Builder requiresAnchor(final BlockState anchor) + { + this.requiredAnchor = anchor; + return this; + } + + /** + * Marks this requirement as optional. It will still appear in the UI, but will not be flagged as mandatory. + * + * @return this builder + */ + public Builder optional() + { + this.optional = true; + return this; + } + + /** + * Builds the {@link PackTypeRequirement}. + * + * @return the constructed requirement + */ + public PackTypeRequirement build() + { + return new PackTypeRequirement(requiredName, requiredPath, requiredLevelCount, requiredAnchor, optional); + } + } +} diff --git a/src/main/java/com/ldtteam/structurize/index/packtypes/models/PackTypeSchematicRequirement.java b/src/main/java/com/ldtteam/structurize/index/packtypes/models/PackTypeSchematicRequirement.java new file mode 100644 index 000000000..81aa7bb74 --- /dev/null +++ b/src/main/java/com/ldtteam/structurize/index/packtypes/models/PackTypeSchematicRequirement.java @@ -0,0 +1,436 @@ +package com.ldtteam.structurize.index.packtypes.models; + +import com.ldtteam.structurize.Structurize; +import com.ldtteam.structurize.api.BlockPosUtil; +import net.minecraft.network.chat.Component; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +import static com.ldtteam.structurize.api.constants.TranslationConstants.ANCHOR_POS_OUTSIDE_SCHEMATIC; +import static com.ldtteam.structurize.api.constants.TranslationConstants.MAX_SCHEMATIC_SIZE_REACHED; + +/** + * A requirement checked against individual schematics to determine whether they are "valid". + * + *

Each requirement may match globally or be scoped to specific schematics via filters: + *

+ * + *

Matched schematics are then validated against: + *

+ * + *

Each check carries an optional {@link Component} message override. If absent, a default + * message is generated (e.g. including the tag name or block name). For additional checks + * the message is mandatory since no meaningful default can be generated. + * + *

Each requirement carries a {@link PackTypeSchematicRequirementSeverity severity level} indicating + * how problematic a violation is. + */ +public class PackTypeSchematicRequirement +{ + /** + * Fires when a schematic has an explicit anchor position that lies outside its bounding box. + */ + public static final PackTypeSchematicRequirement ANCHOR_OUTSIDE_BOUNDS = new PackTypeSchematicRequirement.Builder() + .requiresAdditionalCheck( + (blueprint, details) -> details.getAnchor() == null + || BlockPosUtil.isInbetween(details.getAnchor(), details.getPos1(), details.getPos2()), + Component.translatable(ANCHOR_POS_OUTSIDE_SCHEMATIC)) + .markAsError() + .build(); + + /** + * Fires when a schematic's bounding box exceeds the configured {@code schematicBlockLimit}. + */ + public static final PackTypeSchematicRequirement EXCEEDS_BLOCK_LIMIT = new PackTypeSchematicRequirement.Builder() + .requiresAdditionalCheck( + (blueprint, details) -> { + final BoundingBox box = BoundingBox.fromCorners(details.getPos1(), details.getPos2()); + return (long) box.getXSpan() * box.getYSpan() * box.getZSpan() <= Structurize.getConfig().getServer().schematicBlockLimit.get(); + }, + () -> Component.translatable(MAX_SCHEMATIC_SIZE_REACHED, Structurize.getConfig().getServer().schematicBlockLimit.get())) + .markAsError() + .build(); + + /** + * A required anchor tag paired with an optional message override. + * If {@code message} is {@code null} a default is generated from the tag name. + */ + public record TagCheck(@NotNull String tag, @Nullable Component message) {} + + /** + * A required block paired with its minimum count and an optional message override. + * If {@code message} is {@code null} a default is generated from the block name and counts. + */ + public record BlockCheck(@NotNull BlockState block, int count, @Nullable Component message) {} + + /** + * A required additional check paired with a mandatory failure message, since no meaningful + * default can be generated for an arbitrary predicate. + */ + public record AdditionalCheck(@NotNull SchematicPredicate predicate, @NotNull Supplier message) {} + + /** + * If non-null, this requirement only applies to schematics whose name matches this value. + */ + @Nullable + private final String matchesName; + + /** + * If non-null, this requirement only applies to schematics whose path matches this value. + */ + @Nullable + private final String matchesPath; + + /** + * If non-null, this requirement only applies to schematics whose level matches this value. + */ + @Nullable + private final Integer matchesLevel; + + /** + * If non-null, this requirement only applies to schematics with this anchor block type. + */ + @Nullable + private final BlockState matchesAnchor; + + /** + * If non-empty, this requirement only applies to schematics that have all of these tags on their anchor block. + */ + @NotNull + private final List matchesTags; + + /** + * If non-empty, this requirement only applies to schematics for which all of these predicates + * return {@code true}. This is the blueprint-level equivalent of the tag/name/path/level/anchor + * filter criteria, for cases that cannot be expressed with those simpler filters. + */ + @NotNull + private final List matchesAdditionalChecks; + + /** + * Tags that must be present on the anchor block of any matched schematic. + */ + @NotNull + private final List requiredTags; + + /** + * Blocks that must be present in any matched schematic, with their minimum required counts. + */ + @NotNull + private final List requiredBlocks; + + /** + * Custom predicates evaluated against any matched schematic, each with a mandatory failure message. + */ + @NotNull + private final List requiredAdditionalChecks; + + /** + * The severity of a violation of this requirement. + */ + private final PackTypeSchematicRequirementSeverity warningSeverity; + + private PackTypeSchematicRequirement( + final @Nullable String matchesName, + final @Nullable String matchesPath, + final @Nullable Integer matchesLevel, + final @Nullable BlockState matchesAnchor, + final @NotNull List matchesTags, + final @NotNull List matchesAdditionalChecks, + final @NotNull List requiredTags, + final @NotNull List requiredBlocks, + final @NotNull List requiredAdditionalChecks, + final PackTypeSchematicRequirementSeverity warningSeverity) + { + this.matchesName = matchesName; + this.matchesPath = matchesPath; + this.matchesLevel = matchesLevel; + this.matchesAnchor = matchesAnchor; + this.matchesTags = matchesTags; + this.matchesAdditionalChecks = matchesAdditionalChecks; + this.requiredTags = requiredTags; + this.requiredBlocks = requiredBlocks; + this.requiredAdditionalChecks = requiredAdditionalChecks; + this.warningSeverity = warningSeverity; + } + + @Nullable + public String getMatchesName() + { + return matchesName; + } + + @Nullable + public String getMatchesPath() + { + return matchesPath; + } + + @Nullable + public Integer getMatchesLevel() + { + return matchesLevel; + } + + @Nullable + public BlockState getMatchesAnchor() + { + return matchesAnchor; + } + + @NotNull + public List getMatchesTags() + { + return matchesTags; + } + + @NotNull + public List getMatchesAdditionalChecks() + { + return matchesAdditionalChecks; + } + + @NotNull + public List getRequiredTags() + { + return requiredTags; + } + + @NotNull + public List getRequiredBlocks() + { + return requiredBlocks; + } + + @NotNull + public List getRequiredAdditionalChecks() + { + return requiredAdditionalChecks; + } + + public PackTypeSchematicRequirementSeverity getWarningSeverity() + { + return warningSeverity; + } + + /** + * Builder for {@link PackTypeSchematicRequirement}. + */ + public static class Builder + { + @Nullable + private String matchesName; + + @Nullable + private String matchesPath; + + @Nullable + private Integer matchesLevel; + + @Nullable + private BlockState matchesAnchor; + + @NotNull + private final List matchesTags = new ArrayList<>(); + + @NotNull + private final List matchesAdditionalChecks = new ArrayList<>(); + + @NotNull + private final List requiredTags = new ArrayList<>(); + + @NotNull + private final List requiredBlocks = new ArrayList<>(); + + @NotNull + private final List requiredAdditionalChecks = new ArrayList<>(); + + private PackTypeSchematicRequirementSeverity warningSeverity = PackTypeSchematicRequirementSeverity.INFORMATIONAL; + + /** + * Restricts this requirement to schematics whose name matches the given value. + */ + public Builder matchesName(final String name) + { + this.matchesName = name; + return this; + } + + /** + * Restricts this requirement to schematics whose path matches the given value. + */ + public Builder matchesPath(final String path) + { + this.matchesPath = path; + return this; + } + + /** + * Restricts this requirement to schematics whose level matches the given value. + */ + public Builder matchesLevel(final Integer level) + { + this.matchesLevel = level; + return this; + } + + /** + * Restricts this requirement to schematics that use the given anchor block type. + */ + public Builder matchesAnchor(final BlockState anchor) + { + this.matchesAnchor = anchor; + return this; + } + + /** + * Restricts this requirement to schematics that have the given tag on their anchor block. + * May be called multiple times; all tags must be present for the requirement to apply. + */ + public Builder matchesTag(final String tag) + { + this.matchesTags.add(tag); + return this; + } + + /** + * Restricts this requirement to schematics for which the given predicate returns {@code true}. + * May be called multiple times; all predicates must match for the requirement to apply. + */ + public Builder matchesAdditionalCheck(final SchematicPredicate predicate) + { + this.matchesAdditionalChecks.add(predicate); + return this; + } + + /** + * Requires the anchor block of a matched schematic to have the given tag. + * Uses a default generated failure message. + */ + public Builder requiresTag(final String tag) + { + return requiresTag(tag, null); + } + + /** + * Requires the anchor block of a matched schematic to have the given tag. + * Uses the provided message on failure instead of the default generated one. + */ + public Builder requiresTag(final String tag, final Component message) + { + this.requiredTags.add(new TagCheck(tag, message)); + return this; + } + + /** + * Requires at least one instance of the given block to be present in a matched schematic. + * Uses a default generated failure message. + */ + public Builder requiresBlock(final BlockState block) + { + return requiresBlock(block, 1, null); + } + + /** + * Requires at least one instance of the given block to be present in a matched schematic. + * Uses the provided message on failure instead of the default generated one. + */ + public Builder requiresBlock(final BlockState block, final Component message) + { + return requiresBlock(block, 1, message); + } + + /** + * Requires at least {@code count} instances of the given block to be present in a matched schematic. + * Uses a default generated failure message. + */ + public Builder requiresBlock(final BlockState block, final int count) + { + return requiresBlock(block, count, null); + } + + /** + * Requires at least {@code count} instances of the given block to be present in a matched schematic. + * Uses the provided message on failure instead of the default generated one. + */ + public Builder requiresBlock(final BlockState block, final int count, final @Nullable Component message) + { + this.requiredBlocks.add(new BlockCheck(block, count, message)); + return this; + } + + /** + * Adds a custom predicate evaluated against any matched schematic. + * The message is mandatory since no meaningful default can be generated for an arbitrary predicate. + */ + public Builder requiresAdditionalCheck(final SchematicPredicate predicate, final Component message) + { + this.requiredAdditionalChecks.add(new AdditionalCheck(predicate, () -> message)); + return this; + } + + /** + * Adds a custom predicate evaluated against any matched schematic, with a lazily-evaluated + * message supplier. Use this when the message content depends on runtime state (e.g. config values) + * that is not available at class initialization time. + */ + public Builder requiresAdditionalCheck(final SchematicPredicate predicate, final Supplier message) + { + this.requiredAdditionalChecks.add(new AdditionalCheck(predicate, message)); + return this; + } + + /** + * Sets the warning severity to {@link PackTypeSchematicRequirementSeverity#ISSUE}. + */ + public Builder markAsIssue() + { + this.warningSeverity = PackTypeSchematicRequirementSeverity.ISSUE; + return this; + } + + /** + * Sets the warning severity to {@link PackTypeSchematicRequirementSeverity#ERROR}. + */ + public Builder markAsError() + { + this.warningSeverity = PackTypeSchematicRequirementSeverity.ERROR; + return this; + } + + /** + * Builds the {@link PackTypeSchematicRequirement}. + */ + public PackTypeSchematicRequirement build() + { + return new PackTypeSchematicRequirement( + matchesName, + matchesPath, + matchesLevel, + matchesAnchor, + matchesTags, + matchesAdditionalChecks, + requiredTags, + requiredBlocks, + requiredAdditionalChecks, + warningSeverity); + } + } +} diff --git a/src/main/java/com/ldtteam/structurize/index/packtypes/models/PackTypeSchematicRequirementSeverity.java b/src/main/java/com/ldtteam/structurize/index/packtypes/models/PackTypeSchematicRequirementSeverity.java new file mode 100644 index 000000000..b0385ff5d --- /dev/null +++ b/src/main/java/com/ldtteam/structurize/index/packtypes/models/PackTypeSchematicRequirementSeverity.java @@ -0,0 +1,30 @@ +package com.ldtteam.structurize.index.packtypes.models; + +/** + * Severity level for a schematic requirement violation. + */ +public enum PackTypeSchematicRequirementSeverity +{ + /** + * Purely informational — no immediate problem, but worth noting. + * + *

Example: No {@code groundlevel} tag on a schematic with an anchor block. + * This is fine because the ground level defaults to the block below the anchor. + */ + INFORMATIONAL, + + /** + * A potential problem — not immediately broken, but may not behave as intended. + * + *

Example: No {@code groundlevel} tag on a schematic without an anchor block. + * This causes the bottom of the full schematic to be used as the ground level. + */ + ISSUE, + + /** + * A hard error that will cause problems in-game and must be fixed before release. These issues are blocking and will not allow a scan to be made! + * + *

Example: Two anchor blocks in a schematic without an explicitly defined anchor position. + */ + ERROR +} diff --git a/src/main/java/com/ldtteam/structurize/index/packtypes/models/SchematicPredicate.java b/src/main/java/com/ldtteam/structurize/index/packtypes/models/SchematicPredicate.java new file mode 100644 index 000000000..a6cfa9058 --- /dev/null +++ b/src/main/java/com/ldtteam/structurize/index/packtypes/models/SchematicPredicate.java @@ -0,0 +1,23 @@ +package com.ldtteam.structurize.index.packtypes.models; + +import com.ldtteam.structurize.blueprints.v1.Blueprint; +import com.ldtteam.structurize.blueprints.v1.IBlueprintDetails; + +/** + * A predicate evaluated against a schematic's {@link Blueprint} and its associated {@link IBlueprintDetails}. + * + *

Used both as a filter (to determine whether a {@link PackTypeSchematicRequirement} applies to a + * given schematic) and as a content check (to determine whether a matched schematic is valid). + */ +@FunctionalInterface +public interface SchematicPredicate +{ + /** + * Evaluates this predicate against the given blueprint and its details. + * + * @param blueprint the blueprint data + * @param details the blueprint details providing path, name, level, and anchor + * @return {@code true} if the predicate is satisfied + */ + boolean test(Blueprint blueprint, IBlueprintDetails details); +} \ No newline at end of file diff --git a/src/main/java/com/ldtteam/structurize/items/ItemPackIndexDebug.java b/src/main/java/com/ldtteam/structurize/items/ItemPackIndexDebug.java new file mode 100644 index 000000000..f81c9516c --- /dev/null +++ b/src/main/java/com/ldtteam/structurize/items/ItemPackIndexDebug.java @@ -0,0 +1,67 @@ +// TODO: Remove before publication — debug item only +package com.ldtteam.structurize.items; + +import com.ldtteam.structurize.index.PackManager; +import com.ldtteam.structurize.index.models.Pack; +import com.ldtteam.structurize.index.models.PackSchematic; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResultHolder; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +public class ItemPackIndexDebug extends Item +{ + private static final Logger LOGGER = LogManager.getLogger(); + + public ItemPackIndexDebug() + { + super(new Properties().stacksTo(1)); + } + + @Override + public @NotNull InteractionResultHolder use(final @NotNull Level level, final @NotNull Player player, final @NotNull InteractionHand hand) + { + final List packs = level.isClientSide + ? PackManager.getClientPacks() + : PackManager.getServerPacks(); + + final String side = level.isClientSide ? "CLIENT" : "SERVER"; + + if (packs.isEmpty()) + { + LOGGER.info("[PackIndex][{}] No packs registered.", side); + } + else + { + LOGGER.info("[PackIndex][{}] {} pack(s):", side, packs.size()); + for (final Pack pack : packs) + { + LOGGER.info("[PackIndex][{}] Pack: '{}' | type: {}", + side, + pack.name(), + pack.type().unwrapKey().map(k -> k.location().toString()).orElse("unknown")); + + for (final PackSchematic schematic : pack.schematics()) + { + LOGGER.info("[PackIndex][{}] Schematic: '{}' | path: {} | level: {} | pos1: {} | pos2: {} | anchor: {}", + side, + schematic.name(), + schematic.path(), + schematic.level(), + schematic.pos1(), + schematic.pos2(), + schematic.anchor()); + } + } + } + + return InteractionResultHolder.sidedSuccess(player.getItemInHand(hand), level.isClientSide); + } +} diff --git a/src/main/java/com/ldtteam/structurize/items/ModItems.java b/src/main/java/com/ldtteam/structurize/items/ModItems.java index 6efaa8947..1a04d5ba6 100644 --- a/src/main/java/com/ldtteam/structurize/items/ModItems.java +++ b/src/main/java/com/ldtteam/structurize/items/ModItems.java @@ -25,6 +25,9 @@ private ModItems() { /* prevent construction */ } public static final DeferredItem caliper; public static final DeferredItem blockTagSubstitution; + // TODO: Remove before publication — debug item only + public static final DeferredItem packIndexDebug; + static { buildTool = ITEMS.register("sceptergold", ItemBuildTool::new); @@ -33,5 +36,7 @@ private ModItems() { /* prevent construction */ } tagTool = ITEMS.register("sceptertag", (Supplier) ItemTagTool::new); caliper = ITEMS.register("caliper", ItemCaliper::new); blockTagSubstitution = ITEMS.register("blockTagSubstitution".toLowerCase(), ItemTagSubstitution::new); + // TODO: Remove before publication — debug item only + packIndexDebug = ITEMS.register("packindexdebug", ItemPackIndexDebug::new); } } diff --git a/src/main/java/com/ldtteam/structurize/network/messages/SyncPackManagerMessage.java b/src/main/java/com/ldtteam/structurize/network/messages/SyncPackManagerMessage.java new file mode 100644 index 000000000..5143e0ca4 --- /dev/null +++ b/src/main/java/com/ldtteam/structurize/network/messages/SyncPackManagerMessage.java @@ -0,0 +1,43 @@ +package com.ldtteam.structurize.network.messages; + +import com.ldtteam.common.network.AbstractClientPlayMessage; +import com.ldtteam.common.network.PlayMessageType; +import com.ldtteam.structurize.api.constants.Constants; +import com.ldtteam.structurize.index.PackManager; +import com.ldtteam.structurize.index.models.Pack; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.world.entity.player.Player; +import net.neoforged.neoforge.network.handling.IPayloadContext; + +import java.util.Map; + +public class SyncPackManagerMessage extends AbstractClientPlayMessage +{ + public static final PlayMessageType TYPE = PlayMessageType.forClient(Constants.MOD_ID, "sync_pack_manager", SyncPackManagerMessage::new); + + private final Map packs; + + public SyncPackManagerMessage(final Map packs) + { + super(TYPE); + this.packs = packs; + } + + protected SyncPackManagerMessage(final RegistryFriendlyByteBuf buf, final PlayMessageType type) + { + super(buf, type); + this.packs = Pack.MAP_STREAM_CODEC.decode(buf); + } + + @Override + protected void toBytes(final RegistryFriendlyByteBuf buf) + { + Pack.MAP_STREAM_CODEC.encode(buf, packs); + } + + @Override + protected void onExecute(final IPayloadContext context, final Player player) + { + PackManager.onClientSync(packs); + } +} diff --git a/src/main/resources/assets/structurize/lang/en_us.json b/src/main/resources/assets/structurize/lang/en_us.json index 18610a1f3..9b5f5c211 100644 --- a/src/main/resources/assets/structurize/lang/en_us.json +++ b/src/main/resources/assets/structurize/lang/en_us.json @@ -238,5 +238,13 @@ "com.ldtteam.tag.tooltip.invisible": "Hide Blueprint in Buildtool like Integrated Buildings or Mineshafts", "com.ldtteam.structurize.gui.switchpack.authors": "Authors: %s", "com.ldtteam.structurize.gui.switchpack.list.empty": "There are no structure packs found, please install some", - "com.ldtteam.structurize.gui.switchpack.pack_disabled.hover_text": "This pack is disabled because it only exists on the client. In order to use this pack, the server must enable the config option '%s' on the server." + "com.ldtteam.structurize.gui.switchpack.pack_disabled.hover_text": "This pack is disabled because it only exists on the client. In order to use this pack, the server must enable the config option '%s' on the server.", + + "com.ldtteam.structurize.pack_type.validation.missing_required_schematic": "Pack is missing required schematic with %s", + "com.ldtteam.structurize.pack_type.validation.missing_optional_schematic": "Pack is missing optional schematic with %s", + "com.ldtteam.structurize.pack_type.validation.requirement_part_name": "name \"%s\"", + "com.ldtteam.structurize.pack_type.validation.requirement_part_path": "path \"%s\"", + "com.ldtteam.structurize.pack_type.validation.requirement_part_anchor": "anchor \"%s\"", + "com.ldtteam.structurize.pack_type.validation.requirement_part_and": "and", + "com.ldtteam.structurize.pack_type.validation.missing_level": "Missing level %s for schematic with %s" }