Port structures, weather, vegetation and safe surface arrivals to 26.3 beta
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-11-02 09:30:22 -05:00
1 parent c223f3fdca
commit 48fbae9be9
38 files changed
+1474 -93

No files matched your search

@@ -59,9 +59,17 @@ public final class Rivencraft {
private static final DeferredRegister.Items ITEMS = DeferredRegister.createItems(MOD_ID);
private static final DeferredRegister<SoundEvent> SOUNDS =
DeferredRegister.create(Registries.SOUND_EVENT, MOD_ID);
public static final Supplier<SoundEvent> LINK_FISSURE = SOUNDS.register(
"linking.link-fissure", () -> SoundEvent.createVariableRangeEvent(
Identifier.fromNamespaceAndPath(MOD_ID, "linking.link-fissure")));
public static final Supplier<SoundEvent> LINK = sound("linking.link");
public static final Supplier<SoundEvent> LINK_DISARM = sound("linking.link-disarm");
public static final Supplier<SoundEvent> LINK_INTRA = sound("linking.link-intra");
public static final Supplier<SoundEvent> LINK_FISSURE = sound("linking.link-fissure");
public static final Supplier<SoundEvent> LINK_PORTAL = sound("linking.link-portal");
public static final Supplier<SoundEvent> METEOR_ROAR = sound("entity.meteor.roar");
private static Supplier<SoundEvent> sound(String name) {
return SOUNDS.register(name, () -> SoundEvent.createVariableRangeEvent(
Identifier.fromNamespaceAndPath(MOD_ID, name)));
}
private static final DeferredRegister<BlockEntityType<?>> BLOCK_ENTITY_TYPES =
DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, MOD_ID);
private static final DeferredRegister<MenuType<?>> MENUS =
@@ -170,6 +178,7 @@ public final class Rivencraft {
PortBiomeSources.register(modEventBus);
PortChunkGenerators.register(modEventBus);
NeoForge.EVENT_BUS.addListener(RuntimeAgeBackend::onLevelLoad);
NeoForge.EVENT_BUS.addListener(net.dnorberg.rivencraft.age.PortAgeWeather::onLevelTickPre);
NeoForge.EVENT_BUS.addListener(PortAgeCommands::register);
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogin);
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogout);
@@ -68,6 +68,22 @@ public final class PortAgeCommands {
return 1;
})))
.then(Commands.literal("age")
.then(Commands.literal("surface_probe")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("x", IntegerArgumentType.integer())
.then(Commands.argument("z", IntegerArgumentType.integer())
.executes(PortAgeCommands::probeSurface)))))
.then(Commands.literal("create_weather")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("mode", StringArgumentType.word())
.suggests((context, builder) -> net.minecraft.commands.SharedSuggestionProvider.suggest(
java.util.Arrays.stream(PortWeatherMode.values())
.map(mode -> mode.name().toLowerCase(Locale.ROOT)), builder))
.executes(PortAgeCommands::createWeatherAge)))))
.then(Commands.literal("weather_probe")
.then(Commands.argument("name", StringArgumentType.word())
.executes(PortAgeCommands::probeWeather)))
.then(Commands.literal("create")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
@@ -302,6 +318,62 @@ public final class PortAgeCommands {
.executes(PortAgeCommands::sampleBiome)))))));
}
private static int probeSurface(CommandContext<CommandSourceStack> context) {
Identifier id = ageId(context);
ServerLevel level = context.getSource().getServer().getLevel(ResourceKey.create(Registries.DIMENSION, id));
if (level == null) {
context.getSource().sendFailure(Component.literal("Age not loaded: " + id));
return 0;
}
int x = IntegerArgumentType.getInteger(context, "x");
int z = IntegerArgumentType.getInteger(context, "z");
var obstruction = PortAgeTravel.highestObstruction(level, x, z);
var landing = PortAgeTravel.findSurfaceLanding(level, x, z);
context.getSource().sendSuccess(() -> Component.literal("Surface probe " + id
+ " highest=" + obstruction + " landing=" + landing), false);
return landing == null ? 0 : 1;
}
private static int createWeatherAge(CommandContext<CommandSourceStack> context) {
try {
PortWeatherMode mode = PortWeatherMode.valueOf(
StringArgumentType.getString(context, "mode").toUpperCase(Locale.ROOT));
Identifier id = ageId(context);
var server = context.getSource().getServer();
// Exercise the Binder's page compiler too, not just direct backend creation.
var pages = java.util.List.of(Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID,
"weather/" + mode.name().toLowerCase(Locale.ROOT)));
var biomePlan = net.dnorberg.rivencraft.writing.PortAuthoredWriting.biomePlan(
pages, server.registryAccess());
RuntimeAgeBackend.create(server, id,
LongArgumentType.getLong(context, "seed"), biomePlan, PortAgeTerrain.FLAT,
null, null, 0, null, null, mode);
return probeWeather(context);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
}
private static int probeWeather(CommandContext<CommandSourceStack> context) {
Identifier id = ageId(context);
var server = context.getSource().getServer();
ServerLevel loaded = server.getLevel(ResourceKey.create(Registries.DIMENSION, id));
if (!(loaded instanceof PortAgeLevel level)) {
context.getSource().sendFailure(Component.literal("Age not loaded: " + id));
return 0;
}
var weather = level.getWeatherData();
context.getSource().sendSuccess(() -> Component.literal("Weather probe " + id
+ " mode=" + level.weatherMode() + " raining=" + weather.isRaining()
+ " thundering=" + weather.isThundering() + " rainTicks=" + weather.getRainTime()
+ " thunderTicks=" + weather.getThunderTime()
+ " independent=" + (weather != server.getWeatherData())
+ " overworldRaining=" + server.getWeatherData().isRaining()
+ " overworldThundering=" + server.getWeatherData().isThundering()), false);
return 1;
}
private static int createAge(CommandContext<CommandSourceStack> context, PortAgeTerrain terrain) {
return createAge(context, terrain, null);
}
@@ -0,0 +1,48 @@
package net.dnorberg.rivencraft.age;
import java.util.List;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.dimension.LevelStem;
import net.minecraft.world.level.saveddata.WeatherData;
import net.minecraft.world.level.storage.DerivedLevelData;
/** Runtime Age with independent native weather, rather than 26.3's shared server cycle. */
final class PortAgeLevel extends ServerLevel {
private final WeatherData ageWeather;
private final PortWeatherSettings weatherSettings;
private final boolean cave;
PortAgeLevel(MinecraftServer server, ResourceKey<Level> key, LevelStem stem,
long biomeZoomSeed, PortAgeTerrain terrain) {
super(server, server.executor, server.storageSource,
new DerivedLevelData(server.getWorldData(), server.getWorldData().overworldData()),
key, stem, server.getWorldData().isDebugWorld(), biomeZoomSeed, List.of(), false);
ageWeather = getDataStorage().computeIfAbsent(WeatherData.TYPE);
weatherSettings = getDataStorage().computeIfAbsent(PortWeatherSettings.TYPE);
cave = terrain == PortAgeTerrain.CAVE;
PortAgeWeather.apply(this);
// The base constructor initializes visual levels from server weather.
// Replace them before publishing this Age or sending its join packets.
setRainLevel(ageWeather.isRaining() ? 1.0F : 0.0F);
setThunderLevel(ageWeather.isThundering() ? 1.0F : 0.0F);
}
@Override
public WeatherData getWeatherData() {
// The base constructor may query weather before our fields are initialized.
return ageWeather == null ? super.getWeatherData() : ageWeather;
}
PortWeatherMode weatherMode() { return cave ? PortWeatherMode.OFF : weatherSettings.mode(); }
void configureWeather(PortWeatherMode mode) {
weatherSettings.setMode(mode);
PortAgeWeather.apply(this);
setRainLevel(ageWeather.isRaining() ? 1.0F : 0.0F);
setThunderLevel(ageWeather.isThundering() ? 1.0F : 0.0F);
getDataStorage().saveAndJoin();
}
}
@@ -11,8 +11,6 @@ import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.levelgen.FlatLevelSource;
import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings;
/** Shared, server-authoritative landing for the 26.3 book and operator probe. */
public final class PortAgeTravel {
@@ -28,7 +26,9 @@ public final class PortAgeTravel {
boolean fissure = target.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator features
&& features.terrainFeatures() != null && features.terrainFeatures().starFissures();
BlockPos landing = fissure ? PortStarFissureFeature.savedSpawn(target) : null;
if (landing != null) landing = findFootedDestination(target, landing).orElse(null);
if (landing != null) landing = isCaveTerrain(target)
? findFootedDestination(target, landing).orElse(null)
: findSurfaceLanding(target, landing.getX(), landing.getZ());
if (landing == null) landing = findLanding(target);
if (landing == null) return "No safe test landing available; remaining in source dimension";
if (fissure) PortStarFissureFeature.generateOnce(target, landing, isCaveTerrain(target));
@@ -38,8 +38,6 @@ public final class PortAgeTravel {
}
private static BlockPos findLanding(ServerLevel target) {
net.minecraft.world.level.chunk.ChunkGenerator generator = target.getChunkSource().getGenerator();
if (generator instanceof PortFeatureChunkGenerator features) generator = features.base();
if (isCaveTerrain(target)) {
// The cave roof is not a valid arrival surface. Search down from
// Legacy's Y=64 start, then construct the normal safety platform
@@ -57,36 +55,40 @@ public final class PortAgeTravel {
}
return makePlatform(target, new BlockPos(0, 64, 0));
}
// A Flat Age has a known surface. Use it directly before a heightmap
// fallback can place a platform in the air above a low superflat world.
FlatLevelGeneratorSettings flatSettings = switch (generator) {
case FlatLevelSource flat -> flat.settings();
case PortFlatChunkGenerator flat -> flat.settings();
default -> null;
};
if (flatSettings != null && !flatSettings.getLayersInfo().isEmpty()) {
int surfaceY = target.getMinY() + flatSettings.getLayersInfo().stream()
.mapToInt(layer -> layer.getHeight()).sum();
BlockPos surface = new BlockPos(0, surfaceY, 0);
if (hasFooting(target, surface)) return surface;
}
BlockPos surface = findSurfaceLanding(target, 0, 0);
if (surface != null) return surface;
// Only build above the actual highest obstruction, never in a pocket
// beneath it. Void Ages retain the usual platform floor at Y=63.
BlockPos obstruction = highestObstruction(target, 0, 0);
int y = obstruction == null ? 64 : Math.max(64, obstruction.getY() + 3);
return makePlatform(target, new BlockPos(0, y, 0));
}
static BlockPos findSurfaceLanding(ServerLevel target, int originX, int originZ) {
for (int radius = 0; radius <= 16; radius++) {
for (int x = -radius; x <= radius; x++) {
for (int z = -radius; z <= radius; z++) {
if (Math.max(Math.abs(x), Math.abs(z)) != radius) continue;
int y = target.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z);
BlockPos foot = new BlockPos(x, y, z);
if (hasFooting(target, foot)) {
return foot;
}
BlockPos floor = highestObstruction(target, originX + x, originZ + z);
if (floor != null && hasFooting(target, floor.above())) return floor.above();
}
}
}
// The operator/book probe needs a deterministic landing even when
// origin is ocean or foliage. Final book landing rules are ported later.
int y = Math.max(64, target.getHeight(Heightmap.Types.MOTION_BLOCKING, 0, 0) + 2);
if (y + 2 >= target.getMaxY()) return null;
return makePlatform(target, new BlockPos(0, y, 0));
return null;
}
/** First obstructing surface seen from the sky; never continue through a roof or liquid. */
static BlockPos highestObstruction(ServerLevel level, int x, int z) {
var chunk = level.getChunk(x >> 4, z >> 4);
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(x, level.getMaxY() - 1, z);
for (int y = level.getMaxY() - 1; y >= level.getMinY(); y--) {
pos.setY(y);
var state = chunk.getBlockState(pos);
if (!state.isAir() && (!state.getFluidState().isEmpty()
|| state.is(net.minecraft.tags.BlockTags.LEAVES)
|| !state.getCollisionShape(level, pos).isEmpty())) return pos.immutable();
}
return null;
}
private static boolean isCaveTerrain(ServerLevel target) {
@@ -119,8 +121,13 @@ public final class PortAgeTravel {
}
private static boolean hasFooting(ServerLevel target, BlockPos foot) {
if (foot.getY() <= target.getMinY() || foot.getY() + 1 >= target.getMaxY()
|| !target.getWorldBorder().isWithinBounds(foot)) return false;
BlockPos floor = foot.below();
return target.getBlockState(floor).isFaceSturdy(target, floor, Direction.UP)
var floorState = target.getBlockState(floor);
return (floorState.isFaceSturdy(target, floor, Direction.UP)
|| floorState.is(net.minecraft.tags.BlockTags.LEAVES))
&& floorState.getFluidState().isEmpty()
&& target.getFluidState(foot).isEmpty()
&& target.getFluidState(foot.above()).isEmpty()
&& passable(target, foot) && passable(target, foot.above());
@@ -0,0 +1,52 @@
package net.dnorberg.rivencraft.age;
import java.util.Map;
import java.util.WeakHashMap;
import net.minecraft.world.level.saveddata.WeatherData;
import net.neoforged.neoforge.event.tick.LevelTickEvent;
/** 1.21.1 weather policies, applied before the independent native Age cycle advances. */
public final class PortAgeWeather {
private static final Map<PortAgeLevel, Integer> STATES = new WeakHashMap<>();
private PortAgeWeather() {}
public static void onLevelTickPre(LevelTickEvent.Pre event) {
if (event.getLevel() instanceof PortAgeLevel level) apply(level);
}
static void apply(PortAgeLevel level) {
PortWeatherMode mode = level.weatherMode();
if (mode == PortWeatherMode.NORMAL) {
STATES.remove(level);
return;
}
WeatherData weather = level.getWeatherData();
if (mode == PortWeatherMode.FAST || mode == PortWeatherMode.SLOW) {
int state = (weather.isRaining() ? 1 : 0) | (weather.isThundering() ? 2 : 0);
Integer previous = STATES.put(level, state);
if (previous != null && previous == state) return;
// Keep persisted timers on reload; rescale only when a cycle changes.
if (previous == null && weather.getRainTime() > 0) return;
int scale = mode == PortWeatherMode.FAST ? 1 : 4;
int duration = 6000 * scale + level.getRandom().nextInt(
(weather.isRaining() ? 6000 : 84000) * scale);
set(weather, duration, weather.isRaining(), weather.isThundering());
} else {
STATES.remove(level);
boolean rain = mode != PortWeatherMode.OFF;
boolean thunder = mode == PortWeatherMode.STORM;
if (weather.isRaining() != rain || weather.isThundering() != thunder
|| weather.getRainTime() < 200 || weather.getThunderTime() < 200
|| weather.getClearWeatherTime() != 0) set(weather, 6000, rain, thunder);
}
}
private static void set(WeatherData data, int duration, boolean rain, boolean thunder) {
data.setClearWeatherTime(0);
data.setRainTime(duration);
data.setThunderTime(duration);
data.setRaining(rain);
data.setThundering(thunder);
}
}
@@ -0,0 +1,78 @@
package net.dnorberg.rivencraft.age;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.QuartPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.tags.BiomeTags;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.CarvingMask;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.LegacyRandomSource;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.WorldGenerationContext;
import net.minecraft.world.level.levelgen.WorldgenRandom;
import net.minecraft.world.level.levelgen.carver.WorldCarver;
/** Authored Caves/Ravines, independent of the terrain page, with 1.21.1 source seeds. */
public final class PortCarverPages {
public static final Identifier CAVES = Identifier.fromNamespaceAndPath("rivencraft", "feature/caves");
public static final Identifier RAVINES = Identifier.fromNamespaceAndPath("rivencraft", "feature/ravines");
private static final long CAVE_SALT = 0x4341564553464541L;
private static final long RAVINE_SALT = 0x524156494E45534CL;
private PortCarverPages() {}
public static boolean supports(Identifier page) { return CAVES.equals(page) || RAVINES.equals(page); }
public static void validateCounts(List<Identifier> pages) {
Map<Identifier, Integer> counts = new HashMap<>();
for (Identifier page : pages) if (!supports(page) || counts.merge(page, 1, Integer::sum) > 32)
throw new IllegalArgumentException("Carver plan requires Caves/Ravines pages, at most 32 per type");
}
public static void validateRegistry(List<Identifier> pages, HolderLookup.Provider registries) {
if (pages.stream().noneMatch(PortCarverPages::supports)) return;
var carvers = registries.lookupOrThrow(Registries.CARVER);
for (String name : pages.contains(CAVES) ? List.of("cave", "nether_cave", "canyon") : List.of("canyon")) {
if (name.equals("canyon") && !pages.contains(RAVINES)) continue;
carvers.get(ResourceKey.create(Registries.CARVER, Identifier.withDefaultNamespace(name)))
.orElseThrow(() -> new IllegalArgumentException("Missing authored carver minecraft:" + name));
}
}
public static void carve(ChunkGenerator generator, ChunkAccess chunk, RandomState randomState,
HolderLookup.Provider registries, List<Identifier> pages, long seed, CarvingMask mask) {
if (pages.isEmpty()) return;
var registry = registries.lookupOrThrow(Registries.CARVER);
var biomes = generator.getBiomeSource().createUncachedResolver(randomState);
var context = new WorldGenerationContext(generator, chunk.getHeightAccessorForGeneration());
var random = new WorldgenRandom(new LegacyRandomSource(0L));
ChunkPos target = chunk.getPos();
// 1.21.1 repeats identical seeds for duplicate Caves/Ravines pages.
// Their union is identical; avoid replaying the same mask up to 32 times.
for (Identifier page : pages.stream().distinct().toList()) {
boolean ravine = page.equals(RAVINES);
WorldCarver overworld = registry.getOrThrow(ResourceKey.create(Registries.CARVER,
Identifier.withDefaultNamespace(ravine ? "canyon" : "cave"))).value();
WorldCarver nether = ravine ? null : registry.getOrThrow(ResourceKey.create(Registries.CARVER,
Identifier.withDefaultNamespace("nether_cave"))).value();
for (int dx = -8; dx <= 8; dx++) {
for (int dz = -8; dz <= 8; dz++) {
ChunkPos source = new ChunkPos(target.x() + dx, target.z() + dz);
boolean isNether = biomes.getNoiseBiome(QuartPos.fromBlock(source.getMinBlockX()), 0,
QuartPos.fromBlock(source.getMinBlockZ())).is(BiomeTags.IS_NETHER);
if (ravine && isNether) continue;
WorldCarver carver = isNether ? nether : overworld;
random.setLargeFeatureSeed(seed ^ (ravine ? RAVINE_SALT : CAVE_SALT), source.x(), source.z());
if (carver.isStartChunk(random)) carver.carve(context, random, target, source, mask);
}
}
}
}
}
@@ -113,12 +113,25 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator {
}
@Override public ChunkGeneratorStructureState createState(HolderLookup<StructureSet> sets,
RandomState random, long seed) {
if (hasStructurePages()) {
var lookup = PortStructurePages.filtered(sets, terrainFeatures.structures(), getBiomeSource());
var planning = PortStructurePages.planningBiomes(getBiomeSource(), sets, terrainFeatures.structures());
if (base instanceof PortFlatChunkGenerator || base instanceof net.minecraft.world.level.levelgen.FlatLevelSource)
return ChunkGeneratorStructureState.createForFlat(random, seed, getOrigin(random), planning,
lookup.listElements().map(holder -> (Holder<StructureSet>) holder));
return ChunkGeneratorStructureState.createForNormal(random, seed, getOrigin(random), planning, lookup);
}
return base.createState(sets, random, seed);
}
private boolean hasStructurePages() {
return terrainFeatures != null && !terrainFeatures.structures().isEmpty();
}
@Override public void createStructures(RegistryAccess registries, ChunkGeneratorStructureState state,
StructureManager structures, ChunkAccess chunk,
StructureTemplateManager templates, ResourceKey<Level> dimension) {
base.createStructures(registries, state, structures, chunk, templates, dimension);
if (hasStructurePages()) super.createStructures(registries, state, structures, chunk, templates, dimension);
else base.createStructures(registries, state, structures, chunk, templates, dimension);
}
@Override public void createReferences(WorldGenLevel level, StructureManager structures, ChunkAccess chunk) {
base.createReferences(level, structures, chunk);
@@ -250,19 +263,27 @@ public final class PortFeatureChunkGenerator extends ChunkGenerator {
int surface = chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, 8, 8);
Holder<Biome> biome = level.getBiome(new BlockPos(
chunkPos.getMinBlockX() + 8, surface, chunkPos.getMinBlockZ() + 8));
if (biome.is(BiomeTags.IS_END) || PortRegistryPages.isCaveBiome(biome)) return;
boolean surfaceBiome = !biome.is(BiomeTags.IS_END) && !PortRegistryPages.isCaveBiome(biome);
var registry = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE);
List<BlockPos> mushroomHosts = null;
for (int occurrence = 0; occurrence < vegetation.pages().size(); occurrence++) {
Identifier page = vegetation.pages().get(occurrence);
RandomSource random = RandomSource.create(chunkSeed
^ (long) page.hashCode() * 0x9E3779B97F4A7C15L
^ (long) occurrence * 0xD1B54A32D192ED03L);
if (page.equals(PortVegetationPlan.SHELF_MUSHROOMS)) {
if (mushroomHosts == null) mushroomHosts = PortShelfMushrooms.findHosts(chunk);
PortShelfMushrooms.decorate(level, chunk, mushroomHosts, random);
continue;
}
if (!surfaceBiome) continue;
Identifier placedId = PortVegetationPlan.placedFeature(page);
PlacedFeature vanilla = registry.getOrThrow(
ResourceKey.create(Registries.PLACED_FEATURE, placedId)).value();
PlacedFeature authored = new PlacedFeature(vanilla.feature(), vanilla.placement().stream()
.filter(modifier -> !(modifier instanceof BiomeFilter)).toList());
RandomSource random = RandomSource.create(chunkSeed
^ (long) page.hashCode() * 0x9E3779B97F4A7C15L
^ (long) occurrence * 0xD1B54A32D192ED03L);
authored.place(level, this, random, origin);
if (PortVegetationPlan.addsLogs(page)) mushroomHosts = null;
}
}
}
@@ -0,0 +1,70 @@
package net.dnorberg.rivencraft.age;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.tags.BlockTags;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.ShelfMushroomBlock;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.feature.treedecorators.ShelfMushroomDecorator;
import net.minecraft.world.level.levelgen.feature.treedecorators.TreeDecorator;
/** Shelf mushrooms on exposed log faces, regardless of how the logs were generated. */
final class PortShelfMushrooms {
private PortShelfMushrooms() {}
static List<BlockPos> findHosts(ChunkAccess chunk) {
List<BlockPos> logs = new ArrayList<>();
var sections = chunk.getSections();
for (int index = 0; index < sections.length; index++) {
var section = sections[index];
if (!section.maybeHas(state -> state.is(BlockTags.LOGS))) continue;
int baseY = chunk.getSectionYFromSectionIndex(index) << 4;
for (int y = 0; y < 16; y++) for (int z = 0; z < 16; z++) for (int x = 0; x < 16; x++) {
if (section.getBlockState(x, y, z).is(BlockTags.LOGS))
logs.add(new BlockPos(chunk.getPos().getMinBlockX() + x, baseY + y,
chunk.getPos().getMinBlockZ() + z));
}
}
return logs;
}
static void decorate(WorldGenLevel level, ChunkAccess chunk, List<BlockPos> logs, RandomSource random) {
// Reuse vanilla replaceability/water checks, but not its tree-base height
// band: a log tendril or sphere has no meaningful tree base.
TreeDecorator.Context context = new TreeDecorator.Context(level,
(pos, state) -> level.setBlock(pos, state, 2), random, Set.of(), Set.of(), Set.of());
for (BlockPos log : logs) {
if (!level.getBlockState(log).is(BlockTags.LOGS)) continue;
Direction first = Direction.Plane.HORIZONTAL.getRandomDirection(random);
for (Direction facing : List.of(first, first.getClockWise())) {
if (random.nextFloat() > 0.25F) continue;
BlockPos pos = log.relative(facing);
if ((pos.getX() >> 4) != chunk.getPos().x() || (pos.getZ() >> 4) != chunk.getPos().z()) continue;
if (pos.getY() < level.getMinY() || pos.getY() >= level.getMaxY()) continue;
if (!level.getFluidState(pos).isEmpty()
|| !ShelfMushroomDecorator.isBlockReplaceableWithShelfMushroom(context, pos)
|| level.getBlockState(pos.below()).is(Blocks.SHELF_MUSHROOM)) continue;
boolean adjacent = false;
for (Direction side : Direction.Plane.HORIZONTAL) {
if (level.getBlockState(pos.relative(side)).is(Blocks.SHELF_MUSHROOM)) {
adjacent = true;
break;
}
}
if (adjacent) continue;
var mushroom = Blocks.SHELF_MUSHROOM.defaultBlockState()
.setValue(ShelfMushroomBlock.FACING, facing)
.setValue(ShelfMushroomBlock.AGE, random.nextInt(2));
if (!mushroom.canSurvive(level, pos)) continue;
context.setBlock(pos, mushroom);
break;
}
}
}
}
@@ -0,0 +1,180 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.MapCodec;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
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.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.tags.TagKey;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.biome.Climate;
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;
/** 1.21.1 structure planning/density rules adapted to modern placement classes. */
public final class PortStructurePages {
private static final Map<Identifier, Identifier> SETS = Map.ofEntries(
vanillaSet("villages", "villages"),
vanillaSet("mineshafts", "mineshafts"),
vanillaSet("strongholds", "strongholds"),
vanillaSet("trial_chambers", "trial_chambers"),
vanillaSet("buried_treasure", "buried_treasures"),
vanillaSet("shipwrecks", "shipwrecks"),
vanillaSet("ocean_ruins", "ocean_ruins"),
vanillaSet("pillager_outposts", "pillager_outposts"),
vanillaSet("ruined_portals", "ruined_portals"),
vanillaSet("nether_fossils", "nether_fossils"),
vanillaSet("ancient_cities", "ancient_cities"),
vanillaSet("trail_ruins", "trail_ruins"),
vanillaSet("abandoned_camps", "abandoned_camp"),
Map.entry(page("nether_fortresses"), Identifier.fromNamespaceAndPath("rivencraft", "fortresses")),
Map.entry(page("bastions"), Identifier.fromNamespaceAndPath("rivencraft", "bastions")));
// Suppress vanilla's combined Nether set so an authored Fortress page does
// not also introduce Bastions (or vice versa).
private static final Set<Identifier> CONTROLLED_SETS = Stream.concat(SETS.values().stream(),
Stream.of(Identifier.withDefaultNamespace("nether_complexes")))
.collect(java.util.stream.Collectors.toUnmodifiableSet());
private static final Set<Identifier> CAVE_ONLY = Set.of(
page("nether_fortresses"), page("bastions"), page("nether_fossils"));
private static final Set<Identifier> SURFACE_ONLY = Set.of(
page("villages"), page("strongholds"), page("buried_treasure"), page("shipwrecks"),
page("ocean_ruins"), page("pillager_outposts"), page("trail_ruins"), page("abandoned_camps"));
private static final Set<Identifier> FLAT_ALLOWED = Set.of(page("villages"), page("strongholds"));
private PortStructurePages() {}
private static Identifier page(String name) {
return Identifier.fromNamespaceAndPath("rivencraft", "feature/" + name);
}
private static Map.Entry<Identifier, Identifier> vanillaSet(String page, String set) {
return Map.entry(page(page), Identifier.withDefaultNamespace(set));
}
public static boolean supports(Identifier page) { return SETS.containsKey(page); }
public static void validateCounts(List<Identifier> pages) {
Map<Identifier, Integer> counts = new HashMap<>();
for (Identifier page : pages) {
if (!supports(page) || counts.merge(page, 1, Integer::sum) > 32)
throw new IllegalArgumentException("Structure plan requires supported pages, at most 32 per feature");
}
}
public static boolean supportsTerrain(Identifier page, PortAgeTerrain terrain) {
if (!supports(page) || terrain == PortAgeTerrain.VOID) return false;
if (terrain == PortAgeTerrain.FLAT && !FLAT_ALLOWED.contains(page)) return false;
if (CAVE_ONLY.contains(page)) return terrain == PortAgeTerrain.CAVE;
return terrain != PortAgeTerrain.CAVE || !SURFACE_ONLY.contains(page);
}
public static void validateRegistry(List<Identifier> pages, HolderLookup.Provider registries) {
var sets = registries.lookupOrThrow(Registries.STRUCTURE_SET);
pages.stream().filter(PortStructurePages::supports).distinct().forEach(page ->
sets.get(ResourceKey.create(Registries.STRUCTURE_SET, SETS.get(page)))
.orElseThrow(() -> new IllegalArgumentException("Missing structure set for " + page)));
}
private static Map<Identifier, Integer> enabled(List<Identifier> pages) {
Map<Identifier, Integer> result = new HashMap<>();
for (Identifier page : pages) result.merge(SETS.get(page), 1, Integer::sum);
return result;
}
public static boolean authored(Structure structure, HolderLookup.Provider registries, List<Identifier> pages) {
var sets = registries.lookupOrThrow(Registries.STRUCTURE_SET);
return enabled(pages).keySet().stream()
.map(id -> sets.getOrThrow(ResourceKey.create(Registries.STRUCTURE_SET, id)).value())
.flatMap(set -> set.structures().stream()).anyMatch(entry -> entry.structure().value() == structure);
}
/** Extra possible biomes affect planning only; terrain still uses its original source. */
public static BiomeSource planningBiomes(BiomeSource actual, HolderLookup<StructureSet> sets, List<Identifier> pages) {
List<Holder<Biome>> extra = enabled(pages).keySet().stream()
.map(id -> sets.getOrThrow(ResourceKey.create(Registries.STRUCTURE_SET, id)).value())
.flatMap(set -> set.structures().stream()).flatMap(entry -> entry.structure().value().biomes().stream())
.distinct().toList();
return new BiomeSource() {
@Override protected MapCodec<? extends BiomeSource> codec() {
throw new UnsupportedOperationException("Planning biome sources are never serialized");
}
@Override protected Stream<Holder<Biome>> collectPossibleBiomes() {
return Stream.concat(actual.possibleBiomes().stream(), extra.stream());
}
@Override public net.minecraft.world.level.biome.BiomeResolver createResolver(Climate.Sampler sampler) {
return actual.createResolver(sampler);
}
};
}
public static HolderLookup<StructureSet> filtered(HolderLookup<StructureSet> source, List<Identifier> pages,
BiomeSource actual) {
Map<Identifier, Integer> counts = enabled(pages);
Map<ResourceKey<StructureSet>, Holder.Reference<StructureSet>> copies = new HashMap<>();
return new HolderLookup<>() {
@Override public Optional<Holder.Reference<StructureSet>> get(ResourceKey<StructureSet> key) {
if (CONTROLLED_SETS.contains(key.identifier()) && !counts.containsKey(key.identifier())) return Optional.empty();
return source.get(key).filter(holder -> counts.containsKey(key.identifier()) || hasBiome(holder.value(), actual))
.map(holder -> copies.computeIfAbsent(key, ignored -> {
int count = counts.getOrDefault(key.identifier(), 1);
return count == 1 ? holder : new LocalReference(key, scaled(holder.value(), count));
}));
}
@Override public Optional<HolderSet.Named<StructureSet>> get(TagKey<StructureSet> key) { return source.get(key); }
@Override public Stream<Holder.Reference<StructureSet>> listElements() {
return source.listElements().flatMap(holder -> get(holder.key()).stream());
}
@Override public Stream<HolderSet.Named<StructureSet>> listTags() { return source.listTags(); }
};
}
private static boolean hasBiome(StructureSet set, BiomeSource source) {
Set<Holder<Biome>> possible = source.possibleBiomes();
return set.structures().stream().flatMap(entry -> entry.structure().value().biomes().stream()).anyMatch(possible::contains);
}
private static StructureSet scaled(StructureSet set, int count) {
var placement = set.placement();
if (!(placement instanceof RandomSpreadStructurePlacement)
&& !(placement instanceof ConcentricRingsStructurePlacement)) return set;
StructurePlacementAccessor settings = (StructurePlacementAccessor) placement;
if (placement instanceof RandomSpreadStructurePlacement random) {
float original = settings.rivencraft$frequency();
float frequency = Math.min(1.0F, original * count);
double frequencyFactor = original > 0 ? frequency / original : 1;
int spacing = Math.max(1, (int) Math.round(random.spacing() / Math.sqrt(count / frequencyFactor)));
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(placement.locateOffset(),
settings.rivencraft$frequencyReductionMethod(), frequency, settings.rivencraft$salt(),
settings.rivencraft$exclusionZone(), spacing, separation, random.spreadType()));
}
if (placement instanceof ConcentricRingsStructurePlacement rings) {
int total = (int) Math.min(4095, (long) rings.count() * count);
int spread = Math.min(total, (int) Math.min(1023, (long) rings.spread() * count));
return new StructureSet(set.structures(), new ConcentricRingsStructurePlacement(placement.locateOffset(),
settings.rivencraft$frequencyReductionMethod(), settings.rivencraft$frequency(),
settings.rivencraft$salt(), settings.rivencraft$exclusionZone(), rings.distance(), spread,
total, rings.preferredBiomes()));
}
return set;
}
private static final class LocalReference extends Holder.Reference<StructureSet> {
private static final HolderOwner<StructureSet> OWNER = new HolderOwner<>() {};
private LocalReference(ResourceKey<StructureSet> key, StructureSet value) { super(Type.STAND_ALONE, OWNER, key, value); }
}
}
@@ -8,7 +8,8 @@ import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
/** Frozen terrain filters and ordered solid/surface features for an Age. */
public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres, boolean starFissures) {
public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
boolean starFissures, List<Identifier> structures, List<Identifier> carvers) {
public static final Identifier FLOATING_ISLANDS = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "feature/floating_islands");
public static final Identifier TENDRILS = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/tendrils");
@@ -27,7 +28,9 @@ public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int
Codec.BOOL.optionalFieldOf("skylands", false).forGetter(PortTerrainFeaturePlan::skylands),
Codec.intRange(0, MAX_PER_FEATURE).optionalFieldOf("dense_ores", 0)
.forGetter(PortTerrainFeaturePlan::denseOres),
Codec.BOOL.optionalFieldOf("star_fissures", false).forGetter(PortTerrainFeaturePlan::starFissures)
Codec.BOOL.optionalFieldOf("star_fissures", false).forGetter(PortTerrainFeaturePlan::starFissures),
Identifier.CODEC.listOf().optionalFieldOf("structures", List.of()).forGetter(PortTerrainFeaturePlan::structures),
Identifier.CODEC.listOf().optionalFieldOf("carvers", List.of()).forGetter(PortTerrainFeaturePlan::carvers)
).apply(instance, PortTerrainFeaturePlan::new));
public record Choice(Identifier page, Identifier material) {
@@ -46,9 +49,13 @@ public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int
public PortTerrainFeaturePlan {
choices = List.copyOf(choices);
structures = List.copyOf(structures);
carvers = List.copyOf(carvers);
PortStructurePages.validateCounts(structures);
PortCarverPages.validateCounts(carvers);
if (denseOres < 0 || denseOres > MAX_PER_FEATURE)
throw new IllegalArgumentException("Dense Ores permits at most 32 occurrences");
if (choices.isEmpty() && !skylands && denseOres == 0 && !starFissures)
if (choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty() && carvers.isEmpty())
throw new IllegalArgumentException("Terrain feature plan must not be empty");
var counts = new HashMap<Identifier, Integer>();
for (Choice choice : choices) {
@@ -57,6 +64,15 @@ public record PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int
}
}
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres,
boolean starFissures, List<Identifier> structures) {
this(choices, skylands, denseOres, starFissures, structures, List.of());
}
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres, boolean starFissures) {
this(choices, skylands, denseOres, starFissures, List.of());
}
public PortTerrainFeaturePlan(List<Choice> choices, boolean skylands, int denseOres) {
this(choices, skylands, denseOres, false);
}
@@ -6,11 +6,15 @@ import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
/** Ordered, frozen authored vegetation pages for surface Ages. */
/** Ordered, frozen plants and log decorations; not all require surface terrain. */
public record PortVegetationPlan(List<Identifier> pages) {
public static final Identifier FLOWERS = page("flowers");
public static final Identifier TREES = page("trees");
public static final Identifier HUGE_TREES = page("huge_trees");
public static final Identifier POPLAR_TREES = page("poplar_trees");
public static final Identifier FALLEN_POPLAR_TREES = page("fallen_poplar_trees");
public static final Identifier RED_SHRUBS = page("red_shrubs");
public static final Identifier SHELF_MUSHROOMS = page("shelf_mushrooms");
public static final Codec<PortVegetationPlan> CODEC = RecordCodecBuilder.create(instance -> instance.group(
Identifier.CODEC.listOf().fieldOf("pages").forGetter(PortVegetationPlan::pages)
).apply(instance, PortVegetationPlan::new));
@@ -22,16 +26,30 @@ public record PortVegetationPlan(List<Identifier> pages) {
}
public static boolean isVegetation(Identifier page) {
return page.equals(FLOWERS) || page.equals(TREES) || page.equals(HUGE_TREES);
return page.equals(FLOWERS) || page.equals(TREES) || page.equals(HUGE_TREES)
|| page.equals(POPLAR_TREES) || page.equals(FALLEN_POPLAR_TREES)
|| page.equals(RED_SHRUBS) || page.equals(SHELF_MUSHROOMS);
}
public static Identifier placedFeature(Identifier page) {
if (page.equals(FLOWERS)) return Identifier.withDefaultNamespace("flower_default");
if (page.equals(TREES)) return Identifier.withDefaultNamespace("trees_plains");
if (page.equals(HUGE_TREES)) return Identifier.withDefaultNamespace("trees_old_growth_spruce_taiga");
if (page.equals(POPLAR_TREES)) return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "poplar_trees");
if (page.equals(FALLEN_POPLAR_TREES)) return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "fallen_poplar_trees");
if (page.equals(RED_SHRUBS)) return Identifier.withDefaultNamespace("patch_red_shrub");
throw new IllegalArgumentException("Unsupported vegetation page " + page);
}
public static boolean addsLogs(Identifier page) {
return page.equals(TREES) || page.equals(HUGE_TREES)
|| page.equals(POPLAR_TREES) || page.equals(FALLEN_POPLAR_TREES);
}
public static boolean requiresSurface(Identifier page) {
return isVegetation(page) && !page.equals(SHELF_MUSHROOMS);
}
private static Identifier page(String name) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "feature/" + name);
}
@@ -0,0 +1,33 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.resources.Identifier;
/** The six active weather pages in the 1.21.1 writing system. */
public enum PortWeatherMode {
NORMAL, FAST, SLOW, OFF, ALWAYS, STORM;
public static final Codec<PortWeatherMode> CODEC = Codec.STRING.xmap(
name -> valueOf(name.toUpperCase(java.util.Locale.ROOT)),
mode -> mode.name().toLowerCase(java.util.Locale.ROOT));
public static PortWeatherMode forPage(Identifier page) {
if (!page.getNamespace().equals(Rivencraft.MOD_ID)) return null;
return switch (page.getPath()) {
case "weather/normal" -> NORMAL;
case "weather/fast" -> FAST;
case "weather/slow" -> SLOW;
case "weather/off" -> OFF;
case "weather/always" -> ALWAYS;
case "weather/storm" -> STORM;
default -> null;
};
}
public static PortWeatherMode fromPages(List<Identifier> pages) {
return pages.stream().map(PortWeatherMode::forPage).filter(java.util.Objects::nonNull)
.findFirst().orElse(NORMAL);
}
}
@@ -0,0 +1,24 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.Codec;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.saveddata.SavedData;
import net.minecraft.world.level.saveddata.SavedDataType;
/** Age-local policy; native WeatherData separately persists its live cycle. */
final class PortWeatherSettings extends SavedData {
private static final Codec<PortWeatherSettings> CODEC = PortWeatherMode.CODEC
.fieldOf("mode").codec().xmap(PortWeatherSettings::new, settings -> settings.mode);
static final SavedDataType<PortWeatherSettings> TYPE = new SavedDataType<>(
Identifier.fromNamespaceAndPath("rivencraft", "weather_policy"),
() -> new PortWeatherSettings(PortWeatherMode.NORMAL), CODEC);
private PortWeatherMode mode;
private PortWeatherSettings(PortWeatherMode mode) { this.mode = mode; }
PortWeatherMode mode() { return mode; }
void setMode(PortWeatherMode mode) {
this.mode = mode;
setDirty();
}
}
@@ -34,7 +34,6 @@ import net.minecraft.world.level.levelgen.NoiseGeneratorSettings;
import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings;
import net.minecraft.world.level.levelgen.flat.FlatLayerInfo;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.storage.DerivedLevelData;
import net.minecraft.world.level.storage.LevelResource;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.common.world.chunk.ForcedChunkManager;
@@ -106,11 +105,20 @@ public final class RuntimeAgeBackend {
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures) {
return create(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes,
terrainFeatures, PortWeatherMode.NORMAL);
}
public static ServerLevel create(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain, PortRockPlan rocks,
PortVegetationPlan vegetation, int dungeons, PortLakePlan lakes,
PortTerrainFeaturePlan terrainFeatures, PortWeatherMode weather) {
checkServerThread(server);
ageName(id);
AgeLevelIndex index = index(server);
if (index.contains(id)) throw new IllegalArgumentException("Age already exists: " + id);
ServerLevel level = load(server, id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures);
((PortAgeLevel) level).configureWeather(weather);
index.add(id, seed, biomePlan, terrain, rocks, vegetation, dungeons, lakes, terrainFeatures);
// A newly bound book may be used immediately. Persist its Age identity
// before publishing the catalog so a later crash cannot leave that
@@ -403,9 +411,7 @@ public final class RuntimeAgeBackend {
generator = new PortFeatureChunkGenerator(generator, rocks, vegetation, dungeons, lakes, terrainFeatures, seed);
LevelStem stem = new LevelStem(overworldStem.type(), generator, OptionalLong.of(seed));
long biomeZoomSeed = BiomeManager.obfuscateSeed(server.getWorldGenSettings().options().seed());
ServerLevel level = new ServerLevel(server, server.executor, server.storageSource,
new DerivedLevelData(server.getWorldData(), server.getWorldData().overworldData()),
key, stem, server.getWorldData().isDebugWorld(), biomeZoomSeed, List.of(), false);
ServerLevel level = new PortAgeLevel(server, key, stem, biomeZoomSeed, terrain);
// Publish only after the constructor succeeds. If the load event fails,
// roll back the map and close the newly opened chunk/entity stores.
@@ -56,6 +56,11 @@ public final class StarFissureBlock extends EndPortalBlock {
BlockPos landing = net.dnorberg.rivencraft.age.PortAgeTravel.findFootedDestination(overworld, spawn).orElse(spawn);
level.playSound(null, pos, Rivencraft.LINK_FISSURE.get(), SoundSource.BLOCKS, 1.0F, 1.0F);
return new TeleportTransition(overworld, Vec3.atBottomCenterOf(landing), entity.getDeltaMovement(),
entity.getYRot(), entity.getXRot(), TeleportTransition.PLACE_PORTAL_TICKET);
entity.getYRot(), entity.getXRot(), TeleportTransition.PLACE_PORTAL_TICKET.then(arrived -> {
// Departure audio can be discarded when the client switches levels.
// Play after arrival too so the traveler hears the successful return.
arrived.level().playSound(null, arrived.blockPosition(), Rivencraft.LINK_FISSURE.get(),
SoundSource.BLOCKS, 1.0F, 1.0F);
}));
}
}
@@ -94,6 +94,8 @@ public final class PortBookBinderScreen extends AbstractContainerScreen<PortBook
} else if (PageItem.isLinkPanel(page)) {
graphics.fill(x + 5, y + 6, x + 22, y + 17, 0xFF000000);
} else {
if (!PortAuthoredWriting.isPageImplemented(page.get(Rivencraft.SYMBOL_PAGE_ID.get())))
graphics.fill(x, y, x + PAGE_WIDTH, y + PAGE_HEIGHT, 0x88FF3333);
// Render the full-size transparent glyph through the working
// item pipeline, without its small parchment-backed icon.
ItemStack glyphOnly = page.copy();
@@ -5,6 +5,7 @@ import com.mojang.blaze3d.vertex.VertexConsumer;
import com.mojang.serialization.MapCodec;
import java.util.function.Consumer;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.PortAuthoredWriting;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.rendertype.RenderTypes;
import net.minecraft.client.renderer.special.SpecialModelRenderer;
@@ -18,7 +19,8 @@ public final class PortSymbolPageRenderer implements SpecialModelRenderer<PortSy
public PageArt extractArgument(ItemStack stack) {
Identifier symbol = stack.get(Rivencraft.SYMBOL_PAGE_ID.get());
return symbol == null ? null : new PageArt(symbol,
stack.getOrDefault(Rivencraft.PAGE_GLYPH_ONLY.get(), false));
stack.getOrDefault(Rivencraft.PAGE_GLYPH_ONLY.get(), false),
PortAuthoredWriting.isPageImplemented(symbol) ? 0xFFFFFFFF : 0xFFFF6666);
}
@Override
@@ -30,10 +32,10 @@ public final class PortSymbolPageRenderer implements SpecialModelRenderer<PortSy
: PortSymbolPageTextures.forItem(art.symbol());
collector.submitCustomGeometry(pose, RenderTypes.entityCutout(texture),
(matrix, vertices) -> {
vertex(vertices, matrix, 0, 0, 0, 1, light, overlay);
vertex(vertices, matrix, 1, 0, 1, 1, light, overlay);
vertex(vertices, matrix, 1, 1, 1, 0, light, overlay);
vertex(vertices, matrix, 0, 1, 0, 0, light, overlay);
vertex(vertices, matrix, 0, 0, 0, 1, light, overlay, art.color());
vertex(vertices, matrix, 1, 0, 1, 1, light, overlay, art.color());
vertex(vertices, matrix, 1, 1, 1, 0, light, overlay, art.color());
vertex(vertices, matrix, 0, 1, 0, 0, light, overlay, art.color());
});
}
@@ -44,12 +46,12 @@ public final class PortSymbolPageRenderer implements SpecialModelRenderer<PortSy
}
private static void vertex(VertexConsumer vertices, PoseStack.Pose pose,
float x, float y, float u, float v, int light, int overlay) {
vertices.addVertex(pose, x, y, 0.5F).setColor(255, 255, 255, 255)
float x, float y, float u, float v, int light, int overlay, int color) {
vertices.addVertex(pose, x, y, 0.5F).setColor(color)
.setUv(u, v).setOverlay(overlay).setLight(light).setNormal(pose, 0, 0, 1);
}
public record PageArt(Identifier symbol, boolean glyphOnly) {}
public record PageArt(Identifier symbol, boolean glyphOnly, int color) {}
public record Unbaked() implements SpecialModelRenderer.Unbaked<PageArt> {
public static final Unbaked INSTANCE = new Unbaked();
@@ -0,0 +1,35 @@
package net.dnorberg.rivencraft.item;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.phys.Vec3;
/** Same successful-link feedback and sound precedence as the 1.21.1 target. */
public final class LinkVisualEffects {
private LinkVisualEffects() {}
public static SoundEvent sound(LinkProperties properties, boolean portal) {
if (properties.has(LinkProperties.DISARM)) return Rivencraft.LINK_DISARM.get();
if (portal) return Rivencraft.LINK_PORTAL.get();
if (properties.has(LinkProperties.INTRA_LINKING)) return Rivencraft.LINK_INTRA.get();
return Rivencraft.LINK.get();
}
public static void play(ServerLevel source, Vec3 departure, ServerPlayer player,
LinkProperties properties, boolean portal) {
SoundEvent event = sound(properties, portal);
burst(source, departure, event);
burst(player.level(), player.position(), event);
}
private static void burst(ServerLevel level, Vec3 position, SoundEvent event) {
level.playSound(null, position.x, position.y, position.z, event,
SoundSource.PLAYERS, 0.8F, level.getRandom().nextFloat() * 0.2F + 0.9F);
level.sendParticles(ParticleTypes.CLOUD, position.x, position.y, position.z,
50, 0.5, 1.0, 0.5, 0.005);
}
}
@@ -78,6 +78,7 @@ public final class LinkingBookItem extends Item {
/** Link Panel effects are shared by Linking and Descriptive Books. */
public static void applySuccessfulLink(ServerPlayer player, LinkProperties properties,
ServerLevel source, Vec3 departure, Vec3 incoming, float destinationYaw) {
LinkVisualEffects.play(source, departure, player, properties, false);
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) {
double speed = Math.hypot(incoming.x, incoming.z);
double yaw = Math.toRadians(destinationYaw);
@@ -3,6 +3,7 @@ package net.dnorberg.rivencraft.item;
import java.util.function.Consumer;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.PortSymbolPages;
import net.dnorberg.rivencraft.writing.PortAuthoredWriting;
import net.minecraft.ChatFormatting;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
@@ -12,7 +13,7 @@ import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.component.TooltipDisplay;
/** One Page item with a persistent Link Panel variant; symbol writing is not ported yet. */
/** Shared Page item with persistent Link Panel and authored symbol variants. */
public final class PageItem extends Item {
public PageItem(Properties properties) {
super(properties);
@@ -72,8 +73,10 @@ public final class PageItem extends Item {
? description.copy().withStyle(ChatFormatting.GRAY, ChatFormatting.OBFUSCATED)
: description.copy().withStyle(ChatFormatting.GRAY));
}
tooltip.accept(Component.translatable("item.rivencraft.page.symbol.port_status")
.withStyle(ChatFormatting.DARK_GRAY));
boolean implemented = PortAuthoredWriting.isPageImplemented(symbolId);
tooltip.accept(Component.translatable(implemented
? "item.rivencraft.page.symbol.implemented" : "item.rivencraft.page.symbol.unimplemented")
.withStyle(implemented ? ChatFormatting.DARK_GRAY : ChatFormatting.RED));
return;
}
LinkProperties properties = stack.getOrDefault(Rivencraft.LINK_PROPERTIES.get(), LinkProperties.NONE);
@@ -0,0 +1,36 @@
package net.dnorberg.rivencraft.mixin;
import com.llamalad7.mixinextras.sugar.Local;
import net.dnorberg.rivencraft.age.PortCarverPages;
import net.dnorberg.rivencraft.age.PortFeatureChunkGenerator;
import net.minecraft.server.level.WorldGenRegion;
import net.minecraft.world.level.biome.BiomeManager;
import net.minecraft.world.level.chunk.CarvingMask;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
import net.minecraft.world.level.levelgen.NoiseChunk;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.blending.Blender;
import net.minecraft.world.level.levelgen.material.rule.MaterialRule;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Merge authored geometry before vanilla consumes its mask with aquifer/surface rules. */
@Mixin(NoiseBasedChunkGenerator.class)
public abstract class AgeCarversMixin {
@Inject(method = "generateCarvers", at = @At(value = "INVOKE",
target = "Lnet/minecraft/world/level/chunk/CarvingMask;isEmpty()Z"))
private void rivencraft$authoredCarvers(ChunkAccess chunk, Blender blender, NoiseChunk noise,
RandomState random, BiomeManager biomes, WorldGenRegion region, MaterialRule material,
CallbackInfo ci, @Local CarvingMask mask) {
if (region == null) return;
var level = region.getLevel();
if (level.getChunkSource().getGenerator() instanceof PortFeatureChunkGenerator generator
&& generator.base() == (Object) this && generator.terrainFeatures() != null) {
PortCarverPages.carve((NoiseBasedChunkGenerator) (Object) this, chunk, random,
region.registryAccess(), generator.terrainFeatures().carvers(), level.getSeed(), mask);
}
}
}
@@ -0,0 +1,42 @@
package net.dnorberg.rivencraft.mixin;
import java.util.function.Predicate;
import net.dnorberg.rivencraft.age.PortFeatureChunkGenerator;
import net.dnorberg.rivencraft.age.PortStructurePages;
import net.minecraft.core.Holder;
import net.minecraft.core.RegistryAccess;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.biome.Climate;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.ModifyArg;
/** Matches 1.21.1's explicit-page biome override, scoped only to authored Age structures. */
@Mixin(Structure.class)
public abstract class AgeStructureBiomeMixin {
// 26.3 jigsaw placement checks biome columns inside findGenerationPoint,
// before the final isValidBiome test. Set the predicate when constructing
// the context so both checks see the explicit-page override.
@ModifyArg(method = "generate", at = @At(value = "INVOKE",
target = "Lnet/minecraft/world/level/levelgen/structure/Structure$GenerationContext;<init>(Lnet/minecraft/core/RegistryAccess;Lnet/minecraft/world/level/chunk/ChunkGenerator;Lnet/minecraft/world/level/biome/BiomeSource;Lnet/minecraft/world/level/biome/Climate$Sampler;Lnet/minecraft/world/level/levelgen/RandomState;Lnet/minecraft/world/level/levelgen/structure/templatesystem/StructureTemplateManager;JLnet/minecraft/world/level/ChunkPos;Lnet/minecraft/world/level/LevelHeightAccessor;Ljava/util/function/Predicate;)V"),
index = 9)
private Predicate<Holder<Biome>> rivencraft$authoredBiome(
RegistryAccess registries, ChunkGenerator chunkGenerator, BiomeSource biomes,
Climate.Sampler sampler, RandomState random, StructureTemplateManager templates,
long seed, ChunkPos chunk, LevelHeightAccessor height, Predicate<Holder<Biome>> original) {
if (chunkGenerator instanceof PortFeatureChunkGenerator generator
&& generator.terrainFeatures() != null
&& PortStructurePages.authored((Structure) (Object) this, registries,
generator.terrainFeatures().structures())) {
return biome -> true;
}
return original;
}
}
@@ -0,0 +1,17 @@
package net.dnorberg.rivencraft.mixin;
import java.util.Optional;
import net.minecraft.world.level.levelgen.structure.placement.AbstractSpreadingStructurePlacement;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
/** Read-only placement settings for Age-local copies, never edits global placements. */
@Mixin(AbstractSpreadingStructurePlacement.class)
public interface StructurePlacementAccessor {
@Accessor("frequencyReductionMethod")
AbstractSpreadingStructurePlacement.FrequencyReductionMethod rivencraft$frequencyReductionMethod();
@Accessor("frequency") float rivencraft$frequency();
@Accessor("salt") int rivencraft$salt();
@Accessor("exclusionZone")
Optional<AbstractSpreadingStructurePlacement.ExclusionZone> rivencraft$exclusionZone();
}
@@ -139,7 +139,8 @@ public final class PortBookNetworking {
PortAuthoredWriting.vegetationPlan(pages),
PortAuthoredWriting.dungeonCount(pages),
PortAuthoredWriting.lakePlan(pages, seed),
PortAuthoredWriting.terrainFeaturePlan(pages, seed));
PortAuthoredWriting.terrainFeaturePlan(pages, seed),
net.dnorberg.rivencraft.age.PortWeatherMode.fromPages(pages));
} catch (IllegalArgumentException | IllegalStateException exception) {
player.sendOverlayMessage(Component.literal("Age creation failed: " + exception.getMessage()));
return null;
@@ -12,6 +12,9 @@ import net.dnorberg.rivencraft.age.PortRockPlan;
import net.dnorberg.rivencraft.age.PortVegetationPlan;
import net.dnorberg.rivencraft.age.PortLakePlan;
import net.dnorberg.rivencraft.age.PortTerrainFeaturePlan;
import net.dnorberg.rivencraft.age.PortStructurePages;
import net.dnorberg.rivencraft.age.PortCarverPages;
import net.dnorberg.rivencraft.age.PortWeatherMode;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.registries.Registries;
@@ -45,6 +48,23 @@ public final class PortAuthoredWriting {
private PortAuthoredWriting() {}
/** Port availability only, not validity of a particular ordered book. */
public static boolean isPageImplemented(Identifier page) {
if (page == null || !page.getNamespace().equals(Rivencraft.MOD_ID)) return false;
var entry = PortSymbolPages.find(page);
if (entry == null) return false;
if (isTerrain(page) || controller(page) != null || PortWeatherMode.forPage(page) != null) return true;
if (entry.category().equals("rivencraft:biome") && PortRegistryPages.biomeId(page) != null) return true;
if (isBlockPage(page) && (entry.category().equals("rivencraft:block")
|| entry.category().equals("rivencraft:fluid"))) return true;
return page.equals(ROCKS) || page.equals(DUNGEONS) || PortLakePlan.isLake(page)
|| PortVegetationPlan.isVegetation(page) || PortTerrainFeaturePlan.isSupported(page)
|| page.equals(PortTerrainFeaturePlan.SKYLANDS)
|| page.equals(PortTerrainFeaturePlan.DENSE_ORES)
|| page.equals(PortTerrainFeaturePlan.STAR_FISSURES)
|| PortStructurePages.supports(page) || PortCarverPages.supports(page);
}
/** A page never disappears into an Age unless this port can apply its effect. */
public static boolean supports(List<Identifier> pages) {
boolean terrain = false;
@@ -58,13 +78,18 @@ public final class PortAuthoredWriting {
int skylands = 0;
int denseOres = 0;
int starFissures = 0;
int weather = 0;
Map<Identifier, Integer> terrainFeatures = new HashMap<>();
Map<Identifier, Integer> structures = new HashMap<>();
Map<Identifier, Integer> carvers = new HashMap<>();
for (Identifier page : pages) {
if (pendingMaterial != null && !(pendingMaterial.equals("rivencraft:block")
&& (page.equals(ROCKS) || PortTerrainFeaturePlan.acceptsMaterial(page))
|| pendingMaterial.equals("rivencraft:fluid") && PortLakePlan.isLake(page))) return false;
if (isTerrain(page) && !terrain) {
terrain = true;
} else if (PortWeatherMode.forPage(page) != null && weather++ == 0) {
// A single independent weather policy for this Age.
} else if (controller(page) != null && controllers++ == 0) {
// An explicit controller needs an authored pool.
} else if (isBlockPage(page) && pendingMaterial == null
@@ -91,6 +116,12 @@ public final class PortAuthoredWriting {
} else if (page.equals(PortTerrainFeaturePlan.STAR_FISSURES)
&& starFissures < PortTerrainFeaturePlan.MAX_PER_FEATURE) {
starFissures++;
} else if (PortCarverPages.supports(page)
&& carvers.getOrDefault(page, 0) < PortTerrainFeaturePlan.MAX_PER_FEATURE) {
carvers.merge(page, 1, Integer::sum);
} else if (PortStructurePages.supports(page)
&& structures.getOrDefault(page, 0) < PortTerrainFeaturePlan.MAX_PER_FEATURE) {
structures.merge(page, 1, Integer::sum);
} else if (PortVegetationPlan.isVegetation(page) && vegetation < MAX_VEGETATION) {
vegetation++;
} else if (page.equals(DUNGEONS) && dungeons < MAX_DUNGEONS) {
@@ -103,11 +134,17 @@ public final class PortAuthoredWriting {
biomes++;
} else return false;
}
if (pendingMaterial != null || (rocks > 0 || vegetation > 0)
if (pendingMaterial != null || (rocks > 0 || pages.stream().anyMatch(PortVegetationPlan::requiresSurface))
&& (pages.contains(CAVE_TERRAIN) || pages.contains(VOID_TERRAIN))) return false;
if ((dungeons > 0 || lakes > 0) && (pages.contains(FLAT_TERRAIN) || pages.contains(VOID_TERRAIN)
|| pages.contains(CAVE_TERRAIN))) return false;
if (denseOres > 0 && (pages.contains(FLAT_TERRAIN) || pages.contains(VOID_TERRAIN))) return false;
if (!carvers.isEmpty() && (pages.contains(FLAT_TERRAIN) || pages.contains(VOID_TERRAIN))) return false;
PortAgeTerrain structureTerrain = pages.contains(VOID_TERRAIN) ? PortAgeTerrain.VOID
: pages.contains(FLAT_TERRAIN) ? PortAgeTerrain.FLAT
: pages.contains(CAVE_TERRAIN) ? PortAgeTerrain.CAVE : PortAgeTerrain.STANDARD;
if (structures.keySet().stream().anyMatch(page -> !PortStructurePages.supportsTerrain(page, structureTerrain)))
return false;
if (pages.contains(PortTerrainFeaturePlan.FLOATING_ISLANDS) && pages.contains(CAVE_TERRAIN)) return false;
if (pages.stream().anyMatch(PortTerrainFeaturePlan::isSurface)
&& (pages.contains(CAVE_TERRAIN) || pages.contains(VOID_TERRAIN))) return false;
@@ -184,8 +221,10 @@ public final class PortAuthoredWriting {
boolean skylands = pages.contains(PortTerrainFeaturePlan.SKYLANDS);
int denseOres = (int) pages.stream().filter(PortTerrainFeaturePlan.DENSE_ORES::equals).count();
boolean starFissures = pages.contains(PortTerrainFeaturePlan.STAR_FISSURES);
return choices.isEmpty() && !skylands && denseOres == 0 && !starFissures ? null
: new PortTerrainFeaturePlan(choices, skylands, denseOres, starFissures);
List<Identifier> structures = pages.stream().filter(PortStructurePages::supports).toList();
List<Identifier> carvers = pages.stream().filter(PortCarverPages::supports).toList();
return choices.isEmpty() && !skylands && denseOres == 0 && !starFissures && structures.isEmpty() && carvers.isEmpty() ? null
: new PortTerrainFeaturePlan(choices, skylands, denseOres, starFissures, structures, carvers);
}
private static List<Identifier> solidMaterials(List<Identifier> pages, long seed, Identifier feature) {
@@ -255,19 +294,19 @@ public final class PortAuthoredWriting {
if (!supports(pages)) throw new IllegalArgumentException(
"Unsupported terrain or biome-page combination in this port slice");
List<Identifier> biomes = new ArrayList<>();
PortStructurePages.validateRegistry(pages, registries);
PortCarverPages.validateRegistry(pages, registries);
PortBiomePlan.Distribution distribution = null;
boolean caveTerrain = pages.contains(CAVE_TERRAIN);
boolean hasSurfaceBiome = false;
boolean hasOreBiome = false;
for (Identifier page : pages) {
if (isTerrain(page) || page.equals(ROCKS) || page.equals(DUNGEONS)
|| page.equals(PortTerrainFeaturePlan.SKYLANDS)
|| page.equals(PortTerrainFeaturePlan.DENSE_ORES)
|| page.equals(PortTerrainFeaturePlan.STAR_FISSURES)
|| PortLakePlan.isLake(page) || PortTerrainFeaturePlan.isSupported(page) || isBlockPage(page)
|| PortVegetationPlan.isVegetation(page)) continue;
PortBiomePlan.Distribution selected = controller(page);
if (selected != null) { distribution = selected; continue; }
// supports() has already checked the whole book. Resolve only biome
// pages here, not every page absent from a growing exclusion list.
var entry = PortSymbolPages.find(page);
if (entry == null || !entry.category().equals("rivencraft:biome")) continue;
Identifier biome = PortRegistryPages.biomeId(page);
var registry = registries.lookupOrThrow(Registries.BIOME);
var holder = registry.get(ResourceKey.create(Registries.BIOME, biome))
@@ -279,7 +318,7 @@ public final class PortAuthoredWriting {
if (net.dnorberg.rivencraft.age.PortDenseOres.hasOres(holder)) hasOreBiome = true;
biomes.add(biome);
}
if (pages.stream().anyMatch(PortVegetationPlan::isVegetation)
if (pages.stream().anyMatch(PortVegetationPlan::requiresSurface)
&& !biomes.isEmpty() && !hasSurfaceBiome)
throw new IllegalArgumentException("Vegetation pages need at least one surface Overworld biome");
if (pages.contains(PortTerrainFeaturePlan.DENSE_ORES) && !biomes.isEmpty() && !hasOreBiome)
@@ -1,5 +0,0 @@
{
"linking.link-fissure": {
"sounds": [{"name": "rivencraft:linking/link-fissure"}]
}
}
@@ -0,0 +1,8 @@
{
"type": "minecraft:weighted_random_selector",
"features": [
{"data": "minecraft:red_poplar_leaf_litter", "weight": 200},
{"data": "minecraft:orange_poplar_leaf_litter", "weight": 240},
{"data": "minecraft:yellow_poplar_leaf_litter", "weight": 90}
]
}
@@ -0,0 +1,12 @@
{
"feature": "minecraft:fallen_poplar_tree",
"placement": [
{"type": "minecraft:in_square"},
{"type": "minecraft:surface_water_depth_filter", "max_water_depth": 0},
{"type": "minecraft:heightmap", "heightmap": "OCEAN_FLOOR"},
{
"type": "minecraft:block_predicate_filter",
"predicate": {"type": "minecraft:would_survive", "state": "minecraft:poplar_sapling"}
}
]
}
@@ -0,0 +1,9 @@
{
"feature": "rivencraft:poplar_trees",
"placement": [
{"type": "minecraft:count", "count": 6},
{"type": "minecraft:in_square"},
{"type": "minecraft:surface_water_depth_filter", "max_water_depth": 0},
{"type": "minecraft:heightmap", "heightmap": "OCEAN_FLOOR"}
]
}
@@ -0,0 +1,8 @@
{
"required": true,
"minVersion": "0.8",
"package": "net.dnorberg.rivencraft.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": ["StructurePlacementAccessor", "AgeStructureBiomeMixin", "AgeCarversMixin"],
"injectors": {"defaultRequire": 1}
}
@@ -4,15 +4,18 @@ issueTrackerURL="https://git.paddedcell.org/dnorberg/rivencraft/issues"
[[mods]]
modId="${mod_id}"
version="${mod_version}"
displayName="${mod_name} 26.3 Alpha"
displayName="${mod_name} 26.3 Beta"
displayURL="https://git.paddedcell.org/dnorberg/rivencraft"
authors="dnorberg and contributors"
description='''
Experimental Minecraft 26.3 alpha. Provides a runtime Age prototype and basic
Linking Book travel. Most Rivencraft gameplay has not yet been ported. Use a
test world and keep backups; this alpha is not compatible with 1.21.1 worlds.
Experimental Minecraft 26.3 beta with books, writing stations and selected
authored Age generation. Full 1.21.1 gameplay parity is still in progress.
Use a test world and keep backups; 1.21.1 worlds are not compatible.
'''
[[mixins]]
config="rivencraft.mixins.json"
[[dependencies.${mod_id}]]
modId="neoforge"
type="required"