Complete Milestone 7 acceptance and binder diagnostics (#84)
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-10-22 22:32:12 -04:00
1 parent e7e2cf7e5a
commit 98595c83ea
19 files changed
+290 -31

No files matched your search

@@ -86,7 +86,7 @@ public record AgeSkyProfile(Style style, int color, List<Body> bodies) {
case PERIOD_MODIFIER -> period = CelestialPageValues.value(semantics, number);
case ANGLE_MODIFIER -> angle = (float) CelestialPageValues.value(semantics, number);
case PHASE_MODIFIER -> phase = (float) (CelestialPageValues.value(semantics, number) / 360.0D);
case SIZE_MODIFIER -> size = (float) ((SymbolSemantics.NumberValue) semantics.value()).amount();
case SIZE_MODIFIER -> size = (float) CelestialPageValues.value(semantics, number);
case CELESTIAL_COLOR -> bodyColors.add(color(
((SymbolSemantics.ChoiceValue) semantics.value()).id()));
case DIRECTION_MODIFIER -> clockwise = ((SymbolSemantics.ChoiceValue) semantics.value())
@@ -11,6 +11,7 @@ final class CelestialPageValues {
return switch (semantics.role()) {
case PERIOD_MODIFIER -> number * 0.08D;
case ANGLE_MODIFIER, PHASE_MODIFIER -> (number * 15.0D) % 360.0D;
case SIZE_MODIFIER -> 0.25D + number * (2.75D / 24.0D);
default -> throw new IllegalArgumentException("Not a celestial numeric modifier");
};
}
@@ -0,0 +1,64 @@
package net.dnorberg.rivencraft.book;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.OrderedWriting;
import net.dnorberg.rivencraft.writing.SymbolReference;
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;
/** Focused core coverage without launching the storage-heavy environment archive. */
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class CelestialSizeGameTests {
private CelestialSizeGameTests() {}
@GameTest(template = "empty")
public static void adjacentNumberSetsNextCelestialSize(GameTestHelper helper) {
OrderedWriting writing = new OrderedWriting(List.of(WritingEntry.linkPanel(),
page("number/0"), page("size/small"), page("celestial/sun"),
page("number/24"), page("size/large"), page("celestial/moon"),
page("number/12"), page("size/normal"), page("celestial/sun")));
helper.assertValueEqual(WritingGrammar.parse(writing).writingInstability(), 0,
"Adjacent number and size pages must be valid writing");
AgeTraits seeded = AgeTraits.select(94L);
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 94L,
AgeGenerationProfiles.STANDARD, seeded, helper.getLevel().registryAccess());
AgeSkyProfile sky = AgeSkyProfile.compile(plan, AgeEnvironmentCompiler.apply(plan, seeded));
helper.assertValueEqual(sky.bodies().size(), 3, "Three authored bodies must remain distinct");
helper.assertValueEqual(sky.bodies().get(0).size(), 0.25F, "Digit zero selects minimum size");
helper.assertValueEqual(sky.bodies().get(1).size(), 3.0F, "Digit 24 selects maximum size");
helper.assertValueEqual(sky.bodies().get(2).size(), 1.625F, "Digit 12 selects midpoint size");
helper.assertValueEqual(AgeSkyProfile.compile(AgeGenerationPlan.load(plan.save()),
AgeEnvironmentCompiler.apply(plan, seeded)), sky,
"Numbered sizes must survive an Age-plan save/load round trip");
OrderedWriting plain = new OrderedWriting(List.of(WritingEntry.linkPanel(),
page("size/small"), page("celestial/sun"),
page("size/normal"), page("celestial/moon"),
page("size/large"), page("celestial/sun")));
AgeGenerationPlan plainPlan = AgeGenerationPlan.compile(plain, 95L,
AgeGenerationProfiles.STANDARD, AgeTraits.select(95L), helper.getLevel().registryAccess());
AgeSkyProfile plainSky = AgeSkyProfile.compile(plainPlan,
AgeEnvironmentCompiler.apply(plainPlan, AgeTraits.select(95L)));
helper.assertValueEqual(plainSky.bodies().get(0).size(), 0.5F, "Small retains its fixed value");
helper.assertValueEqual(plainSky.bodies().get(1).size(), 1.0F, "Normal retains its fixed value");
helper.assertValueEqual(plainSky.bodies().get(2).size(), 2.0F, "Large retains its fixed value");
OrderedWriting separated = new OrderedWriting(List.of(WritingEntry.linkPanel(),
page("number/24"), page("direction/clockwise"),
page("size/large"), page("celestial/sun")));
helper.assertValueEqual(WritingGrammar.parse(separated).writingInstability(), 100,
"An intervening page must not allow a number to modify size");
helper.succeed();
}
private static WritingEntry page(String path) {
return WritingEntry.symbol(new SymbolReference(ResourceLocation.fromNamespaceAndPath("rivencraft", path)));
}
}
@@ -43,8 +43,9 @@ public final class HandheldBookTravelGameTests {
helper.assertTrue(player.getInventory().getItem(1).is(Items.DIAMOND),
"An unrelated inventory item must remain with the player");
var drops = helper.getLevel().getEntitiesOfClass(DroppedBookEntity.class, new AABB(source, source).inflate(2.0),
entity -> entity.getItem().is(ModItems.LINKING_BOOK.get()));
helper.assertValueEqual(drops.size(), 1, "Exactly one recoverable book must remain at the departure point");
entity -> entity.getItem() == book);
helper.assertValueEqual(drops.size(), 1,
"Exactly one entity for this active book must remain at the departure point");
helper.assertTrue(drops.getFirst().getItem() == book,
"The model-rendered entity must retain the exact active book stack");
helper.assertTrue(drops.getFirst().copy() instanceof DroppedBookEntity,
@@ -107,7 +107,7 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
}
graphics.disableScissor();
drawScrollEnds(graphics);
var severity = menu.grammarSeverity().orElse(null);
var severity = menu.diagnosticsVisible() ? menu.grammarSeverity().orElse(null) : null;
Component status = severity == WritingGrammar.Severity.FATAL
? Component.translatable("screen.rivencraft.book_binder.status.fatal")
: severity == WritingGrammar.Severity.INSTABILITY
@@ -117,9 +117,9 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
if (status != null) graphics.drawString(font, status, leftPos + 77, topPos + 30,
severity == WritingGrammar.Severity.FATAL ? 0xFFAA3030
: severity == WritingGrammar.Severity.INSTABILITY ? 0xFF924F18 : 0xFF50624B, false);
Component grammarTooltip = menu.grammarError().map(code -> Component.translatable(
Component grammarTooltip = menu.diagnosticsVisible() ? menu.grammarError().map(code -> Component.translatable(
"screen.rivencraft.book_binder.grammar." + code.name().toLowerCase(Locale.ROOT),
errorStripIndex + 1)).orElse(null);
errorStripIndex + 1)).orElse(null) : null;
if (grammarTooltip != null && ((mouseX >= leftPos + 77 && mouseX < leftPos + 151)
|| (mouseX >= leftPos + 152 && mouseX < leftPos + 168))
&& mouseY >= topPos + 27 && mouseY < topPos + 43
@@ -12,6 +12,7 @@ import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.dnorberg.rivencraft.book.AgeInstabilityControls;
import net.dnorberg.rivencraft.book.AgeInstabilityRuntime;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.menu.BookBinderDiagnostics;
import net.minecraft.commands.Commands;
import net.minecraft.commands.CommandSourceStack;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
@@ -29,6 +30,13 @@ public final class RivencraftCommands {
public static void register(RegisterCommandsEvent event) {
event.getDispatcher().register(Commands.literal("rivencraft")
.requires(source -> source.hasPermission(2))
.then(Commands.literal("binder")
.then(Commands.literal("messages")
.executes(context -> binderMessages(context.getSource(), null))
.then(Commands.literal("enable")
.executes(context -> binderMessages(context.getSource(), true)))
.then(Commands.literal("disable")
.executes(context -> binderMessages(context.getSource(), false)))))
.then(Commands.literal("library")
.then(Commands.literal("nearest")
.executes(context -> LibraryLocateService.start(context.getSource())))
@@ -94,6 +102,14 @@ public final class RivencraftCommands {
.then(environmentCommand())));
}
private static int binderMessages(CommandSourceStack source, Boolean enabled) {
BookBinderDiagnostics diagnostics = BookBinderDiagnostics.get(source.getLevel());
if (enabled != null) diagnostics.setEnabled(enabled);
source.sendSuccess(() -> Component.literal("Book Binder diagnostic messages are "
+ (diagnostics.enabled() ? "enabled for operators" : "disabled") + "."), false);
return diagnostics.enabled() ? 1 : 0;
}
private static LiteralArgumentBuilder<CommandSourceStack> environmentCommand() {
return Commands.literal("environment")
.then(Commands.literal("pulse")
@@ -0,0 +1,46 @@
package net.dnorberg.rivencraft.menu;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.util.datafix.DataFixTypes;
import net.minecraft.world.level.saveddata.SavedData;
/** World-persistent OP-only visibility switch for Book Binder writing diagnostics. */
public final class BookBinderDiagnostics extends SavedData {
private static final String DATA_NAME = "rivencraft_binder_diagnostics";
private static final Factory<BookBinderDiagnostics> FACTORY = new Factory<>(
BookBinderDiagnostics::new, BookBinderDiagnostics::load, DataFixTypes.SAVED_DATA_MAP_DATA);
private boolean enabled;
public static BookBinderDiagnostics get(ServerLevel level) {
return level.getServer().overworld().getDataStorage().computeIfAbsent(FACTORY, DATA_NAME);
}
public boolean enabled() { return enabled; }
public boolean visibleTo(ServerPlayer player) {
return visibleTo(player.hasPermissions(2));
}
boolean visibleTo(boolean operator) { return enabled && operator; }
public boolean setEnabled(boolean enabled) {
if (this.enabled == enabled) return false;
this.enabled = enabled;
setDirty();
return true;
}
static BookBinderDiagnostics load(CompoundTag tag, HolderLookup.Provider registries) {
BookBinderDiagnostics data = new BookBinderDiagnostics();
data.enabled = tag.getBoolean("Enabled");
return data;
}
@Override public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
tag.putBoolean("Enabled", enabled);
return tag;
}
}
@@ -0,0 +1,32 @@
package net.dnorberg.rivencraft.menu;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.nbt.CompoundTag;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
/** Binder diagnostics are world-persistent but never visible to non-operators. */
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class BookBinderDiagnosticsGameTests {
private BookBinderDiagnosticsGameTests() {}
@GameTest(template = "empty")
public static void binderMessagesDefaultOffAndPersist(GameTestHelper helper) {
BookBinderDiagnostics data = BookBinderDiagnostics.get(helper.getLevel());
helper.assertFalse(data.enabled(), "Binder diagnostic messages must default to off");
helper.assertTrue(data.setEnabled(true), "An OP toggle must change the world setting");
helper.assertFalse(data.setEnabled(true), "Repeating a toggle must not change the setting");
CompoundTag saved = data.save(new CompoundTag(), helper.getLevel().registryAccess());
BookBinderDiagnostics restored = BookBinderDiagnostics.load(saved, helper.getLevel().registryAccess());
helper.assertTrue(restored.enabled(), "The OP setting must survive world reload");
helper.assertTrue(restored.visibleTo(true), "An OP must see enabled binder diagnostics");
helper.assertFalse(restored.visibleTo(false), "A non-OP must not see enabled binder diagnostics");
helper.assertTrue(restored.setEnabled(false), "An OP toggle must disable messages");
helper.assertFalse(restored.visibleTo(true), "Disabled messages must be invisible even to OPs");
data.setEnabled(false);
helper.succeed();
}
}
@@ -43,6 +43,7 @@ public final class BookBinderMenu extends AbstractContainerMenu {
private int grammarErrorStripIndex = -1;
private int grammarInstability;
private int grammarIncompleteCount;
private int diagnosticsVisible;
private WritingGrammar.Result serverGrammar;
public BookBinderMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
@@ -62,34 +63,38 @@ public final class BookBinderMenu extends AbstractContainerMenu {
});
addDataSlot(new DataSlot() {
@Override public int get() {
return clientSide || binder == null ? grammarErrorCode : binderDiagnostic()
return clientSide || binder == null ? grammarErrorCode : !diagnosticsVisibleToAuthor() ? -1 : binderDiagnostic()
.map(diagnostic -> diagnostic.code().ordinal()).orElse(-1);
}
@Override public void set(int value) { grammarErrorCode = value; }
});
addDataSlot(new DataSlot() {
@Override public int get() {
return clientSide || binder == null ? grammarErrorStripIndex : binderDiagnostic()
return clientSide || binder == null ? grammarErrorStripIndex : !diagnosticsVisibleToAuthor() ? -1 : binderDiagnostic()
.map(diagnostic -> binder.writingStripIndex(diagnostic.index())).orElse(-1);
}
@Override public void set(int value) { grammarErrorStripIndex = value; }
});
addDataSlot(new DataSlot() {
@Override public int get() {
return clientSide || binder == null ? grammarInstability
return clientSide || binder == null ? grammarInstability : !diagnosticsVisibleToAuthor() ? 0
: serverGrammar().writingInstability();
}
@Override public void set(int value) { grammarInstability = value; }
});
addDataSlot(new DataSlot() {
@Override public int get() {
return clientSide || binder == null ? grammarIncompleteCount
return clientSide || binder == null ? grammarIncompleteCount : !diagnosticsVisibleToAuthor() ? 0
: (int) serverGrammar().diagnostics().stream()
.filter(diagnostic -> diagnostic.severity()
== WritingGrammar.Severity.SEEDED_COMPLETION).count();
}
@Override public void set(int value) { grammarIncompleteCount = value; }
});
addDataSlot(new DataSlot() {
@Override public int get() { return clientSide ? diagnosticsVisible : diagnosticsVisibleToAuthor() ? 1 : 0; }
@Override public void set(int value) { diagnosticsVisible = 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));
@@ -130,7 +135,12 @@ public final class BookBinderMenu extends AbstractContainerMenu {
}
public int grammarInstability() { return grammarInstability; }
public int grammarIncompleteCount() { return grammarIncompleteCount; }
public boolean diagnosticsVisible() { return diagnosticsVisible != 0; }
public int grammarErrorStripIndex() { return grammarErrorStripIndex; }
private boolean diagnosticsVisibleToAuthor() {
return bookAuthor instanceof ServerPlayer serverPlayer
&& BookBinderDiagnostics.get(serverPlayer.serverLevel()).visibleTo(serverPlayer);
}
public String title() { return binder == null ? "" : binder.title(); }
public boolean setTitle(Player player, String title) {
if (clientSide || !stillValid(player) || title == null
@@ -191,7 +191,8 @@ public final class WritingGrammar {
private static boolean numberTarget(Role role) {
return role == Role.COLOR || role == Role.PERIOD_MODIFIER
|| role == Role.ANGLE_MODIFIER || role == Role.PHASE_MODIFIER;
|| role == Role.ANGLE_MODIFIER || role == Role.PHASE_MODIFIER
|| role == Role.SIZE_MODIFIER;
}
private static int stage(Role role) {
@@ -260,7 +260,7 @@
"symbol.rivencraft.number.22": "D'ni 22",
"symbol.rivencraft.number.23": "D'ni 23",
"symbol.rivencraft.number.24": "D'ni 24",
"symbol.rivencraft.number.description": "Set the next color page to this number × 10, Angle or Phase to × 15°, or Length to × 0.08. A number page affects only the immediately following page.",
"symbol.rivencraft.number.description": "Set the next color page to this number × 10, Angle or Phase to × 15°, Length to × 0.08, or Celestial Size from 0.25× to 3×. A number page affects only the immediately following page.",
"symbol.rivencraft.weather.normal": "Normal Weather",
"symbol.rivencraft.weather.normal.description": "Use the normal weather cycle.",
"symbol.rivencraft.weather.fast": "Fast Weather",