Render authored Descriptive Books and persist random Age seasons (#40)
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-10-04 02:40:47 -04:00
1 parent 6452d2c324
commit 916755e810
19 files changed
+262 -38

No files matched your search

@@ -5,6 +5,7 @@ import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.ResourceLocation;
import org.jetbrains.annotations.Nullable;
/** Immutable, server-owned generation definition for one Age. */
public record AgeDefinition(
@@ -17,7 +18,8 @@ public record AgeDefinition(
ResourceLocation dimensionId,
BlockPos spawn,
AgeTraits traits,
AgeGenerationPlan plan
AgeGenerationPlan plan,
@Nullable AgeSeasonMode seasonMode
) {
public static final ResourceLocation RANDOM_VANILLA_PROFILE = ResourceLocation.fromNamespaceAndPath(
Rivencraft.MOD_ID, "random_vanilla"
@@ -39,7 +41,14 @@ public record AgeDefinition(
int generationVersion, long createdGameTime, ResourceLocation dimensionId,
BlockPos spawn, AgeTraits traits) {
this(id, name, seed, generationProfile, generationVersion, createdGameTime,
dimensionId, spawn, traits, null);
dimensionId, spawn, traits, null, null);
}
public AgeDefinition(UUID id, String name, long seed, ResourceLocation generationProfile,
int generationVersion, long createdGameTime, ResourceLocation dimensionId,
BlockPos spawn, AgeTraits traits, AgeGenerationPlan plan) {
this(id, name, seed, generationProfile, generationVersion, createdGameTime,
dimensionId, spawn, traits, plan, null);
}
public static ResourceLocation dimensionId(UUID ageId) {
@@ -70,6 +79,6 @@ public record AgeDefinition(
public AgeDefinition withSpawn(BlockPos spawn) {
return new AgeDefinition(id, name, seed, generationProfile, generationVersion,
createdGameTime, dimensionId, spawn.immutable(), traits, plan);
createdGameTime, dimensionId, spawn.immutable(), traits, plan, seasonMode);
}
}
@@ -15,6 +15,7 @@ import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.datafix.DataFixTypes;
import net.minecraft.world.level.saveddata.SavedData;
import net.neoforged.fml.ModList;
/** Server-wide persistent mapping from stable book UUIDs to immutable Age definitions. */
public final class AgeRegistrySavedData extends SavedData {
@@ -32,6 +33,7 @@ public final class AgeRegistrySavedData extends SavedData {
private static final String SPAWN_Z_TAG = "SpawnZ";
private static final String TRAITS_TAG = "Traits";
private static final String PLAN_TAG = "GenerationPlan";
private static final String SEASON_MODE_TAG = "SeasonMode";
private static final String BIOME_DISTRIBUTION_TAG = "BiomeDistribution";
private static final String LIGHTING_TAG = "Lighting";
private static final String WEATHER_TAG = "Weather";
@@ -130,7 +132,8 @@ public final class AgeRegistrySavedData extends SavedData {
dimensionId,
null,
AgeEnvironmentCompiler.apply(plan, traits),
plan
plan,
AgeSeasonMode.select(plan, seed, ModList.get().isLoaded("sereneseasons"))
);
ages.put(created.id(), created);
setDirty();
@@ -174,7 +177,9 @@ public final class AgeRegistrySavedData extends SavedData {
spawn,
readTraits(entry),
entry.contains(PLAN_TAG, CompoundTag.TAG_COMPOUND)
? AgeGenerationPlan.load(entry.getCompound(PLAN_TAG)) : null
? AgeGenerationPlan.load(entry.getCompound(PLAN_TAG)) : null,
entry.contains(SEASON_MODE_TAG, CompoundTag.TAG_STRING)
? AgeSeasonMode.parseOrNull(entry.getString(SEASON_MODE_TAG)) : null
));
}
return data;
@@ -208,6 +213,7 @@ public final class AgeRegistrySavedData extends SavedData {
traits.putFloat(CELESTIAL_PHASE_TAG, age.traits().celestialPhase());
entry.put(TRAITS_TAG, traits);
if (age.plan() != null) entry.put(PLAN_TAG, age.plan().save());
if (age.seasonMode() != null) entry.putString(SEASON_MODE_TAG, age.seasonMode().name());
serializedAges.add(entry);
});
tag.put(AGES_TAG, serializedAges);
@@ -1,10 +1,11 @@
package net.dnorberg.rivencraft.book;
import java.util.Locale;
import java.util.Random;
import net.dnorberg.rivencraft.writing.SymbolSemantics;
import net.neoforged.fml.ModList;
/** One authored season policy per Age; unspecified Ages retain the normal cycle. */
/** One authored or seed-selected season policy per Age. */
public enum AgeSeasonMode {
NO_SEASONS,
SEASONS,
@@ -13,6 +14,32 @@ public enum AgeSeasonMode {
ETERNAL_FALL,
ETERNAL_SPRING;
private static final long RANDOM_SALT = 0x5E45_0A5E_5EED_21L;
/** An omitted page is selected once when an Age is created with Serene installed. */
public static AgeSeasonMode select(AgeGenerationPlan plan, long ageSeed, boolean sereneInstalled) {
if (hasAuthoredPage(plan)) return from(plan);
return sereneInstalled ? values()[new Random(ageSeed ^ RANDOM_SALT).nextInt(values().length)] : null;
}
/** Existing Ages without a stored choice retain their old cycling behavior. */
public static AgeSeasonMode from(AgeDefinition age) {
return age.seasonMode() == null ? from(age.plan()) : age.seasonMode();
}
public static boolean hasAuthoredPage(AgeGenerationPlan plan) {
return plan != null && plan.symbols().stream()
.anyMatch(symbol -> symbol.semantics().role() == SymbolSemantics.Role.SEASON);
}
public static AgeSeasonMode parseOrNull(String value) {
try {
return valueOf(value);
} catch (IllegalArgumentException exception) {
return null;
}
}
public static AgeSeasonMode from(AgeGenerationPlan plan) {
if (plan == null) return SEASONS;
return plan.symbols().stream()
@@ -24,9 +51,7 @@ public enum AgeSeasonMode {
public static void validate(AgeGenerationPlan plan) {
if (plan == null) return;
boolean authored = plan.symbols().stream()
.anyMatch(symbol -> symbol.semantics().role() == SymbolSemantics.Role.SEASON);
if (authored && from(plan) != NO_SEASONS
if (hasAuthoredPage(plan) && from(plan) != NO_SEASONS
&& !ModList.get().isLoaded("sereneseasons")) {
throw new IllegalArgumentException("This seasonal page requires Serene Seasons to create the Age");
}
@@ -13,6 +13,9 @@ import net.dnorberg.rivencraft.writing.WritingEntry;
import net.dnorberg.rivencraft.writing.WritingGrammar;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.level.dimension.BuiltinDimensionTypes;
import net.neoforged.neoforge.gametest.GameTestHolder;
@@ -93,6 +96,43 @@ public final class EnvironmentWritingGameTests {
helper.succeed();
}
@GameTest(template = "empty")
public static void omittedSeasonPageSelectsOnlyWhenSereneIsPresent(GameTestHelper helper) {
helper.assertTrue(AgeSeasonMode.select(null, 42L, false) == null,
"An Age made without Serene must not receive a random seasonal modifier");
AgeSeasonMode selected = AgeSeasonMode.select(null, 42L, true);
helper.assertValueEqual(selected, AgeSeasonMode.select(null, 42L, true),
"The same Age seed must select the same seasonal modifier");
helper.assertTrue(selected != null, "Serene must select a mode when no season page is authored");
AgeDefinition age = new AgeDefinition(UUID.randomUUID(), "Season Test", 42L,
AgeGenerationProfiles.STANDARD, AgeDefinition.CURRENT_GENERATION_VERSION, 0L,
AgeDefinition.dimensionId(UUID.randomUUID()), null, AgeTraits.select(42L), null, selected);
helper.assertValueEqual(AgeSeasonMode.from(age.withSpawn(BlockPos.ZERO)), selected,
"Updating an Age spawn must preserve the chosen seasonal modifier");
OrderedWriting explicit = new OrderedWriting(List.of(WritingEntry.linkPanel(), page("season/no_seasons")));
AgeGenerationPlan plan = AgeGenerationPlan.compile(explicit, 42L, AgeGenerationProfiles.STANDARD,
AgeTraits.select(42L), helper.getLevel().registryAccess());
helper.assertValueEqual(AgeSeasonMode.select(plan, 42L, true), AgeSeasonMode.NO_SEASONS,
"An authored season page must override random selection");
AgeReference reference = new AgeReference(UUID.randomUUID(), "Season Persistence");
AgeRegistrySavedData data = AgeRegistrySavedData.get(helper.getLevel());
AgeRegistrySavedData.resolveOrCreate(helper.getLevel(), reference);
CompoundTag saved = data.save(new CompoundTag(), helper.getLevel().registryAccess());
ListTag entries = saved.getList("Ages", CompoundTag.TAG_COMPOUND);
for (int index = 0; index < entries.size(); index++) {
CompoundTag entry = entries.getCompound(index);
if (entry.getUUID("Id").equals(reference.id())) {
entry.putString("SeasonMode", selected.name());
break;
}
}
AgeDefinition restored = AgeRegistrySavedData.load(saved, helper.getLevel().registryAccess())
.find(reference.id()).orElseThrow();
helper.assertValueEqual(restored.seasonMode(), selected,
"The selected seasonal modifier must survive Age data reload");
helper.succeed();
}
@GameTest(template = "empty")
public static void writtenEnvironmentPersistsAndPageFreeAgesStaySeeded(GameTestHelper helper) {
OrderedWriting writing = new OrderedWriting(List.of(
@@ -92,8 +92,10 @@ public final class ClientBookInteractionHandler {
return;
}
minecraft.setScreen(payload.fromStand()
? new DescriptiveBookScreen(payload.source().pos(), payload.book(), payload.traits(), payload.writingSymbols())
: new DescriptiveBookScreen(hand, payload.book(), payload.traits(), payload.writingSymbols()));
? new DescriptiveBookScreen(payload.source().pos(), payload.book(), payload.traits(),
payload.writingSymbols(), payload.diagnostics())
: new DescriptiveBookScreen(hand, payload.book(), payload.traits(),
payload.writingSymbols(), payload.diagnostics()));
});
}
}
@@ -69,6 +69,13 @@ public final class SymbolPageTextures {
return forPage(stack, true);
}
/** Book pages use the same server-resolved poem and glyph composition as Page items. */
public static ResourceLocation forDisplay(SymbolDisplayMetadata display) {
return display.resolved()
? forSymbol(display.id(), display.poem(), false)
: texture("item/page_unresolved");
}
private static ResourceLocation forPage(ItemStack stack, boolean item) {
PageContent content = PageItem.content(stack);
if (content.kind() == PageContent.Kind.UNRESOLVED_SYMBOL) return texture("item/page_unresolved");
@@ -16,6 +16,7 @@ import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.dnorberg.rivencraft.writing.WritingEntry;
import net.dnorberg.rivencraft.writing.WritingGrammar;
import net.dnorberg.rivencraft.client.preview.DestinationPreviewRenderer;
import net.dnorberg.rivencraft.client.preview.DestinationPreviewRenderers;
import net.minecraft.core.BlockPos;
@@ -44,6 +45,7 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
private static final int LINK_PANEL_Y = 20;
private static final int LINK_PANEL_WIDTH = 132;
private static final int LINK_PANEL_HEIGHT = 83;
private static final int DIAGNOSTICS_PER_PAGE = 2;
private static final Component EXPLORE_ICON = Component.literal("👁");
private final AgeReference age;
@@ -51,7 +53,9 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
private final AgeTraits traits;
private final OrderedWriting writing;
private final List<SymbolDisplayMetadata> writingSymbols;
private final List<WritingGrammar.Diagnostic> diagnostics;
private final boolean pageFree;
private final String author;
private final InteractionHand hand;
private final BlockPos bookStandPos;
private DestinationPreviewRenderer preview;
@@ -63,27 +67,33 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
private boolean movingToExploreOverlay;
public DescriptiveBookScreen(InteractionHand hand, ItemStack book, @Nullable AgeTraits traits,
List<SymbolDisplayMetadata> writingSymbols) {
List<SymbolDisplayMetadata> writingSymbols,
List<WritingGrammar.Diagnostic> diagnostics) {
super(book.getHoverName());
age = book.get(ModDataComponents.AGE_REFERENCE.get());
generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get());
this.traits = traits;
writing = book.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY);
this.writingSymbols = List.copyOf(writingSymbols);
this.diagnostics = List.copyOf(diagnostics);
pageFree = writing.entries().stream().noneMatch(entry -> entry.kind() == WritingEntry.Kind.SYMBOL);
author = book.get(ModDataComponents.BOOK_AUTHOR.get());
this.hand = hand;
bookStandPos = null;
}
public DescriptiveBookScreen(BlockPos bookStandPos, ItemStack book, @Nullable AgeTraits traits,
List<SymbolDisplayMetadata> writingSymbols) {
List<SymbolDisplayMetadata> writingSymbols,
List<WritingGrammar.Diagnostic> diagnostics) {
super(book.getHoverName());
age = book.get(ModDataComponents.AGE_REFERENCE.get());
generationProfile = book.get(ModDataComponents.AGE_GENERATION_PROFILE.get());
this.traits = traits;
writing = book.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY);
this.writingSymbols = List.copyOf(writingSymbols);
this.diagnostics = List.copyOf(diagnostics);
pageFree = writing.entries().stream().noneMatch(entry -> entry.kind() == WritingEntry.Kind.SYMBOL);
author = book.get(ModDataComponents.BOOK_AUTHOR.get());
hand = InteractionHand.MAIN_HAND;
this.bookStandPos = bookStandPos.immutable();
}
@@ -163,6 +173,33 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
else if (pageIndex < pageCount() && mouseX >= left + 293 && mouseX < left + 320)
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.book.next_page"), mouseX, mouseY);
}
if (pageIndex > 0 && pageIndex < writing.entries().size()
&& mouseX >= left + 285 && mouseX < left + 306
&& mouseY >= top + 149 && mouseY < top + 169) {
diagnostics.stream().filter(diagnostic -> diagnostic.index() == pageIndex)
.findFirst().ifPresent(diagnostic -> graphics.renderTooltip(font,
Component.literal(diagnostic.message()), mouseX, mouseY));
}
if (pageIndex == 0 && age != null && !pageFree && traits != null
&& mouseX >= left + 38 && mouseX < left + 154
&& mouseY >= top + 38 && mouseY < top + 67) {
graphics.renderComponentTooltip(font, List.of(
Component.translatable("screen.rivencraft.age.trait.biomes",
traitName("biome_distribution", traits.biomeDistribution())),
Component.translatable("screen.rivencraft.age.trait.lighting",
traitName("lighting", traits.lighting())),
Component.translatable("screen.rivencraft.age.trait.weather",
traitName("weather", traits.weather())),
Component.translatable("screen.rivencraft.age.trait.day_length",
traits.hasStoppedCelestialCycle()
? Component.translatable("trait.rivencraft.day_length.stopped")
: Component.literal(String.format(Locale.ROOT, "%.2fx", traits.celestialPeriod()))),
Component.translatable("screen.rivencraft.age.trait.celestial_angle",
Component.literal(String.format(Locale.ROOT, "%.0f°", traits.celestialAngle()))),
Component.translatable("screen.rivencraft.age.trait.celestial_phase",
Component.literal(String.format(Locale.ROOT, "%.0f%%", traits.celestialPhase() * 100.0F)))
), mouseX, mouseY);
}
}
private void renderBook(GuiGraphics graphics, float partialTick) {
@@ -182,9 +219,12 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
graphics.enableScissor(left + 38, top + 38, left + 154, top + 174);
renderFirstPageText(graphics, left, top);
graphics.disableScissor();
} else if (pageIndex < pageCount()) {
} else if (pageIndex < writing.entries().size()) {
LegacyBookArtwork.drawFullPage(graphics, left, top);
renderWrittenPage(graphics, left, top);
} else if (!diagnostics.isEmpty()) {
LegacyBookArtwork.drawFullPage(graphics, left, top);
renderDiagnosticsPage(graphics, left, top);
}
String counter = pageIndex + "/" + pageCount();
graphics.drawString(font, counter, left + 165 - font.width(counter) / 2,
@@ -193,20 +233,27 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
private int pageCount() {
// Older page-free Descriptive Books do not have a BOOK_WRITING component.
return Math.max(1, writing.entries().size());
int authoredPages = Math.max(1, writing.entries().size() - 1);
return authoredPages + (diagnostics.isEmpty() ? 0
: (diagnostics.size() + DIAGNOSTICS_PER_PAGE - 1) / DIAGNOSTICS_PER_PAGE);
}
private void renderFirstPageText(GuiGraphics graphics, int left, int top) {
int x = left + 40;
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.age.title"),
x, top + 40, 110);
if (age == null) {
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.age.title"),
x, top + 40, 110);
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.age.name_prompt"),
x, top + 52, 110);
return;
}
int detailsY = drawWrappedWithoutShadow(graphics, Component.literal(age.name()),
x + 10, top + 52, 100) + 10;
int nameEnd = drawWrappedWithoutShadow(graphics, Component.literal(age.name()),
x, top + 40, 110);
int detailsY = drawWrappedWithoutShadow(graphics,
author == null || author.isBlank()
? Component.translatable("screen.rivencraft.age.unknown_author")
: Component.literal(author), x + 10, nameEnd + 2, 100) + 10;
if (!pageFree) return;
if (generationProfile != null) {
Component profileName = generationProfile.getNamespace().equals("rivencraft")
? Component.translatable("generator.rivencraft." + generationProfile.getPath())
@@ -242,11 +289,37 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
.filter(candidate -> candidate.kind() == WritingEntry.Kind.SYMBOL).count() - 1;
SymbolDisplayMetadata display = symbolIndex < writingSymbols.size()
? writingSymbols.get(symbolIndex) : SymbolDisplayMetadata.unresolved(entry.symbol().orElseThrow());
int color = display.resolved() ? BOOK_TEXT_COLOR : UNRESOLVED_TEXT_COLOR;
drawWrappedWithoutShadow(graphics, Component.translatable(display.nameTranslationKey()),
x, top + 39, 126, color);
drawWrappedWithoutShadow(graphics, Component.literal(display.id().toString()),
x, top + 64, 126, color);
graphics.enableScissor(left + 174, top + 26, left + 306, top + 172);
int textureSize = display.resolved() ? 128 : 16;
graphics.blit(SymbolPageTextures.forDisplay(display), left + 177, top + 29,
128, 128, 0, 0, textureSize, textureSize, textureSize, textureSize);
if (!display.resolved()) {
drawWrappedWithoutShadow(graphics, Component.literal(display.id().toString()),
x, top + 151, 126, UNRESOLVED_TEXT_COLOR);
}
graphics.disableScissor();
if (diagnostics.stream().anyMatch(diagnostic -> diagnostic.index() == pageIndex)) {
graphics.drawString(font, "!", left + 293, top + 153, UNRESOLVED_TEXT_COLOR, false);
}
}
private void renderDiagnosticsPage(GuiGraphics graphics, int left, int top) {
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.age.writing_notes"),
left + 176, top + 35, 126);
int start = (pageIndex - Math.max(1, writing.entries().size() - 1) - 1) * DIAGNOSTICS_PER_PAGE;
int y = top + 52;
graphics.enableScissor(left + 174, top + 50, left + 306, top + 173);
for (int index = start; index < Math.min(start + DIAGNOSTICS_PER_PAGE, diagnostics.size()); index++) {
WritingGrammar.Diagnostic diagnostic = diagnostics.get(index);
int color = diagnostic.error() ? UNRESOLVED_TEXT_COLOR : BOOK_TEXT_COLOR;
Component label = diagnostic.index() < 0
? Component.translatable("screen.rivencraft.age.writing_general")
: Component.translatable("screen.rivencraft.age.writing_page", diagnostic.index() + 1);
y = drawWrappedWithoutShadow(graphics, label, left + 176, y, 126, color) + 3;
y = drawWrappedWithoutShadow(graphics, Component.literal(diagnostic.message()),
left + 176, y, 126, color) + 10;
}
graphics.disableScissor();
}
private void turnPage(int direction) {
@@ -43,6 +43,8 @@ public final class RivencraftCommands {
source.sendSuccess(() -> Component.literal(
(loaded ? "[loaded] " : "[unloaded] ")
+ age.name() + " — " + age.dimensionId()
+ (age.seasonMode() == null ? "" : " (season: "
+ age.seasonMode().name().toLowerCase(java.util.Locale.ROOT) + ")")
), false);
}
return ages.size();
@@ -7,7 +7,6 @@ import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.dnorberg.rivencraft.book.AgeSeasonMode;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.network.SereneAgeWhitelistPayload;
import net.dnorberg.rivencraft.writing.SymbolSemantics;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
@@ -24,9 +23,8 @@ public final class SereneSeasonsCompatibility {
public static void registerAge(MinecraftServer server, AgeDefinition age) {
if (!available() || !isAge(age.dimensionId())) return;
AgeSeasonMode mode = AgeSeasonMode.from(age.plan());
boolean explicit = age.plan() != null && age.plan().symbols().stream()
.anyMatch(symbol -> symbol.semantics().role() == SymbolSemantics.Role.SEASON);
AgeSeasonMode mode = AgeSeasonMode.from(age);
boolean explicit = AgeSeasonMode.hasAuthoredPage(age.plan());
if (mode != AgeSeasonMode.NO_SEASONS && !explicit && !RivencraftConfig.autoWhitelistSereneAges()) {
return;
}
@@ -52,8 +50,9 @@ public final class SereneSeasonsCompatibility {
AgeDefinition age = AgeRegistrySavedData.get(level)
.findByDimension(level.dimension().location()).orElse(null);
if (age == null) return;
AgeSeasonMode mode = AgeSeasonMode.from(age.plan());
if (!mode.eternal()) return;
AgeSeasonMode mode = AgeSeasonMode.from(age);
if (!mode.eternal() || !AgeSeasonMode.hasAuthoredPage(age.plan())
&& !RivencraftConfig.autoWhitelistSereneAges()) return;
try {
SereneSeasonsAdapter.holdSeason(level, mode);
} catch (RuntimeException exception) {
@@ -3,6 +3,7 @@ package net.dnorberg.rivencraft.menu;
import java.util.Optional;
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModDataComponents;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.registry.ModMenus;
import net.dnorberg.rivencraft.writing.PageContent;
@@ -33,6 +34,7 @@ public final class BookBinderMenu extends AbstractContainerMenu {
private final Container inputs;
private final SimpleContainer result = new SimpleContainer(1);
private final boolean clientSide;
private final Player bookAuthor;
private int selectedPage = -1;
private int grammarErrorCode = -1;
private int grammarErrorStripIndex = -1;
@@ -47,6 +49,7 @@ public final class BookBinderMenu extends AbstractContainerMenu {
this.binder = binder;
inputs = binder == null ? new SimpleContainer(BookBinderBlockEntity.SIZE) : binder;
clientSide = inventory.player.level().isClientSide;
bookAuthor = inventory.player;
addDataSlot(new DataSlot() {
@Override public int get() { return selectedPage; }
@Override public void set(int value) { selectedPage = value; }
@@ -167,6 +170,7 @@ public final class BookBinderMenu extends AbstractContainerMenu {
refreshResult();
if (!binder.canAssemble()) return ItemStack.EMPTY;
ItemStack book = binder.previewBook();
book.set(ModDataComponents.BOOK_AUTHOR.get(), player.getGameProfile().getName());
ItemStack remainder = book.copy();
if (!moveItemStackTo(remainder, PLAYER_START, slots.size(), true) || !remainder.isEmpty()) {
return ItemStack.EMPTY;
@@ -221,6 +225,9 @@ public final class BookBinderMenu extends AbstractContainerMenu {
private void refreshResult() {
if (clientSide) return;
ItemStack preview = binder == null ? ItemStack.EMPTY : binder.previewBook();
if (!preview.isEmpty()) {
preview.set(ModDataComponents.BOOK_AUTHOR.get(), bookAuthor.getGameProfile().getName());
}
if (!ItemStack.matches(result.getItem(0), preview)) result.setItem(0, preview);
}
@@ -59,7 +59,7 @@ public final class ModNetworking {
}
public static void register(RegisterPayloadHandlersEvent event) {
PayloadRegistrar registrar = event.registrar("13");
PayloadRegistrar registrar = event.registrar("14");
registrar.playToServer(RequestDescriptiveBookScreenPayload.TYPE, RequestDescriptiveBookScreenPayload.STREAM_CODEC,
ModNetworking::requestDescriptiveBookScreen);
registrar.playToClient(OpenDescriptiveBookScreenPayload.TYPE, OpenDescriptiveBookScreenPayload.STREAM_CODEC,
@@ -114,17 +114,21 @@ public final class ModNetworking {
// Requests run on the same server thread as Age creation. A request queued
// during generation can only open the finished book, never a stale client copy.
player.inventoryMenu.broadcastChanges();
List<SymbolDisplayMetadata> writingSymbols = access.stack()
.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY).entries().stream()
OrderedWriting writing = access.stack()
.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY);
List<SymbolDisplayMetadata> writingSymbols = writing.entries().stream()
.filter(entry -> entry.kind() == WritingEntry.Kind.SYMBOL)
.map(entry -> SymbolCatalog.display(entry.symbol().orElseThrow()))
.toList();
// Diagnostics are advisory display data from the authoritative server catalog.
// Opening a book never recompiles or changes its persisted Age plan.
var diagnostics = WritingGrammar.parse(writing).diagnostics();
PacketDistributor.sendToPlayer(player, new OpenDescriptiveBookScreenPayload(
payload.fromStand(), payload.offhand(),
GlobalPos.of(player.level().dimension(), payload.standPos()), access.stack().copy(),
definition == null ? null : definition.plan() != null && definition.plan().biomeSelection() != null
? definition.traits().withBiomeDistribution(definition.plan().biomeSelection().distribution())
: definition.traits(), writingSymbols));
: definition.traits(), writingSymbols, diagnostics));
}
private static void bindDescriptiveBook(BindDescriptiveBookPayload payload, IPayloadContext context) {
@@ -168,6 +172,7 @@ public final class ModNetworking {
}
access.stack().set(ModDataComponents.AGE_REFERENCE.get(), reference);
access.stack().set(ModDataComponents.BOOK_AUTHOR.get(), player.getGameProfile().getName());
AgeRegistrySavedData.get(player.serverLevel()).find(reference.id()).ifPresent(definition ->
access.stack().set(ModDataComponents.AGE_GENERATION_PROFILE.get(), definition.generationProfile()));
access.stack().set(DataComponents.CUSTOM_NAME, Component.literal(title));
@@ -6,6 +6,7 @@ import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.book.AgeTraits;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
import net.dnorberg.rivencraft.writing.WritingGrammar;
import net.minecraft.core.GlobalPos;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
@@ -21,7 +22,8 @@ public record OpenDescriptiveBookScreenPayload(
GlobalPos source,
ItemStack book,
@Nullable AgeTraits traits,
List<SymbolDisplayMetadata> writingSymbols
List<SymbolDisplayMetadata> writingSymbols,
List<WritingGrammar.Diagnostic> diagnostics
)
implements CustomPacketPayload {
public static final Type<OpenDescriptiveBookScreenPayload> TYPE = new Type<>(
@@ -50,6 +52,15 @@ public record OpenDescriptiveBookScreenPayload(
}
buffer.writeVarInt(payload.writingSymbols().size());
payload.writingSymbols().forEach(symbol -> SymbolDisplayMetadata.STREAM_CODEC.encode(buffer, symbol));
if (payload.diagnostics().size() > OrderedWriting.MAX_ENTRIES * 2) {
throw new IllegalArgumentException("Too many book grammar diagnostics");
}
buffer.writeVarInt(payload.diagnostics().size());
payload.diagnostics().forEach(diagnostic -> {
buffer.writeEnum(diagnostic.code());
buffer.writeVarInt(diagnostic.index());
buffer.writeUtf(diagnostic.message(), 1024);
});
}
private static OpenDescriptiveBookScreenPayload decode(RegistryFriendlyByteBuf buffer) {
@@ -75,8 +86,17 @@ public record OpenDescriptiveBookScreenPayload(
for (int index = 0; index < count; index++) {
writingSymbols.add(SymbolDisplayMetadata.STREAM_CODEC.decode(buffer));
}
int diagnosticCount = buffer.readVarInt();
if (diagnosticCount < 0 || diagnosticCount > OrderedWriting.MAX_ENTRIES * 2) {
throw new IllegalArgumentException("Invalid book grammar diagnostic count " + diagnosticCount);
}
List<WritingGrammar.Diagnostic> diagnostics = new ArrayList<>(diagnosticCount);
for (int index = 0; index < diagnosticCount; index++) {
diagnostics.add(new WritingGrammar.Diagnostic(
buffer.readEnum(WritingGrammar.Code.class), buffer.readVarInt(), buffer.readUtf(1024)));
}
return new OpenDescriptiveBookScreenPayload(fromStand, offhand, source, book, traits,
List.copyOf(writingSymbols));
List.copyOf(writingSymbols), List.copyOf(diagnostics));
}
@Override
@@ -75,6 +75,12 @@ public final class ModDataComponents {
.networkSynchronized(OrderedWriting.STREAM_CODEC)
.cacheEncoding()
.build());
public static final DeferredHolder<DataComponentType<?>, DataComponentType<String>> BOOK_AUTHOR =
DATA_COMPONENTS.register("book_author", () -> DataComponentType.<String>builder()
.persistent(Codec.STRING)
.networkSynchronized(ByteBufCodecs.STRING_UTF8)
.cacheEncoding()
.build());
public static final DeferredHolder<DataComponentType<?>, DataComponentType<Integer>> INK_REMAINING =
DATA_COMPONENTS.register("ink_remaining", () -> DataComponentType.<Integer>builder()
.persistent(Codec.intRange(0, 16))
@@ -88,12 +88,16 @@
"screen.rivencraft.book.unwritten": "This Descriptive Book is unwritten",
"screen.rivencraft.age.title": "Age name",
"screen.rivencraft.age.title_hint": "Name this Age",
"screen.rivencraft.age.unknown_author": "Unknown author",
"screen.rivencraft.age.name_prompt": "Name this Age before writing the Descriptive Book",
"screen.rivencraft.age.create": "Create",
"screen.rivencraft.age.generator": "Terrain: %s",
"screen.rivencraft.age.pages": "Pages",
"screen.rivencraft.age.details": "Details",
"screen.rivencraft.age.pages_count": "Writing %s/%s",
"screen.rivencraft.age.writing_notes": "Writing notes",
"screen.rivencraft.age.writing_general": "Book",
"screen.rivencraft.age.writing_page": "Page %s",
"screen.rivencraft.age.trait.biomes": "Biomes: %s",
"screen.rivencraft.age.trait.lighting": "Lighting: %s",
"screen.rivencraft.age.trait.weather": "Weather: %s",