feat(itsalive): transformer le Codex en Livre interdit (#65) #70

Merged
koka merged 1 commits from feature/canaplia-forbidden-book into main 2026-08-29 12:19:25 +00:00
20 changed files with 401 additions and 50 deletions
+4 -2
View File
@@ -32,9 +32,11 @@ Ambiance possède les trois payloads historiques canaplia:hemp_visual_sync, cana
## Codex
Les monstres tués par un joueur peuvent laisser une `canaplia:codex_page` et, très rarement, le `canaplia:codex` complet. Quatre pages récupérées permettent aussi de relier le Codex autour d'un livre. L'objet utilise la texture du livre Minecraft et son écran repose sur les pages vanilla. Il distingue les découvertes personnelles des silhouettes connues par la communauté ; les origines génériques et les conditions détaillées se révèlent progressivement sans envoyer de payload de découverte client-vers-serveur.
Les monstres tués par un joueur peuvent laisser une `canaplia:codex_page` et, très rarement, le `canaplia:codex` complet. Quatre pages récupérées permettent aussi d'assembler le Livre interdit autour d'un livre vanilla. Un clic droit sur une page découvre au hasard une entrée encore inconnue et ne consomme la page qu'en cas de nouvelle découverte.
Les données canaplia:codex sont versionnées. Les clés futures syntaxiquement valides restent conservées lors d'un aller-retour afin qu'une migration ne perde pas les découvertes ajoutées par une version ultérieure.
L'ID historique `canaplia:codex` est conservé, mais l'objet s'affiche désormais comme le « Livre interdit » : il utilise la couleur du livre enchanté, ouvre directement les seize entrées et les présente sur des pages noires. En main, son contenu est strictement personnel. Posé sur un pupitre, il est temporairement inscrit au nom du joueur qui le pose et partage ses découvertes avec tous les lecteurs ; le récupérer en s'accroupissant ou casser le pupitre efface cette inscription.
Les données `canaplia:codex` restent en version 1. Les clés futures syntaxiquement valides et l'ancien champ partagé restent conservés lors d'un aller-retour afin qu'une migration ne perde aucune découverte, mais les nouvelles découvertes ne sont plus publiées globalement. Aucun payload de découverte n'est accepté du client vers le serveur et le protocole It's Alive reste en version 4.
## Tickets livrés
+4 -1
View File
@@ -8,10 +8,13 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve
## Non distribué
Module modifié : **I Like To Move It `0.0.0-alpha.15`**. Le pack distribué reste en `26.2.0-alpha.209` jusqu’à la prochaine release.
Modules modifiés : **I Like To Move It `0.0.0-alpha.15`** et **It's Alive `0.0.0-alpha.35`**. Le pack distribué reste en `26.2.0-alpha.209` jusqu’à la prochaine release.
- double la vitesse de croisière du Happy Ghast lorsquun joueur le pilote, sans accélérer ses déplacements autonomes ;
- conserve les données v3 et le protocole réseau 5, sans migration de sauvegarde ni nouvel ID.
- transforme le Codex Canaplia en « Livre interdit » aux pages noires, sans page d'accueil, et fait découvrir une entrée aléatoire encore inconnue à chaque page consommée ;
- garde les découvertes du Livre interdit personnelles en main et partage temporairement celles du propriétaire uniquement lorsque le livre est posé sur un pupitre ;
- conserve les IDs et les anciennes données partagées, ainsi que les données v3 et le protocole réseau 4 d'It's Alive, sans migration de sauvegarde.
## `26.2.0-alpha.209`
+12 -10
View File
@@ -4172,7 +4172,7 @@ tasks.register("verifyCuisineCropsRelease") {
def rice = file("itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/agriculture/RiceCropBlock.java").text
def bean = file("itsalive/src/main/java/fr/koka99cab/sanctuary26/itsalive/agriculture/BeanCropBlock.java").text
if (rootProject.pack_version != "26.2.0-alpha.209"
|| rootProject.itsalive_version != "0.0.0-alpha.34"
|| rootProject.itsalive_version != "0.0.0-alpha.35"
|| release.pack_version != rootProject.pack_version || index.versionId != rootProject.pack_version
|| migration.source?.pack_version != "26.2.0-alpha.193"
|| migration.source?.modules != [itsalive: "0.0.0-alpha.26"]
@@ -4313,7 +4313,7 @@ tasks.register("verifyQuestRequestsCauldronBatchesRelease") {
def packwiz = file("pack/packwiz/pack.toml").text
def sanctuaryManifest = new JsonSlurper().parse(file("sanctuary/src/main/resources/fabric.mod.json"))
if (rootProject.pack_version != "26.2.0-alpha.209"
|| rootProject.itsalive_version != "0.0.0-alpha.34"
|| rootProject.itsalive_version != "0.0.0-alpha.35"
|| rootProject.sanctuary_version != "0.0.0-alpha.117"
|| migration.source?.pack_version != "26.2.0-alpha.196"
|| migration.target?.pack_version != "26.2.0-alpha.197"
@@ -4363,7 +4363,7 @@ tasks.register("verifyFermentationBarrelAutomationRelease") {
def packwizIndex = file("pack/packwiz/index.toml").text
def itsAliveManifest = new JsonSlurper().parse(file("itsalive/src/main/resources/fabric.mod.json"))
if (rootProject.pack_version != "26.2.0-alpha.209"
|| rootProject.itsalive_version != "0.0.0-alpha.34"
|| rootProject.itsalive_version != "0.0.0-alpha.35"
|| migration.source?.pack_version != "26.2.0-alpha.197"
|| migration.target?.pack_version != "26.2.0-alpha.198"
|| migration.target?.modules != [itsalive: "0.0.0-alpha.30", sanctuary: "0.0.0-alpha.114"]
@@ -4413,7 +4413,7 @@ tasks.register("verifyQuestRequestContractsRelease") {
def packwizIndex = file("pack/packwiz/index.toml").text
def sanctuaryManifest = new JsonSlurper().parse(file("sanctuary/src/main/resources/fabric.mod.json"))
if (rootProject.pack_version != "26.2.0-alpha.209"
|| rootProject.itsalive_version != "0.0.0-alpha.34"
|| rootProject.itsalive_version != "0.0.0-alpha.35"
|| rootProject.sanctuary_version != "0.0.0-alpha.117"
|| migration.source?.pack_version != "26.2.0-alpha.198"
|| migration.source?.modules?.sanctuary != "0.0.0-alpha.114"
@@ -4589,7 +4589,7 @@ tasks.register("verifyWaystoneShopCulinaryAlpha202Release") {
"sanctuary/src/main/resources/fabric.mod.json"))
if (rootProject.pack_version != "26.2.0-alpha.209"
|| rootProject.anotherworld_version != "0.0.0-alpha.55"
|| rootProject.itsalive_version != "0.0.0-alpha.34"
|| rootProject.itsalive_version != "0.0.0-alpha.35"
|| rootProject.iliketomoveit_version != "0.0.0-alpha.15"
|| rootProject.sanctuary_version != "0.0.0-alpha.117"
|| migration.source?.pack_version != "26.2.0-alpha.201"
@@ -5238,7 +5238,7 @@ tasks.register("verifyCanapliaFoundation") {
def visualApiSource = file(
"ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/api/visual/AmbianceVisualEffectApi.java").text
if (rootProject.pack_version != "26.2.0-alpha.209"
|| rootProject.itsalive_version != "0.0.0-alpha.34"
|| rootProject.itsalive_version != "0.0.0-alpha.35"
|| rootProject.ambiance_version != "0.0.0-alpha.14"
|| migration.issue != 35
|| migration.source?.fabric_id != "canaplia"
@@ -5349,8 +5349,10 @@ tasks.register("verifyCanapliaGameplay") {
def resinState = new JsonSlurper().parse(new File(generated, "assets/canaplia/blockstates/resin_block.json"))
def codexRecipe = new JsonSlurper().parse(new File(generated, "data/canaplia/recipe/codex.json"))
def requiredCodexKeys = [
"screen.canaplia.codex.introduction", "screen.canaplia.codex.entry_number",
"screen.canaplia.codex.origin", "item.canaplia.codex_page"
"screen.canaplia.codex.title", "screen.canaplia.codex.page",
"screen.canaplia.codex.entry_number", "screen.canaplia.codex.origin",
"screen.canaplia.codex.hidden", "message.canaplia.codex_page.complete",
"tooltip.canaplia.codex.personal", "item.canaplia.codex", "item.canaplia.codex_page"
]
def languageKeysMissing = ["fr_fr", "en_us", "ru_ru"].collectMany { locale ->
def lang = new JsonSlurper().parse(file(
@@ -5446,7 +5448,7 @@ tasks.register("verifyCanapliaGameplay") {
|| !codexScreen.contains("extends BookViewScreen")
|| !harvesting.contains("NEIGHBOR_RADIUS = 1")
|| !effects.contains("ServerPlayConnectionEvents.DISCONNECT")
|| !knowledge.contains("unknown future keys survive round-trips")
|| !knowledge.contains("the legacy shared field is preserved")
|| !policy.contains('namespace.equals("canaplia")')) {
throw new GradleException(
"The complete Canaplia gameplay contract is incomplete; missing languages="
@@ -5486,7 +5488,7 @@ tasks.register("verifyCanapliaAlpha209Release") {
onlyfun: "0.0.0-alpha.20"]
if (rootProject.pack_version != "26.2.0-alpha.209"
|| rootProject.ambiance_version != expectedTarget.ambiance
|| rootProject.itsalive_version != "0.0.0-alpha.34"
|| rootProject.itsalive_version != "0.0.0-alpha.35"
|| rootProject.onlyfun_version != expectedTarget.onlyfun
|| migration.source?.pack_version != "26.2.0-alpha.207"
|| migration.source?.modules != expectedSource
+1 -1
View File
@@ -18,7 +18,7 @@ redstoner_version=0.0.0-alpha.10
redstoner_lifecycle=active
anotherworld_version=0.0.0-alpha.55
anotherworld_lifecycle=active
itsalive_version=0.0.0-alpha.34
itsalive_version=0.0.0-alpha.35
itsalive_lifecycle=active
ouch_version=0.0.0-alpha.9
ouch_lifecycle=active
+72 -6
View File
@@ -153,7 +153,7 @@ tasks.register("generateCanapliaResources") {
writeJson("data/c/tags/item/seeds.json", [replace: false, values: seedIds])
writeJson("data/canaplia/tags/item/hemp_buds.json", [replace: false, values: budIds])
itemDefinition("codex", "minecraft:item/book")
itemDefinition("codex", "minecraft:item/enchanted_book")
itemDefinition("codex_page", "minecraft:item/paper")
writeJson("data/canaplia/recipe/codex.json", [
type: "minecraft:crafting_shaped", category: "misc",
@@ -556,7 +556,7 @@ tasks.register("verifyItsAlive") {
def climateSource = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/agriculture/ClimateCropForaging.java").text
def pineappleSource = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/agriculture/PineappleItem.java").text
def worldgenSource = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/agriculture/ItsAliveAgricultureWorldGeneration.java").text
if (project.version.toString() != "0.0.0-alpha.34"
if (project.version.toString() != "0.0.0-alpha.35"
|| climateMigration.target?.pack_version != "26.2.0-alpha.157"
|| climateMigration.target?.modules?.itsalive != "0.0.0-alpha.24"
|| !climateSource.contains("PlayerBlockBreakEvents.AFTER.register")
@@ -1313,7 +1313,7 @@ tasks.register("verifyCulinaryReliability") {
}
}
}
if (project.version.toString() != "0.0.0-alpha.34"
if (project.version.toString() != "0.0.0-alpha.35"
|| migration.target?.modules?.itsalive != "0.0.0-alpha.28"
|| migration.culinary_fixes?.food_press_contents_destroyed_on_break != true
|| migration.culinary_fixes?.comparator_contract?.blocks?.size() != 6
@@ -1351,7 +1351,7 @@ tasks.register("verifyCauldronBatching") {
def recipe = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/recipe/CulinaryProcessRecipe.java").text
def cauldron = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/block/entity/CookingCauldronBlockEntity.java").text
def timed = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/block/entity/AbstractTimedCulinaryBlockEntity.java").text
if (project.version.toString() != "0.0.0-alpha.34"
if (project.version.toString() != "0.0.0-alpha.35"
|| migration.target?.modules?.itsalive != "0.0.0-alpha.29"
|| migration.cauldron_batches?.logical_stack_limit != 64
|| migration.cauldron_batches?.identity != "same_item_same_components"
@@ -1389,7 +1389,7 @@ tasks.register("verifyFermentationBarrelAutomation") {
def partialMatching = file("src/main/java/fr/koka99cab/sanctuary26/itsalive/culinary/recipe/CulinaryPartialMatching.java").text
def blockstate = new JsonSlurper().parse(
file("src/main/resources/assets/itsalive/blockstates/fermentation_barrel.json"))
if (project.version.toString() != "0.0.0-alpha.34"
if (project.version.toString() != "0.0.0-alpha.35"
|| migration.source?.modules?.itsalive != "0.0.0-alpha.29"
|| migration.target?.modules?.itsalive != "0.0.0-alpha.30"
|| migration.fermentation_barrel?.maximum_units_per_input_slot != 1
@@ -1437,7 +1437,7 @@ tasks.register("verifyCulinaryLecternRelease") {
[(locale): new JsonSlurper().parse(file(
"src/main/resources/assets/itsalive/lang/${locale}.json"))]
}
if (project.version.toString() != "0.0.0-alpha.34"
if (project.version.toString() != "0.0.0-alpha.35"
|| migration.target?.modules?.itsalive != "0.0.0-alpha.31"
|| migration.culinary_lectern?.book_tag != "minecraft:lectern_books"
|| migration.culinary_lectern?.owner_catalog_shared_while_placed != true
@@ -1461,9 +1461,75 @@ tasks.register("verifyCulinaryLecternRelease") {
}
}
tasks.register("verifyCanapliaForbiddenBook") {
group = "verification"
description = "Checks personal Forbidden Books, random page learning and lectern sharing."
dependsOn tasks.named("generateCanapliaResources")
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/CanapliaKnowledge.java"))
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/CanapliaKnowledgeData.java"))
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/CanapliaLectern.java"))
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/item/CanapliaCodexPageItem.java"))
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/client/CanapliaCodexScreen.java"))
inputs.file(file("src/main/java/fr/koka99cab/sanctuary26/itsalive/registry/ItsAliveDataComponents.java"))
inputs.file(file("src/main/resources/data/minecraft/tags/item/lectern_books.json"))
inputs.files(fileTree("src/main/resources/assets/canaplia/lang") { include "*.json" })
inputs.file(rootProject.file("CANAPLIA.md"))
doLast {
def knowledge = file(
"src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/CanapliaKnowledge.java").text
def knowledgeData = file(
"src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/CanapliaKnowledgeData.java").text
def lectern = file(
"src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/CanapliaLectern.java").text
def pageItem = file(
"src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/item/CanapliaCodexPageItem.java").text
def screen = file(
"src/main/java/fr/koka99cab/sanctuary26/itsalive/canaplia/client/CanapliaCodexScreen.java").text
def components = file(
"src/main/java/fr/koka99cab/sanctuary26/itsalive/registry/ItsAliveDataComponents.java").text
def lecternBooks = new JsonSlurper().parse(file(
"src/main/resources/data/minecraft/tags/item/lectern_books.json"))
def generatedCodex = new JsonSlurper().parse(new File(
generatedCanapliaResources.get().asFile, "assets/canaplia/items/codex.json"))
def locales = ["fr_fr", "en_us", "ru_ru"].collectEntries { locale ->
[(locale): new JsonSlurper().parse(file(
"src/main/resources/assets/canaplia/lang/${locale}.json"))]
}
def expectedNames = [
fr_fr: "Livre interdit",
en_us: "Forbidden Book",
ru_ru: "Запретная книга"]
if (project.version.toString() != "0.0.0-alpha.35"
|| !pageItem.contains("CanapliaKnowledge.learnRandomUnknown(serverPlayer)")
|| !knowledge.contains("unknownVariants(data.personal(player.getUUID()))")
|| !knowledge.contains("data.personal(inscription.ownerId())")
|| knowledge.contains("data.shared()")
|| !knowledgeData.contains("the legacy shared field is preserved")
|| !lectern.contains("UseBlockCallback.EVENT.register")
|| !lectern.contains("PlayerBlockBreakEvents.BEFORE.register")
|| !lectern.contains("CanapliaKnowledge.open(serverPlayer, book)")
|| !lectern.contains("CanapliaKnowledge.forgetInscription(taken)")
|| !screen.contains("PAGE_BACKGROUND = 0xFF09070E")
|| screen.contains("screen.canaplia.codex.introduction")
|| !components.contains('ItsAliveMod.id("canaplia_codex")')
|| lecternBooks["values"] != ["itsalive:culinary_recipe_book", "canaplia:codex"]
|| generatedCodex.model?.model != "minecraft:item/enchanted_book"
|| locales.any { locale, values ->
values["item.canaplia.codex"] != expectedNames[locale]
|| values["screen.canaplia.codex.title"] != expectedNames[locale]
|| !values.containsKey("message.canaplia.codex_page.complete")
|| !values.containsKey("tooltip.canaplia.codex.personal")
}) {
throw new GradleException("The Canaplia Forbidden Book contract is incomplete")
}
}
}
tasks.named("check") {
dependsOn tasks.named("verifyItsAlive")
dependsOn tasks.named("canapliaFoundationSmoke")
dependsOn tasks.named("verifyCanapliaForbiddenBook")
dependsOn tasks.named("verifyCulinaryReliability")
dependsOn tasks.named("verifyCauldronBatching")
dependsOn tasks.named("culinaryBatchingSmoke")
@@ -0,0 +1,43 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
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 placed Forbidden Book to the player whose knowledge it shares. */
public record CanapliaCodexInscription(UUID ownerId, String ownerName) {
private static final int MAX_NAME_LENGTH = 32;
public static final Codec<CanapliaCodexInscription> CODEC = RecordCodecBuilder.create(instance ->
instance.group(
UUIDUtil.CODEC.fieldOf("owner_id").forGetter(CanapliaCodexInscription::ownerId),
Codec.STRING.fieldOf("owner_name").forGetter(CanapliaCodexInscription::ownerName)
).apply(instance, CanapliaCodexInscription::new)
);
public static final StreamCodec<RegistryFriendlyByteBuf, CanapliaCodexInscription> STREAM_CODEC =
StreamCodec.composite(
UUIDUtil.STREAM_CODEC,
CanapliaCodexInscription::ownerId,
ByteBufCodecs.STRING_UTF8,
CanapliaCodexInscription::ownerName,
CanapliaCodexInscription::new
);
public CanapliaCodexInscription {
if (ownerId == null) throw new IllegalArgumentException("Forbidden Book owner is required");
ownerName = sanitizeName(ownerName);
}
public static CanapliaCodexInscription of(UUID ownerId, String ownerName) {
return new CanapliaCodexInscription(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;
}
}
@@ -6,6 +6,7 @@ import fr.koka99cab.sanctuary26.itsalive.canaplia.block.CanapliaHempTopBlock;
import fr.koka99cab.sanctuary26.itsalive.canaplia.block.CanapliaResinBlock;
import fr.koka99cab.sanctuary26.itsalive.canaplia.block.CanapliaResinBlockEntity;
import fr.koka99cab.sanctuary26.itsalive.canaplia.item.CanapliaCodexItem;
import fr.koka99cab.sanctuary26.itsalive.canaplia.item.CanapliaCodexPageItem;
import fr.koka99cab.sanctuary26.itsalive.canaplia.item.CanapliaJointItem;
import fr.koka99cab.sanctuary26.itsalive.canaplia.item.CanapliaVariantBlockItem;
import java.util.ArrayList;
@@ -37,7 +38,7 @@ public final class CanapliaContent {
public static final BlockEntityType<CanapliaResinBlockEntity> RESIN_BLOCK_ENTITY_TYPE = registerResinBlockEntity();
private static final Map<CanapliaVariantKey, Family> FAMILIES = registerFamilies();
public static final Item CODEX_PAGE = registerItem("codex_page", key ->
new Item(new Item.Properties().setId(key)));
new CanapliaCodexPageItem(new Item.Properties().setId(key)));
public static final Item CODEX = registerItem("codex", key ->
new CanapliaCodexItem(new Item.Properties().stacksTo(1).setId(key)));
private static final List<Item> CREATIVE_ITEMS = createCreativeItems();
@@ -21,6 +21,7 @@ public final class CanapliaFoundation {
throw new IllegalStateException("Unsupported Ambiance visual contract");
}
CanapliaContent.register();
CanapliaLectern.initialize();
CanapliaCodexDrops.initialize();
CanapliaDiscoverySources.register();
CanapliaDiscovery.initialize();
@@ -1,11 +1,16 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
import fr.koka99cab.sanctuary26.itsalive.network.CanapliaCodexPayload;
import fr.koka99cab.sanctuary26.itsalive.registry.ItsAliveDataComponents;
import fr.koka99cab.sanctuary26.sanctuary.progression.SanctuaryProgressionService;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
/** Trusted discovery service and compact Codex snapshot builder. */
public final class CanapliaKnowledge {
@@ -22,10 +27,50 @@ public final class CanapliaKnowledge {
}
public static void open(ServerPlayer player) {
open(player, player.getUUID(), Set.of());
}
public static void open(ServerPlayer player, ItemStack book) {
CanapliaCodexInscription inscription = book.get(ItsAliveDataComponents.CANAPLIA_CODEX);
if (inscription == null) {
open(player);
return;
}
CanapliaKnowledgeData data = CanapliaKnowledgeData.get(player.level().getServer());
open(player, player.getUUID(), data.personal(inscription.ownerId()));
}
public static boolean learnRandomUnknown(ServerPlayer player) {
CanapliaKnowledgeData data = CanapliaKnowledgeData.get(player.level().getServer());
List<CanapliaVariantKey> unknown = unknownVariants(data.personal(player.getUUID()));
if (unknown.isEmpty()) {
player.sendSystemMessage(Component.translatable("message.canaplia.codex_page.complete"));
return false;
}
CanapliaVariantKey selected = unknown.get(player.getRandom().nextInt(unknown.size()));
return discover(player, selected, "codex_page");
}
public static void inscribe(ItemStack stack, ServerPlayer player) {
if (!stack.is(CanapliaContent.CODEX)) return;
stack.set(ItsAliveDataComponents.CANAPLIA_CODEX, CanapliaCodexInscription.of(
player.getUUID(), player.getGameProfile().name()));
}
public static void forgetInscription(ItemStack stack) {
if (stack.is(CanapliaContent.CODEX)) stack.remove(ItsAliveDataComponents.CANAPLIA_CODEX);
}
static List<CanapliaVariantKey> unknownVariants(Set<String> learned) {
return Arrays.stream(CanapliaVariantKey.values())
.filter(variant -> !learned.contains(variant.key()))
.toList();
}
private static void open(ServerPlayer player, UUID personalOwner, Set<String> shared) {
if (!ServerPlayNetworking.canSend(player, CanapliaCodexPayload.ID)) return;
CanapliaKnowledgeData data = CanapliaKnowledgeData.get(player.level().getServer());
Set<String> personal = data.personal(player.getUUID());
Set<String> shared = data.shared();
Set<String> personal = data.personal(personalOwner);
int personalMask = mask(personal);
int sharedMask = mask(shared);
int originHints = 0;
@@ -15,7 +15,7 @@ import net.minecraft.world.level.Level;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Versioned personal and shared discoveries; unknown future keys survive round-trips. */
/** Versioned personal discoveries; the legacy shared field is preserved but no longer exposed globally. */
public final class CanapliaKnowledgeData extends SavedData {
public static final int DATA_VERSION = 1;
private static final Codec<CanapliaKnowledgeData> CODEC = RecordCodecBuilder.create(instance -> instance.group(
@@ -57,8 +57,7 @@ public final class CanapliaKnowledgeData extends SavedData {
public boolean discover(UUID player, String key) {
if (!validKey(key)) return false;
boolean personal = players.computeIfAbsent(player.toString(), ignored -> new LinkedHashSet<>()).add(key);
boolean community = shared.add(key);
if (personal || community) setDirty();
if (personal) setDirty();
return personal;
}
@@ -0,0 +1,77 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia;
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;
/** Shares only the placer's Canaplia knowledge while the Forbidden Book remains on a lectern. */
public final class CanapliaLectern {
private CanapliaLectern() {
}
public static void initialize() {
UseBlockCallback.EVENT.register(CanapliaLectern::use);
PlayerBlockBreakEvents.BEFORE.register((level, player, pos, state, blockEntity) -> {
if (blockEntity instanceof LecternBlockEntity lectern) {
CanapliaKnowledge.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(CanapliaContent.CODEX)
&& !level.isClientSide()
&& player instanceof ServerPlayer serverPlayer) {
CanapliaKnowledge.inscribe(held, serverPlayer);
}
return InteractionResult.PASS;
}
if (!(level.getBlockEntity(pos) instanceof LecternBlockEntity lectern)) {
return InteractionResult.PASS;
}
ItemStack book = lectern.getBook();
if (!book.is(CanapliaContent.CODEX)) 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);
}
CanapliaKnowledge.open(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;
CanapliaKnowledge.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;
}
}
@@ -5,19 +5,23 @@ import fr.koka99cab.sanctuary26.itsalive.network.CanapliaCodexPayload;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.ChatFormatting;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.screens.inventory.BookViewScreen;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.MutableComponent;
/** Vanilla book presentation of the server-authored Canaplia catalogue. */
public final class CanapliaCodexScreen extends BookViewScreen {
private static final int PAGE_BACKGROUND = 0xFF09070E;
private static final int PAGE_BORDER = 0xFF5B3268;
private int displayedPage;
public CanapliaCodexScreen(CanapliaCodexPayload snapshot) {
super(new BookAccess(pages(snapshot)));
}
private static List<Component> pages(CanapliaCodexPayload snapshot) {
List<Component> pages = new ArrayList<>(CanapliaVariantKey.values().length + 1);
pages.add(Component.translatable("screen.canaplia.codex.introduction"));
List<Component> pages = new ArrayList<>(CanapliaVariantKey.values().length);
CanapliaVariantKey[] variants = CanapliaVariantKey.values();
for (int index = 0; index < variants.length; index++) {
pages.add(variantPage(snapshot, variants[index], index));
@@ -25,6 +29,40 @@ public final class CanapliaCodexScreen extends BookViewScreen {
return List.copyOf(pages);
}
@Override
public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
super.extractBackground(graphics, mouseX, mouseY, partialTick);
int left = (this.width - IMAGE_WIDTH) / 2;
graphics.fill(left + 18, 14, left + 174, 177, PAGE_BACKGROUND);
graphics.outline(left + 18, 14, 156, 163, PAGE_BORDER);
}
@Override
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
super.extractRenderState(graphics, mouseX, mouseY, partialTick);
graphics.nextStratum();
int left = (this.width - IMAGE_WIDTH) / 2;
graphics.fill(left + 94, 14, left + 174, 27, PAGE_BACKGROUND);
graphics.text(this.font, Component.translatable("screen.canaplia.codex.page",
displayedPage + 1, CanapliaVariantKey.values().length), left + 99, 18, 0xFFE3C7ED);
}
@Override
public boolean setPage(int page) {
displayedPage = Math.clamp(page, 0, CanapliaVariantKey.values().length - 1);
return super.setPage(page);
}
@Override
protected void pageBack() {
setPage(displayedPage - 1);
}
@Override
protected void pageForward() {
setPage(displayedPage + 1);
}
private static Component variantPage(CanapliaCodexPayload snapshot,
CanapliaVariantKey variant, int index) {
boolean personal = bit(snapshot.personalMask(), index);
@@ -33,22 +71,22 @@ public final class CanapliaCodexScreen extends BookViewScreen {
boolean conditionHint = bit(snapshot.conditionHintMask(), index);
MutableComponent page = Component.translatable("screen.canaplia.codex.entry_number", index + 1)
.withStyle(ChatFormatting.DARK_GRAY).append("\n\n");
.withStyle(ChatFormatting.LIGHT_PURPLE).append("\n\n");
if (!personal && !shared) {
return page.append(Component.translatable("screen.canaplia.codex.hidden")
.withStyle(ChatFormatting.GRAY));
}
page.append((personal
? Component.translatable("variant.canaplia." + variant.key())
: Component.translatable("screen.canaplia.codex.silhouette"))
.withStyle(personal ? ChatFormatting.DARK_GREEN : ChatFormatting.GRAY));
if (personal || originHint) {
page.append(Component.translatable("variant.canaplia." + variant.key())
.withStyle(personal ? ChatFormatting.GREEN : ChatFormatting.LIGHT_PURPLE));
if (personal || shared || originHint) {
page.append("\n\n").append(Component.translatable("screen.canaplia.codex.origin",
Component.translatable("origin.canaplia." + variant.key())));
Component.translatable("origin.canaplia." + variant.key()))
.withStyle(ChatFormatting.GRAY));
}
if (personal || conditionHint) {
page.append("\n\n").append(Component.translatable("hint.canaplia." + variant.key()));
if (personal || shared || conditionHint) {
page.append("\n\n").append(Component.translatable("hint.canaplia." + variant.key())
.withStyle(ChatFormatting.GRAY));
}
return page;
}
@@ -1,11 +1,17 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia.item;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaKnowledge;
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 server-authored personal/shared Canaplia collection snapshot. */
@@ -20,4 +26,11 @@ public final class CanapliaCodexItem extends Item {
if (player instanceof ServerPlayer serverPlayer) CanapliaKnowledge.open(serverPlayer);
return InteractionResult.SUCCESS_SERVER;
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display,
Consumer<Component> tooltip, TooltipFlag flag) {
tooltip.accept(Component.translatable("tooltip.canaplia.codex.personal")
.withStyle(ChatFormatting.DARK_PURPLE));
}
}
@@ -0,0 +1,28 @@
package fr.koka99cab.sanctuary26.itsalive.canaplia.item;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaKnowledge;
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.level.Level;
/** Consumable unidentified page which discovers one random unknown Canaplia entry. */
public final class CanapliaCodexPageItem extends Item {
public CanapliaCodexPageItem(Properties properties) {
super(properties);
}
@Override
public InteractionResult use(Level level, Player player, InteractionHand hand) {
if (level.isClientSide()) return InteractionResult.SUCCESS;
if (!(player instanceof ServerPlayer serverPlayer)) return InteractionResult.PASS;
if (CanapliaKnowledge.learnRandomUnknown(serverPlayer)) {
ItemStack held = player.getItemInHand(hand);
if (!player.getAbilities().instabuild) held.shrink(1);
}
return InteractionResult.SUCCESS_SERVER;
}
}
@@ -1,6 +1,7 @@
package fr.koka99cab.sanctuary26.itsalive.registry;
import fr.koka99cab.sanctuary26.itsalive.ItsAliveMod;
import fr.koka99cab.sanctuary26.itsalive.canaplia.CanapliaCodexInscription;
import fr.koka99cab.sanctuary26.itsalive.culinary.knowledge.CulinaryBookInscription;
import net.minecraft.core.Registry;
import net.minecraft.core.component.DataComponentType;
@@ -8,6 +9,15 @@ import net.minecraft.core.registries.BuiltInRegistries;
/** Persistent item components owned by It's Alive. */
public final class ItsAliveDataComponents {
public static final DataComponentType<CanapliaCodexInscription> CANAPLIA_CODEX = Registry.register(
BuiltInRegistries.DATA_COMPONENT_TYPE,
ItsAliveMod.id("canaplia_codex"),
DataComponentType.<CanapliaCodexInscription>builder()
.persistent(CanapliaCodexInscription.CODEC)
.networkSynchronized(CanapliaCodexInscription.STREAM_CODEC)
.cacheEncoding()
.build()
);
public static final DataComponentType<CulinaryBookInscription> CULINARY_BOOK = Registry.register(
BuiltInRegistries.DATA_COMPONENT_TYPE,
ItsAliveMod.id("culinary_book"),
@@ -1,18 +1,18 @@
{
"description.canaplia": "Complete Canaplia gameplay: sixteen plants, server-authoritative discovery, hybrids, effects and an Ambiance-owned visual layer.",
"screen.canaplia.codex.title": "Canaplia Codex",
"screen.canaplia.codex.title": "Forbidden Book",
"screen.canaplia.codex.page": "Page %s / %s",
"screen.canaplia.codex.introduction": "CANAPLIA CODEX\n\nSlay monsters to recover scattered pages and, very rarely, a complete Codex.\n\nGrow and discover varieties to fill its pages.",
"screen.canaplia.codex.entry_number": "Entry %s",
"screen.canaplia.codex.origin": "Origin: %s",
"screen.canaplia.codex.hidden": "Undiscovered",
"screen.canaplia.codex.silhouette": "Known silhouette",
"message.canaplia.discovered": "Canaplia discovered: %s",
"message.canaplia.codex_page.complete": "You already know every entry in the Forbidden Book.",
"message.canaplia.composter.mixed": "This composter already contains another Canaplia variant.",
"tooltip.canaplia.codex.personal": "Personal in hand; shares its owner's knowledge on a lectern",
"block.canaplia.hemp_crop": "Canaplia Plant",
"block.canaplia.hemp_top": "Canaplia Plant Top",
"block.canaplia.resin_block": "Canaplia Resin Block",
"item.canaplia.codex": "Canaplia Codex",
"item.canaplia.codex": "Forbidden Book",
"item.canaplia.codex_page": "Canaplia Page",
"variant.canaplia.common": "Common",
"origin.canaplia.common": "Wild",
@@ -1,18 +1,18 @@
{
"description.canaplia": "Gameplay Canaplia complet : seize plantes, découvertes, hybrides et effets autoritaires côté serveur, avec une couche visuelle gérée par Ambiance.",
"screen.canaplia.codex.title": "Codex Canaplia",
"screen.canaplia.codex.title": "Livre interdit",
"screen.canaplia.codex.page": "Page %s / %s",
"screen.canaplia.codex.introduction": "CODEX CANAPLIA\n\nTuez des monstres pour retrouver des pages éparses et, très rarement, un Codex complet.\n\nCultivez et découvrez les variétés pour remplir ses pages.",
"screen.canaplia.codex.entry_number": "Entrée %s",
"screen.canaplia.codex.origin": "Origine : %s",
"screen.canaplia.codex.hidden": "Non découverte",
"screen.canaplia.codex.silhouette": "Silhouette connue",
"message.canaplia.discovered": "Canaplia découverte : %s",
"message.canaplia.codex_page.complete": "Tu connais déjà toutes les entrées du Livre interdit.",
"message.canaplia.composter.mixed": "Ce composteur contient déjà une autre variante de Canaplia.",
"tooltip.canaplia.codex.personal": "Personnel en main ; partage le savoir du propriétaire sur un pupitre",
"block.canaplia.hemp_crop": "Plant de Canaplia",
"block.canaplia.hemp_top": "Sommet de Canaplia",
"block.canaplia.resin_block": "Bloc de résine Canaplia",
"item.canaplia.codex": "Codex Canaplia",
"item.canaplia.codex": "Livre interdit",
"item.canaplia.codex_page": "Page de Canaplia",
"variant.canaplia.common": "Commune",
"origin.canaplia.common": "Sauvage",
@@ -1,18 +1,18 @@
{
"description.canaplia": "Полная механика Canaplia: шестнадцать растений, серверные открытия, гибриды и эффекты с визуальным слоем Ambiance.",
"screen.canaplia.codex.title": "Кодекс Canaplia",
"screen.canaplia.codex.title": "Запретная книга",
"screen.canaplia.codex.page": "Страница %s / %s",
"screen.canaplia.codex.introduction": "КОДЕКС CANAPLIA\n\nУбивайте монстров, чтобы находить отдельные страницы и, очень редко, полный Кодекс.\n\nВыращивайте и открывайте сорта, чтобы заполнить его.",
"screen.canaplia.codex.entry_number": "Запись %s",
"screen.canaplia.codex.origin": "Происхождение: %s",
"screen.canaplia.codex.hidden": "Не открыто",
"screen.canaplia.codex.silhouette": "Известный силуэт",
"message.canaplia.discovered": "Открыта Canaplia: %s",
"message.canaplia.codex_page.complete": "Вы уже знаете все записи Запретной книги.",
"message.canaplia.composter.mixed": "В этом компостере уже находится другой вариант Canaplia.",
"tooltip.canaplia.codex.personal": "Личная в руке; на кафедре делится знаниями владельца",
"block.canaplia.hemp_crop": "Растение Canaplia",
"block.canaplia.hemp_top": "Верхушка Canaplia",
"block.canaplia.resin_block": "Блок смолы Canaplia",
"item.canaplia.codex": "Кодекс Canaplia",
"item.canaplia.codex": "Запретная книга",
"item.canaplia.codex_page": "Страница Canaplia",
"variant.canaplia.common": "Обычная",
"origin.canaplia.common": "Дикая",
@@ -1,6 +1,7 @@
{
"replace": false,
"values": [
"itsalive:culinary_recipe_book"
"itsalive:culinary_recipe_book",
"canaplia:codex"
]
}
@@ -97,6 +97,28 @@ public final class CanapliaGameplaySmoke {
"unknown future personal discovery preserved");
assertTrue(futureKnowledge.shared().contains("future_variant_17"),
"unknown future shared discovery preserved");
assertTrue(futureKnowledge.discover(owner, CanapliaVariantKey.COMMON.key()),
"new personal discovery recorded");
assertTrue(futureKnowledge.personal(owner).contains(CanapliaVariantKey.COMMON.key()),
"new discovery remains personal");
assertTrue(!futureKnowledge.shared().contains(CanapliaVariantKey.COMMON.key()),
"new discovery is not published globally");
assertEquals(16, CanapliaKnowledge.unknownVariants(Set.of()).size(),
"blank Codex page can select every variant");
Set<String> almostComplete = new HashSet<>(CanapliaVariants.all().stream()
.map(definition -> definition.key().key()).toList());
almostComplete.remove(CanapliaVariantKey.EMBER.key());
assertEquals(java.util.List.of(CanapliaVariantKey.EMBER),
CanapliaKnowledge.unknownVariants(almostComplete), "last unknown Codex entry");
assertTrue(CanapliaKnowledge.unknownVariants(
CanapliaVariants.all().stream().map(definition -> definition.key().key()).collect(
java.util.stream.Collectors.toSet())).isEmpty(), "complete Codex has no unknown entry");
CanapliaCodexInscription inscription = CanapliaCodexInscription.of(owner,
" Can\u0000aplia owner with a deliberately long name ");
assertTrue(inscription.ownerName().length() <= 32, "Codex owner name bound");
assertTrue(!inscription.ownerName().contains("\u0000"), "Codex owner control characters removed");
CanapliaCodexPayload payload = new CanapliaCodexPayload(99, -1, 0x1FFFF, -2, Integer.MAX_VALUE);
assertEquals(CanapliaCodexPayload.PROTOCOL_VERSION, payload.protocol(), "codex protocol bound");