feat(itsalive): poser le livre culinaire sur un pupitre vanilla #12

Merged
koka merged 1 commits from feature/culinary-lectern-book into main 2026-08-27 10:00:26 +00:00
11 changed files with 257 additions and 3 deletions
+26 -1
View File
@@ -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)) {
@@ -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<Component> tooltip,
TooltipFlag flag
) {
tooltip.accept(Component.translatable("tooltip.itsalive.culinary_recipe_book.blank")
.withStyle(ChatFormatting.DARK_GRAY));
}
}
@@ -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<CulinaryBookInscription> 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<RegistryFriendlyByteBuf, CulinaryBookInscription> 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;
}
}
@@ -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;
}
}
@@ -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<CatalogRecipe> recipes = catalog(player.level().getServer());
Set<String> learned = CulinaryRecipeKnowledgeData.get(player.level().getServer()).learned(player.getUUID());
Set<String> learned = CulinaryRecipeKnowledgeData.get(player.level().getServer()).learned(ownerId);
List<CulinaryRecipeBookPayload.Entry> entries = new ArrayList<>();
for (int index = 0; index < recipes.size(); index++) {
CatalogRecipe recipe = recipes.get(index);
@@ -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<CulinaryBookInscription> CULINARY_BOOK = Registry.register(
BuiltInRegistries.DATA_COMPONENT_TYPE,
ItsAliveMod.id("culinary_book"),
DataComponentType.<CulinaryBookInscription>builder()
.persistent(CulinaryBookInscription.CODEC)
.networkSynchronized(CulinaryBookInscription.STREAM_CODEC)
.cacheEncoding()
.build()
);
private ItsAliveDataComponents() {
}
public static void initialize() {
}
}
@@ -21,6 +21,7 @@ public final class ItsAliveRegistries {
return;
}
initialized = true;
ItsAliveDataComponents.initialize();
ItsAliveEntityTypes.register();
ItsAliveBlocks.register();
ItsAliveItems.register();
@@ -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",
@@ -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",
@@ -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",
@@ -0,0 +1,6 @@
{
"replace": false,
"values": [
"itsalive:culinary_recipe_book"
]
}