Public Access
Validate ordered Age writing and highlight Binder errors (#30)
Build / build (push) Canceled after 0s
Build / build (push) Canceled after 0s
This commit is contained in:
1 parent
5538ca6aa6
commit
27abebe3aa
14 files changed
+493
-19
No files matched your search
@@ -12,6 +12,7 @@ import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.dnorberg.rivencraft.writing.PageContent;
|
||||
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
||||
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
@@ -108,7 +109,40 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
|
||||
if (!page.isEmpty() && (!validPage(page) || !PageItem.isUsableWriting(page) || page.getCount() != 1
|
||||
|| PageItem.content(page).kind() == PageContent.Kind.BLANK)) return false;
|
||||
}
|
||||
return true;
|
||||
return WritingGrammar.parse(currentWriting()).canCreate();
|
||||
}
|
||||
|
||||
public WritingGrammar.Result writingGrammar() {
|
||||
return WritingGrammar.parse(currentWriting());
|
||||
}
|
||||
|
||||
/** Map the parser's compact entry index back to the visible strip, including empty page positions. */
|
||||
public int writingStripIndex(int grammarIndex) {
|
||||
if (grammarIndex < 0) return -1;
|
||||
int entryIndex = 0;
|
||||
if (canPlaceItem(LINK_PANEL, items.get(LINK_PANEL))) {
|
||||
if (grammarIndex == 0) return 0;
|
||||
entryIndex++;
|
||||
}
|
||||
for (int slot = FIRST_PAGE; slot < SIZE; slot++) {
|
||||
ItemStack page = items.get(slot);
|
||||
if (!page.isEmpty() && PageItem.content(page).symbol().isPresent()) {
|
||||
if (entryIndex == grammarIndex) return slot - FIRST_PAGE + 1;
|
||||
entryIndex++;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private OrderedWriting currentWriting() {
|
||||
List<WritingEntry> entries = new ArrayList<>();
|
||||
if (canPlaceItem(LINK_PANEL, items.get(LINK_PANEL))) entries.add(WritingEntry.linkPanel());
|
||||
for (int slot = FIRST_PAGE; slot < SIZE; slot++) {
|
||||
ItemStack page = items.get(slot);
|
||||
if (!page.isEmpty()) PageItem.content(page).symbol().ifPresent(reference ->
|
||||
entries.add(WritingEntry.symbol(reference)));
|
||||
}
|
||||
return new OrderedWriting(entries);
|
||||
}
|
||||
|
||||
public ItemStack previewBook() {
|
||||
@@ -117,15 +151,9 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
|
||||
pendingAgeId = UUID.randomUUID();
|
||||
setChanged();
|
||||
}
|
||||
List<WritingEntry> entries = new ArrayList<>();
|
||||
entries.add(WritingEntry.linkPanel());
|
||||
for (int slot = FIRST_PAGE; slot < SIZE; slot++) {
|
||||
ItemStack page = items.get(slot);
|
||||
if (!page.isEmpty()) entries.add(WritingEntry.symbol(PageItem.content(page).symbol().orElseThrow()));
|
||||
}
|
||||
ItemStack book = new ItemStack(ModItems.DESCRIPTIVE_BOOK.get());
|
||||
book.set(ModDataComponents.AGE_REFERENCE.get(), new AgeReference(pendingAgeId, title));
|
||||
book.set(ModDataComponents.BOOK_WRITING.get(), new OrderedWriting(entries));
|
||||
book.set(ModDataComponents.BOOK_WRITING.get(), currentWriting());
|
||||
book.set(DataComponents.CUSTOM_NAME, Component.literal(title));
|
||||
return book;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package net.dnorberg.rivencraft.client.screen;
|
||||
|
||||
import java.util.Locale;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
@@ -32,6 +33,7 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
|
||||
|
||||
private EditBox titleInput;
|
||||
private int firstVisible;
|
||||
private int lastGrammarErrorStripIndex = -1;
|
||||
private boolean stripClickPendingRelease;
|
||||
|
||||
public BookBinderScreen(BookBinderMenu menu, Inventory inventory, Component title) {
|
||||
@@ -86,6 +88,13 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
|
||||
@Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
|
||||
super.render(graphics, mouseX, mouseY, partialTick);
|
||||
firstVisible = Math.min(firstVisible, maxFirstVisible());
|
||||
int errorStripIndex = menu.grammarErrorStripIndex();
|
||||
if (errorStripIndex != lastGrammarErrorStripIndex) {
|
||||
if (errorStripIndex >= 0
|
||||
&& (errorStripIndex < firstVisible || errorStripIndex >= firstVisible + VISIBLE_PAGES))
|
||||
firstVisible = Math.clamp(errorStripIndex - VISIBLE_PAGES + 1, 0, maxFirstVisible());
|
||||
lastGrammarErrorStripIndex = errorStripIndex;
|
||||
}
|
||||
graphics.enableScissor(leftPos + STRIP_X + 8, topPos + STRIP_Y,
|
||||
leftPos + STRIP_X + STRIP_WIDTH - 8, topPos + STRIP_Y + STRIP_HEIGHT);
|
||||
int count = visiblePageCount();
|
||||
@@ -97,6 +106,16 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
|
||||
}
|
||||
graphics.disableScissor();
|
||||
drawScrollEnds(graphics);
|
||||
Component grammarTooltip = menu.grammarError().map(code -> Component.translatable(
|
||||
"screen.rivencraft.book_binder.grammar." + code.name().toLowerCase(Locale.ROOT),
|
||||
errorStripIndex + 1)).orElse(null);
|
||||
if (menu.getSlot(BookBinderMenu.RESULT).getItem().isEmpty()
|
||||
&& mouseX >= leftPos + 152 && mouseX < leftPos + 168
|
||||
&& mouseY >= topPos + 27 && mouseY < topPos + 43
|
||||
&& grammarTooltip != null) {
|
||||
graphics.renderTooltip(font, grammarTooltip, mouseX, mouseY);
|
||||
return;
|
||||
}
|
||||
if (mouseY >= topPos + STRIP_Y && mouseY < topPos + STRIP_Y + STRIP_HEIGHT) {
|
||||
if (firstVisible > 0 && mouseX >= leftPos + STRIP_X && mouseX < leftPos + STRIP_X + 8)
|
||||
graphics.renderTooltip(font, Component.translatable("screen.rivencraft.book_binder.previous_pages"), mouseX, mouseY);
|
||||
@@ -111,7 +130,9 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
|
||||
return;
|
||||
}
|
||||
int hovered = pageIndexAt(mouseX, mouseY);
|
||||
if (hovered >= 0 && hovered < displayedPages() && !pageAtIndex(hovered).isEmpty())
|
||||
if (hovered >= 0 && hovered == errorStripIndex && grammarTooltip != null)
|
||||
graphics.renderTooltip(font, grammarTooltip, mouseX, mouseY);
|
||||
else if (hovered >= 0 && hovered < displayedPages() && !pageAtIndex(hovered).isEmpty())
|
||||
graphics.renderTooltip(font, pageAtIndex(hovered), mouseX, mouseY);
|
||||
else renderTooltip(graphics, mouseX, mouseY);
|
||||
}
|
||||
@@ -148,6 +169,10 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
|
||||
case SYMBOL, UNRESOLVED_SYMBOL -> graphics.blit(NotebookScreen.symbolTexture(page),
|
||||
x + 1, y + 5, 24, 24, 0, 0, 128, 128, 128, 128);
|
||||
}
|
||||
if (index == menu.grammarErrorStripIndex() && menu.grammarError().isPresent()) {
|
||||
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT, 0x55FF2020);
|
||||
graphics.renderOutline(x, y, PAGE_WIDTH, PAGE_HEIGHT, 0xFFFF3030);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawScrollEnds(GuiGraphics graphics) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import net.dnorberg.rivencraft.registry.ModItems;
|
||||
import net.dnorberg.rivencraft.writing.NotebookContents;
|
||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
@@ -86,12 +87,41 @@ public final class BookBinderGameTests {
|
||||
restored.loadWithComponents(saved, helper.getLevel().registryAccess());
|
||||
helper.assertValueEqual(PageItem.content(restored.getItem(BookBinderBlockEntity.FIRST_PAGE))
|
||||
.symbol().orElseThrow(), missing, "Reload must preserve the removed data-pack symbol ID");
|
||||
helper.assertFalse(restored.canAssemble(), "Missing symbol cannot create a new written Age");
|
||||
restored.getItem(BookBinderBlockEntity.FIRST_PAGE).set(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(),
|
||||
SymbolCatalog.display(new SymbolReference(ResourceLocation.parse("rivencraft:terrain/standard"))));
|
||||
helper.assertFalse(restored.canAssemble(),
|
||||
"Forged display metadata cannot make a missing symbol valid for a new Age");
|
||||
helper.assertTrue(restored.getItem(BookBinderBlockEntity.FIRST_PAGE).is(ModItems.PAGE.get()),
|
||||
"Rejected writing must remain recoverable as a Page");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void conflictingGrammarKeepsBinderInputsRecoverable(GameTestHelper helper) {
|
||||
BookBinderBlockEntity binder = place(helper);
|
||||
binder.setTitle("Recoverable Duplicate");
|
||||
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
|
||||
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
|
||||
binder.setItem(BookBinderBlockEntity.FIRST_PAGE, terrainPage());
|
||||
binder.setItem(BookBinderBlockEntity.FIRST_PAGE + 5, terrainPage());
|
||||
helper.assertFalse(binder.canAssemble(), "Duplicate terrain must not assemble a new Age");
|
||||
int errorEntry = binder.writingGrammar().firstError().orElseThrow().index();
|
||||
helper.assertValueEqual(errorEntry, 2, "Parser must identify the second authored symbol");
|
||||
helper.assertValueEqual(binder.writingStripIndex(errorEntry), 6,
|
||||
"A grammar error must highlight the correct strip page despite empty positions");
|
||||
helper.assertFalse(binder.consumeAssembly(), "Rejected grammar must not consume binder inputs");
|
||||
helper.assertTrue(binder.getItem(BookBinderBlockEntity.COVER).is(Items.LEATHER),
|
||||
"Rejected grammar must leave the cover recoverable");
|
||||
helper.assertTrue(binder.getItem(BookBinderBlockEntity.LINK_PANEL).is(ModItems.PAGE.get()),
|
||||
"Rejected grammar must leave the Link Panel recoverable");
|
||||
helper.assertTrue(binder.getItem(BookBinderBlockEntity.FIRST_PAGE + 5).is(ModItems.PAGE.get()),
|
||||
"Rejected grammar must leave the duplicate page removable");
|
||||
ItemStack removed = binder.takePage(5);
|
||||
helper.assertTrue(removed.is(ModItems.PAGE.get()), "Player must be able to correct the duplicate page");
|
||||
helper.assertTrue(binder.canAssemble(), "Corrected partial writing must become assemblable");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
public static void assemblyIsAtomicAndRetainsPageOrder(GameTestHelper helper) {
|
||||
BookBinderBlockEntity binder = place(helper);
|
||||
binder.setTitle("A New Age");
|
||||
|
||||
@@ -47,6 +47,11 @@ public final class CoreGameTests {
|
||||
WritingDataGameTests.semanticSchemaIsServerOnlyBoundedAndV1Compatible(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void orderedWritingGrammar(GameTestHelper helper) {
|
||||
WritingGrammarGameTests.orderedGrammarAcceptsPartialWritingAndDiagnosesInvalidSequences(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void notebookPersistence(GameTestHelper helper) {
|
||||
WritingDataGameTests.notebookContentsPreserveOrderGapsNamesAndUnknownPages(helper);
|
||||
@@ -82,6 +87,11 @@ public final class CoreGameTests {
|
||||
BookBinderGameTests.assemblyIsAtomicAndRetainsPageOrder(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void binderRejectsConflictingGrammar(GameTestHelper helper) {
|
||||
BookBinderGameTests.conflictingGrammarKeepsBinderInputsRecoverable(helper);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void binderRejectsForgedAndReplayedActions(GameTestHelper helper) {
|
||||
BookBinderGameTests.forgedBinderActionsAndReplayedOutputCannotDuplicateBook(helper);
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package net.dnorberg.rivencraft.gametest;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||
import net.dnorberg.rivencraft.writing.SymbolDefinition;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.dnorberg.rivencraft.writing.SymbolSemantics;
|
||||
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.resources.ResourceLocation;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
/** Issue #30: parsing must not mutate writing or trust client display metadata. */
|
||||
@GameTestHolder("rivencraft_archive_grammar")
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class WritingGrammarGameTests {
|
||||
private WritingGrammarGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void orderedGrammarAcceptsPartialWritingAndDiagnosesInvalidSequences(GameTestHelper helper) {
|
||||
SymbolReference terrain = ref("rivencraft:terrain/standard");
|
||||
SymbolReference nativeBiomes = ref("rivencraft:biome/native");
|
||||
SymbolReference weather = ref("rivencraft:weather/normal");
|
||||
SymbolReference lighting = ref("rivencraft:lighting/normal");
|
||||
SymbolReference celestial = ref("rivencraft:celestial/normal_time");
|
||||
SymbolReference caves = ref("rivencraft:feature/caves");
|
||||
SymbolReference plains = ref("test:biome/plains");
|
||||
SymbolReference forest = ref("test:biome/forest");
|
||||
SymbolReference single = ref("test:controller/single");
|
||||
SymbolReference alternateTerrain = ref("test:terrain/alternate");
|
||||
SymbolReference periodHalf = ref("test:period/half");
|
||||
SymbolReference periodFull = ref("test:period/full");
|
||||
SymbolReference displayOnly = ref("test:display_only");
|
||||
Map<SymbolReference, SymbolDefinition> definitions = Map.of(
|
||||
plains, definition(SymbolSemantics.Role.BIOME,
|
||||
new SymbolSemantics.ResourceValue(ResourceLocation.parse("minecraft:plains"))),
|
||||
forest, definition(SymbolSemantics.Role.BIOME,
|
||||
new SymbolSemantics.ResourceValue(ResourceLocation.parse("minecraft:forest"))),
|
||||
single, definition(SymbolSemantics.Role.BIOME_CONTROLLER,
|
||||
new SymbolSemantics.ChoiceValue("single")),
|
||||
alternateTerrain, definition(SymbolSemantics.Role.TERRAIN,
|
||||
new SymbolSemantics.ResourceValue(ResourceLocation.parse("test:alternate"))),
|
||||
periodHalf, definition(SymbolSemantics.Role.PERIOD_MODIFIER,
|
||||
new SymbolSemantics.NumberValue(0.5)),
|
||||
periodFull, definition(SymbolSemantics.Role.PERIOD_MODIFIER,
|
||||
new SymbolSemantics.NumberValue(1.0)),
|
||||
displayOnly, new SymbolDefinition(1, "symbol.test", "symbol.test.description",
|
||||
ResourceLocation.parse("test:display"), Optional.empty(), ""));
|
||||
java.util.function.Function<SymbolReference, Optional<SymbolDefinition>> resolver = reference ->
|
||||
Optional.ofNullable(definitions.get(reference)).or(() -> SymbolCatalog.find(reference));
|
||||
|
||||
helper.assertTrue(parse(resolver, WritingEntry.linkPanel()).randomFallback(),
|
||||
"Link-Panel-only writing must retain the random Age path");
|
||||
helper.assertTrue(WritingGrammar.parse(OrderedWriting.EMPTY, resolver).randomFallback(),
|
||||
"Old page-free books must retain the random Age path");
|
||||
OrderedWriting partial = writing(WritingEntry.linkPanel(), WritingEntry.symbol(terrain));
|
||||
WritingGrammar.Result incomplete = WritingGrammar.parse(partial, resolver);
|
||||
helper.assertTrue(incomplete.canCreate() && !incomplete.randomFallback(),
|
||||
"Partial authored writing must remain valid, without pretending it is page-free");
|
||||
helper.assertTrue(incomplete.diagnostics().stream().anyMatch(d -> d.code() == WritingGrammar.Code.INCOMPLETE),
|
||||
"Missing branches must be reported for deterministic completion");
|
||||
helper.assertValueEqual(partial.entries().size(), 2, "Parsing must not rewrite saved page order");
|
||||
|
||||
WritingGrammar.Result complete = parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(terrain),
|
||||
WritingEntry.symbol(plains), WritingEntry.symbol(single), WritingEntry.symbol(weather),
|
||||
WritingEntry.symbol(lighting), WritingEntry.symbol(periodHalf), WritingEntry.symbol(celestial),
|
||||
WritingEntry.symbol(caves));
|
||||
helper.assertTrue(complete.canCreate(), "Legacy-ordered categories and prefix modifiers must parse");
|
||||
helper.assertValueEqual(complete.symbols().size(), 8, "Parsed symbols must preserve every authored page");
|
||||
helper.assertValueEqual(complete.symbols().get(1).reference(), plains,
|
||||
"A biome page must precede the controller that consumes it");
|
||||
helper.assertValueEqual(WritingGrammar.parse(writing(WritingEntry.linkPanel(), WritingEntry.symbol(terrain),
|
||||
WritingEntry.symbol(plains), WritingEntry.symbol(single), WritingEntry.symbol(weather),
|
||||
WritingEntry.symbol(lighting), WritingEntry.symbol(periodHalf), WritingEntry.symbol(celestial),
|
||||
WritingEntry.symbol(caves)), resolver), complete,
|
||||
"Identical ordered writing and server definitions must parse deterministically");
|
||||
|
||||
WritingGrammar.Result repeatedModifiers = parse(resolver, WritingEntry.linkPanel(),
|
||||
WritingEntry.symbol(periodHalf), WritingEntry.symbol(periodFull), WritingEntry.symbol(celestial));
|
||||
helper.assertTrue(repeatedModifiers.canCreate(), "Repeated period modifiers must remain legal");
|
||||
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(terrain),
|
||||
WritingEntry.symbol(terrain)), WritingGrammar.Code.DUPLICATE);
|
||||
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(terrain),
|
||||
WritingEntry.symbol(alternateTerrain)), WritingGrammar.Code.CONFLICT);
|
||||
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(weather),
|
||||
WritingEntry.symbol(terrain)), WritingGrammar.Code.OUT_OF_ORDER);
|
||||
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(celestial),
|
||||
WritingEntry.symbol(periodHalf)), WritingGrammar.Code.MISPLACED_MODIFIER);
|
||||
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(nativeBiomes),
|
||||
WritingEntry.symbol(plains)), WritingGrammar.Code.OUT_OF_ORDER);
|
||||
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(plains),
|
||||
WritingEntry.symbol(nativeBiomes)), WritingGrammar.Code.CONFLICT);
|
||||
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(plains),
|
||||
WritingEntry.symbol(forest), WritingEntry.symbol(single)), WritingGrammar.Code.CONFLICT);
|
||||
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(caves),
|
||||
WritingEntry.symbol(caves)), WritingGrammar.Code.DUPLICATE);
|
||||
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(ref("test:missing"))),
|
||||
WritingGrammar.Code.UNRESOLVED);
|
||||
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.symbol(displayOnly)),
|
||||
WritingGrammar.Code.UNSUPPORTED);
|
||||
assertCode(helper, parse(resolver, WritingEntry.symbol(terrain)), WritingGrammar.Code.MISSING_PANEL);
|
||||
assertCode(helper, parse(resolver, WritingEntry.linkPanel(), WritingEntry.linkPanel()),
|
||||
WritingGrammar.Code.DUPLICATE_PANEL);
|
||||
|
||||
List<WritingEntry> oversized = new ArrayList<>();
|
||||
for (int index = 0; index <= WritingGrammar.MAX_NEW_ENTRIES; index++)
|
||||
oversized.add(WritingEntry.linkPanel());
|
||||
assertCode(helper, WritingGrammar.parse(new OrderedWriting(oversized), resolver), WritingGrammar.Code.TOO_MANY);
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
private static SymbolReference ref(String id) {
|
||||
return new SymbolReference(ResourceLocation.parse(id));
|
||||
}
|
||||
|
||||
private static SymbolDefinition definition(SymbolSemantics.Role role, SymbolSemantics.Value value) {
|
||||
return new SymbolDefinition(2, "symbol.test", "symbol.test.description",
|
||||
ResourceLocation.parse("test:" + role.id()), Optional.of(new SymbolSemantics(role, value)), "");
|
||||
}
|
||||
|
||||
private static OrderedWriting writing(WritingEntry... entries) {
|
||||
return new OrderedWriting(List.of(entries));
|
||||
}
|
||||
|
||||
private static WritingGrammar.Result parse(
|
||||
java.util.function.Function<SymbolReference, Optional<SymbolDefinition>> resolver,
|
||||
WritingEntry... entries) {
|
||||
return WritingGrammar.parse(writing(entries), resolver);
|
||||
}
|
||||
|
||||
private static void assertCode(GameTestHelper helper, WritingGrammar.Result result, WritingGrammar.Code code) {
|
||||
helper.assertFalse(result.canCreate(), "Writing with " + code + " must not create a new Age");
|
||||
helper.assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.code() == code),
|
||||
"Writing must report " + code + " rather than silently dropping a page");
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
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.ModItems;
|
||||
import net.dnorberg.rivencraft.registry.ModMenus;
|
||||
import net.dnorberg.rivencraft.writing.PageContent;
|
||||
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.world.Container;
|
||||
import net.minecraft.world.SimpleContainer;
|
||||
@@ -32,6 +34,8 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
||||
private final SimpleContainer result = new SimpleContainer(1);
|
||||
private final boolean clientSide;
|
||||
private int selectedPage = -1;
|
||||
private int grammarErrorCode = -1;
|
||||
private int grammarErrorStripIndex = -1;
|
||||
|
||||
public BookBinderMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
|
||||
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
|
||||
@@ -47,6 +51,20 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
||||
@Override public int get() { return selectedPage; }
|
||||
@Override public void set(int value) { selectedPage = value; }
|
||||
});
|
||||
addDataSlot(new DataSlot() {
|
||||
@Override public int get() {
|
||||
return clientSide || binder == null ? grammarErrorCode : binder.writingGrammar().firstError()
|
||||
.map(error -> error.code().ordinal()).orElse(-1);
|
||||
}
|
||||
@Override public void set(int value) { grammarErrorCode = value; }
|
||||
});
|
||||
addDataSlot(new DataSlot() {
|
||||
@Override public int get() {
|
||||
return clientSide || binder == null ? grammarErrorStripIndex : binder.writingGrammar().firstError()
|
||||
.map(error -> binder.writingStripIndex(error.index())).orElse(-1);
|
||||
}
|
||||
@Override public void set(int value) { grammarErrorStripIndex = value; }
|
||||
});
|
||||
addSlot(new BinderSlot(BookBinderBlockEntity.COVER, 8, 27, 64));
|
||||
addSlot(new BinderSlot(BookBinderBlockEntity.LINK_PANEL, -1000, -1000, 1));
|
||||
addSlot(new BinderSlot(BookBinderBlockEntity.NOTEBOOK, -1000, -1000, 1));
|
||||
@@ -66,6 +84,12 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
||||
}
|
||||
|
||||
public int selectedPage() { return selectedPage; }
|
||||
public Optional<WritingGrammar.Code> grammarError() {
|
||||
WritingGrammar.Code[] codes = WritingGrammar.Code.values();
|
||||
return grammarErrorCode >= 0 && grammarErrorCode < codes.length
|
||||
? Optional.of(codes[grammarErrorCode]) : Optional.empty();
|
||||
}
|
||||
public int grammarErrorStripIndex() { return grammarErrorStripIndex; }
|
||||
public String title() { return binder == null ? "" : binder.title(); }
|
||||
public boolean setTitle(Player player, String title) {
|
||||
if (clientSide || !stillValid(player) || title == null
|
||||
|
||||
@@ -20,6 +20,7 @@ import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.dnorberg.rivencraft.writing.SymbolCatalog;
|
||||
import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata;
|
||||
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.GlobalPos;
|
||||
@@ -133,6 +134,14 @@ public final class ModNetworking {
|
||||
return;
|
||||
}
|
||||
|
||||
var grammar = WritingGrammar.parse(access.stack()
|
||||
.getOrDefault(ModDataComponents.BOOK_WRITING.get(), OrderedWriting.EMPTY));
|
||||
if (!grammar.canCreate()) {
|
||||
player.displayClientMessage(Component.translatable("message.rivencraft.descriptive_book.invalid_writing",
|
||||
grammar.firstError().orElseThrow().message()), true);
|
||||
return;
|
||||
}
|
||||
|
||||
WRITING_BOOKS.add(access.stack());
|
||||
try {
|
||||
AgeReference reference = new AgeReference(UUID.randomUUID(), title);
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package net.dnorberg.rivencraft.writing;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import net.dnorberg.rivencraft.writing.SymbolSemantics.ChoiceValue;
|
||||
import net.dnorberg.rivencraft.writing.SymbolSemantics.Role;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
|
||||
/** Server-owned interpretation of ordered writing; compilation belongs to the next release slice. */
|
||||
public final class WritingGrammar {
|
||||
// A newly assembled book has one Link Panel plus at most 27 page positions.
|
||||
public static final int MAX_NEW_ENTRIES = NotebookContents.MAX_PAGES + 1;
|
||||
|
||||
private WritingGrammar() {}
|
||||
|
||||
public static Result parse(OrderedWriting writing) {
|
||||
return parse(writing, SymbolCatalog::find);
|
||||
}
|
||||
|
||||
/** The resolver must be the authoritative server catalog, never page display metadata. */
|
||||
public static Result parse(OrderedWriting writing,
|
||||
Function<SymbolReference, Optional<SymbolDefinition>> resolver) {
|
||||
Objects.requireNonNull(writing, "writing");
|
||||
Objects.requireNonNull(resolver, "resolver");
|
||||
List<ParsedSymbol> symbols = new ArrayList<>();
|
||||
List<Diagnostic> diagnostics = new ArrayList<>();
|
||||
if (writing.entries().size() > MAX_NEW_ENTRIES) {
|
||||
diagnostics.add(new Diagnostic(Code.TOO_MANY, -1, "At most " + MAX_NEW_ENTRIES + " entries are allowed"));
|
||||
return new Result(symbols, diagnostics);
|
||||
}
|
||||
if (writing.entries().isEmpty()) return new Result(symbols, diagnostics);
|
||||
|
||||
EnumMap<Role, ParsedSymbol> singular = new EnumMap<>(Role.class);
|
||||
Set<ResourceLocation> features = new HashSet<>();
|
||||
int panels = 0;
|
||||
int lastStage = -1;
|
||||
int biomeCount = 0;
|
||||
for (int index = 0; index < writing.entries().size(); index++) {
|
||||
WritingEntry entry = writing.entries().get(index);
|
||||
if (entry.kind() == WritingEntry.Kind.LINK_PANEL) {
|
||||
panels++;
|
||||
if (panels > 1) diagnostics.add(new Diagnostic(Code.DUPLICATE_PANEL, index,
|
||||
"Use exactly one Link Panel"));
|
||||
else if (index != 0) diagnostics.add(new Diagnostic(Code.PANEL_ORDER, index,
|
||||
"Move the Link Panel to the first position"));
|
||||
continue;
|
||||
}
|
||||
|
||||
SymbolReference reference = entry.symbol().orElseThrow();
|
||||
Optional<SymbolDefinition> definition = resolver.apply(reference);
|
||||
if (definition.isEmpty()) {
|
||||
diagnostics.add(new Diagnostic(Code.UNRESOLVED, index,
|
||||
"Restore the data pack for " + reference.id() + " or remove this page"));
|
||||
continue;
|
||||
}
|
||||
Optional<SymbolSemantics> semantics = definition.orElseThrow().semantics();
|
||||
if (semantics.isEmpty()) {
|
||||
diagnostics.add(new Diagnostic(Code.UNSUPPORTED, index,
|
||||
"Update " + reference.id() + " to semantic schema v2 or remove this page"));
|
||||
continue;
|
||||
}
|
||||
|
||||
Role role = semantics.orElseThrow().role();
|
||||
ParsedSymbol parsed = new ParsedSymbol(index, reference, semantics.orElseThrow());
|
||||
symbols.add(parsed);
|
||||
if (singular(role)) {
|
||||
ParsedSymbol previous = singular.putIfAbsent(role, parsed);
|
||||
if (previous != null) {
|
||||
Code code = previous.semantics().value().equals(parsed.semantics().value())
|
||||
? Code.DUPLICATE : Code.CONFLICT;
|
||||
diagnostics.add(new Diagnostic(code, index,
|
||||
"Remove this " + role.id() + " page; position " + (previous.index() + 1)
|
||||
+ " already supplies that category"));
|
||||
continue;
|
||||
}
|
||||
} else if (role == Role.FEATURE && !features.add(reference.id())) {
|
||||
diagnostics.add(new Diagnostic(Code.DUPLICATE, index,
|
||||
"Remove the repeated feature page " + reference.id()));
|
||||
continue;
|
||||
}
|
||||
if (role == Role.BIOME) biomeCount++;
|
||||
int stage = stage(role);
|
||||
if (stage < lastStage) {
|
||||
diagnostics.add(new Diagnostic(modifier(role) ? Code.MISPLACED_MODIFIER : Code.OUT_OF_ORDER,
|
||||
index, "Move this " + role.id() + " page before later grammar categories"));
|
||||
} else {
|
||||
lastStage = stage;
|
||||
}
|
||||
}
|
||||
if (panels == 0) diagnostics.add(new Diagnostic(Code.MISSING_PANEL, -1,
|
||||
"Add a Link Panel as the first page"));
|
||||
if (symbols.isEmpty()) return new Result(symbols, diagnostics);
|
||||
|
||||
ParsedSymbol controller = singular.get(Role.BIOME_CONTROLLER);
|
||||
if (controller != null && controller.semantics().value() instanceof ChoiceValue choice
|
||||
&& choice.id().equals("native") && biomeCount > 0) {
|
||||
diagnostics.add(new Diagnostic(Code.CONFLICT, controller.index(),
|
||||
"Native biome distribution cannot use authored biome pages"));
|
||||
} else if (controller != null && controller.semantics().value() instanceof ChoiceValue choice
|
||||
&& choice.id().equals("single") && biomeCount > 1) {
|
||||
diagnostics.add(new Diagnostic(Code.CONFLICT, controller.index(),
|
||||
"Single-biome distribution accepts only one authored biome page"));
|
||||
}
|
||||
for (Role required : List.of(Role.TERRAIN, Role.BIOME_CONTROLLER, Role.WEATHER,
|
||||
Role.LIGHTING, Role.CELESTIAL)) {
|
||||
if (!singular.containsKey(required)) diagnostics.add(new Diagnostic(Code.INCOMPLETE, -1,
|
||||
"The Age compiler will fill the missing " + required.id() + " branch from the Age seed"));
|
||||
}
|
||||
if (controller != null && controller.semantics().value() instanceof ChoiceValue choice) {
|
||||
int minimum = switch (choice.id()) {
|
||||
case "single" -> 1;
|
||||
case "tiled", "grid" -> 2;
|
||||
case "native" -> 0;
|
||||
default -> 3;
|
||||
};
|
||||
if (biomeCount < minimum) diagnostics.add(new Diagnostic(Code.INCOMPLETE, -1,
|
||||
"The Age compiler will fill " + (minimum - biomeCount) + " missing biome page(s)"));
|
||||
}
|
||||
return new Result(symbols, diagnostics);
|
||||
}
|
||||
|
||||
private static boolean singular(Role role) {
|
||||
return switch (role) {
|
||||
case TERRAIN, BIOME_CONTROLLER, WEATHER, LIGHTING, CELESTIAL -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean modifier(Role role) {
|
||||
return role == Role.PERIOD_MODIFIER || role == Role.ANGLE_MODIFIER || role == Role.PHASE_MODIFIER;
|
||||
}
|
||||
|
||||
private static int stage(Role role) {
|
||||
return switch (role) {
|
||||
case TERRAIN -> 0;
|
||||
case BIOME -> 1;
|
||||
case BIOME_CONTROLLER -> 2;
|
||||
case WEATHER -> 3;
|
||||
case LIGHTING -> 4;
|
||||
case PERIOD_MODIFIER, ANGLE_MODIFIER, PHASE_MODIFIER -> 5;
|
||||
case CELESTIAL -> 6;
|
||||
case FEATURE -> 7;
|
||||
};
|
||||
}
|
||||
|
||||
public enum Code {
|
||||
TOO_MANY, MISSING_PANEL, PANEL_ORDER, DUPLICATE_PANEL, UNRESOLVED, UNSUPPORTED,
|
||||
DUPLICATE, CONFLICT, OUT_OF_ORDER, MISPLACED_MODIFIER, INCOMPLETE
|
||||
}
|
||||
|
||||
public record Diagnostic(Code code, int index, String message) {
|
||||
public Diagnostic {
|
||||
Objects.requireNonNull(code, "code");
|
||||
Objects.requireNonNull(message, "message");
|
||||
}
|
||||
|
||||
public boolean error() { return code != Code.INCOMPLETE; }
|
||||
}
|
||||
|
||||
public record ParsedSymbol(int index, SymbolReference reference, SymbolSemantics semantics) {
|
||||
public ParsedSymbol {
|
||||
Objects.requireNonNull(reference, "reference");
|
||||
Objects.requireNonNull(semantics, "semantics");
|
||||
}
|
||||
}
|
||||
|
||||
public record Result(List<ParsedSymbol> symbols, List<Diagnostic> diagnostics) {
|
||||
public Result {
|
||||
symbols = List.copyOf(symbols);
|
||||
diagnostics = List.copyOf(diagnostics);
|
||||
}
|
||||
|
||||
public boolean randomFallback() { return symbols.isEmpty() && diagnostics.isEmpty(); }
|
||||
public boolean canCreate() { return diagnostics.stream().noneMatch(Diagnostic::error); }
|
||||
public Optional<Diagnostic> firstError() { return diagnostics.stream().filter(Diagnostic::error).findFirst(); }
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,16 @@
|
||||
"screen.rivencraft.book_binder.previous_pages": "Scroll pages left (Left Arrow)",
|
||||
"screen.rivencraft.book_binder.next_pages": "Scroll pages right (Right Arrow)",
|
||||
"screen.rivencraft.book_binder.select_hint": "Right-click page to select",
|
||||
"screen.rivencraft.book_binder.grammar.too_many": "Too many pages for a new Age; remove excess pages",
|
||||
"screen.rivencraft.book_binder.grammar.missing_panel": "Add a Link Panel as the first page",
|
||||
"screen.rivencraft.book_binder.grammar.panel_order": "Page %s: move the Link Panel to the first position",
|
||||
"screen.rivencraft.book_binder.grammar.duplicate_panel": "Page %s: remove the extra Link Panel",
|
||||
"screen.rivencraft.book_binder.grammar.unresolved": "Page %s: missing symbol definition; restore its data pack or remove it",
|
||||
"screen.rivencraft.book_binder.grammar.unsupported": "Page %s: this symbol needs semantic schema v2; update its data pack or remove it",
|
||||
"screen.rivencraft.book_binder.grammar.duplicate": "Page %s: remove the duplicate symbol",
|
||||
"screen.rivencraft.book_binder.grammar.conflict": "Page %s: replace the conflicting symbol",
|
||||
"screen.rivencraft.book_binder.grammar.out_of_order": "Page %s: move this symbol before later categories",
|
||||
"screen.rivencraft.book_binder.grammar.misplaced_modifier": "Page %s: move this modifier before its celestial page",
|
||||
"screen.rivencraft.writing_desk.ink": "Ink: %s/%s",
|
||||
"screen.rivencraft.writing_desk.search": "Search",
|
||||
"screen.rivencraft.writing_desk.sort": "Sort pages by name (A–Z)",
|
||||
@@ -58,6 +68,7 @@
|
||||
"message.rivencraft.descriptive_book.unwritten": "This Descriptive Book is unwritten",
|
||||
"message.rivencraft.descriptive_book.created": "Created Age: %s",
|
||||
"message.rivencraft.descriptive_book.unavailable": "That Age is unavailable",
|
||||
"message.rivencraft.descriptive_book.invalid_writing": "Writing cannot create an Age: %s",
|
||||
"message.rivencraft.descriptive_book.unsafe": "No safe destination was found in that Age",
|
||||
"message.rivencraft.descriptive_book.linked_first": "Linked to %s — a Return Link was created",
|
||||
"message.rivencraft.descriptive_book.linked": "Linked to %s",
|
||||
|
||||
Reference in new issue
Block a user