Public Access
Freeze Age instability scores and classify authored writing (#64 #65)
Build / build (push) Canceled after 0s
Build / build (push) Canceled after 0s
This commit is contained in:
1 parent
86cda69d11
commit
7a47e40e2d
14 files changed
+467
-51
No files matched your search
@@ -5,7 +5,6 @@ import java.util.List;
|
||||
import java.util.UUID;
|
||||
import net.dnorberg.rivencraft.book.AgeReference;
|
||||
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
|
||||
import net.dnorberg.rivencraft.book.AgeGenerationPlan;
|
||||
import net.dnorberg.rivencraft.book.AgeGenerationProfiles;
|
||||
import net.dnorberg.rivencraft.book.AgeRuntimeProfiles;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
@@ -117,19 +116,7 @@ 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;
|
||||
}
|
||||
OrderedWriting writing = currentWriting();
|
||||
if (!writingGrammar().canCreate()) return false;
|
||||
try {
|
||||
if (level instanceof ServerLevel serverLevel) {
|
||||
AgeRegistrySavedData.validateNewWriting(serverLevel, title, writing);
|
||||
} else {
|
||||
AgeGenerationPlan.compile(writing, 0L, AgeGenerationProfiles.STANDARD,
|
||||
net.dnorberg.rivencraft.book.AgeTraits.select(0L), level.registryAccess());
|
||||
}
|
||||
return true;
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return false;
|
||||
}
|
||||
return writingGrammar().canCreate();
|
||||
}
|
||||
|
||||
public WritingGrammar.Result writingGrammar() {
|
||||
@@ -181,18 +168,21 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe
|
||||
// check. This also pinpoints biome-pool conflicts that cannot be inferred from
|
||||
// an individual feature page alone (for example Trees with only Nether biomes).
|
||||
if (level instanceof ServerLevel serverLevel && diagnostics.stream().noneMatch(
|
||||
diagnostic -> diagnostic.code() == WritingGrammar.Code.UNSUPPORTED_FEATURE)) {
|
||||
WritingGrammar.Diagnostic::error)) {
|
||||
try {
|
||||
AgeRegistrySavedData.validateNewWriting(serverLevel, title, currentWriting());
|
||||
} catch (IllegalArgumentException exception) {
|
||||
if (exception.getMessage() != null && (exception.getMessage().startsWith("Feature ")
|
||||
|| exception.getMessage().startsWith("Caves "))) {
|
||||
parsed.symbols().stream().filter(symbol -> symbol.semantics().role()
|
||||
== SymbolSemantics.Role.FEATURE).findFirst().ifPresent(symbol ->
|
||||
diagnostics.add(new WritingGrammar.Diagnostic(
|
||||
WritingGrammar.Code.UNSUPPORTED_FEATURE, symbol.index(),
|
||||
exception.getMessage())));
|
||||
}
|
||||
boolean feature = exception.getMessage() != null
|
||||
&& (exception.getMessage().startsWith("Feature ")
|
||||
|| exception.getMessage().startsWith("Caves "));
|
||||
int index = feature ? parsed.symbols().stream()
|
||||
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.FEATURE)
|
||||
.mapToInt(WritingGrammar.ParsedSymbol::index).findFirst().orElse(-1) : -1;
|
||||
diagnostics.add(new WritingGrammar.Diagnostic(
|
||||
feature ? WritingGrammar.Code.UNSUPPORTED_FEATURE
|
||||
: WritingGrammar.Code.UNSUPPORTED_COMBINATION,
|
||||
index, exception.getMessage() == null ? "The Age compiler rejected this writing"
|
||||
: exception.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ public record AgeDefinition(
|
||||
AgeTraits traits,
|
||||
AgeGenerationPlan plan,
|
||||
@Nullable AgeSeasonMode seasonMode,
|
||||
List<AgeFeatureSelection.Choice> randomFeatures
|
||||
List<AgeFeatureSelection.Choice> randomFeatures,
|
||||
AgeInstability instability
|
||||
) {
|
||||
public static final ResourceLocation RANDOM_VANILLA_PROFILE = ResourceLocation.fromNamespaceAndPath(
|
||||
Rivencraft.MOD_ID, "random_vanilla"
|
||||
@@ -34,6 +35,7 @@ public record AgeDefinition(
|
||||
Objects.requireNonNull(generationProfile, "generationProfile");
|
||||
Objects.requireNonNull(dimensionId, "dimensionId");
|
||||
Objects.requireNonNull(traits, "traits");
|
||||
Objects.requireNonNull(instability, "instability");
|
||||
randomFeatures = List.copyOf(randomFeatures);
|
||||
if (plan != null && !randomFeatures.isEmpty()) {
|
||||
throw new IllegalArgumentException("Authored Ages cannot also have random features");
|
||||
@@ -47,14 +49,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, null, List.of());
|
||||
dimensionId, spawn, traits, null, null, List.of(), AgeInstability.legacyNeutral());
|
||||
}
|
||||
|
||||
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, List.of());
|
||||
dimensionId, spawn, traits, plan, null, List.of(), AgeInstability.legacyNeutral());
|
||||
}
|
||||
|
||||
public AgeDefinition(UUID id, String name, long seed, ResourceLocation generationProfile,
|
||||
@@ -62,7 +64,15 @@ public record AgeDefinition(
|
||||
BlockPos spawn, AgeTraits traits, AgeGenerationPlan plan,
|
||||
@Nullable AgeSeasonMode seasonMode) {
|
||||
this(id, name, seed, generationProfile, generationVersion, createdGameTime,
|
||||
dimensionId, spawn, traits, plan, seasonMode, List.of());
|
||||
dimensionId, spawn, traits, plan, seasonMode, List.of(), AgeInstability.legacyNeutral());
|
||||
}
|
||||
|
||||
public AgeDefinition(UUID id, String name, long seed, ResourceLocation generationProfile,
|
||||
int generationVersion, long createdGameTime, ResourceLocation dimensionId,
|
||||
BlockPos spawn, AgeTraits traits, AgeGenerationPlan plan,
|
||||
@Nullable AgeSeasonMode seasonMode, List<AgeFeatureSelection.Choice> randomFeatures) {
|
||||
this(id, name, seed, generationProfile, generationVersion, createdGameTime,
|
||||
dimensionId, spawn, traits, plan, seasonMode, randomFeatures, AgeInstability.legacyNeutral());
|
||||
}
|
||||
|
||||
public static ResourceLocation dimensionId(UUID ageId) {
|
||||
@@ -93,6 +103,6 @@ public record AgeDefinition(
|
||||
|
||||
public AgeDefinition withSpawn(BlockPos spawn) {
|
||||
return new AgeDefinition(id, name, seed, generationProfile, generationVersion,
|
||||
createdGameTime, dimensionId, spawn.immutable(), traits, plan, seasonMode, randomFeatures);
|
||||
createdGameTime, dimensionId, spawn.immutable(), traits, plan, seasonMode, randomFeatures, instability);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.ListTag;
|
||||
|
||||
/** Frozen, server-owned score. Only new Age creation consults the current symbol catalog. */
|
||||
public record AgeInstability(int version, int score, List<Contribution> breakdown) {
|
||||
public static final int CURRENT_VERSION = 1;
|
||||
|
||||
public AgeInstability {
|
||||
if (version != CURRENT_VERSION) throw new IllegalArgumentException("Unsupported instability version " + version);
|
||||
breakdown = List.copyOf(breakdown);
|
||||
if (score < 0 || score != breakdown.stream().mapToInt(Contribution::points).sum()) {
|
||||
throw new IllegalArgumentException("Instability score does not match breakdown");
|
||||
}
|
||||
}
|
||||
|
||||
/** Pre-scoring saves are explicitly neutral, never rescored from current data packs. */
|
||||
public static AgeInstability legacyNeutral() {
|
||||
return new AgeInstability(CURRENT_VERSION, 0,
|
||||
List.of(new Contribution("legacy_neutral", -1, 0, "Age predates frozen instability scoring")));
|
||||
}
|
||||
|
||||
static AgeInstability compute(AgeGenerationPlan plan, AgeTraits traits) {
|
||||
Objects.requireNonNull(traits, "traits");
|
||||
List<Contribution> contributions = new ArrayList<>();
|
||||
if (plan == null) {
|
||||
contributions.add(new Contribution("random_baseline", -1, 0, "Page-free or Link-Panel-only Age"));
|
||||
} else {
|
||||
contributions.add(new Contribution("authored_baseline", -1, 0, "Authored Age"));
|
||||
WritingGrammar.Result parsed = WritingGrammar.parse(plan.writing());
|
||||
if (!parsed.canCreate() || parsed.writingInstability() != plan.writingInstability()) {
|
||||
throw new IllegalArgumentException("Writing changed between Age compilation and scoring");
|
||||
}
|
||||
for (WritingGrammar.Diagnostic diagnostic : parsed.diagnostics()) {
|
||||
contributions.add(new Contribution(diagnostic.code().name().toLowerCase(java.util.Locale.ROOT),
|
||||
diagnostic.index(), diagnostic.instability(), diagnostic.message()));
|
||||
}
|
||||
}
|
||||
// Legacy's bright-lighting symbol adds +500. Other balance costs are deferred
|
||||
// until their corresponding authored symbols and audited values exist.
|
||||
if (traits.lighting() == AgeTraits.Lighting.BRIGHT) {
|
||||
int pageIndex = plan == null ? -1 : plan.activeSymbols().stream()
|
||||
.filter(symbol -> symbol.semantics().role()
|
||||
== net.dnorberg.rivencraft.writing.SymbolSemantics.Role.LIGHTING)
|
||||
.mapToInt(AgeGenerationPlan.ResolvedSymbol::index).findFirst().orElse(-1);
|
||||
contributions.add(new Contribution("bright_lighting", pageIndex, 500,
|
||||
pageIndex < 0 ? "Seeded bright lighting" : "Authored bright lighting"));
|
||||
}
|
||||
return new AgeInstability(CURRENT_VERSION,
|
||||
contributions.stream().mapToInt(Contribution::points).sum(), contributions);
|
||||
}
|
||||
|
||||
public CompoundTag save() {
|
||||
CompoundTag tag = new CompoundTag();
|
||||
tag.putInt("Version", version);
|
||||
tag.putInt("Score", score);
|
||||
ListTag entries = new ListTag();
|
||||
for (Contribution contribution : breakdown) {
|
||||
CompoundTag entry = new CompoundTag();
|
||||
entry.putString("Reason", contribution.reason());
|
||||
entry.putInt("PageIndex", contribution.pageIndex());
|
||||
entry.putInt("Points", contribution.points());
|
||||
entry.putString("Detail", contribution.detail());
|
||||
entries.add(entry);
|
||||
}
|
||||
tag.put("Breakdown", entries);
|
||||
return tag;
|
||||
}
|
||||
|
||||
public static AgeInstability load(CompoundTag tag) {
|
||||
ListTag entries = tag.getList("Breakdown", CompoundTag.TAG_COMPOUND);
|
||||
List<Contribution> contributions = new ArrayList<>();
|
||||
for (int index = 0; index < entries.size(); index++) {
|
||||
CompoundTag entry = entries.getCompound(index);
|
||||
contributions.add(new Contribution(entry.getString("Reason"), entry.getInt("PageIndex"),
|
||||
entry.getInt("Points"), entry.getString("Detail")));
|
||||
}
|
||||
return new AgeInstability(tag.getInt("Version"), tag.getInt("Score"), contributions);
|
||||
}
|
||||
|
||||
public record Contribution(String reason, int pageIndex, int points, String detail) {
|
||||
public Contribution {
|
||||
Objects.requireNonNull(reason, "reason");
|
||||
Objects.requireNonNull(detail, "detail");
|
||||
if (reason.isBlank() || pageIndex < -1 || points < 0) {
|
||||
throw new IllegalArgumentException("Invalid instability contribution");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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.core.BlockPos;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
@@ -55,6 +56,103 @@ public final class AgePersistenceGameTests {
|
||||
registry.save(new CompoundTag(), helper.getLevel().registryAccess()),
|
||||
helper.getLevel().registryAccess()).find(id).orElseThrow();
|
||||
helper.assertValueEqual(restored, created, "The complete plan must survive a saved-data round trip");
|
||||
helper.assertValueEqual(restored.instability(), created.instability(),
|
||||
"The complete instability breakdown must survive reload");
|
||||
helper.assertValueEqual(registry.instability(id).orElseThrow(), created.instability(),
|
||||
"The server diagnostic query must return the saved score");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void instabilitySeparatesStableAndDefectiveWriting(GameTestHelper helper) {
|
||||
OrderedWriting stable = new OrderedWriting(List.of(
|
||||
WritingEntry.linkPanel(),
|
||||
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:terrain/standard"))),
|
||||
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:lighting/normal")))
|
||||
));
|
||||
OrderedWriting defective = new OrderedWriting(List.of(
|
||||
WritingEntry.linkPanel(),
|
||||
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:lighting/normal"))),
|
||||
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:terrain/standard"))),
|
||||
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:terrain/standard")))
|
||||
));
|
||||
AgeDefinition stableAge = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
||||
new AgeReference(UUID.randomUUID(), "Stable Writing"), stable);
|
||||
AgeDefinition defectiveAge = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
||||
new AgeReference(UUID.randomUUID(), "Defective Writing"), defective);
|
||||
helper.assertValueEqual(stableAge.instability().score(), 0,
|
||||
"Missing seeded branches do not add instability");
|
||||
helper.assertValueEqual(defectiveAge.instability().score(), 600,
|
||||
"Out-of-order and repeated terrain pages each add their saved penalty");
|
||||
helper.assertTrue(defectiveAge.instability().breakdown().stream()
|
||||
.anyMatch(part -> part.reason().equals("duplicate") && part.pageIndex() == 3),
|
||||
"The score must explain the offending page");
|
||||
AgeDefinition reused = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
||||
new AgeReference(defectiveAge.id(), "Changed Name"), stable);
|
||||
helper.assertValueEqual(reused, defectiveAge, "Repeated book use must not rescore an Age");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void randomBaselineAndBrightBalanceAreFrozen(GameTestHelper helper) {
|
||||
AgeDefinition random = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
||||
new AgeReference(UUID.randomUUID(), "Random Score"), OrderedWriting.EMPTY);
|
||||
helper.assertTrue(random.instability().breakdown().stream()
|
||||
.anyMatch(part -> part.reason().equals("random_baseline") && part.points() == 0),
|
||||
"Random Ages require an explicit baseline entry");
|
||||
helper.assertValueEqual(random.instability().score(),
|
||||
random.traits().lighting() == AgeTraits.Lighting.BRIGHT ? 500 : 0,
|
||||
"Seeded bright lighting must use the audited balance cost");
|
||||
AgeInstability bright = AgeInstability.compute(null, new AgeTraits(
|
||||
AgeTraits.LEGACY_DEFAULTS.biomeDistribution(), AgeTraits.Lighting.BRIGHT,
|
||||
AgeTraits.LEGACY_DEFAULTS.weather(), AgeTraits.LEGACY_DEFAULTS.celestialPeriod(),
|
||||
AgeTraits.LEGACY_DEFAULTS.celestialAngle(), AgeTraits.LEGACY_DEFAULTS.celestialPhase()));
|
||||
helper.assertValueEqual(bright.score(), 500, "Bright lighting has the audited Legacy cost");
|
||||
helper.assertValueEqual(AgeInstability.load(bright.save()), bright,
|
||||
"A score must survive its own versioned round trip");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void missingRegistryEntryDoesNotReinterpretAnOldAge(GameTestHelper helper) {
|
||||
UUID id = UUID.randomUUID();
|
||||
CompoundTag root = new CompoundTag();
|
||||
ListTag entries = new ListTag();
|
||||
entries.add(entry(id, "Old Age", 17L, AgeGenerationProfiles.STANDARD, 15, 1L,
|
||||
AgeDefinition.dimensionId(id), new BlockPos(0, 64, 0), AgeTraits.LEGACY_DEFAULTS));
|
||||
root.put("Ages", entries);
|
||||
AgeRegistrySavedData old = AgeRegistrySavedData.load(root, helper.getLevel().registryAccess());
|
||||
helper.assertValueEqual(old.instability(id).orElseThrow(), AgeInstability.legacyNeutral(),
|
||||
"Old Ages without a score must migrate to a neutral saved value");
|
||||
helper.assertValueEqual(AgeRegistrySavedData.load(
|
||||
old.save(new CompoundTag(), helper.getLevel().registryAccess()),
|
||||
helper.getLevel().registryAccess()).instability(id).orElseThrow(), AgeInstability.legacyNeutral(),
|
||||
"Neutral migration must persist after restart");
|
||||
OrderedWriting missing = new OrderedWriting(List.of(WritingEntry.linkPanel(),
|
||||
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:missing_symbol")))));
|
||||
helper.assertTrue(!WritingGrammar.parse(missing, symbol -> java.util.Optional.empty()).canCreate(),
|
||||
"Missing registry symbols remain fatal for new Ages");
|
||||
|
||||
OrderedWriting authored = new OrderedWriting(List.of(WritingEntry.linkPanel(),
|
||||
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:terrain/standard")))));
|
||||
AgeDefinition created = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
||||
new AgeReference(UUID.randomUUID(), "Catalog Snapshot"), authored);
|
||||
CompoundTag saved = AgeRegistrySavedData.get(helper.getLevel())
|
||||
.save(new CompoundTag(), helper.getLevel().registryAccess());
|
||||
ListTag savedAges = saved.getList("Ages", CompoundTag.TAG_COMPOUND);
|
||||
for (int index = 0; index < savedAges.size(); index++) {
|
||||
CompoundTag savedAge = savedAges.getCompound(index);
|
||||
if (!savedAge.getUUID("Id").equals(created.id())) continue;
|
||||
CompoundTag plan = savedAge.getCompound("GenerationPlan");
|
||||
plan.getList("Resolved", CompoundTag.TAG_COMPOUND).getCompound(0)
|
||||
.putString("Symbol", "rivencraft:removed_from_catalog");
|
||||
plan.getList("Writing", CompoundTag.TAG_COMPOUND).getCompound(1)
|
||||
.putString("Symbol", "rivencraft:removed_from_catalog");
|
||||
}
|
||||
AgeDefinition restored = AgeRegistrySavedData.load(saved, helper.getLevel().registryAccess())
|
||||
.find(created.id()).orElseThrow();
|
||||
helper.assertValueEqual(restored.instability(), created.instability(),
|
||||
"A removed symbol must not reroll or reinterpret the frozen instability score");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@@ -170,6 +268,8 @@ public final class AgePersistenceGameTests {
|
||||
"Definitions predating traits must receive stable legacy defaults");
|
||||
helper.assertTrue(definition.plan() == null,
|
||||
"Existing Ages must not be reinterpreted as authored plans during migration");
|
||||
helper.assertValueEqual(definition.instability(), AgeInstability.legacyNeutral(),
|
||||
"Old Ages must not be rescored from modern page rules");
|
||||
|
||||
CompoundTag serialized = migrated.save(new CompoundTag(), helper.getLevel().registryAccess());
|
||||
AgeDefinition reloaded = AgeRegistrySavedData.load(serialized, helper.getLevel().registryAccess())
|
||||
|
||||
@@ -35,6 +35,7 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
private static final String PLAN_TAG = "GenerationPlan";
|
||||
private static final String RANDOM_FEATURES_TAG = "RandomFeatures";
|
||||
private static final String SEASON_MODE_TAG = "SeasonMode";
|
||||
private static final String INSTABILITY_TAG = "Instability";
|
||||
private static final String BIOME_DISTRIBUTION_TAG = "BiomeDistribution";
|
||||
private static final String LIGHTING_TAG = "Lighting";
|
||||
private static final String WEATHER_TAG = "Weather";
|
||||
@@ -89,6 +90,11 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
return Optional.ofNullable(ages.get(ageId));
|
||||
}
|
||||
|
||||
/** Read-only server diagnostic; never compiles writing or mutates a saved Age. */
|
||||
public Optional<AgeInstability> instability(UUID ageId) {
|
||||
return find(ageId).map(AgeDefinition::instability);
|
||||
}
|
||||
|
||||
public Optional<AgeDefinition> findByDimension(ResourceLocation dimensionId) {
|
||||
return ages.values().stream()
|
||||
.filter(age -> age.dimensionId().equals(dimensionId))
|
||||
@@ -123,6 +129,8 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
ResourceLocation seedProfile = AgeRuntimeProfiles.selectForWriting(seed, writing, registries);
|
||||
AgeTraits traits = AgeTraits.select(seed);
|
||||
AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, seed, seedProfile, traits, registries);
|
||||
AgeTraits effectiveTraits = AgeEnvironmentCompiler.apply(plan, traits);
|
||||
AgeInstability instability = AgeInstability.compute(plan, effectiveTraits);
|
||||
AgeDefinition created = new AgeDefinition(
|
||||
reference.id(),
|
||||
name,
|
||||
@@ -132,11 +140,12 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
createdGameTime,
|
||||
dimensionId,
|
||||
null,
|
||||
AgeEnvironmentCompiler.apply(plan, traits),
|
||||
effectiveTraits,
|
||||
plan,
|
||||
AgeSeasonMode.select(plan, seed, ModList.get().isLoaded("sereneseasons")),
|
||||
plan == null ? AgeFeatureSelection.randomChoices(seed,
|
||||
AgeRuntimeProfiles.resolve(seedProfile), registries) : List.of()
|
||||
AgeRuntimeProfiles.resolve(seedProfile), registries) : List.of(),
|
||||
instability
|
||||
);
|
||||
ages.put(created.id(), created);
|
||||
setDirty();
|
||||
@@ -183,7 +192,10 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
? AgeGenerationPlan.load(entry.getCompound(PLAN_TAG)) : null,
|
||||
entry.contains(SEASON_MODE_TAG, CompoundTag.TAG_STRING)
|
||||
? AgeSeasonMode.parseOrNull(entry.getString(SEASON_MODE_TAG)) : null,
|
||||
readRandomFeatures(entry)
|
||||
readRandomFeatures(entry),
|
||||
entry.contains(INSTABILITY_TAG, CompoundTag.TAG_COMPOUND)
|
||||
? AgeInstability.load(entry.getCompound(INSTABILITY_TAG))
|
||||
: AgeInstability.legacyNeutral()
|
||||
));
|
||||
}
|
||||
return data;
|
||||
@@ -228,6 +240,7 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
entry.put(RANDOM_FEATURES_TAG, featureTags);
|
||||
}
|
||||
if (age.seasonMode() != null) entry.putString(SEASON_MODE_TAG, age.seasonMode().name());
|
||||
entry.put(INSTABILITY_TAG, age.instability().save());
|
||||
serializedAges.add(entry);
|
||||
});
|
||||
tag.put(AGES_TAG, serializedAges);
|
||||
|
||||
@@ -6,6 +6,7 @@ import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.menu.BookBinderMenu;
|
||||
import net.dnorberg.rivencraft.network.SetBookBinderTitlePayload;
|
||||
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
||||
import net.minecraft.client.gui.GuiGraphics;
|
||||
import net.minecraft.client.gui.components.EditBox;
|
||||
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
|
||||
@@ -106,14 +107,33 @@ public final class BookBinderScreen extends AbstractContainerScreen<BookBinderMe
|
||||
}
|
||||
graphics.disableScissor();
|
||||
drawScrollEnds(graphics);
|
||||
var severity = menu.grammarSeverity().orElse(null);
|
||||
Component status = severity == WritingGrammar.Severity.FATAL
|
||||
? Component.translatable("screen.rivencraft.book_binder.status.fatal")
|
||||
: severity == WritingGrammar.Severity.INSTABILITY
|
||||
? Component.translatable("screen.rivencraft.book_binder.status.unstable", menu.grammarInstability())
|
||||
: severity == WritingGrammar.Severity.SEEDED_COMPLETION
|
||||
? Component.translatable("screen.rivencraft.book_binder.status.seeded") : null;
|
||||
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(
|
||||
"screen.rivencraft.book_binder.grammar." + code.name().toLowerCase(Locale.ROOT),
|
||||
errorStripIndex + 1)).orElse(null);
|
||||
if ((menu.grammarWarning() || menu.getSlot(BookBinderMenu.RESULT).getItem().isEmpty())
|
||||
&& mouseX >= leftPos + 152 && mouseX < leftPos + 168
|
||||
if (grammarTooltip != null && ((mouseX >= leftPos + 77 && mouseX < leftPos + 151)
|
||||
|| (mouseX >= leftPos + 152 && mouseX < leftPos + 168))
|
||||
&& mouseY >= topPos + 27 && mouseY < topPos + 43
|
||||
&& grammarTooltip != null) {
|
||||
graphics.renderTooltip(font, grammarTooltip, mouseX, mouseY);
|
||||
&& (status != null || menu.getSlot(BookBinderMenu.RESULT).getItem().isEmpty())) {
|
||||
java.util.List<Component> details = new java.util.ArrayList<>();
|
||||
if (status != null) details.add(status);
|
||||
details.add(grammarTooltip);
|
||||
if (menu.grammarInstability() > 0 && severity != WritingGrammar.Severity.INSTABILITY)
|
||||
details.add(Component.translatable("screen.rivencraft.book_binder.status.unstable",
|
||||
menu.grammarInstability()));
|
||||
if (menu.grammarIncompleteCount() > 0 && severity != WritingGrammar.Severity.SEEDED_COMPLETION)
|
||||
details.add(Component.translatable("screen.rivencraft.book_binder.status.seeded_count",
|
||||
menu.grammarIncompleteCount()));
|
||||
graphics.renderComponentTooltip(font, details, mouseX, mouseY);
|
||||
return;
|
||||
}
|
||||
if (mouseY >= topPos + STRIP_Y && mouseY < topPos + STRIP_Y + STRIP_HEIGHT) {
|
||||
|
||||
@@ -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;
|
||||
@@ -226,6 +227,22 @@ public final class DescriptiveBookScreen extends Screen implements ExplorableBoo
|
||||
x, top + 40, 110);
|
||||
drawWrappedWithoutShadow(graphics, Component.translatable("screen.rivencraft.age.name_prompt"),
|
||||
x, top + 52, 110);
|
||||
WritingGrammar.Result grammar = WritingGrammar.parse(writing);
|
||||
boolean unresolved = writingSymbols.stream().anyMatch(display -> !display.resolved());
|
||||
if (!grammar.canCreate() || unresolved) {
|
||||
drawWrappedWithoutShadow(graphics,
|
||||
Component.translatable("screen.rivencraft.age.writing.fatal"),
|
||||
x, top + 132, 110, UNRESOLVED_TEXT_COLOR);
|
||||
} else if (grammar.writingInstability() > 0) {
|
||||
drawWrappedWithoutShadow(graphics,
|
||||
Component.translatable("screen.rivencraft.age.writing.unstable",
|
||||
grammar.writingInstability()), x, top + 132, 110);
|
||||
}
|
||||
if (grammar.firstIncomplete().isPresent()) {
|
||||
drawWrappedWithoutShadow(graphics,
|
||||
Component.translatable("screen.rivencraft.age.writing.seeded"),
|
||||
x, top + 145, 110);
|
||||
}
|
||||
return;
|
||||
}
|
||||
int nameEnd = drawWrappedWithoutShadow(graphics, Component.literal(age.name()),
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package net.dnorberg.rivencraft.gametest;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
|
||||
import net.dnorberg.rivencraft.book.AgeReference;
|
||||
import net.dnorberg.rivencraft.item.PageItem;
|
||||
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||
import net.dnorberg.rivencraft.registry.ModDataComponents;
|
||||
import net.dnorberg.rivencraft.registry.ModItems;
|
||||
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.core.BlockPos;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
import net.minecraft.gametest.framework.GameTestHelper;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
/** Focused classification and transaction checks for Milestone 6 issue #65. */
|
||||
@GameTestHolder(Rivencraft.MOD_ID)
|
||||
@PrefixGameTestTemplate(false)
|
||||
public final class Milestone6GrammarGameTests {
|
||||
private Milestone6GrammarGameTests() {}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void writingOutcomesAreDistinct(GameTestHelper helper) {
|
||||
var terrain = symbol("rivencraft:terrain/standard");
|
||||
var weather = symbol("rivencraft:weather/normal");
|
||||
var missing = symbol("example:missing_definition");
|
||||
var fatal = WritingGrammar.parse(new OrderedWriting(List.of(terrain)));
|
||||
helper.assertValueEqual(fatal.firstError().orElseThrow().severity(), WritingGrammar.Severity.FATAL,
|
||||
"A missing Link Panel is fatal");
|
||||
helper.assertTrue(!WritingGrammar.parse(new OrderedWriting(List.of(WritingEntry.linkPanel(), missing)))
|
||||
.canCreate(), "An unresolved symbol must remain fatal");
|
||||
|
||||
var incomplete = WritingGrammar.parse(new OrderedWriting(List.of(WritingEntry.linkPanel(), terrain)));
|
||||
helper.assertTrue(incomplete.canCreate() && incomplete.firstIncomplete().isPresent(),
|
||||
"Missing branches should be seeded rather than rejected");
|
||||
helper.assertValueEqual(incomplete.firstIncomplete().orElseThrow().severity(),
|
||||
WritingGrammar.Severity.SEEDED_COMPLETION, "Seeded completion needs its own classification");
|
||||
helper.assertValueEqual(incomplete.writingInstability(), 0,
|
||||
"Missing branches alone must not make an Age unstable");
|
||||
|
||||
OrderedWriting unstableWriting = new OrderedWriting(List.of(WritingEntry.linkPanel(), weather,
|
||||
terrain, terrain));
|
||||
var unstable = WritingGrammar.parse(unstableWriting);
|
||||
helper.assertTrue(unstable.canCreate() && unstable.firstInstability().isPresent(),
|
||||
"Supported order and duplicate defects should remain authorable");
|
||||
helper.assertValueEqual(unstable.writingInstability(), 600,
|
||||
"A misplaced terrain and duplicate choice should score once each");
|
||||
helper.assertValueEqual(unstable.symbols().size(), 3,
|
||||
"The diagnostic must preserve all authored page IDs and order");
|
||||
helper.assertValueEqual(unstable.symbols().get(2).reference().id(),
|
||||
ResourceLocation.parse("rivencraft:terrain/standard"),
|
||||
"The duplicate page must remain in the frozen writing");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void rejectedBinderKeepsInputsAndUnstablePreview(GameTestHelper helper) {
|
||||
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
|
||||
helper.getLevel().setBlock(pos, ModBlocks.BOOK_BINDER.get().defaultBlockState(), 3);
|
||||
var binder = (BookBinderBlockEntity) helper.getLevel().getBlockEntity(pos);
|
||||
helper.assertTrue(binder != null, "Binder must exist");
|
||||
binder.setTitle("Grammar Transaction " + UUID.randomUUID());
|
||||
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
|
||||
binder.setItem(BookBinderBlockEntity.FIRST_PAGE, page("rivencraft:terrain/standard"));
|
||||
helper.assertTrue(!binder.canAssemble() && !binder.consumeAssembly(),
|
||||
"Missing panel must reject before consuming inputs");
|
||||
helper.assertTrue(binder.getItem(BookBinderBlockEntity.COVER).is(Items.LEATHER)
|
||||
&& binder.getItem(BookBinderBlockEntity.FIRST_PAGE).is(ModItems.PAGE.get()),
|
||||
"A structural failure must preserve cover and page");
|
||||
|
||||
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
|
||||
binder.setItem(BookBinderBlockEntity.FIRST_PAGE + 1,
|
||||
PageItem.createRecoveredPage(new SymbolReference(ResourceLocation.parse("example:missing"))));
|
||||
helper.assertTrue(!binder.canAssemble() && !binder.consumeAssembly(),
|
||||
"Missing registry entry must reject without consuming ink or pages");
|
||||
helper.assertTrue(!binder.getItem(BookBinderBlockEntity.LINK_PANEL).isEmpty()
|
||||
&& !binder.getItem(BookBinderBlockEntity.FIRST_PAGE + 1).isEmpty(),
|
||||
"Rejected writing must keep the Link Panel and unresolved Page");
|
||||
|
||||
binder.setItem(BookBinderBlockEntity.FIRST_PAGE + 1, page("rivencraft:terrain/standard"));
|
||||
helper.assertTrue(binder.canAssemble(), "Known duplicate terrain should be accepted as unstable");
|
||||
helper.assertValueEqual(binder.writingGrammar().writingInstability(), 500,
|
||||
"Binder must expose the instability cost before assembly");
|
||||
ItemStack preview = binder.previewBook();
|
||||
helper.assertTrue(preview.get(ModDataComponents.AGE_REFERENCE.get()) instanceof AgeReference,
|
||||
"Accepted writing must preview a stable Age identity without consuming inputs");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
private static WritingEntry symbol(String id) {
|
||||
return WritingEntry.symbol(new SymbolReference(ResourceLocation.parse(id)));
|
||||
}
|
||||
|
||||
private static ItemStack page(String id) {
|
||||
return PageItem.createSymbolPage(new SymbolReference(ResourceLocation.parse(id)));
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,9 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
||||
private int selectedPage = -1;
|
||||
private int grammarErrorCode = -1;
|
||||
private int grammarErrorStripIndex = -1;
|
||||
private int grammarInstability;
|
||||
private int grammarIncompleteCount;
|
||||
private WritingGrammar.Result serverGrammar;
|
||||
|
||||
public BookBinderMenu(int id, Inventory inventory, RegistryFriendlyByteBuf data) {
|
||||
this(id, inventory, inventory.player.level().getBlockEntity(data.readBlockPos())
|
||||
@@ -68,6 +71,22 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
||||
}
|
||||
@Override public void set(int value) { grammarErrorStripIndex = value; }
|
||||
});
|
||||
addDataSlot(new DataSlot() {
|
||||
@Override public int get() {
|
||||
return clientSide || binder == null ? grammarInstability
|
||||
: serverGrammar().writingInstability();
|
||||
}
|
||||
@Override public void set(int value) { grammarInstability = value; }
|
||||
});
|
||||
addDataSlot(new DataSlot() {
|
||||
@Override public int get() {
|
||||
return clientSide || binder == null ? grammarIncompleteCount
|
||||
: (int) serverGrammar().diagnostics().stream()
|
||||
.filter(diagnostic -> diagnostic.severity()
|
||||
== WritingGrammar.Severity.SEEDED_COMPLETION).count();
|
||||
}
|
||||
@Override public void set(int value) { grammarIncompleteCount = 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));
|
||||
@@ -87,9 +106,13 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
||||
}
|
||||
|
||||
public int selectedPage() { return selectedPage; }
|
||||
private WritingGrammar.Result serverGrammar() {
|
||||
if (serverGrammar == null) serverGrammar = binder.writingGrammar();
|
||||
return serverGrammar;
|
||||
}
|
||||
private Optional<WritingGrammar.Diagnostic> binderDiagnostic() {
|
||||
WritingGrammar.Result grammar = binder.writingGrammar();
|
||||
return grammar.firstError().or(grammar::firstWarning);
|
||||
WritingGrammar.Result grammar = serverGrammar();
|
||||
return grammar.firstError().or(grammar::firstInstability).or(grammar::firstIncomplete);
|
||||
}
|
||||
public Optional<WritingGrammar.Code> grammarError() {
|
||||
WritingGrammar.Code[] codes = WritingGrammar.Code.values();
|
||||
@@ -97,11 +120,13 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
||||
? Optional.of(codes[grammarErrorCode]) : Optional.empty();
|
||||
}
|
||||
public boolean grammarWarning() {
|
||||
return grammarError().map(code -> switch (code) {
|
||||
case DUPLICATE, CONFLICT, OUT_OF_ORDER, MISPLACED_MODIFIER -> true;
|
||||
default -> false;
|
||||
}).orElse(false);
|
||||
return grammarSeverity().orElse(null) == WritingGrammar.Severity.INSTABILITY;
|
||||
}
|
||||
public Optional<WritingGrammar.Severity> grammarSeverity() {
|
||||
return grammarError().map(WritingGrammar.Code::severity);
|
||||
}
|
||||
public int grammarInstability() { return grammarInstability; }
|
||||
public int grammarIncompleteCount() { return grammarIncompleteCount; }
|
||||
public int grammarErrorStripIndex() { return grammarErrorStripIndex; }
|
||||
public String title() { return binder == null ? "" : binder.title(); }
|
||||
public boolean setTitle(Player player, String title) {
|
||||
@@ -228,6 +253,7 @@ public final class BookBinderMenu extends AbstractContainerMenu {
|
||||
}
|
||||
|
||||
@Override public void broadcastChanges() {
|
||||
serverGrammar = null;
|
||||
refreshResult();
|
||||
super.broadcastChanges();
|
||||
}
|
||||
|
||||
@@ -175,22 +175,32 @@ public final class WritingGrammar {
|
||||
public enum Code {
|
||||
TOO_MANY, MISSING_PANEL, PANEL_ORDER, DUPLICATE_PANEL, UNRESOLVED, UNSUPPORTED,
|
||||
DUPLICATE, CONFLICT, OUT_OF_ORDER, MISPLACED_MODIFIER, INCOMPLETE, UNSUPPORTED_PROFILE,
|
||||
UNKNOWN_BIOME, UNSUPPORTED_BIOME, UNSUPPORTED_ENVIRONMENT, UNSUPPORTED_FEATURE
|
||||
UNKNOWN_BIOME, UNSUPPORTED_BIOME, UNSUPPORTED_ENVIRONMENT, UNSUPPORTED_FEATURE,
|
||||
UNSUPPORTED_COMBINATION;
|
||||
|
||||
public Severity severity() {
|
||||
return switch (this) {
|
||||
case INCOMPLETE -> Severity.SEEDED_COMPLETION;
|
||||
case DUPLICATE, CONFLICT, OUT_OF_ORDER, MISPLACED_MODIFIER -> Severity.INSTABILITY;
|
||||
default -> Severity.FATAL;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public enum Severity { FATAL, SEEDED_COMPLETION, INSTABILITY }
|
||||
|
||||
public record Diagnostic(Code code, int index, String message) {
|
||||
public Diagnostic {
|
||||
Objects.requireNonNull(code, "code");
|
||||
Objects.requireNonNull(message, "message");
|
||||
}
|
||||
|
||||
public boolean error() {
|
||||
return switch (code) {
|
||||
case INCOMPLETE, DUPLICATE, CONFLICT, OUT_OF_ORDER, MISPLACED_MODIFIER -> false;
|
||||
default -> true;
|
||||
};
|
||||
public Severity severity() {
|
||||
return code.severity();
|
||||
}
|
||||
|
||||
public boolean error() { return severity() == Severity.FATAL; }
|
||||
|
||||
/** The known writing defects have an explicit, provisional M6 score. */
|
||||
public int instability() {
|
||||
return switch (code) {
|
||||
@@ -218,7 +228,13 @@ public final class WritingGrammar {
|
||||
public boolean canCreate() { return diagnostics.stream().noneMatch(Diagnostic::error); }
|
||||
public Optional<Diagnostic> firstError() { return diagnostics.stream().filter(Diagnostic::error).findFirst(); }
|
||||
public Optional<Diagnostic> firstWarning() {
|
||||
return diagnostics.stream().filter(diagnostic -> diagnostic.instability() > 0).findFirst();
|
||||
return firstInstability();
|
||||
}
|
||||
public Optional<Diagnostic> firstInstability() {
|
||||
return diagnostics.stream().filter(diagnostic -> diagnostic.severity() == Severity.INSTABILITY).findFirst();
|
||||
}
|
||||
public Optional<Diagnostic> firstIncomplete() {
|
||||
return diagnostics.stream().filter(diagnostic -> diagnostic.severity() == Severity.SEEDED_COMPLETION).findFirst();
|
||||
}
|
||||
public int writingInstability() {
|
||||
return diagnostics.stream().mapToInt(Diagnostic::instability).sum();
|
||||
|
||||
@@ -51,6 +51,18 @@
|
||||
"screen.rivencraft.book_binder.grammar.conflict": "Page %s: conflicting page adds instability; the supported choice is used",
|
||||
"screen.rivencraft.book_binder.grammar.out_of_order": "Page %s: incorrect order adds instability; the Age repairs the order",
|
||||
"screen.rivencraft.book_binder.grammar.misplaced_modifier": "Page %s: misplaced modifier adds instability; the Age repairs its order",
|
||||
"screen.rivencraft.book_binder.grammar.incomplete": "Missing branches will be completed from the Age seed",
|
||||
"screen.rivencraft.book_binder.grammar.unknown_biome": "Page %s: biome is unavailable; restore its data pack",
|
||||
"screen.rivencraft.book_binder.grammar.unsupported_biome": "Page %s: biome is incompatible with this terrain",
|
||||
"screen.rivencraft.book_binder.grammar.unsupported_feature": "Page %s: feature is incompatible with this Age",
|
||||
"screen.rivencraft.book_binder.grammar.unsupported_combination": "These pages cannot generate together; no inputs will be consumed",
|
||||
"screen.rivencraft.book_binder.status.fatal": "Blocked",
|
||||
"screen.rivencraft.book_binder.status.unstable": "Unstable +%s",
|
||||
"screen.rivencraft.book_binder.status.seeded": "Seeded",
|
||||
"screen.rivencraft.book_binder.status.seeded_count": "%s missing branches will be seeded",
|
||||
"screen.rivencraft.age.writing.fatal": "Writing blocked",
|
||||
"screen.rivencraft.age.writing.unstable": "Unstable +%s",
|
||||
"screen.rivencraft.age.writing.seeded": "Missing parts seeded",
|
||||
"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)",
|
||||
|
||||
Reference in new issue
Block a user