Add blank symbol and link-panel pages (#18)
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-09-23 14:49:44 -04:00
1 parent b8d23ee0c0
commit 62693c2229
33 files changed
+422 -1

No files matched your search

@@ -5,13 +5,18 @@ import io.netty.buffer.Unpooled;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.dnorberg.rivencraft.writing.WritingEntry;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@@ -63,6 +68,56 @@ public final class WritingDataGameTests {
helper.succeed();
}
@GameTest(template = "empty")
public static void pageStatesPersistAndNetworkRoundTrip(GameTestHelper helper) {
SymbolReference known = reference("rivencraft:terrain/standard");
SymbolReference removed = reference("example:removed_symbol");
List<PageContent> states = List.of(
PageContent.BLANK,
PageContent.symbol(known),
PageContent.unresolved(removed),
PageContent.LINK_PANEL
);
for (PageContent state : states) {
var json = PageContent.CODEC.encodeStart(JsonOps.INSTANCE, state).result().orElseThrow();
PageContent persistentCopy = PageContent.CODEC.parse(JsonOps.INSTANCE, json).result().orElseThrow();
helper.assertValueEqual(persistentCopy, state, "Page content must persist exactly");
FriendlyByteBuf buffer = new FriendlyByteBuf(Unpooled.buffer());
PageContent.STREAM_CODEC.encode(buffer, state);
helper.assertValueEqual(PageContent.STREAM_CODEC.decode(buffer), state,
"Page content must network-sync exactly");
}
helper.succeed();
}
@GameTest(template = "empty")
public static void pageItemPreservesContentAndRejectsForgedUnknownSymbols(GameTestHelper helper) {
SymbolReference known = reference("rivencraft:terrain/standard");
SymbolReference unknown = reference("example:forged_symbol");
ItemStack page = PageItem.createSymbolPage(known);
CompoundTag serialized = (CompoundTag) page.saveOptional(helper.getLevel().registryAccess());
ItemStack restored = ItemStack.parseOptional(helper.getLevel().registryAccess(), serialized);
helper.assertValueEqual(restored.get(ModDataComponents.PAGE_CONTENT.get()), PageContent.symbol(known),
"Symbol page content must survive an ItemStack save/load");
helper.assertValueEqual(
restored.get(ModDataComponents.PAGE_SYMBOL_DISPLAY.get()),
SymbolCatalog.display(known),
"Server-selected display metadata must survive for remote clients"
);
ItemStack recovered = PageItem.createRecoveredPage(unknown);
helper.assertFalse(PageItem.isUsableWriting(recovered),
"An unresolved page must remain recoverable without becoming valid writing");
try {
PageItem.createSymbolPage(unknown);
helper.fail("The server must reject new pages for unknown symbols");
} catch (IllegalArgumentException expected) {
helper.succeed();
}
}
private static SymbolReference reference(String id) {
return new SymbolReference(ResourceLocation.parse(id));
}
@@ -0,0 +1,131 @@
package net.dnorberg.rivencraft.item;
import java.util.List;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.minecraft.ChatFormatting;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.component.CustomModelData;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
/** One stackable Page item whose server-owned component identifies its writing state. */
public final class PageItem extends Item {
public PageItem(Properties properties) {
super(properties);
}
public static ItemStack createSymbolPage(SymbolReference reference) {
if (!SymbolCatalog.isKnown(reference)) {
throw new IllegalArgumentException("Cannot create a page for unknown symbol " + reference.id());
}
return create(PageContent.symbol(reference));
}
public static ItemStack createLinkPanelPage() {
return create(PageContent.LINK_PANEL);
}
/** Restores saved unknown references without granting them validity for new writing operations. */
public static ItemStack createRecoveredPage(SymbolReference reference) {
return create(PageContent.unresolved(reference));
}
public static PageContent content(ItemStack stack) {
PageContent content = stack.get(ModDataComponents.PAGE_CONTENT.get());
return content == null ? PageContent.BLANK : content;
}
/** Server-side gate for Desk and Binder inputs; unresolved pages remain removable but not usable. */
public static boolean isUsableWriting(ItemStack stack) {
if (!stack.is(ModItems.PAGE.get())) {
return false;
}
PageContent resolved = content(stack).resolve();
return resolved.kind() == PageContent.Kind.BLANK
|| resolved.kind() == PageContent.Kind.LINK_PANEL
|| resolved.isResolvedSymbol();
}
@Override
public Component getName(ItemStack stack) {
PageContent content = content(stack);
return switch (content.kind()) {
case BLANK -> Component.translatable("item.rivencraft.page.blank");
case LINK_PANEL -> Component.translatable("item.rivencraft.page.link_panel");
case UNRESOLVED_SYMBOL -> Component.translatable("item.rivencraft.page.unresolved");
case SYMBOL -> symbolName(stack, content.symbol().orElseThrow());
};
}
@Override
public void appendHoverText(ItemStack stack, TooltipContext context,
List<Component> tooltipComponents, TooltipFlag tooltipFlag) {
PageContent content = content(stack);
if (content.kind() == PageContent.Kind.BLANK) {
tooltipComponents.add(Component.translatable("item.rivencraft.page.blank.description")
.withStyle(ChatFormatting.GRAY));
return;
}
if (content.kind() == PageContent.Kind.LINK_PANEL) {
tooltipComponents.add(Component.translatable("item.rivencraft.page.link_panel.description")
.withStyle(ChatFormatting.GRAY));
return;
}
SymbolReference reference = content.symbol().orElseThrow();
SymbolDisplayMetadata metadata = content.kind() == PageContent.Kind.SYMBOL
? stack.getOrDefault(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(), SymbolCatalog.display(reference))
: SymbolDisplayMetadata.unresolved(reference);
if (metadata.resolved()) {
tooltipComponents.add(Component.translatable(metadata.descriptionTranslationKey())
.withStyle(ChatFormatting.GRAY));
} else {
tooltipComponents.add(Component.translatable(
"symbol.rivencraft.unresolved.description", reference.unresolvedName())
.withStyle(ChatFormatting.RED));
}
tooltipComponents.add(Component.literal(reference.id().toString()).withStyle(ChatFormatting.DARK_GRAY));
}
private static ItemStack create(PageContent content) {
ItemStack stack = new ItemStack(ModItems.PAGE.get());
stack.set(ModDataComponents.PAGE_CONTENT.get(), content);
stack.set(DataComponents.CUSTOM_MODEL_DATA, new CustomModelData(modelIndex(content)));
if (content.kind() == PageContent.Kind.SYMBOL) {
stack.set(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(),
SymbolCatalog.display(content.symbol().orElseThrow()));
}
return stack;
}
private static Component symbolName(ItemStack stack, SymbolReference reference) {
SymbolDisplayMetadata metadata = stack.getOrDefault(
ModDataComponents.PAGE_SYMBOL_DISPLAY.get(), SymbolCatalog.display(reference));
return metadata.resolved()
? Component.translatable(metadata.nameTranslationKey())
: Component.translatable("item.rivencraft.page.unresolved");
}
private static int modelIndex(PageContent content) {
return switch (content.kind()) {
case BLANK -> 0;
case LINK_PANEL -> 1;
case UNRESOLVED_SYMBOL -> 2;
case SYMBOL -> switch (content.symbol().orElseThrow().id().toString()) {
case "rivencraft:terrain/standard" -> 100;
case "rivencraft:biome/native" -> 101;
case "rivencraft:lighting/normal" -> 102;
case "rivencraft:weather/normal" -> 103;
case "rivencraft:celestial/normal_time" -> 104;
case "rivencraft:feature/caves" -> 105;
default -> 3;
};
};
}
}
@@ -19,6 +19,7 @@ public final class ModCreativeTabs {
.displayItems((parameters, output) -> {
output.accept(ModItems.DESCRIPTIVE_BOOK.get());
output.accept(ModItems.LINKING_BOOK.get());
output.accept(ModItems.PAGE.get());
})
.build());
@@ -3,6 +3,8 @@ package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.book.LinkTarget;
import net.dnorberg.rivencraft.book.AgeReference;
import net.dnorberg.rivencraft.writing.PageContent;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.minecraft.core.component.DataComponentType;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceLocation;
@@ -39,6 +41,18 @@ public final class ModDataComponents {
.networkSynchronized(ByteBufCodecs.STRING_UTF8)
.cacheEncoding()
.build());
public static final DeferredHolder<DataComponentType<?>, DataComponentType<PageContent>> PAGE_CONTENT =
DATA_COMPONENTS.register("page_content", () -> DataComponentType.<PageContent>builder()
.persistent(PageContent.CODEC)
.networkSynchronized(PageContent.STREAM_CODEC)
.cacheEncoding()
.build());
public static final DeferredHolder<DataComponentType<?>, DataComponentType<SymbolDisplayMetadata>> PAGE_SYMBOL_DISPLAY =
DATA_COMPONENTS.register("page_symbol_display", () -> DataComponentType.<SymbolDisplayMetadata>builder()
.persistent(SymbolDisplayMetadata.CODEC)
.networkSynchronized(SymbolDisplayMetadata.STREAM_CODEC)
.cacheEncoding()
.build());
private ModDataComponents() {
}
@@ -3,6 +3,7 @@ package net.dnorberg.rivencraft.registry;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.DescriptiveBookItem;
import net.dnorberg.rivencraft.item.LinkingBookItem;
import net.dnorberg.rivencraft.item.PageItem;
import net.minecraft.world.item.Item;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredItem;
@@ -21,6 +22,11 @@ public final class ModItems {
LinkingBookItem::new,
new Item.Properties().stacksTo(1)
);
public static final DeferredItem<PageItem> PAGE = ITEMS.registerItem(
"page",
PageItem::new,
new Item.Properties().stacksTo(64)
);
private ModItems() {
}
@@ -0,0 +1,95 @@
package net.dnorberg.rivencraft.writing;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DataResult;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import io.netty.buffer.ByteBuf;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
/** Persistent contents of a Page item. Unknown symbols remain explicit and recoverable. */
public record PageContent(Kind kind, Optional<SymbolReference> symbol) {
public static final PageContent BLANK = new PageContent(Kind.BLANK, Optional.empty());
public static final PageContent LINK_PANEL = new PageContent(Kind.LINK_PANEL, Optional.empty());
private static final Codec<Kind> KIND_CODEC = Codec.STRING.comapFlatMap(
value -> {
try {
return DataResult.success(Kind.valueOf(value.toUpperCase(Locale.ROOT)));
} catch (IllegalArgumentException exception) {
return DataResult.error(() -> "Unknown page content kind: " + value);
}
},
value -> value.name().toLowerCase(Locale.ROOT)
);
private static final Codec<PageContent> RECORD_CODEC = RecordCodecBuilder.create(instance -> instance.group(
KIND_CODEC.fieldOf("kind").forGetter(PageContent::kind),
SymbolReference.CODEC.optionalFieldOf("symbol").forGetter(PageContent::symbol)
).apply(instance, PageContent::new));
public static final Codec<PageContent> CODEC = RECORD_CODEC.flatXmap(PageContent::validate, PageContent::validate);
public static final StreamCodec<ByteBuf, PageContent> STREAM_CODEC = StreamCodec.of(
PageContent::encode, PageContent::decode);
public PageContent {
Objects.requireNonNull(kind, "kind");
symbol = Objects.requireNonNull(symbol, "symbol");
boolean needsSymbol = kind == Kind.SYMBOL || kind == Kind.UNRESOLVED_SYMBOL;
if (needsSymbol != symbol.isPresent()) {
throw new IllegalArgumentException("Page contents do not match kind " + kind);
}
}
public static PageContent symbol(SymbolReference symbol) {
return new PageContent(Kind.SYMBOL, Optional.of(symbol));
}
public static PageContent unresolved(SymbolReference symbol) {
return new PageContent(Kind.UNRESOLVED_SYMBOL, Optional.of(symbol));
}
/** Re-resolves persisted data against the authoritative current server catalog. */
public PageContent resolve() {
if (kind != Kind.SYMBOL && kind != Kind.UNRESOLVED_SYMBOL) {
return this;
}
SymbolReference reference = symbol.orElseThrow();
return SymbolCatalog.isKnown(reference) ? symbol(reference) : unresolved(reference);
}
public boolean isResolvedSymbol() {
return kind == Kind.SYMBOL;
}
private static DataResult<PageContent> validate(PageContent content) {
boolean needsSymbol = content.kind == Kind.SYMBOL || content.kind == Kind.UNRESOLVED_SYMBOL;
return needsSymbol == content.symbol.isPresent()
? DataResult.success(content)
: DataResult.error(() -> "Page contents do not match kind " + content.kind);
}
private static void encode(ByteBuf buffer, PageContent content) {
ByteBufCodecs.VAR_INT.encode(buffer, content.kind.ordinal());
content.symbol.ifPresent(reference -> SymbolReference.STREAM_CODEC.encode(buffer, reference));
}
private static PageContent decode(ByteBuf buffer) {
int ordinal = ByteBufCodecs.VAR_INT.decode(buffer);
if (ordinal < 0 || ordinal >= Kind.values().length) {
throw new IllegalArgumentException("Unknown page content kind ordinal " + ordinal);
}
Kind kind = Kind.values()[ordinal];
Optional<SymbolReference> symbol = kind == Kind.SYMBOL || kind == Kind.UNRESOLVED_SYMBOL
? Optional.of(SymbolReference.STREAM_CODEC.decode(buffer))
: Optional.empty();
return new PageContent(kind, symbol);
}
public enum Kind {
BLANK,
SYMBOL,
UNRESOLVED_SYMBOL,
LINK_PANEL
}
}
@@ -6,6 +6,12 @@
"item.rivencraft.linking_book.unbound": "Unbound — sneak-use to bind",
"item.rivencraft.linking_book.destination": "%s (%s, %s, %s)",
"item.rivencraft.linking_book.return": "Return Link",
"item.rivencraft.page": "Page",
"item.rivencraft.page.blank": "Blank Page",
"item.rivencraft.page.blank.description": "Ready for writing at a Writing Desk.",
"item.rivencraft.page.link_panel": "Link Panel Page",
"item.rivencraft.page.link_panel.description": "The baseline panel required to assemble a Descriptive Book.",
"item.rivencraft.page.unresolved": "Unresolved Symbol Page",
"message.rivencraft.linking_book.bind_hint": "Sneak-use to bind this book to your current location",
"message.rivencraft.linking_book.unbound": "This Linking Book is unbound",
"message.rivencraft.linking_book.bound": "Linking Book bound as %s",
@@ -0,0 +1,17 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "rivencraft:item/page"
},
"overrides": [
{ "predicate": { "custom_model_data": 1 }, "model": "rivencraft:item/page_link_panel" },
{ "predicate": { "custom_model_data": 2 }, "model": "rivencraft:item/page_unresolved" },
{ "predicate": { "custom_model_data": 3 }, "model": "rivencraft:item/page_symbol" },
{ "predicate": { "custom_model_data": 100 }, "model": "rivencraft:item/page_symbol_terrain_standard" },
{ "predicate": { "custom_model_data": 101 }, "model": "rivencraft:item/page_symbol_biome_native" },
{ "predicate": { "custom_model_data": 102 }, "model": "rivencraft:item/page_symbol_lighting_normal" },
{ "predicate": { "custom_model_data": 103 }, "model": "rivencraft:item/page_symbol_weather_normal" },
{ "predicate": { "custom_model_data": 104 }, "model": "rivencraft:item/page_symbol_celestial_normal_time" },
{ "predicate": { "custom_model_data": 105 }, "model": "rivencraft:item/page_symbol_feature_caves" }
]
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "rivencraft:item/page_link_panel"
}
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "rivencraft:item/page_symbol"
}
}
@@ -0,0 +1,4 @@
{
"parent": "minecraft:item/generated",
"textures": { "layer0": "rivencraft:item/page_symbol_biome_native" }
}
@@ -0,0 +1,4 @@
{
"parent": "minecraft:item/generated",
"textures": { "layer0": "rivencraft:item/page_symbol_celestial_normal_time" }
}
@@ -0,0 +1,4 @@
{
"parent": "minecraft:item/generated",
"textures": { "layer0": "rivencraft:item/page_symbol_feature_caves" }
}
@@ -0,0 +1,4 @@
{
"parent": "minecraft:item/generated",
"textures": { "layer0": "rivencraft:item/page_symbol_lighting_normal" }
}
@@ -0,0 +1,4 @@
{
"parent": "minecraft:item/generated",
"textures": { "layer0": "rivencraft:item/page_symbol_terrain_standard" }
}
@@ -0,0 +1,4 @@
{
"parent": "minecraft:item/generated",
"textures": { "layer0": "rivencraft:item/page_symbol_weather_normal" }
}
@@ -0,0 +1,6 @@
{
"parent": "minecraft:item/generated",
"textures": {
"layer0": "rivencraft:item/page_unresolved"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 941 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,13 @@
{
"type": "minecraft:crafting_shapeless",
"category": "misc",
"ingredients": [
{
"item": "minecraft:paper"
}
],
"result": {
"count": 1,
"id": "rivencraft:page"
}
}