Compare commits

..
Author SHA1 Message Date
dnorberg fa294be685 Port Flat and Void Age terrain to Minecraft 26.3 beta
Build / build (push) Canceled after 0s
2026-10-31 20:35:08 -04:00
dnorberg cb31156584 Add Modrinth and CurseForge API publishing helper
Build / build (push) Canceled after 0s
2026-10-31 02:53:32 -04:00
16 changed files with 747 additions and 48 deletions

No files matched your search

+4
View File
@@ -32,6 +32,10 @@ bin/
### Mac OS ###
.DS_Store
### Python tools ###
__pycache__/
*.pyc
### Minecraft Modding ###
run/
logs/
+15
View File
@@ -480,6 +480,21 @@ symbol-page data and authoring pipeline, not just drawing more placeholders.
The local `1.0.1.15-alpha.1` follow-up changes the missing-panel cue to the
Link Panel's 16×16 item icon while preserving its red pulse and tooltip.
The local `1.0.1.33-beta.1` slice adds Flat and Void terrain to authored Binder
books. Flat uses vanilla superflat layers and can use one authored Biome; Void
generates no terrain but can use one authored Biome for environmental behavior.
Without a Biome page, Void uses `minecraft:the_void`. Books with multiple Biome
pages and Flat/Void terrain remain unassembled until their custom distribution
generator is ported. The dedicated-server smoke creates, samples, and reloads
both terrain families in a disposable world.
The local `1.0.1.34-beta.1` follow-up moves Flat ground to Y=0 and targets its
surface for travel. `1.0.1.35-beta.1` enables the selected biome's decoration
on Flat. `1.0.1.36-beta.1` ports a multi-biome Flat/Void chunk generator and
accepts the existing authored distribution pages for those terrains. New Flat
chunks can vary vegetation by biome; Void keeps biome climate without adding
a floor or decorations. These are beta-port slices, not full 1.21.1 parity.
## Verification of this first slice
The 1.21.1 `compileJava` and `build` tasks passed offline after the backend
+63
View File
@@ -0,0 +1,63 @@
# Publishing Rivencraft with the Modrinth and CurseForge APIs
The local helper is [`scripts/publish_mod_sites.py`](../scripts/publish_mod_sites.py).
It previews a release unless `--publish` is explicitly supplied. The current
configuration is for the Minecraft **26.3** artifact only. The 1.21.1 line needs
its required external dependencies mapped on both sites before API publishing
is enabled for it.
## One-time token setup
Create a [Modrinth personal access token](https://docs.modrinth.com/api/#authentication)
in account settings with `VERSION_CREATE` (upload) and `USER_READ` (read-only
credential check) scopes. Create a
[CurseForge Upload API token](https://support.curseforge.com/support/solutions/articles/9000197321)
from the author's API Tokens page. Treat both as passwords: never put them in
git, chat, a release note, or a command-line argument.
In a terminal at the repository root, run these one at a time and paste the
corresponding token into the hidden prompt:
```sh
python3 scripts/publish_mod_sites.py store-token modrinth
python3 scripts/publish_mod_sites.py store-token curseforge
```
The tokens are stored in the desktop Secret Service keyring under
`application=rivencraft-publishing`, separately by site. The script also accepts
`RIVENCRAFT_MODRINTH_TOKEN` or `RIVENCRAFT_CURSEFORGE_TOKEN` environment variables,
but these take precedence over keyring entries and should not be put in shell
history or startup files.
Verify the credentials without uploading anything:
```sh
python3 scripts/publish_mod_sites.py check-token modrinth
python3 scripts/publish_mod_sites.py check-token curseforge
```
## Release workflow
Build the JAR, write a Markdown changelog, and preview the metadata:
```sh
python3 scripts/publish_mod_sites.py release \
--jar ports/mc263/build/libs/rivencraft-26.3-1.0.1.32-beta.1.jar \
--changelog ports/mc263/RELEASE_NOTES_1.0.1.32-beta.1.md \
--version 1.0.1.32-beta.1 \
--name 'Rivencraft 1.0.1.32-beta.1 — NeoForge 26.3' \
--minecraft 26.3 --java 25 --channel beta
```
For a *new* version only, append `--publish` to upload to both sites. Use
`--site modrinth` or `--site curseforge` to target one site. Modrinth rejects an
existing version number; CurseForge may not, so check its file list before any
retry after a partial failure. CurseForge approval may take additional time.
The helper uses Modrinth's documented multipart
[`POST /version`](https://docs.modrinth.com/api/operations/createversion/) and
CurseForge's documented
[`POST /api/projects/{projectId}/upload-file`](https://support.curseforge.com/support/solutions/articles/9000197321)
with the JAR, changelog, version channel, NeoForge, Minecraft version, Java
version, and client/server environment. It sets no external dependencies for
26.3, which uses Rivencraft's first-party Age backend.
+26 -2
View File
@@ -3,8 +3,9 @@
This is an **experimental beta port** for issues #97–#104, not gameplay parity
with the Minecraft 1.21.1 release. The 26.3 backend persists separate Ages and
their seeds. Books, the Book Binder, and the Ink Mixer work, and the Binder can
author Standard, Amplified, or Island terrain with supported biome-distribution
pages. Other terrain families, feature/material effects, portals, and much of
author Standard, Amplified, Island, Flat, or Void terrain with supported
biome-distribution pages. Other terrain families, feature/material effects,
portals, and much of
the 1.21.1 gameplay remain unported. Use a test world and keep a backup; do not
substitute this JAR for the 1.21.1 mod or import 1.21.1 saves.
@@ -153,6 +154,29 @@ terrain; and saved terrain/biome plans across reload. A Binder or Ink Mixer
beside another block no longer hides that block's faces. Unsupported authoring
still stays in the Binder rather than silently producing an incorrect Age.
The local `1.0.1.33-beta.1` slice adds Flat and Void terrain to Binder
authoring. Flat uses vanilla's superflat layers; Void has no generated floor
and uses the selected Biome for its environmental rules. Each accepts zero or
one authored Biome page. Unsupported multi-biome Flat/Void books remain in the
Binder until their custom generator is ported. The operator probe can create
test Ages with `rivencraft_port age create <name> <seed> flat|void`.
The local `1.0.1.34-beta.1` follow-up starts Flat's solid layers at Y=0
(bedrock at 0, dirt at 1–2, grass at 3) and lands players at the ground surface
instead of creating an elevated fallback platform. This affects newly
generated Flat chunks; previously generated Age chunks are not rewritten.
The local `1.0.1.35-beta.1` follow-up enables biome decoration on newly
generated Flat chunks, allowing the selected biome's trees, vegetation, and
other placed features to appear on the Flat surface. Void remains undecorated.
The local `1.0.1.36-beta.1` slice accepts multiple Biome pages with Flat and
Void terrain. It ports the 1.21.1 authored-biome Flat generator to 26.3, so
Tiled, Grid, and scaled regions affect climate in both terrain types and
surface decoration in Flat. Void continues to generate no floor or biome
decoration. The operator-only `age create_multi` probe creates two-biome test
Ages for the dedicated-server smoke test.
From the repository root:
```sh
@@ -0,0 +1,12 @@
# Rivencraft 26.3 beta 1.0.1.36-beta.1
This beta adds Flat and Void terrain to authored Ages on Minecraft 26.3.
- Bind Descriptive Books with Flat or Void terrain, including multiple Biome pages and supported biome-distribution pages.
- Flat ground starts at Y=0; travel lands on its surface rather than on an elevated fallback platform.
- Flat Ages generate the selected biomes' surface decorations, including trees and vegetation. Void Ages remain empty while retaining authored biome climate.
- Expanded dedicated-server checks for Flat and Void terrain, biome distribution, and persistence after restart.
This 26.3 port is still experimental and does not yet include all features of the separate Minecraft 1.21.1 version. Use a test world and keep a backup. Minecraft 1.21.1 saves are not supported by this beta.
Requires Minecraft 26.3, NeoForge 26.3.0.99 or a compatible 26.3 build, and Java 25.
+1 -1
View File
@@ -7,4 +7,4 @@ neo_version=26.3.0.99
mod_id=rivencraft
mod_name=Rivencraft
mod_license=LGPL-3.0-or-later
mod_version=1.0.1.32-beta.1
mod_version=1.0.1.36-beta.1
@@ -21,6 +21,7 @@ import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.network.PortBookNetworking;
import net.dnorberg.rivencraft.registry.ModCreativeTabs;
import net.dnorberg.rivencraft.registry.PortBiomeSources;
import net.dnorberg.rivencraft.registry.PortChunkGenerators;
import net.dnorberg.rivencraft.writing.LinkingBookRecipe;
import net.minecraft.core.component.DataComponentType;
import net.minecraft.core.registries.Registries;
@@ -152,6 +153,7 @@ public final class Rivencraft {
RECIPE_SERIALIZERS.register(modEventBus);
ModCreativeTabs.register(modEventBus);
PortBiomeSources.register(modEventBus);
PortChunkGenerators.register(modEventBus);
NeoForge.EVENT_BUS.addListener(RuntimeAgeBackend::onLevelLoad);
NeoForge.EVENT_BUS.addListener(PortAgeCommands::register);
NeoForge.EVENT_BUS.addListener(AgeCatalogSync::onLogin);
@@ -3,9 +3,11 @@ package net.dnorberg.rivencraft.age;
import com.mojang.brigadier.arguments.LongArgumentType;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.context.CommandContext;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.item.LinkBookTarget;
import net.minecraft.commands.Commands;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.core.registries.Registries;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
@@ -20,6 +22,7 @@ import net.neoforged.neoforge.event.RegisterCommandsEvent;
import net.neoforged.fml.ModList;
import java.util.Map;
import java.util.TreeMap;
import java.util.Locale;
/** Operator-only backend and synchronized-travel probes, not normal book behavior. */
public final class PortAgeCommands {
@@ -66,23 +69,44 @@ public final class PortAgeCommands {
.then(Commands.literal("create")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.executes(context -> {
String name = StringArgumentType.getString(context, "name");
Identifier id = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "age/" + name);
long seed = LongArgumentType.getLong(context, "seed");
ServerLevel level;
try {
level = RuntimeAgeBackend.create(
context.getSource().getServer(), id, seed);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal(
"Created " + id + " with seed " + level.getSeed()), true);
return 1;
}))))
.executes(context -> createAge(context, PortAgeTerrain.STANDARD))
.then(Commands.argument("terrain", StringArgumentType.word())
.executes(context -> {
PortAgeTerrain terrain;
try {
terrain = PortAgeTerrain.valueOf(StringArgumentType.getString(
context, "terrain").toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
context.getSource().sendFailure(Component.literal(
"Unknown terrain; use standard, amplified, island, flat, or void"));
return 0;
}
return createAge(context, terrain);
})))))
.then(Commands.literal("create_multi")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("seed", LongArgumentType.longArg())
.then(Commands.argument("terrain", StringArgumentType.word())
.executes(context -> {
PortAgeTerrain terrain;
try {
terrain = PortAgeTerrain.valueOf(StringArgumentType.getString(
context, "terrain").toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
context.getSource().sendFailure(Component.literal(
"Unknown terrain; use flat or void"));
return 0;
}
if (terrain != PortAgeTerrain.FLAT && terrain != PortAgeTerrain.VOID) {
context.getSource().sendFailure(Component.literal(
"Multi-biome probe supports flat or void only"));
return 0;
}
return createAge(context, terrain, new PortBiomePlan(
PortBiomePlan.Distribution.TILED, java.util.List.of(
Identifier.withDefaultNamespace("plains"),
Identifier.withDefaultNamespace("forest"))));
})))))
.then(Commands.literal("list")
.executes(context -> {
var ages = RuntimeAgeBackend.savedAges(context.getSource().getServer());
@@ -185,7 +209,33 @@ public final class PortAgeCommands {
.executes(context -> sampleAge(context, 0))
.then(Commands.argument("chunkOffset", IntegerArgumentType.integer(0))
.executes(context -> sampleAge(context,
IntegerArgumentType.getInteger(context, "chunkOffset"))))))));
IntegerArgumentType.getInteger(context, "chunkOffset"))))))
.then(Commands.literal("biome")
.then(Commands.argument("name", StringArgumentType.word())
.then(Commands.argument("x", IntegerArgumentType.integer())
.then(Commands.argument("z", IntegerArgumentType.integer())
.executes(PortAgeCommands::sampleBiome)))))));
}
private static int createAge(CommandContext<CommandSourceStack> context, PortAgeTerrain terrain) {
return createAge(context, terrain, null);
}
private static int createAge(CommandContext<CommandSourceStack> context,
PortAgeTerrain terrain, PortBiomePlan biomePlan) {
String name = StringArgumentType.getString(context, "name");
Identifier id = Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID, "age/" + name);
long seed = LongArgumentType.getLong(context, "seed");
ServerLevel level;
try {
level = RuntimeAgeBackend.create(context.getSource().getServer(), id, seed, biomePlan, terrain);
} catch (IllegalArgumentException | IllegalStateException exception) {
context.getSource().sendFailure(Component.literal(exception.getMessage()));
return 0;
}
context.getSource().sendSuccess(() -> Component.literal(
"Created " + id + " with seed " + level.getSeed() + " terrain=" + terrain), true);
return 1;
}
private static int sampleAge(com.mojang.brigadier.context.CommandContext<net.minecraft.commands.CommandSourceStack> context,
@@ -213,6 +263,23 @@ public final class PortAgeCommands {
return 1;
}
private static int sampleBiome(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");
level.getChunk(x >> 4, z >> 4);
Identifier biome = level.getBiome(new net.minecraft.core.BlockPos(x, 4, z))
.unwrapKey().orElseThrow().identifier();
context.getSource().sendSuccess(() -> Component.literal(
id + " biome at " + x + "," + z + "=" + biome), false);
return 1;
}
private static Identifier ageId(com.mojang.brigadier.context.CommandContext<net.minecraft.commands.CommandSourceStack> context) {
return Identifier.fromNamespaceAndPath(Rivencraft.MOD_ID,
"age/" + StringArgumentType.getString(context, "name"));
@@ -4,5 +4,7 @@ package net.dnorberg.rivencraft.age;
public enum PortAgeTerrain {
STANDARD,
AMPLIFIED,
ISLAND
ISLAND,
FLAT,
VOID
}
@@ -11,6 +11,8 @@ 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 {
@@ -31,16 +33,26 @@ public final class PortAgeTravel {
}
private static BlockPos findLanding(ServerLevel target) {
// 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 (target.getChunkSource().getGenerator()) {
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;
}
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);
BlockPos floor = foot.below();
if (target.getBlockState(floor).isFaceSturdy(target, floor, Direction.UP)
&& target.getBlockState(foot).getCollisionShape(target, foot).isEmpty()
&& target.getBlockState(foot.above()).getCollisionShape(target, foot.above()).isEmpty()) {
if (hasFooting(target, foot)) {
return foot;
}
}
@@ -60,4 +72,11 @@ public final class PortAgeTravel {
target.setBlock(foot.above(), Blocks.AIR.defaultBlockState(), 3);
return foot;
}
private static boolean hasFooting(ServerLevel target, BlockPos foot) {
BlockPos floor = foot.below();
return target.getBlockState(floor).isFaceSturdy(target, floor, Direction.UP)
&& target.getBlockState(foot).getCollisionShape(target, foot).isEmpty()
&& target.getBlockState(foot.above()).getCollisionShape(target, foot.above()).isEmpty();
}
}
@@ -0,0 +1,142 @@
package net.dnorberg.rivencraft.age;
import com.mojang.serialization.MapCodec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Stream;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.HolderSet;
import net.minecraft.server.level.WorldGenRegion;
import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.NoiseColumn;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeGenerationSettings;
import net.minecraft.world.level.biome.BiomeManager;
import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
import net.minecraft.world.level.levelgen.GenerationStep;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.blending.Blender;
import net.minecraft.world.level.levelgen.densityfunction.SamplerContext;
import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings;
import net.minecraft.world.level.levelgen.placement.PlacedFeature;
import net.minecraft.world.level.levelgen.structure.StructureSet;
import net.neoforged.neoforge.common.util.MemoizedFunction;
import org.jspecify.annotations.Nullable;
/** Flat layers with the authored biome distribution used by 1.21.1 Ages. */
public final class PortFlatChunkGenerator extends ChunkGenerator {
public static final MapCodec<PortFlatChunkGenerator> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
BiomeSource.CODEC.fieldOf("biome_source").forGetter(generator -> generator.biomeSource),
FlatLevelGeneratorSettings.CODEC.fieldOf("settings").forGetter(PortFlatChunkGenerator::settings)
).apply(instance, PortFlatChunkGenerator::new));
private final FlatLevelGeneratorSettings settings;
private final MemoizedFunction<Holder<Biome>, BiomeGenerationSettings> generationSettingsGetter;
public PortFlatChunkGenerator(BiomeSource biomeSource, FlatLevelGeneratorSettings settings) {
super(biomeSource, this.generationSettingsGetter = MemoizedFunction.of(
biome -> generationSettings(settings, biome)));
this.settings = settings;
}
public FlatLevelGeneratorSettings settings() {
return settings;
}
private static BiomeGenerationSettings generationSettings(FlatLevelGeneratorSettings settings, Holder<Biome> biome) {
// Void biomes still control climate, but must not place terrain features.
if (settings.getLayersInfo().isEmpty()) return BiomeGenerationSettings.EMPTY;
if (biome.equals(settings.getBiome())) return settings.adjustGenerationSettings(biome);
BiomeGenerationSettings.PlainBuilder builder = new BiomeGenerationSettings.PlainBuilder();
List<HolderSet<PlacedFeature>> features = biome.value().getGenerationSettings().features();
for (int step = 0; step < features.size(); step++) {
if (step == GenerationStep.Decoration.UNDERGROUND_STRUCTURES.ordinal()
|| step == GenerationStep.Decoration.SURFACE_STRUCTURES.ordinal()) continue;
for (Holder<PlacedFeature> feature : features.get(step)) builder.addFeature(step, feature);
}
return builder.build();
}
@Override
protected MapCodec<? extends ChunkGenerator> codec() {
return CODEC;
}
@Override
public ChunkGeneratorStructureState createState(HolderLookup<StructureSet> structures,
RandomState random, long seed) {
Stream<Holder<StructureSet>> entries = settings.structureOverrides()
.map(HolderSet::stream)
.orElseGet(() -> structures.listElements().map(holder -> (Holder<StructureSet>) holder));
return ChunkGeneratorStructureState.createForFlat(random, seed, getOrigin(random), biomeSource, entries);
}
@Override
public int getSpawnHeight(LevelHeightAccessor height) {
return height.getMinY() + Math.min(height.getHeight(), settings.getLayers().size());
}
@Override
public CompletableFuture<ChunkAccess> buildTerrain(ChunkAccess chunk, Blender blender,
RandomState random, StructureManager structures, BiomeManager biomes,
@Nullable WorldGenRegion carverBiomeRegion, Set<Holder<Biome>> possibleBiomes) {
List<BlockState> layers = settings.getLayers();
BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos();
Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG);
Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG);
for (int layer = 0; layer < Math.min(chunk.getHeight(), layers.size()); layer++) {
BlockState state = layers.get(layer);
if (state == null) continue;
int y = chunk.getMinY() + layer;
for (int x = 0; x < 16; x++) for (int z = 0; z < 16; z++) {
chunk.setBlockState(pos.set(x, y, z), state);
floor.update(x, y, z, state);
surface.update(x, y, z, state);
}
}
return CompletableFuture.completedFuture(chunk);
}
@Override
public int getBaseHeight(int x, int z, Heightmap.Types type,
LevelHeightAccessor height, RandomState random) {
List<BlockState> layers = settings.getLayers();
for (int layer = Math.min(layers.size() - 1, height.getHeight() - 1); layer >= 0; layer--) {
BlockState state = layers.get(layer);
if (state != null && type.isOpaque().test(state)) return height.getMinY() + layer + 1;
}
return height.getMinY();
}
@Override
public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor height, RandomState random) {
return new NoiseColumn(height.getMinY(), settings.getLayers().stream().limit(height.getHeight())
.map(state -> state == null ? Blocks.AIR.defaultBlockState() : state)
.toArray(BlockState[]::new));
}
@Override public void addDebugScreenInfo(List<String> info, RandomState random,
BlockPos pos, SamplerContext context) { }
@Override public void spawnOriginalMobs(WorldGenRegion level) { }
@Override public int getMinY() { return 0; }
@Override public int getGenDepth() { return 384; }
@Override public int getSeaLevel() { return -63; }
@Override
public void refreshFeaturesPerStep() {
super.refreshFeaturesPerStep();
generationSettingsGetter.clear();
settings.updateLayers();
}
}
@@ -8,6 +8,7 @@ import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalLong;
import net.dnorberg.rivencraft.Rivencraft;
import net.minecraft.core.registries.Registries;
@@ -20,12 +21,18 @@ import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.TicketStorage;
import net.minecraft.world.level.biome.BiomeManager;
import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.biome.FixedBiomeSource;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.Biomes;
import net.minecraft.world.level.biome.CheckerboardColumnBiomeSource;
import net.minecraft.world.level.dimension.LevelStem;
import net.minecraft.world.level.levelgen.FlatLevelSource;
import net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator;
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;
@@ -254,6 +261,25 @@ public final class RuntimeAgeBackend {
private record ArchivePaths(Path live, Path archive) {
}
private static BiomeSource authoredBiomeSource(MinecraftServer server, PortBiomePlan plan, long seed) {
if (plan.distribution() == PortBiomePlan.Distribution.NATIVE)
throw new IllegalArgumentException("Native biome distribution has no authored source");
var registry = server.registryAccess().lookupOrThrow(Registries.BIOME);
List<Holder<Biome>> biomes = plan.biomes().stream().map(id ->
(Holder<Biome>) registry.get(ResourceKey.create(Registries.BIOME, id))
.orElseThrow(() -> new IllegalArgumentException("Unknown authored biome: " + id)))
.toList();
return switch (plan.distribution()) {
case NATIVE -> throw new IllegalStateException("Native is not an override");
case SINGLE -> new FixedBiomeSource(biomes.getFirst());
case TILED, GRID -> new CheckerboardColumnBiomeSource(HolderSet.direct(biomes),
plan.distribution() == PortBiomePlan.Distribution.GRID ? 0 : 4);
case TINY, SMALL, MEDIUM, LARGE, HUGE -> new PortScaledBiomeSource(
HolderSet.direct(biomes), seed, plan.distribution().ordinal() -
PortBiomePlan.Distribution.TINY.ordinal());
};
}
private static ServerLevel load(MinecraftServer server, Identifier id, long seed,
PortBiomePlan biomePlan, PortAgeTerrain terrain) {
ResourceKey<Level> key = ResourceKey.create(Registries.DIMENSION, id);
@@ -266,26 +292,42 @@ public final class RuntimeAgeBackend {
.getValue(LevelStem.OVERWORLD);
if (overworldStem == null) throw new IllegalStateException("Missing Overworld LevelStem");
var generator = overworldStem.generator();
if ((biomePlan != null && biomePlan.distribution() != PortBiomePlan.Distribution.NATIVE)
if (terrain == PortAgeTerrain.FLAT || terrain == PortAgeTerrain.VOID) {
var biomes = server.registryAccess().lookupOrThrow(Registries.BIOME);
Holder<Biome> biome = biomes.getOrThrow(terrain == PortAgeTerrain.VOID
? Biomes.THE_VOID : Biomes.PLAINS);
if (biomePlan != null && !biomePlan.biomes().isEmpty()) {
Identifier biomeId = biomePlan.biomes().getFirst();
biome = biomes.get(ResourceKey.create(Registries.BIOME, biomeId))
.orElseThrow(() -> new IllegalArgumentException("Unknown authored biome: " + biomeId));
}
FlatLevelGeneratorSettings settings;
if (terrain == PortAgeTerrain.FLAT) {
settings = FlatLevelGeneratorSettings.getDefault(biomes,
server.registryAccess().lookupOrThrow(Registries.STRUCTURE_SET),
server.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE));
// Vanilla superflat begins at the modern minimum build height.
// Pad the column with air so the first solid layer begins at Y=0.
int airBelowZero = Math.max(0, -server.overworld().getMinY());
if (airBelowZero > 0)
settings.getLayersInfo().addFirst(new FlatLayerInfo(airBelowZero, Blocks.AIR));
settings = settings.withBiomeAndLayers(settings.getLayersInfo(),
settings.structureOverrides(), biome);
settings.setDecoration();
} else {
settings = new FlatLevelGeneratorSettings(Optional.empty(), biome, List.of());
settings.updateLayers();
}
generator = biomePlan != null && biomePlan.biomes().size() > 1
? new PortFlatChunkGenerator(authoredBiomeSource(server, biomePlan, seed), settings)
: new FlatLevelSource(settings);
} else if ((biomePlan != null && biomePlan.distribution() != PortBiomePlan.Distribution.NATIVE)
|| terrain != PortAgeTerrain.STANDARD) {
if (!(generator instanceof NoiseBasedChunkGenerator noise))
throw new IllegalStateException("Overworld generator cannot accept authored noise settings");
var source = generator.getBiomeSource();
if (biomePlan != null && biomePlan.distribution() != PortBiomePlan.Distribution.NATIVE) {
var registry = server.registryAccess().lookupOrThrow(Registries.BIOME);
List<Holder<Biome>> biomes = biomePlan.biomes().stream().map(biome ->
(Holder<Biome>) registry.get(ResourceKey.create(Registries.BIOME, biome))
.orElseThrow(() -> new IllegalArgumentException("Unknown authored biome: " + biome)))
.toList();
source = switch (biomePlan.distribution()) {
case NATIVE -> throw new IllegalStateException("Native is not an override");
case SINGLE -> new FixedBiomeSource(biomes.getFirst());
case TILED, GRID -> new CheckerboardColumnBiomeSource(HolderSet.direct(biomes),
biomePlan.distribution() == PortBiomePlan.Distribution.GRID ? 0 : 4);
case TINY, SMALL, MEDIUM, LARGE, HUGE -> new PortScaledBiomeSource(
HolderSet.direct(biomes), seed, biomePlan.distribution().ordinal() -
PortBiomePlan.Distribution.TINY.ordinal());
};
source = authoredBiomeSource(server, biomePlan, seed);
}
var settings = switch (terrain) {
case STANDARD -> noise.generatorSettings();
@@ -293,6 +335,7 @@ public final class RuntimeAgeBackend {
.getOrThrow(NoiseGeneratorSettings.AMPLIFIED);
case ISLAND -> server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS)
.getOrThrow(NoiseGeneratorSettings.FLOATING_ISLANDS);
case FLAT, VOID -> throw new IllegalStateException("Flat terrain should use FlatLevelSource");
};
generator = new NoiseBasedChunkGenerator(source, settings);
}
@@ -0,0 +1,25 @@
package net.dnorberg.rivencraft.registry;
import com.mojang.serialization.MapCodec;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.age.PortFlatChunkGenerator;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.registries.DeferredRegister;
/** Codec for the authored Flat/Void biome source and layer combination. */
public final class PortChunkGenerators {
private static final DeferredRegister<MapCodec<? extends ChunkGenerator>> GENERATORS =
DeferredRegister.create(Registries.CHUNK_GENERATOR, Rivencraft.MOD_ID);
static {
GENERATORS.register("age_flat", () -> PortFlatChunkGenerator.CODEC);
}
private PortChunkGenerators() {}
public static void register(IEventBus bus) {
GENERATORS.register(bus);
}
}
@@ -19,6 +19,10 @@ public final class PortAuthoredWriting {
Rivencraft.MOD_ID, "terrain/amplified");
private static final Identifier ISLAND_TERRAIN = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "terrain/island");
private static final Identifier FLAT_TERRAIN = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "terrain/flat");
private static final Identifier VOID_TERRAIN = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "terrain/void");
private PortAuthoredWriting() {}
@@ -28,8 +32,7 @@ public final class PortAuthoredWriting {
int biomes = 0;
int controllers = 0;
for (Identifier page : pages) {
if ((page.equals(STANDARD_TERRAIN) || page.equals(AMPLIFIED_TERRAIN)
|| page.equals(ISLAND_TERRAIN)) && !terrain) {
if (isTerrain(page) && !terrain) {
terrain = true;
} else if (controller(page) != null && controllers++ == 0) {
// An explicit controller needs an authored pool.
@@ -44,15 +47,22 @@ public final class PortAuthoredWriting {
if (controllers != 0) {
PortBiomePlan.Distribution distribution = pages.stream().map(PortAuthoredWriting::controller)
.filter(value -> value != null).findFirst().orElseThrow();
return switch (distribution) {
boolean valid = switch (distribution) {
case NATIVE -> biomes == 0;
case SINGLE -> biomes == 1;
default -> biomes >= 2;
};
return valid;
}
return true;
}
private static boolean isTerrain(Identifier page) {
return page.equals(STANDARD_TERRAIN) || page.equals(AMPLIFIED_TERRAIN)
|| page.equals(ISLAND_TERRAIN) || page.equals(FLAT_TERRAIN)
|| page.equals(VOID_TERRAIN);
}
private static PortBiomePlan.Distribution controller(Identifier page) {
if (!page.getNamespace().equals(Rivencraft.MOD_ID)) return null;
return switch (page.getPath()) {
@@ -73,18 +83,19 @@ public final class PortAuthoredWriting {
if (!supports(pages)) throw new IllegalArgumentException("Unsupported authored Age pages");
if (pages.contains(AMPLIFIED_TERRAIN)) return PortAgeTerrain.AMPLIFIED;
if (pages.contains(ISLAND_TERRAIN)) return PortAgeTerrain.ISLAND;
if (pages.contains(FLAT_TERRAIN)) return PortAgeTerrain.FLAT;
if (pages.contains(VOID_TERRAIN)) return PortAgeTerrain.VOID;
return PortAgeTerrain.STANDARD;
}
/** Resolves all authored biomes from the active registry before creating a level. */
public static PortBiomePlan biomePlan(List<Identifier> pages, HolderLookup.Provider registries) {
if (!supports(pages)) throw new IllegalArgumentException(
"Only Standard/Amplified/Island terrain with supported Biome pages is available in this port slice");
"Unsupported terrain or biome-page combination in this port slice");
List<Identifier> biomes = new ArrayList<>();
PortBiomePlan.Distribution distribution = null;
for (Identifier page : pages) {
if (page.equals(STANDARD_TERRAIN) || page.equals(AMPLIFIED_TERRAIN)
|| page.equals(ISLAND_TERRAIN)) continue;
if (isTerrain(page)) continue;
PortBiomePlan.Distribution selected = controller(page);
if (selected != null) { distribution = selected; continue; }
Identifier biome = PortRegistryPages.biomeId(page);
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""Publish a Rivencraft JAR to Modrinth and/or CurseForge.
Preview is the default. Tokens live in the Secret Service keyring, never in git.
"""
import argparse
import getpass
import json
import mimetypes
import os
from pathlib import Path
import secrets
import subprocess
import sys
import urllib.error
import urllib.request
MODRINTH_PROJECT = "GH2xfZJ8"
CURSEFORGE_PROJECT = "1766326"
USER_AGENT = "RivencraftReleaseTool/1.0 (https://git.paddedcell.org/dnorberg/rivencraft)"
SITES = ("modrinth", "curseforge")
def token_for(site: str) -> str:
environment = os.environ.get(f"RIVENCRAFT_{site.upper()}_TOKEN", "").strip()
if environment:
return environment
result = subprocess.run(
["secret-tool", "lookup", "application", "rivencraft-publishing", "site", site],
text=True,
capture_output=True,
check=False,
)
if result.returncode:
raise RuntimeError(f"Could not read {site} token from the system keyring")
token = result.stdout.strip()
if not token:
raise RuntimeError(f"No {site} token found; run `store-token {site}` first")
return token
def store_token(site: str) -> None:
token = getpass.getpass(f"Paste the {site} API token (input hidden): ").strip()
if not token:
raise RuntimeError("Empty token; nothing was stored")
subprocess.run(
[
"secret-tool", "store", "--label", f"Rivencraft {site} publishing token",
"application", "rivencraft-publishing", "site", site,
],
input=token + "\n",
text=True,
check=True,
)
print(f"Stored {site} token in the system keyring")
def check_token(site: str) -> None:
token = token_for(site)
if site == "modrinth":
try:
account = request_json("https://api.modrinth.com/v2/user", token=token)
except RuntimeError as error:
raise RuntimeError(
"Modrinth check failed. The token needs USER_READ for this "
"read-only check, plus VERSION_CREATE for publishing. "
f"API response: {error}"
) from None
print(f"Modrinth API access works for {account['username']}")
else:
versions = request_json(
"https://www.curseforge.com/api/game/versions",
token=token, token_header="X-Api-Token",
)
if not isinstance(versions, list):
raise RuntimeError("CurseForge returned an unexpected response")
print("CurseForge API token accepted")
def request_json(url: str, *, token: str | None = None, token_header: str = "Authorization",
payload: bytes | None = None, content_type: str | None = None,
method: str = "GET") -> dict | list:
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
if token:
headers[token_header] = token
if content_type:
headers["Content-Type"] = content_type
request = urllib.request.Request(url, data=payload, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=120) as response:
return json.load(response)
except urllib.error.HTTPError as error:
# Error bodies can contain server details, but never echo tokens.
detail = error.read(600).decode("utf-8", "replace")
raise RuntimeError(f"{url}: HTTP {error.code}: {detail}") from None
def multipart(fields: dict[str, str], jar: Path) -> tuple[bytes, str]:
boundary = "rivencraft-" + secrets.token_hex(16)
chunks = []
for name, value in fields.items():
chunks.extend((
f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n".encode(),
value.encode(), b"\r\n",
))
filename = jar.name.replace('"', "")
mime = mimetypes.guess_type(filename)[0] or "application/java-archive"
chunks.extend((
f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\nContent-Type: {mime}\r\n\r\n".encode(),
jar.read_bytes(), b"\r\n", f"--{boundary}--\r\n".encode(),
))
return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
def release_metadata(args: argparse.Namespace) -> tuple[dict, dict]:
changelog = args.changelog.read_text(encoding="utf-8")
if not changelog.strip():
raise RuntimeError("Changelog is empty")
modrinth = {
"name": args.name,
"version_number": args.version,
"changelog": changelog,
"dependencies": [],
"game_versions": [args.minecraft],
"version_type": args.channel,
"loaders": ["neoforge"],
"featured": False,
"status": "listed",
"project_id": MODRINTH_PROJECT,
"file_parts": ["file"],
"primary_file": "file",
"environment": "client_and_server",
}
curseforge = {
"changelog": changelog,
"changelogType": "markdown",
"displayName": args.name,
"gameVersionNames": [args.minecraft, "NeoForge", "Client", "Server", f"Java {args.java}"],
"releaseType": args.channel,
"isMarkedForManualRelease": False,
"relations": {"projects": []},
}
return modrinth, curseforge
def publish_modrinth(metadata: dict, jar: Path) -> None:
token = token_for("modrinth")
versions = request_json(
f"https://api.modrinth.com/v2/project/{MODRINTH_PROJECT}/version",
token=token,
)
if any(version.get("version_number") == metadata["version_number"] for version in versions):
raise RuntimeError(f"Modrinth version {metadata['version_number']} already exists")
body, content_type = multipart({"data": json.dumps(metadata)}, jar)
result = request_json(
"https://api.modrinth.com/v2/version", token=token, payload=body,
content_type=content_type, method="POST",
)
print(f"Modrinth: https://modrinth.com/mod/rivencraft/version/{result['version_number']}")
def publish_curseforge(metadata: dict, jar: Path) -> None:
token = token_for("curseforge")
body, content_type = multipart({"metadata": json.dumps(metadata)}, jar)
result = request_json(
f"https://www.curseforge.com/api/projects/{CURSEFORGE_PROJECT}/upload-file",
token=token, token_header="X-Api-Token", payload=body,
content_type=content_type, method="POST",
)
print(f"CurseForge: https://www.curseforge.com/minecraft/mc-mods/rivencraft/files/{result['id']} (pending approval)")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
store = sub.add_parser("store-token", help="Securely save a token in the system keyring")
store.add_argument("site", choices=SITES)
check = sub.add_parser("check-token", help="Verify stored token with a read-only API call")
check.add_argument("site", choices=SITES)
publish = sub.add_parser("release", help="Preview or publish a JAR release")
publish.add_argument("--jar", type=Path, required=True)
publish.add_argument("--changelog", type=Path, required=True)
publish.add_argument("--version", required=True)
publish.add_argument("--name", required=True)
publish.add_argument("--minecraft", required=True)
publish.add_argument("--java", required=True)
publish.add_argument("--channel", choices=("alpha", "beta", "release"), required=True)
publish.add_argument("--site", choices=(*SITES, "both"), default="both")
publish.add_argument("--publish", action="store_true", help="Actually upload; omitted by default")
args = parser.parse_args()
if args.command == "store-token":
store_token(args.site)
return
if args.command == "check-token":
check_token(args.site)
return
if not args.jar.is_file() or args.jar.suffix != ".jar":
raise RuntimeError(f"JAR does not exist: {args.jar}")
if not args.changelog.is_file():
raise RuntimeError(f"Changelog does not exist: {args.changelog}")
if args.minecraft != "26.3":
raise RuntimeError("Only the 26.3 line is configured; 1.21.1 needs required dependency relations")
if args.jar.name != f"rivencraft-{args.minecraft}-{args.version}.jar":
raise RuntimeError("JAR filename does not match Minecraft and release versions")
modrinth, curseforge = release_metadata(args)
sites = SITES if args.site == "both" else (args.site,)
print(f"JAR: {args.jar.resolve()} ({args.jar.stat().st_size} bytes)")
print(f"Release: {args.version} | {args.channel} | Minecraft {args.minecraft} | NeoForge | Java {args.java}")
print(f"Destinations: {', '.join(sites)}")
if not args.publish:
print("PREVIEW ONLY: add --publish to upload")
return
if "modrinth" in sites:
publish_modrinth(modrinth, args.jar)
if "curseforge" in sites:
publish_curseforge(curseforge, args.jar)
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as error:
print(f"Publishing failed: {error}", file=sys.stderr)
sys.exit(1)
+45 -1
View File
@@ -69,6 +69,10 @@ start_server first
send_and_wait 'rivencraft_port compat' 'Registered biomes by namespace:'
send_and_wait 'rivencraft_port age create smoke_alpha 123456789' 'Created rivencraft:age/smoke_alpha with seed 123456789'
send_and_wait 'rivencraft_port age create smoke_beta 987654321' 'Created rivencraft:age/smoke_beta with seed 987654321'
send_and_wait 'rivencraft_port age create smoke_flat 24680 flat' 'Created rivencraft:age/smoke_flat with seed 24680 terrain=FLAT'
send_and_wait 'rivencraft_port age create smoke_void 13579 void' 'Created rivencraft:age/smoke_void with seed 13579 terrain=VOID'
send_and_wait 'rivencraft_port age create_multi smoke_flat_multi 24681 flat' 'Created rivencraft:age/smoke_flat_multi with seed 24681 terrain=FLAT'
send_and_wait 'rivencraft_port age create_multi smoke_void_multi 13580 void' 'Created rivencraft:age/smoke_void_multi with seed 13580 terrain=VOID'
# The index must reach disk while the server is still running. A graceful
# shutdown would hide the interrupted-save regression this checks.
index_file="$server_dir/world/dimensions/minecraft/overworld/data/rivencraft/runtime_ages.dat"
@@ -78,26 +82,66 @@ index_file="$server_dir/world/dimensions/minecraft/overworld/data/rivencraft/run
}
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_alpha'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_beta'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_flat'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_void'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_flat_multi'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_void_multi'
send_and_wait 'rivencraft_port age sample smoke_alpha' 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_beta' 'rivencraft:age/smoke_beta seed=987654321 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_flat' 'rivencraft:age/smoke_flat seed=24680 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_void' 'rivencraft:age/smoke_void seed=13579 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_flat_multi' 'rivencraft:age/smoke_flat_multi seed=24681 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_void_multi' 'rivencraft:age/smoke_void_multi seed=13580 chunkOffset=0 terrain='
send_and_wait 'execute in rivencraft:age/smoke_flat run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_flat to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_void run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_void to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_flat_multi run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_flat_multi to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_void_multi run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_void_multi to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_flat if block 0 0 0 minecraft:bedrock run say Flat bedrock starts at Y0' 'Flat bedrock starts at Y0'
send_and_wait 'execute in rivencraft:age/smoke_flat if block 0 3 0 minecraft:grass_block run say Flat surface is Y3' 'Flat surface is Y3'
send_and_wait 'execute in rivencraft:age/smoke_void if block 0 0 0 minecraft:air run say Void remains empty' 'Void remains empty'
send_and_wait 'execute in rivencraft:age/smoke_flat_multi if block 0 0 0 minecraft:bedrock run say Multi Flat bedrock starts at Y0' 'Multi Flat bedrock starts at Y0'
send_and_wait 'execute in rivencraft:age/smoke_void_multi if block 0 0 0 minecraft:air run say Multi Void remains empty' 'Multi Void remains empty'
# Check region interiors after loading the sampled chunks.
send_and_wait 'rivencraft_port age biome smoke_flat_multi 128 128' 'rivencraft:age/smoke_flat_multi biome at 128,128=minecraft:plains'
send_and_wait 'rivencraft_port age biome smoke_flat_multi 384 128' 'rivencraft:age/smoke_flat_multi biome at 384,128=minecraft:forest'
send_and_wait 'rivencraft_port age biome smoke_void_multi 384 128' 'rivencraft:age/smoke_void_multi biome at 384,128=minecraft:forest'
alpha_before=$(grep -F 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
beta_before=$(grep -F 'rivencraft:age/smoke_beta seed=987654321 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
flat_before=$(grep -F 'rivencraft:age/smoke_flat seed=24680 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
void_before=$(grep -F 'rivencraft:age/smoke_void seed=13579 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
if [[ "$alpha_before" == "$beta_before" ]]; then
printf 'Different Age seeds produced the same terrain probe: %s\n' "$alpha_before" >&2
exit 1
fi
if [[ "$flat_before" == "$void_before" ]]; then
printf 'Flat and Void Ages produced the same height probe: %s\n' "$flat_before" >&2
exit 1
fi
stop_server
start_server restart
send_and_wait 'rivencraft_port age list' 'smoke_alpha'
send_and_wait 'rivencraft_port age sample smoke_alpha' 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_beta' 'rivencraft:age/smoke_beta seed=987654321 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_flat' 'rivencraft:age/smoke_flat seed=24680 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_void' 'rivencraft:age/smoke_void seed=13579 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_flat_multi' 'rivencraft:age/smoke_flat_multi seed=24681 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_void_multi' 'rivencraft:age/smoke_void_multi seed=13580 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age biome smoke_flat_multi 384 128' 'rivencraft:age/smoke_flat_multi biome at 384,128=minecraft:forest'
send_and_wait 'rivencraft_port age biome smoke_void_multi 384 128' 'rivencraft:age/smoke_void_multi biome at 384,128=minecraft:forest'
alpha_after=$(grep -F 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
beta_after=$(grep -F 'rivencraft:age/smoke_beta seed=987654321 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
flat_after=$(grep -F 'rivencraft:age/smoke_flat seed=24680 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
void_after=$(grep -F 'rivencraft:age/smoke_void seed=13579 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
[[ "$alpha_before" == "$alpha_after" && "$beta_before" == "$beta_after" ]] || {
printf 'Age terrain changed across restart: %s/%s -> %s/%s\n' \
"$alpha_before" "$beta_before" "$alpha_after" "$beta_after" >&2
exit 1
}
[[ "$flat_before" == "$flat_after" && "$void_before" == "$void_after" ]] || {
printf 'Flat/Void terrain changed across restart: %s/%s -> %s/%s\n' \
"$flat_before" "$void_before" "$flat_after" "$void_after" >&2
exit 1
}
stop_server
printf '26.3 dedicated-server smoke passed: two distinct Ages persisted across restart.\n'
printf '26.3 dedicated-server smoke passed: Standard, Flat and Void Ages persisted across restart.\n'