Public Access
Completed issue 71 and allowed village pages to be placed in illegal biomes adding +100 instability
Build / build (push) Canceled after 0s
Build / build (push) Canceled after 0s
This commit is contained in:
1 parent
b48cc8a6ce
commit
dd219bb58a
14 files changed
+530
-34
No files matched your search
@@ -88,6 +88,9 @@ public record AgeBiomeSelection(AgeTraits.BiomeDistribution distribution, List<R
|
||||
Random countRandom = new Random(seed ^ 0x42494F4D45434F55L);
|
||||
while (target < maximum && countRandom.nextInt(3) != 0) target++;
|
||||
}
|
||||
completeStructureBiomes(writing, registries, availableBiomes, authored, distribution,
|
||||
selected, distinct, seed);
|
||||
target = Math.max(target, distinct.size());
|
||||
// An authored Void Age must not acquire The Void as an unrequested second biome.
|
||||
List<ResourceLocation> filler = profile.terrain() == AgeRuntimeProfile.Terrain.VOID && !authored.isEmpty()
|
||||
? new ArrayList<>() : new ArrayList<>(availableBiomes);
|
||||
@@ -103,6 +106,59 @@ public record AgeBiomeSelection(AgeTraits.BiomeDistribution distribution, List<R
|
||||
return new AgeBiomeSelection(distribution, selected);
|
||||
}
|
||||
|
||||
/** Seed-completed biome branches must be able to host every authored structure page. */
|
||||
private static void completeStructureBiomes(WritingGrammar.Result writing, RegistryAccess registries,
|
||||
List<ResourceLocation> available,
|
||||
List<ResourceLocation> authored,
|
||||
AgeTraits.BiomeDistribution distribution,
|
||||
List<ResourceLocation> selected,
|
||||
Set<ResourceLocation> distinct,
|
||||
long seed) {
|
||||
List<Set<ResourceLocation>> requirements = writing.symbols().stream()
|
||||
.filter(symbol -> symbol.semantics().role() == SymbolSemantics.Role.FEATURE)
|
||||
.map(symbol -> ((SymbolSemantics.ResourceValue) symbol.semantics().value()).id())
|
||||
.filter(AgeStructureSelection::feature)
|
||||
.filter(feature -> !AgeStructureSelection.dungeons(feature))
|
||||
.distinct()
|
||||
.map(feature -> AgeStructureSelection.compatibleBiomes(feature, registries))
|
||||
.filter(requirement -> !requirement.isEmpty())
|
||||
.toList();
|
||||
if (requirements.isEmpty()) return;
|
||||
|
||||
if (distribution == AgeTraits.BiomeDistribution.SINGLE) {
|
||||
if (!authored.isEmpty()) return;
|
||||
List<ResourceLocation> candidates = available.stream()
|
||||
.filter(candidate -> requirements.stream().allMatch(requirement -> requirement.contains(candidate)))
|
||||
.toList();
|
||||
if (candidates.isEmpty()) return;
|
||||
ResourceLocation selectedBiome = deterministicChoice(candidates, seed, 0x5354525543544249L);
|
||||
selected.clear();
|
||||
distinct.clear();
|
||||
selected.add(selectedBiome);
|
||||
distinct.add(selectedBiome);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int index = 0; index < requirements.size(); index++) {
|
||||
Set<ResourceLocation> requirement = requirements.get(index);
|
||||
if (selected.stream().anyMatch(requirement::contains)) continue;
|
||||
List<ResourceLocation> candidates = available.stream()
|
||||
.filter(requirement::contains)
|
||||
.filter(candidate -> !distinct.contains(candidate))
|
||||
.toList();
|
||||
if (candidates.isEmpty()) continue;
|
||||
ResourceLocation selectedBiome = deterministicChoice(candidates, seed, 0x5354525543544249L + index);
|
||||
selected.add(selectedBiome);
|
||||
distinct.add(selectedBiome);
|
||||
}
|
||||
}
|
||||
|
||||
private static ResourceLocation deterministicChoice(List<ResourceLocation> choices, long seed, long salt) {
|
||||
List<ResourceLocation> shuffled = new ArrayList<>(choices);
|
||||
java.util.Collections.shuffle(shuffled, new Random(seed ^ salt));
|
||||
return shuffled.getFirst();
|
||||
}
|
||||
|
||||
public static List<ResourceLocation> eligible(RegistryAccess registries, AgeRuntimeProfile profile) {
|
||||
var registry = registries.lookupOrThrow(Registries.BIOME);
|
||||
if (isCave(profile)) {
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import net.dnorberg.rivencraft.writing.WritingGrammar;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.ListTag;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
|
||||
/** 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;
|
||||
private static final int BRIGHT_LIGHTING_COST = 500;
|
||||
private static final int EXCESS_LEGACY_STRUCTURE_COST = 100;
|
||||
private static final int FORCED_STRUCTURE_BIOME_COST = 100;
|
||||
private static final Map<ResourceLocation, String> COUNTED_STRUCTURES = Map.ofEntries(
|
||||
Map.entry(id("mineshafts"), "Mineshafts"),
|
||||
Map.entry(id("nether_fortresses"), "Nether Fortresses"),
|
||||
Map.entry(id("strongholds"), "Strongholds"),
|
||||
Map.entry(id("villages"), "Villages"),
|
||||
Map.entry(id("bastions"), "Bastions"),
|
||||
Map.entry(id("trial_chambers"), "Trial Chambers"),
|
||||
Map.entry(id("ancient_cities"), "Ancient Cities"),
|
||||
Map.entry(id("shipwrecks"), "Shipwrecks"),
|
||||
Map.entry(id("ocean_ruins"), "Ocean Ruins"),
|
||||
Map.entry(id("pillager_outposts"), "Pillager Outposts"),
|
||||
Map.entry(id("ruined_portals"), "Ruined Portals"),
|
||||
Map.entry(id("nether_fossils"), "Nether Fossils"),
|
||||
Map.entry(id("buried_treasure"), "Buried Treasure"),
|
||||
Map.entry(id("trail_ruins"), "Trail Ruins")
|
||||
);
|
||||
|
||||
public AgeInstability {
|
||||
if (version != CURRENT_VERSION) throw new IllegalArgumentException("Unsupported instability version " + version);
|
||||
@@ -25,7 +48,7 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
||||
List.of(new Contribution("legacy_neutral", -1, 0, "Age predates frozen instability scoring")));
|
||||
}
|
||||
|
||||
static AgeInstability compute(AgeGenerationPlan plan, AgeTraits traits) {
|
||||
static AgeInstability compute(AgeGenerationPlan plan, AgeTraits traits, RegistryAccess registries) {
|
||||
Objects.requireNonNull(traits, "traits");
|
||||
List<Contribution> contributions = new ArrayList<>();
|
||||
if (plan == null) {
|
||||
@@ -40,21 +63,69 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
||||
contributions.add(new Contribution(diagnostic.code().name().toLowerCase(java.util.Locale.ROOT),
|
||||
diagnostic.index(), diagnostic.instability(), diagnostic.message()));
|
||||
}
|
||||
addAuditedSymbolCosts(plan, registries, contributions);
|
||||
}
|
||||
// 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"));
|
||||
// A missing lighting controller is seed-completed. Legacy runs the generated
|
||||
// symbol's own modifier, so a generated Bright Lighting choice still costs 500.
|
||||
if (traits.lighting() == AgeTraits.Lighting.BRIGHT && (plan == null || plan.symbols().stream()
|
||||
.noneMatch(symbol -> symbol.semantics().role()
|
||||
== net.dnorberg.rivencraft.writing.SymbolSemantics.Role.LIGHTING))) {
|
||||
contributions.add(new Contribution("bright_lighting", -1, BRIGHT_LIGHTING_COST,
|
||||
"Seeded bright lighting"));
|
||||
}
|
||||
return new AgeInstability(CURRENT_VERSION,
|
||||
contributions.stream().mapToInt(Contribution::points).sum(), contributions);
|
||||
}
|
||||
|
||||
private static void addAuditedSymbolCosts(AgeGenerationPlan plan, RegistryAccess registries,
|
||||
List<Contribution> contributions) {
|
||||
Map<ResourceLocation, Integer> structureCounts = new HashMap<>();
|
||||
for (AgeGenerationPlan.ResolvedSymbol symbol : plan.symbols()) {
|
||||
if (symbol.semantics().role()
|
||||
== net.dnorberg.rivencraft.writing.SymbolSemantics.Role.LIGHTING
|
||||
&& symbol.semantics().value()
|
||||
instanceof net.dnorberg.rivencraft.writing.SymbolSemantics.ChoiceValue choice
|
||||
&& choice.id().equals("bright")) {
|
||||
contributions.add(new Contribution("bright_lighting", symbol.index(), BRIGHT_LIGHTING_COST,
|
||||
"Authored bright lighting"));
|
||||
continue;
|
||||
}
|
||||
if (symbol.semantics().role()
|
||||
!= net.dnorberg.rivencraft.writing.SymbolSemantics.Role.FEATURE
|
||||
|| !(symbol.semantics().value()
|
||||
instanceof net.dnorberg.rivencraft.writing.SymbolSemantics.ResourceValue resource)) continue;
|
||||
String name = COUNTED_STRUCTURES.get(resource.id());
|
||||
if (name == null) continue;
|
||||
int count = structureCounts.merge(resource.id(), 1, Integer::sum);
|
||||
if (!AgeStructureSelection.biomeCompatible(resource.id(), plan.biomeSelection(), registries)) {
|
||||
contributions.add(new Contribution("forced_biome_" + resource.id().getPath(), symbol.index(),
|
||||
FORCED_STRUCTURE_BIOME_COST,
|
||||
name + " occurrence " + count + " is forced into an unsupported biome"));
|
||||
}
|
||||
int cost = featureCost(resource.id(), count);
|
||||
if (cost > 0) {
|
||||
contributions.add(new Contribution("excess_" + resource.id().getPath(), symbol.index(),
|
||||
cost,
|
||||
name + " occurrence " + count + " exceeds Legacy's first three free occurrences"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static int featureCost(ResourceLocation feature, int occurrence) {
|
||||
if (occurrence < 1) throw new IllegalArgumentException("Feature occurrence must be positive");
|
||||
return COUNTED_STRUCTURES.containsKey(feature) && occurrence > 3
|
||||
? EXCESS_LEGACY_STRUCTURE_COST : 0;
|
||||
}
|
||||
|
||||
/** Current totals exposed to diagnostics. Player-driven mutable bonuses are deliberately disabled. */
|
||||
public Totals totals() {
|
||||
return new Totals(score, 0, 0, score);
|
||||
}
|
||||
|
||||
private static ResourceLocation id(String path) {
|
||||
return ResourceLocation.fromNamespaceAndPath("rivencraft", path);
|
||||
}
|
||||
|
||||
public CompoundTag save() {
|
||||
CompoundTag tag = new CompoundTag();
|
||||
tag.putInt("Version", version);
|
||||
@@ -92,4 +163,13 @@ public record AgeInstability(int version, int score, List<Contribution> breakdow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record Totals(int baseScore, int stabilityBonus, int instabilityPenalty, int effectiveScore) {
|
||||
public Totals {
|
||||
if (baseScore < 0 || stabilityBonus < 0 || instabilityPenalty < 0
|
||||
|| effectiveScore != Math.max(0, baseScore + instabilityPenalty - stabilityBonus)) {
|
||||
throw new IllegalArgumentException("Invalid instability totals");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,13 +125,91 @@ public final class AgePersistenceGameTests {
|
||||
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()));
|
||||
AgeTraits.LEGACY_DEFAULTS.celestialAngle(), AgeTraits.LEGACY_DEFAULTS.celestialPhase()),
|
||||
helper.getLevel().registryAccess());
|
||||
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 auditedSymbolCostsAreCountedAndAgeScoped(GameTestHelper helper) {
|
||||
List<String> legacyStructures = List.of("mineshafts", "nether_fortresses", "strongholds", "villages");
|
||||
for (String structure : legacyStructures) {
|
||||
AgeDefinition three = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
||||
new AgeReference(UUID.randomUUID(), "Three " + structure), structureWriting(structure, 3));
|
||||
AgeDefinition four = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
||||
new AgeReference(UUID.randomUUID(), "Four " + structure), structureWriting(structure, 4));
|
||||
helper.assertValueEqual(three.instability().score(), 0,
|
||||
"The first three " + structure + " pages retain Legacy's zero cost");
|
||||
helper.assertValueEqual(four.instability().score(), 100,
|
||||
"The fourth " + structure + " page must add Legacy's 100-point cost");
|
||||
helper.assertTrue(four.instability().breakdown().stream().anyMatch(part ->
|
||||
part.reason().equals("excess_" + structure) && part.pageIndex() == 8),
|
||||
"The excess structure cost must identify its authored page");
|
||||
}
|
||||
for (String structure : List.of("bastions", "trial_chambers", "ancient_cities", "shipwrecks",
|
||||
"ocean_ruins", "pillager_outposts", "ruined_portals", "nether_fossils", "buried_treasure",
|
||||
"trail_ruins")) {
|
||||
ResourceLocation id = ResourceLocation.fromNamespaceAndPath("rivencraft", structure);
|
||||
helper.assertTrue(AgeInstability.featureCost(id, 1) == 0
|
||||
&& AgeInstability.featureCost(id, 3) == 0
|
||||
&& AgeInstability.featureCost(id, 4) == 100
|
||||
&& AgeInstability.featureCost(id, 5) == 100,
|
||||
structure + " must follow the selected Legacy Mineshaft repeat rule");
|
||||
}
|
||||
helper.assertValueEqual(AgeInstability.featureCost(
|
||||
ResourceLocation.parse("rivencraft:trial_chambers"), 4), 100,
|
||||
"Modern repeated structures must use the same deterministic scoring path");
|
||||
helper.assertValueEqual(AgeInstability.featureCost(ResourceLocation.parse("rivencraft:dungeons"), 4), 0,
|
||||
"A Legacy-neutral feature must remain neutral when repeated");
|
||||
|
||||
OrderedWriting duplicateBright = new OrderedWriting(List.of(
|
||||
WritingEntry.linkPanel(),
|
||||
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:terrain/standard"))),
|
||||
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:lighting/bright"))),
|
||||
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:lighting/bright")))
|
||||
));
|
||||
AgeDefinition bright = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
||||
new AgeReference(UUID.randomUUID(), "Double Bright"), duplicateBright);
|
||||
helper.assertValueEqual(bright.instability().score(), 1500,
|
||||
"Each Bright Lighting page costs 500 and the extra controller costs another 500");
|
||||
helper.assertValueEqual(bright.instability().breakdown().stream()
|
||||
.filter(part -> part.reason().equals("bright_lighting")).count(), 2L,
|
||||
"Every authored Bright Lighting occurrence must retain its own cost");
|
||||
|
||||
AgeInstability.Totals totals = bright.instability().totals();
|
||||
helper.assertValueEqual(totals.baseScore(), 1500, "Diagnostics must expose the immutable base score");
|
||||
helper.assertTrue(totals.stabilityBonus() == 0 && totals.instabilityPenalty() == 0
|
||||
&& totals.effectiveScore() == totals.baseScore(),
|
||||
"Dormant player bonus classes must not create mutable, cross-Age credit");
|
||||
AgeDefinition restored = AgeRegistrySavedData.load(
|
||||
AgeRegistrySavedData.get(helper.getLevel()).save(new CompoundTag(), helper.getLevel().registryAccess()),
|
||||
helper.getLevel().registryAccess()).find(bright.id()).orElseThrow();
|
||||
helper.assertValueEqual(restored.instability().totals(), totals,
|
||||
"The base/adjustment diagnostic must remain identical after reload");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
private static OrderedWriting structureWriting(String structure, int count) {
|
||||
boolean nether = structure.equals("nether_fortresses");
|
||||
List<WritingEntry> entries = new java.util.ArrayList<>(List.of(
|
||||
WritingEntry.linkPanel(),
|
||||
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse(
|
||||
nether ? "rivencraft:terrain/cave" : "rivencraft:terrain/standard"))),
|
||||
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse(
|
||||
nether ? "rivencraft:biome/nether_wastes" : "rivencraft:biome/plains"))),
|
||||
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:biome/single"))),
|
||||
WritingEntry.symbol(new SymbolReference(ResourceLocation.parse("rivencraft:lighting/normal")))
|
||||
));
|
||||
for (int index = 0; index < count; index++) {
|
||||
entries.add(WritingEntry.symbol(new SymbolReference(
|
||||
ResourceLocation.parse("rivencraft:feature/" + structure))));
|
||||
}
|
||||
return new OrderedWriting(entries);
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void missingRegistryEntryDoesNotReinterpretAnOldAge(GameTestHelper helper) {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
@@ -135,7 +135,7 @@ public final class AgeRegistrySavedData extends SavedData {
|
||||
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);
|
||||
AgeInstability instability = AgeInstability.compute(plan, effectiveTraits, registries);
|
||||
AgeInstabilityPlan instabilityPlan = AgeInstabilityPlan.select(instability, seed);
|
||||
AgeDefinition created = new AgeDefinition(
|
||||
reference.id(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import net.dnorberg.rivencraft.Rivencraft;
|
||||
import net.dnorberg.rivencraft.mixin.StructurePlacementAccessor;
|
||||
import net.dnorberg.rivencraft.writing.OrderedWriting;
|
||||
import net.dnorberg.rivencraft.writing.SymbolReference;
|
||||
import net.dnorberg.rivencraft.writing.WritingEntry;
|
||||
@@ -14,6 +15,8 @@ import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.level.levelgen.structure.BuiltinStructureSets;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureSet;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.ConcentricRingsStructurePlacement;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
@@ -90,6 +93,84 @@ public final class AgeStructureGameTests {
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void repeatedStructurePagesIncreasePlacementFrequency(GameTestHelper helper) {
|
||||
var lookup = helper.getLevel().registryAccess().lookupOrThrow(Registries.STRUCTURE_SET);
|
||||
|
||||
RandomSpreadStructurePlacement oneVillage = (RandomSpreadStructurePlacement) lookup
|
||||
.getOrThrow(BuiltinStructureSets.VILLAGES).value().placement();
|
||||
RandomSpreadStructurePlacement twoVillages = (RandomSpreadStructurePlacement) AgeStructureSelection
|
||||
.filteredLookup(lookup, repeated("villages", 2)).getOrThrow(BuiltinStructureSets.VILLAGES)
|
||||
.value().placement();
|
||||
helper.assertTrue(oneVillage.spacing() == 34 && twoVillages.spacing() == 24,
|
||||
"A second Village page must approximately double density by reducing grid spacing");
|
||||
|
||||
RandomSpreadStructurePlacement oneMineshaft = (RandomSpreadStructurePlacement) lookup
|
||||
.getOrThrow(BuiltinStructureSets.MINESHAFTS).value().placement();
|
||||
RandomSpreadStructurePlacement twoMineshafts = (RandomSpreadStructurePlacement) AgeStructureSelection
|
||||
.filteredLookup(lookup, repeated("mineshafts", 2)).getOrThrow(BuiltinStructureSets.MINESHAFTS)
|
||||
.value().placement();
|
||||
float oneFrequency = ((StructurePlacementAccessor) oneMineshaft).rivencraft$frequency();
|
||||
float twoFrequency = ((StructurePlacementAccessor) twoMineshafts).rivencraft$frequency();
|
||||
helper.assertTrue(oneMineshaft.spacing() == 1 && twoMineshafts.spacing() == 1
|
||||
&& Math.abs(twoFrequency - oneFrequency * 2.0F) < 0.000001F,
|
||||
"A second Mineshaft page must double its per-chunk probability");
|
||||
|
||||
ConcentricRingsStructurePlacement oneStronghold = (ConcentricRingsStructurePlacement) lookup
|
||||
.getOrThrow(BuiltinStructureSets.STRONGHOLDS).value().placement();
|
||||
ConcentricRingsStructurePlacement twoStrongholds = (ConcentricRingsStructurePlacement) AgeStructureSelection
|
||||
.filteredLookup(lookup, repeated("strongholds", 2)).getOrThrow(BuiltinStructureSets.STRONGHOLDS)
|
||||
.value().placement();
|
||||
helper.assertTrue(twoStrongholds.distance() == oneStronghold.distance()
|
||||
&& twoStrongholds.spread() == oneStronghold.spread() * 2
|
||||
&& twoStrongholds.count() == oneStronghold.count() * 2,
|
||||
"A second Stronghold page must double positions per ring and total positions");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void repeatedStructuresConstrainSeedCompletedBiomes(GameTestHelper helper) {
|
||||
OrderedWriting fourVillages = new OrderedWriting(new java.util.ArrayList<>(java.util.stream.Stream.concat(
|
||||
java.util.stream.Stream.of(WritingEntry.linkPanel()),
|
||||
java.util.stream.IntStream.range(0, 4).mapToObj(ignored -> page("feature/villages")))
|
||||
.toList()));
|
||||
try {
|
||||
AgeRegistrySavedData.validateNewWriting(helper.getLevel(), "four", fourVillages);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
helper.fail("Four Village pages without explicit biome pages must use compatible seeded completion: "
|
||||
+ exception.getMessage());
|
||||
}
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void authoredVillagesOverrideBiomeWithInstability(GameTestHelper helper) {
|
||||
OrderedWriting writing = new OrderedWriting(List.of(WritingEntry.linkPanel(),
|
||||
page("terrain/standard"), page("biome/the_end"), page("biome/single"),
|
||||
page("feature/villages"), page("feature/villages")));
|
||||
AgeDefinition age;
|
||||
try {
|
||||
age = AgeRegistrySavedData.resolveOrCreate(helper.getLevel(),
|
||||
new AgeReference(java.util.UUID.randomUUID(), "Forced End Villages"), writing);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
helper.fail("Authored Villages must be allowed outside vanilla Village biomes: "
|
||||
+ exception.getMessage());
|
||||
return;
|
||||
}
|
||||
helper.assertValueEqual(age.instability().score(), 200,
|
||||
"Each of two Villages forced into The End biome must add 100 instability");
|
||||
helper.assertValueEqual(age.instability().breakdown().stream()
|
||||
.filter(part -> part.reason().equals("forced_biome_villages")).count(), 2L,
|
||||
"Every forced Village occurrence must retain its own instability contribution");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
private static List<AgeFeatureSelection.Choice> repeated(String feature, int count) {
|
||||
return java.util.stream.IntStream.range(0, count)
|
||||
.mapToObj(ignored -> new AgeFeatureSelection.Choice(id(feature), Optional.<ResourceLocation>empty()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static ResourceLocation id(String path) {
|
||||
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package net.dnorberg.rivencraft.book;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Random;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
import net.dnorberg.rivencraft.mixin.StructurePlacementAccessor;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.core.HolderOwner;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
@@ -16,7 +19,11 @@ import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.tags.TagKey;
|
||||
import net.minecraft.world.level.levelgen.structure.BuiltinStructureSets;
|
||||
import net.minecraft.world.level.levelgen.structure.Structure;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureSet;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.ConcentricRingsStructurePlacement;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement;
|
||||
|
||||
/** Frozen Age structure-page choices and their vanilla structure-set gate. */
|
||||
public final class AgeStructureSelection {
|
||||
@@ -98,14 +105,9 @@ public final class AgeStructureSelection {
|
||||
var set = registries.lookupOrThrow(Registries.STRUCTURE_SET)
|
||||
.get(ResourceKey.create(Registries.STRUCTURE_SET, setId));
|
||||
if (set.isEmpty()) return "Feature " + feature + " requires missing structure set " + setId;
|
||||
if (biomes == null) return null;
|
||||
Set<ResourceLocation> selected = Set.copyOf(biomes.biomes());
|
||||
boolean compatible = set.orElseThrow().value().structures().stream()
|
||||
.flatMap(entry -> entry.structure().value().biomes().stream())
|
||||
.map(holder -> holder.unwrapKey().map(ResourceKey::location).orElse(null))
|
||||
.anyMatch(selected::contains);
|
||||
return compatible ? null : "Feature " + feature
|
||||
+ " needs at least one biome allowed by structure set " + setId;
|
||||
// Authored structures intentionally override vanilla biome eligibility. An
|
||||
// incompatible selection is legal writing and receives instability instead.
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean placedExists(RegistryAccess registries, ResourceLocation id) {
|
||||
@@ -113,10 +115,40 @@ public final class AgeStructureSelection {
|
||||
.get(ResourceKey.create(Registries.PLACED_FEATURE, id)).isPresent();
|
||||
}
|
||||
|
||||
static Set<ResourceLocation> compatibleBiomes(ResourceLocation feature, RegistryAccess registries) {
|
||||
ResourceLocation structureSet = setId(feature);
|
||||
if (structureSet == null) return Set.of();
|
||||
return registries.lookupOrThrow(Registries.STRUCTURE_SET)
|
||||
.get(ResourceKey.create(Registries.STRUCTURE_SET, structureSet)).stream()
|
||||
.flatMap(holder -> holder.value().structures().stream())
|
||||
.flatMap(entry -> entry.structure().value().biomes().stream())
|
||||
.flatMap(holder -> holder.unwrapKey().stream())
|
||||
.map(ResourceKey::location)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
public static boolean biomeCompatible(ResourceLocation feature, AgeBiomeSelection biomes,
|
||||
RegistryAccess registries) {
|
||||
Set<ResourceLocation> compatible = compatibleBiomes(feature, registries);
|
||||
return compatible.isEmpty() || biomes.biomes().stream().anyMatch(compatible::contains);
|
||||
}
|
||||
|
||||
/** True when this exact registered structure belongs to an authored structure page. */
|
||||
public static boolean authoredStructure(Structure structure, RegistryAccess registries,
|
||||
List<AgeFeatureSelection.Choice> choices) {
|
||||
Set<ResourceLocation> enabled = enabledSets(choices).keySet();
|
||||
var sets = registries.lookupOrThrow(Registries.STRUCTURE_SET);
|
||||
return enabled.stream().map(id -> ResourceKey.create(Registries.STRUCTURE_SET, id))
|
||||
.flatMap(key -> sets.get(key).stream())
|
||||
.flatMap(holder -> holder.value().structures().stream())
|
||||
.anyMatch(entry -> entry.structure().value() == structure);
|
||||
}
|
||||
|
||||
public static List<AgeFeatureSelection.Choice> randomChoices(long ageSeed, AgeRuntimeProfile profile,
|
||||
AgeBiomeSelection biomes, RegistryAccess registries) {
|
||||
List<LegacyRankChoice.Option<ResourceLocation>> eligible = new ArrayList<>(FEATURES.stream()
|
||||
.filter(id -> error(id, profile, biomes, registries) == null)
|
||||
.filter(id -> dungeons(id) || biomeCompatible(id, biomes, registries))
|
||||
.map(id -> new LegacyRankChoice.Option<>(id, randomRank(id))).toList());
|
||||
Random random = new Random(ageSeed ^ 0x5354525543545552L);
|
||||
int count = random.nextInt(3);
|
||||
@@ -136,25 +168,31 @@ public final class AgeStructureSelection {
|
||||
return 3; // Structures added after Legacy are uncommon, but still discoverable.
|
||||
}
|
||||
|
||||
private static Set<ResourceLocation> enabledSets(List<AgeFeatureSelection.Choice> choices) {
|
||||
return choices.stream().filter(choice -> feature(choice.id()) && !dungeons(choice.id()))
|
||||
.map(choice -> setId(choice.id())).collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
private static Map<ResourceLocation, Integer> enabledSets(List<AgeFeatureSelection.Choice> choices) {
|
||||
Map<ResourceLocation, Integer> counts = new HashMap<>();
|
||||
choices.stream().filter(choice -> feature(choice.id()) && !dungeons(choice.id()))
|
||||
.forEach(choice -> counts.merge(setId(choice.id()), 1, Integer::sum));
|
||||
return Map.copyOf(counts);
|
||||
}
|
||||
|
||||
public static Stream<Holder<StructureSet>> allowedSets(Stream<Holder<StructureSet>> sets,
|
||||
List<AgeFeatureSelection.Choice> choices) {
|
||||
Set<ResourceLocation> enabled = enabledSets(choices);
|
||||
Map<ResourceLocation, Integer> enabled = enabledSets(choices);
|
||||
return sets.filter(holder -> holder.unwrapKey()
|
||||
.map(key -> allowed(key.location(), enabled)).orElse(false));
|
||||
.map(key -> allowed(key.location(), enabled.keySet())).orElse(false))
|
||||
.map(holder -> scaledHolder(holder, enabled));
|
||||
}
|
||||
|
||||
public static HolderLookup<StructureSet> filteredLookup(HolderLookup<StructureSet> lookup,
|
||||
List<AgeFeatureSelection.Choice> choices) {
|
||||
Set<ResourceLocation> enabled = enabledSets(choices);
|
||||
Map<ResourceLocation, Integer> enabled = enabledSets(choices);
|
||||
Map<ResourceKey<StructureSet>, Holder.Reference<StructureSet>> scaled = new HashMap<>();
|
||||
return new HolderLookup<>() {
|
||||
@Override
|
||||
public Optional<Holder.Reference<StructureSet>> get(ResourceKey<StructureSet> key) {
|
||||
return allowed(key.location(), enabled) ? lookup.get(key) : Optional.empty();
|
||||
if (!allowed(key.location(), enabled.keySet())) return Optional.empty();
|
||||
return lookup.get(key).map(holder -> scaled.computeIfAbsent(key,
|
||||
ignored -> scaledReference(holder, enabled.getOrDefault(key.location(), 1))));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -164,7 +202,11 @@ public final class AgeStructureSelection {
|
||||
|
||||
@Override
|
||||
public Stream<Holder.Reference<StructureSet>> listElements() {
|
||||
return lookup.listElements().filter(holder -> allowed(holder.key().location(), enabled));
|
||||
return lookup.listElements()
|
||||
.filter(holder -> allowed(holder.key().location(), enabled.keySet()))
|
||||
.map(holder -> scaled.computeIfAbsent(holder.key(),
|
||||
ignored -> scaledReference(holder,
|
||||
enabled.getOrDefault(holder.key().location(), 1))));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -177,4 +219,57 @@ public final class AgeStructureSelection {
|
||||
private static boolean allowed(ResourceLocation set, Set<ResourceLocation> enabled) {
|
||||
return !CONTROLLED_SETS.contains(set) || enabled.contains(set);
|
||||
}
|
||||
|
||||
private static Holder<StructureSet> scaledHolder(Holder<StructureSet> holder,
|
||||
Map<ResourceLocation, Integer> enabled) {
|
||||
int occurrences = holder.unwrapKey().map(key -> enabled.getOrDefault(key.location(), 1)).orElse(1);
|
||||
return occurrences == 1 ? holder : Holder.direct(scaled(holder.value(), occurrences));
|
||||
}
|
||||
|
||||
private static Holder.Reference<StructureSet> scaledReference(Holder.Reference<StructureSet> holder,
|
||||
int occurrences) {
|
||||
if (occurrences == 1) return holder;
|
||||
return new ScaledReference(holder.key(), scaled(holder.value(), occurrences));
|
||||
}
|
||||
|
||||
static StructureSet scaled(StructureSet set, int occurrences) {
|
||||
if (occurrences < 1) throw new IllegalArgumentException("Structure occurrence count must be positive");
|
||||
if (occurrences == 1) return set;
|
||||
StructurePlacement placement = set.placement();
|
||||
StructurePlacementAccessor base = (StructurePlacementAccessor) placement;
|
||||
if (placement instanceof RandomSpreadStructurePlacement random) {
|
||||
float originalFrequency = base.rivencraft$frequency();
|
||||
float frequency = Math.min(1.0F, originalFrequency * occurrences);
|
||||
double frequencyFactor = originalFrequency > 0.0F ? frequency / originalFrequency : 1.0;
|
||||
double spacingFactor = occurrences / frequencyFactor;
|
||||
int spacing = Math.max(1, (int) Math.round(random.spacing() / Math.sqrt(spacingFactor)));
|
||||
int separation = spacing == 1 ? 0 : Math.min(spacing - 1,
|
||||
(int) Math.round((double) random.separation() * spacing / random.spacing()));
|
||||
return new StructureSet(set.structures(), new RandomSpreadStructurePlacement(
|
||||
base.rivencraft$locateOffset(), base.rivencraft$frequencyReductionMethod(), frequency,
|
||||
base.rivencraft$salt(), base.rivencraft$exclusionZone(), spacing, separation,
|
||||
random.spreadType()));
|
||||
}
|
||||
if (placement instanceof ConcentricRingsStructurePlacement rings) {
|
||||
int count = saturatedMultiply(rings.count(), occurrences, 4095);
|
||||
int spread = Math.min(count, saturatedMultiply(rings.spread(), occurrences, 1023));
|
||||
return new StructureSet(set.structures(), new ConcentricRingsStructurePlacement(
|
||||
base.rivencraft$locateOffset(), base.rivencraft$frequencyReductionMethod(),
|
||||
base.rivencraft$frequency(), base.rivencraft$salt(), base.rivencraft$exclusionZone(),
|
||||
rings.distance(), spread, count, rings.preferredBiomes()));
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
private static int saturatedMultiply(int value, int multiplier, int maximum) {
|
||||
return (int) Math.min(maximum, (long) value * multiplier);
|
||||
}
|
||||
|
||||
private static final class ScaledReference extends Holder.Reference<StructureSet> {
|
||||
private static final HolderOwner<StructureSet> OWNER = new HolderOwner<>() {};
|
||||
|
||||
private ScaledReference(ResourceKey<StructureSet> key, StructureSet value) {
|
||||
super(Type.STAND_ALONE, OWNER, key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,17 @@ public final class RivencraftCommands {
|
||||
.then(Commands.literal("age")
|
||||
.then(Commands.literal("list")
|
||||
.executes(context -> listAges(context.getSource())))
|
||||
.then(Commands.literal("instability")
|
||||
.executes(context -> inspectInstability(context.getSource(),
|
||||
context.getSource().getLevel().dimension().location().toString()))
|
||||
.then(Commands.argument("dimension", StringArgumentType.string())
|
||||
.suggests((context, builder) -> {
|
||||
AgeRegistrySavedData.get(context.getSource().getLevel()).definitions()
|
||||
.forEach(age -> builder.suggest(age.dimensionId().toString()));
|
||||
return builder.buildFuture();
|
||||
})
|
||||
.executes(context -> inspectInstability(context.getSource(),
|
||||
StringArgumentType.getString(context, "dimension")))))
|
||||
.then(Commands.literal("unload")
|
||||
.then(Commands.argument("dimension", StringArgumentType.string())
|
||||
.suggests((context, builder) -> {
|
||||
@@ -119,6 +130,28 @@ public final class RivencraftCommands {
|
||||
};
|
||||
}
|
||||
|
||||
private static int inspectInstability(CommandSourceStack source, String requestedDimension) {
|
||||
ResourceLocation dimension = ResourceLocation.tryParse(requestedDimension);
|
||||
if (dimension == null) {
|
||||
source.sendFailure(Component.literal("Invalid dimension id: " + requestedDimension));
|
||||
return 0;
|
||||
}
|
||||
AgeDefinition age = AgeRegistrySavedData.get(source.getLevel()).findByDimension(dimension).orElse(null);
|
||||
if (age == null) {
|
||||
source.sendFailure(Component.literal("Unknown Rivencraft Age: " + dimension));
|
||||
return 0;
|
||||
}
|
||||
var totals = age.instability().totals();
|
||||
source.sendSuccess(() -> Component.literal(age.name() + " — " + age.dimensionId()), false);
|
||||
source.sendSuccess(() -> Component.literal("Instability: base " + totals.baseScore()
|
||||
+ ", stability bonus " + totals.stabilityBonus()
|
||||
+ ", penalty " + totals.instabilityPenalty()
|
||||
+ ", effective " + totals.effectiveScore()), false);
|
||||
source.sendSuccess(() -> Component.literal("Mutable player bonuses: disabled (no active Legacy registration)"),
|
||||
false);
|
||||
return totals.effectiveScore();
|
||||
}
|
||||
|
||||
private static int pulseDecay(net.minecraft.commands.CommandSourceStack source, String colorName, int attempts) {
|
||||
DecayBlock.Color color;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package net.dnorberg.rivencraft.mixin;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import net.dnorberg.rivencraft.book.AgeFeatureSelection;
|
||||
import net.dnorberg.rivencraft.book.AgeStructureSelection;
|
||||
import net.dnorberg.rivencraft.world.AgeFeatureChunkGenerator;
|
||||
import net.dnorberg.rivencraft.world.AgeFlatChunkGenerator;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.structure.Structure;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.Shadow;
|
||||
import org.spongepowered.asm.mixin.injection.At;
|
||||
import org.spongepowered.asm.mixin.injection.Inject;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
/** Lets explicitly authored structures generate outside their vanilla biome sets. */
|
||||
@Mixin(Structure.class)
|
||||
public abstract class AgeStructureBiomeMixin {
|
||||
@Shadow
|
||||
protected abstract Optional<Structure.GenerationStub> findGenerationPoint(
|
||||
Structure.GenerationContext context);
|
||||
|
||||
@Inject(method = "findValidGenerationPoint", at = @At("HEAD"), cancellable = true)
|
||||
private void rivencraft$allowAuthoredStructureBiome(Structure.GenerationContext context,
|
||||
CallbackInfoReturnable<Optional<Structure.GenerationStub>> cir) {
|
||||
ChunkGenerator generator = context.chunkGenerator();
|
||||
List<AgeFeatureSelection.Choice> features;
|
||||
if (generator instanceof AgeFeatureChunkGenerator age) features = age.ageFeatures();
|
||||
else if (generator instanceof AgeFlatChunkGenerator age) features = age.ageFeatures();
|
||||
else return;
|
||||
if (AgeStructureSelection.authoredStructure((Structure) (Object) this,
|
||||
context.registryAccess(), features)) {
|
||||
cir.setReturnValue(findGenerationPoint(context));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package net.dnorberg.rivencraft.mixin;
|
||||
|
||||
import java.util.Optional;
|
||||
import net.minecraft.core.Vec3i;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
||||
|
||||
/** Exposes immutable placement settings so per-Age structure density can preserve vanilla behavior. */
|
||||
@Mixin(StructurePlacement.class)
|
||||
public interface StructurePlacementAccessor {
|
||||
@Accessor("locateOffset")
|
||||
Vec3i rivencraft$locateOffset();
|
||||
|
||||
@Accessor("frequencyReductionMethod")
|
||||
StructurePlacement.FrequencyReductionMethod rivencraft$frequencyReductionMethod();
|
||||
|
||||
@Accessor("frequency")
|
||||
float rivencraft$frequency();
|
||||
|
||||
@Accessor("salt")
|
||||
int rivencraft$salt();
|
||||
|
||||
@Accessor("exclusionZone")
|
||||
Optional<StructurePlacement.ExclusionZone> rivencraft$exclusionZone();
|
||||
}
|
||||
@@ -4,7 +4,9 @@
|
||||
"package": "net.dnorberg.rivencraft.mixin",
|
||||
"compatibilityLevel": "JAVA_21",
|
||||
"mixins": [
|
||||
"AgeDungeonPlacementMixin"
|
||||
"AgeDungeonPlacementMixin",
|
||||
"AgeStructureBiomeMixin",
|
||||
"StructurePlacementAccessor"
|
||||
],
|
||||
"client": [
|
||||
"client.ClientLevelSoundMixin",
|
||||
|
||||
Reference in new issue
Block a user