diff --git a/itsalive/build.gradle b/itsalive/build.gradle index 702cf48..ad73a08 100644 --- a/itsalive/build.gradle +++ b/itsalive/build.gradle @@ -1041,6 +1041,30 @@ tasks.register("verifyItsAlive") { throw new GradleException("Missing culinary substitution-family tag: itsalive:${path}") } } + def lecternBooks = new JsonSlurper().parse(file("src/main/resources/data/minecraft/tags/item/lectern_books.json")) + def lecternSource = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/knowledge/CulinaryLectern.java").text + if (!(lecternBooks.values instanceof List) || !lecternBooks.values.contains("itsalive:culinary_recipe_book") + || !lecternSource.contains("UseBlockCallback.EVENT.register") + || !lecternSource.contains("LecternBlock.HAS_BOOK") + || !lecternSource.contains("CulinaryRecipeKnowledge.inscribe") + || !lecternSource.contains("CulinaryRecipeKnowledge.openBook(serverPlayer, book)") + || !lecternSource.contains("CulinaryRecipeKnowledge.forgetInscription") + || !lecternSource.contains("PlayerBlockBreakEvents.BEFORE") + || !lecternSource.contains("isSecondaryUseActive()")) { + throw new GradleException("The culinary recipe book must sit on vanilla lecterns and open its owner's catalogue") + } + def inscription = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/knowledge/CulinaryBookInscription.java").text + def components = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/registry/ItsAliveDataComponents.java").text + def knowledge = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/knowledge/CulinaryRecipeKnowledge.java").text + def bookItem = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/item/CulinaryRecipeBookItem.java").text + if (!inscription.contains("ownerId") + || !components.contains("CULINARY_BOOK") + || !knowledge.contains("openLearned(player, inscription.ownerId())") + || !knowledge.contains("stack.set(ItsAliveDataComponents.CULINARY_BOOK") + || !knowledge.contains("forgetInscription") + || bookItem.contains("openBook(serverPlayer, player.getItemInHand")) { + throw new GradleException("A placed culinary book must keep the placer's recipe discoveries only while on a lectern") + } def creativeTab = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/registry/ItsAliveCreativeTab.java").text if (!creativeTab.contains('ItsAliveMod.id("agriculture")') || !creativeTab.contains("PLANTING_ITEMS") || !creativeTab.contains("PRODUCE_ITEMS")) { throw new GradleException("The itsalive:agriculture creative tab must expose planting and produce items") @@ -1066,7 +1090,8 @@ tasks.register("verifyItsAlive") { ] } def culinaryKeys = (culinaryCatalogItems + ["vinegar", "failed_dish"]).collect { "item.itsalive." + it } + [ "tag.item.itsalive.culinary_mushrooms", "tag.item.itsalive.culinary_fish", - "tag.item.itsalive.culinary_berries", "tag.item.itsalive.culinary_cooked_meats" + "tag.item.itsalive.culinary_berries", "tag.item.itsalive.culinary_cooked_meats", + "tooltip.itsalive.culinary_recipe_book.blank" ] (livingKeys + moobloomKeys + infectedKeys + culinaryKeys + ["hud.itsalive.hunter.struggle"]).each { key -> if (!language.containsKey(key)) { diff --git a/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/item/CulinaryRecipeBookItem.java b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/item/CulinaryRecipeBookItem.java index 84d54ba..82cb61f 100644 --- a/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/item/CulinaryRecipeBookItem.java +++ b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/item/CulinaryRecipeBookItem.java @@ -1,14 +1,20 @@ package fr.koka99cab.sanctuary26.itsalive.culinary.item; import fr.koka99cab.sanctuary26.itsalive.culinary.knowledge.CulinaryRecipeKnowledge; +import java.util.function.Consumer; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionResult; import net.minecraft.world.entity.player.Player; import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.item.component.TooltipDisplay; import net.minecraft.world.level.Level; -/** Opens the persistent recipe catalogue without gating recipes the player can guess. */ +/** Opens the holder's personal catalogue. Shared recipes are only readable on a lectern. */ public final class CulinaryRecipeBookItem extends Item { public CulinaryRecipeBookItem(Properties properties) { super(properties); @@ -25,4 +31,16 @@ public final class CulinaryRecipeBookItem extends Item { } return InteractionResult.PASS; } + + @Override + public void appendHoverText( + ItemStack stack, + TooltipContext context, + TooltipDisplay display, + Consumer tooltip, + TooltipFlag flag + ) { + tooltip.accept(Component.translatable("tooltip.itsalive.culinary_recipe_book.blank") + .withStyle(ChatFormatting.DARK_GRAY)); + } } diff --git a/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/knowledge/CulinaryBookInscription.java b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/knowledge/CulinaryBookInscription.java new file mode 100644 index 0000000..e186fdd --- /dev/null +++ b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/knowledge/CulinaryBookInscription.java @@ -0,0 +1,47 @@ +package fr.koka99cab.sanctuary26.itsalive.culinary.knowledge; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import java.util.UUID; +import net.minecraft.core.UUIDUtil; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; + +/** Binds a physical culinary book to the player who last placed it on a lectern. */ +public record CulinaryBookInscription(UUID ownerId, String ownerName) { + private static final int MAX_NAME_LENGTH = 32; + public static final Codec CODEC = RecordCodecBuilder.create(instance -> + instance.group( + UUIDUtil.CODEC.fieldOf("owner_id").forGetter(CulinaryBookInscription::ownerId), + Codec.STRING.fieldOf("owner_name").forGetter(CulinaryBookInscription::ownerName) + ).apply(instance, CulinaryBookInscription::new) + ); + public static final StreamCodec STREAM_CODEC = + StreamCodec.composite( + UUIDUtil.STREAM_CODEC, + CulinaryBookInscription::ownerId, + ByteBufCodecs.STRING_UTF8, + CulinaryBookInscription::ownerName, + CulinaryBookInscription::new + ); + + public CulinaryBookInscription { + if (ownerId == null) { + throw new IllegalArgumentException("Culinary book owner is required"); + } + ownerName = sanitizeName(ownerName); + } + + public static CulinaryBookInscription of(UUID ownerId, String ownerName) { + return new CulinaryBookInscription(ownerId, ownerName); + } + + private static String sanitizeName(String ownerName) { + String cleaned = ownerName == null ? "" : ownerName.replaceAll("\\p{Cntrl}", "").trim(); + if (cleaned.length() > MAX_NAME_LENGTH) { + cleaned = cleaned.substring(0, MAX_NAME_LENGTH); + } + return cleaned.isEmpty() ? "?" : cleaned; + } +} diff --git a/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/knowledge/CulinaryLectern.java b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/knowledge/CulinaryLectern.java new file mode 100644 index 0000000..f17bffa --- /dev/null +++ b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/knowledge/CulinaryLectern.java @@ -0,0 +1,95 @@ +package fr.koka99cab.sanctuary26.itsalive.culinary.knowledge; + +import fr.koka99cab.sanctuary26.itsalive.registry.ItsAliveFoodProcessing; +import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents; +import net.fabricmc.fabric.api.event.player.UseBlockCallback; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.LecternBlock; +import net.minecraft.world.level.block.entity.LecternBlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.BlockHitResult; + +/** + * Lets the culinary recipe book sit on a vanilla lectern. + * Only a book still on the lectern exposes the placer's catalogue. + */ +public final class CulinaryLectern { + private CulinaryLectern() { + } + + public static void initialize() { + UseBlockCallback.EVENT.register(CulinaryLectern::use); + PlayerBlockBreakEvents.BEFORE.register((level, player, pos, state, blockEntity) -> { + if (blockEntity instanceof LecternBlockEntity lectern) { + CulinaryRecipeKnowledge.forgetInscription(lectern.getBook()); + } + return true; + }); + } + + private static InteractionResult use(Player player, Level level, InteractionHand hand, BlockHitResult hit) { + if (hand != InteractionHand.MAIN_HAND || player.isSpectator()) { + return InteractionResult.PASS; + } + BlockPos pos = hit.getBlockPos(); + BlockState state = level.getBlockState(pos); + if (!state.is(Blocks.LECTERN)) { + return InteractionResult.PASS; + } + ItemStack held = player.getItemInHand(hand); + if (!state.getValue(LecternBlock.HAS_BOOK)) { + if (held.is(ItsAliveFoodProcessing.CULINARY_RECIPE_BOOK) + && !level.isClientSide() + && player instanceof ServerPlayer serverPlayer) { + CulinaryRecipeKnowledge.inscribe(held, serverPlayer); + } + return InteractionResult.PASS; + } + if (!(level.getBlockEntity(pos) instanceof LecternBlockEntity lectern)) { + return InteractionResult.PASS; + } + ItemStack book = lectern.getBook(); + if (!book.is(ItsAliveFoodProcessing.CULINARY_RECIPE_BOOK)) { + return InteractionResult.PASS; + } + if (level.isClientSide()) { + return InteractionResult.SUCCESS; + } + if (!(player instanceof ServerPlayer serverPlayer) || !level.mayInteract(serverPlayer, pos)) { + return InteractionResult.FAIL; + } + if (serverPlayer.isSecondaryUseActive()) { + return takeBook(serverPlayer, level, pos, state, lectern); + } + CulinaryRecipeKnowledge.openBook(serverPlayer, book); + return InteractionResult.SUCCESS; + } + + private static InteractionResult takeBook( + ServerPlayer player, + Level level, + BlockPos pos, + BlockState state, + LecternBlockEntity lectern + ) { + ItemStack taken = lectern.getBook().copy(); + if (taken.isEmpty()) { + return InteractionResult.PASS; + } + CulinaryRecipeKnowledge.forgetInscription(taken); + lectern.setBook(ItemStack.EMPTY); + LecternBlock.resetBookState(player, level, pos, state, false); + player.getInventory().placeItemBackInInventory(taken); + level.playSound(null, pos, SoundEvents.ITEM_FRAME_REMOVE_ITEM, SoundSource.BLOCKS, 1.0F, 1.0F); + return InteractionResult.SUCCESS; + } +} diff --git a/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/knowledge/CulinaryRecipeKnowledge.java b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/knowledge/CulinaryRecipeKnowledge.java index 6c19a86..f9e87d1 100644 --- a/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/knowledge/CulinaryRecipeKnowledge.java +++ b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/knowledge/CulinaryRecipeKnowledge.java @@ -4,12 +4,14 @@ import fr.koka99cab.sanctuary26.itsalive.ItsAliveMod; import fr.koka99cab.sanctuary26.itsalive.culinary.recipe.CulinaryProcessRecipe; import fr.koka99cab.sanctuary26.itsalive.network.CulinaryRecipeBookPayload; import fr.koka99cab.sanctuary26.itsalive.recipe.RedstoneMillingRecipe; +import fr.koka99cab.sanctuary26.itsalive.registry.ItsAliveDataComponents; import fr.koka99cab.sanctuary26.itsalive.registry.ItsAliveFoodProcessing; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; import net.minecraft.core.Holder; @@ -78,6 +80,7 @@ public final class CulinaryRecipeKnowledge { return; } initialized = true; + CulinaryLectern.initialize(); ServerLivingEntityEvents.AFTER_DEATH.register(CulinaryRecipeKnowledge::afterDeath); ItsAliveMod.LOGGER.info( "[{}] Culinary discoveries enabled: books 1/{} hostile, pages 1/{} hostile.", @@ -107,9 +110,39 @@ public final class CulinaryRecipeKnowledge { return true; } + public static void inscribe(ItemStack stack, ServerPlayer player) { + if (!stack.is(ItsAliveFoodProcessing.CULINARY_RECIPE_BOOK)) { + return; + } + stack.set(ItsAliveDataComponents.CULINARY_BOOK, CulinaryBookInscription.of( + player.getUUID(), + player.getGameProfile().name() + )); + } + + public static void forgetInscription(ItemStack stack) { + if (!stack.is(ItsAliveFoodProcessing.CULINARY_RECIPE_BOOK)) { + return; + } + stack.remove(ItsAliveDataComponents.CULINARY_BOOK); + } + public static void openBook(ServerPlayer player) { + openLearned(player, player.getUUID()); + } + + public static void openBook(ServerPlayer player, ItemStack book) { + CulinaryBookInscription inscription = book.get(ItsAliveDataComponents.CULINARY_BOOK); + if (inscription == null) { + openLearned(player, player.getUUID()); + return; + } + openLearned(player, inscription.ownerId()); + } + + private static void openLearned(ServerPlayer player, UUID ownerId) { List recipes = catalog(player.level().getServer()); - Set learned = CulinaryRecipeKnowledgeData.get(player.level().getServer()).learned(player.getUUID()); + Set learned = CulinaryRecipeKnowledgeData.get(player.level().getServer()).learned(ownerId); List entries = new ArrayList<>(); for (int index = 0; index < recipes.size(); index++) { CatalogRecipe recipe = recipes.get(index); diff --git a/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/registry/ItsAliveDataComponents.java b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/registry/ItsAliveDataComponents.java new file mode 100644 index 0000000..52a3cf0 --- /dev/null +++ b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/registry/ItsAliveDataComponents.java @@ -0,0 +1,26 @@ +package fr.koka99cab.sanctuary26.itsalive.registry; + +import fr.koka99cab.sanctuary26.itsalive.ItsAliveMod; +import fr.koka99cab.sanctuary26.itsalive.culinary.knowledge.CulinaryBookInscription; +import net.minecraft.core.Registry; +import net.minecraft.core.component.DataComponentType; +import net.minecraft.core.registries.BuiltInRegistries; + +/** Persistent item components owned by It's Alive. */ +public final class ItsAliveDataComponents { + public static final DataComponentType CULINARY_BOOK = Registry.register( + BuiltInRegistries.DATA_COMPONENT_TYPE, + ItsAliveMod.id("culinary_book"), + DataComponentType.builder() + .persistent(CulinaryBookInscription.CODEC) + .networkSynchronized(CulinaryBookInscription.STREAM_CODEC) + .cacheEncoding() + .build() + ); + + private ItsAliveDataComponents() { + } + + public static void initialize() { + } +} diff --git a/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/registry/ItsAliveRegistries.java b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/registry/ItsAliveRegistries.java index 050f51e..38defa7 100644 --- a/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/registry/ItsAliveRegistries.java +++ b/itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/registry/ItsAliveRegistries.java @@ -21,6 +21,7 @@ public final class ItsAliveRegistries { return; } initialized = true; + ItsAliveDataComponents.initialize(); ItsAliveEntityTypes.register(); ItsAliveBlocks.register(); ItsAliveItems.register(); diff --git a/itsalive/src/main/resources/assets/itsalive/lang/en_us.json b/itsalive/src/main/resources/assets/itsalive/lang/en_us.json index 8d0c26d..99a275d 100644 --- a/itsalive/src/main/resources/assets/itsalive/lang/en_us.json +++ b/itsalive/src/main/resources/assets/itsalive/lang/en_us.json @@ -270,6 +270,7 @@ "item.itsalive.burger": "Burger", "item.itsalive.culinary_recipe_book": "Culinary Recipe Book", "item.itsalive.recipe_page": "Recipe Page", + "tooltip.itsalive.culinary_recipe_book.blank": "Place it on a lectern to share your recipes.", "message.itsalive.recipe_page.learned": "Recipe discovered: %s (page %s/%s)", "message.itsalive.recipe_page.complete": "You already know every culinary recipe.", "book.itsalive.appliance.mill": "Redstone mill", diff --git a/itsalive/src/main/resources/assets/itsalive/lang/fr_fr.json b/itsalive/src/main/resources/assets/itsalive/lang/fr_fr.json index 23e6d69..fe881c1 100644 --- a/itsalive/src/main/resources/assets/itsalive/lang/fr_fr.json +++ b/itsalive/src/main/resources/assets/itsalive/lang/fr_fr.json @@ -270,6 +270,7 @@ "item.itsalive.burger": "Burger", "item.itsalive.culinary_recipe_book": "Livre de recettes culinaires", "item.itsalive.recipe_page": "Page de recette", + "tooltip.itsalive.culinary_recipe_book.blank": "Pose-le sur un pupitre pour partager tes recettes.", "message.itsalive.recipe_page.learned": "Recette découverte : %s (page %s/%s)", "message.itsalive.recipe_page.complete": "Tu connais déjà toutes les recettes culinaires.", "book.itsalive.appliance.mill": "Moulin redstone", diff --git a/itsalive/src/main/resources/assets/itsalive/lang/ru_ru.json b/itsalive/src/main/resources/assets/itsalive/lang/ru_ru.json index add075d..654f427 100644 --- a/itsalive/src/main/resources/assets/itsalive/lang/ru_ru.json +++ b/itsalive/src/main/resources/assets/itsalive/lang/ru_ru.json @@ -270,6 +270,7 @@ "item.itsalive.burger": "Burger", "item.itsalive.culinary_recipe_book": "Culinary Recipe Book", "item.itsalive.recipe_page": "Recipe Page", + "tooltip.itsalive.culinary_recipe_book.blank": "Положи на кафедру, чтобы поделиться рецептами.", "message.itsalive.recipe_page.learned": "Recipe discovered: %s (page %s/%s)", "message.itsalive.recipe_page.complete": "You already know every culinary recipe.", "book.itsalive.appliance.mill": "Redstone mill", diff --git a/itsalive/src/main/resources/data/minecraft/tags/item/lectern_books.json b/itsalive/src/main/resources/data/minecraft/tags/item/lectern_books.json new file mode 100644 index 0000000..30f444a --- /dev/null +++ b/itsalive/src/main/resources/data/minecraft/tags/item/lectern_books.json @@ -0,0 +1,6 @@ +{ + "replace": false, + "values": [ + "itsalive:culinary_recipe_book" + ] +}