Compare commits

...
Author SHA1 Message Date
dnorberg 9ccec18c18 Port dry Cave Ages and safe landing to 26.3 beta
Build / build (push) Canceled after 0s
2026-11-01 01:34:14 -05:00
dnorberg d9464fad76 Add Gitea API release publishing to CLI helper
Build / build (push) Canceled after 0s
2026-10-31 20:46:41 -04:00
dnorberg d6e9a58a24 Omit empty CurseForge relations in release uploads
Build / build (push) Canceled after 0s
2026-10-31 20:37:48 -04:00
14 changed files with 240 additions and 29 deletions

No files matched your search

+13
View File
@@ -495,6 +495,19 @@ accepts the existing authored distribution pages for those terrains. New Flat
chunks can vary vegetation by biome; Void keeps biome climate without adding 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. a floor or decorations. These are beta-port slices, not full 1.21.1 parity.
The local `1.0.1.37-beta.1` slice ports Cave terrain authoring using the
26.3 caves noise preset. Cave and Nether Biome pages can be distributed
through an Age, and travel searches for footing from Y=64 downward rather
than mistaking the cave roof for the surface. Nether-style cave material
replacement from 1.21.1 remains a separate port task. The local
`1.0.1.38-beta.1` follow-up
keeps Cave Ages dry by default: the vanilla 26.3 Cave preset otherwise fills
low openings with water. This changes newly generated chunks only; a future
Flooded page will deliberately set an authored water level in both maintained
versions.
The local `1.0.1.39-beta.1` follow-up makes the 26.3 landing search reject
fluid at feet and head height and prevents a submerged fallback platform.
## Verification of this first slice ## Verification of this first slice
The 1.21.1 `compileJava` and `build` tasks passed offline after the backend The 1.21.1 `compileJava` and `build` tasks passed offline after the backend
+21 -2
View File
@@ -1,4 +1,4 @@
# Publishing Rivencraft with the Modrinth and CurseForge APIs # Publishing Rivencraft with the Gitea, Modrinth, and CurseForge APIs
The local helper is [`scripts/publish_mod_sites.py`](../scripts/publish_mod_sites.py). 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 It previews a release unless `--publish` is explicitly supplied. The current
@@ -8,6 +8,12 @@ is enabled for it.
## One-time token setup ## One-time token setup
For Gitea, an existing personal access token is sufficient if it has
`write:repository` scope. That scope covers release creation and attachment
upload; do not grant `all` if a narrower token is available. Create or inspect
tokens under [Gitea user settings → Applications](https://git.paddedcell.org/user/settings/applications).
The helper checks token identity and scope without displaying the secret.
Create a [Modrinth personal access token](https://docs.modrinth.com/api/#authentication) Create a [Modrinth personal access token](https://docs.modrinth.com/api/#authentication)
in account settings with `VERSION_CREATE` (upload) and `USER_READ` (read-only in account settings with `VERSION_CREATE` (upload) and `USER_READ` (read-only
credential check) scopes. Create a credential check) scopes. Create a
@@ -21,6 +27,7 @@ corresponding token into the hidden prompt:
```sh ```sh
python3 scripts/publish_mod_sites.py store-token modrinth python3 scripts/publish_mod_sites.py store-token modrinth
python3 scripts/publish_mod_sites.py store-token curseforge python3 scripts/publish_mod_sites.py store-token curseforge
python3 scripts/publish_mod_sites.py store-token gitea
``` ```
The tokens are stored in the desktop Secret Service keyring under The tokens are stored in the desktop Secret Service keyring under
@@ -34,6 +41,7 @@ Verify the credentials without uploading anything:
```sh ```sh
python3 scripts/publish_mod_sites.py check-token modrinth python3 scripts/publish_mod_sites.py check-token modrinth
python3 scripts/publish_mod_sites.py check-token curseforge python3 scripts/publish_mod_sites.py check-token curseforge
python3 scripts/publish_mod_sites.py check-token gitea
``` ```
## Release workflow ## Release workflow
@@ -54,7 +62,18 @@ For a *new* version only, append `--publish` to upload to both sites. Use
existing version number; CurseForge may not, so check its file list before any existing version number; CurseForge may not, so check its file list before any
retry after a partial failure. CurseForge approval may take additional time. retry after a partial failure. CurseForge approval may take additional time.
The helper uses Modrinth's documented multipart The default destination is now all three sites. The Gitea release step requires
the annotated version tag to have been pushed first. Gitea's `Vx.y.z.b-mc26.3-beta.1`
tag convention is derived from the version and channel; an existing release
with different title or notes is never overwritten. If release creation
succeeds but its JAR upload fails, rerun with `--site gitea --publish` to resume
only the missing attachment. `--site both` retains the earlier Modrinth and
CurseForge-only behavior.
The helper uses Gitea's documented
[`POST /repos/{owner}/{repo}/releases`](https://docs.gitea.com/api/operations/repo-create-release/)
and [release attachment endpoint](https://docs.gitea.com/api/operations/repo-create-release-attachment/),
plus Modrinth's documented multipart
[`POST /version`](https://docs.modrinth.com/api/operations/createversion/) and [`POST /version`](https://docs.modrinth.com/api/operations/createversion/) and
CurseForge's documented CurseForge's documented
[`POST /api/projects/{projectId}/upload-file`](https://support.curseforge.com/support/solutions/articles/9000197321) [`POST /api/projects/{projectId}/upload-file`](https://support.curseforge.com/support/solutions/articles/9000197321)
+18 -1
View File
@@ -3,7 +3,7 @@
This is an **experimental beta port** for issues #97–#104, not gameplay parity 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 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 their seeds. Books, the Book Binder, and the Ink Mixer work, and the Binder can
author Standard, Amplified, Island, Flat, or Void terrain with supported author Standard, Amplified, Island, Flat, Void, or Cave terrain with supported
biome-distribution pages. Other terrain families, feature/material effects, biome-distribution pages. Other terrain families, feature/material effects,
portals, and much of portals, and much of
the 1.21.1 gameplay remain unported. Use a test world and keep a backup; do not the 1.21.1 gameplay remain unported. Use a test world and keep a backup; do not
@@ -177,6 +177,23 @@ surface decoration in Flat. Void continues to generate no floor or biome
decoration. The operator-only `age create_multi` probe creates two-biome test decoration. The operator-only `age create_multi` probe creates two-biome test
Ages for the dedicated-server smoke test. Ages for the dedicated-server smoke test.
The local `1.0.1.37-beta.1` slice adds the Cave terrain page to Binder
authoring. It uses Minecraft's caves noise settings, accepts Cave and Nether
biome pages (including multi-biome distribution), and searches below Y=64 for
a safe arrival instead of landing on the roof. The 26.3 vanilla Cave preset
defaults to water below Y=32, so this port derives a dry Cave setting with
air as its default fluid and the sea level below the world floor. This applies
to newly generated Cave chunks; it does not drain existing chunks. Cave-specific Nether surface
materials from 1.21.1 are not ported yet. The dedicated-server smoke now
creates and reloads Cave Ages alongside the other terrain families.
The dry-Cave follow-up is packaged as `1.0.1.38-beta.1`; no 26.3 release is
published until the in-game check passes and publication is explicitly requested.
The local `1.0.1.39-beta.1` follow-up rejects fluid at both feet and head
height during safe-landing searches. It also refuses to carve the fallback
platform into submerged space if no dry footing can be found.
From the repository root: From the repository root:
```sh ```sh
@@ -0,0 +1,14 @@
# Rivencraft 26.3 beta 1.0.1.39-beta.1
This beta adds Cave terrain to authored Ages on Minecraft 26.3.
- Bind Descriptive Books with the Cave terrain page and Cave or Nether biome pages, including supported multi-biome distributions.
- Cave Ages use the cave noise shape but remain dry by default. Newly generated chunks no longer flood below the vanilla cave sea level.
- Cave travel searches from Y=64 downward for dry, properly footed space instead of landing on the cave roof or underwater.
- Expanded dedicated-server checks for Cave creation, biome distribution, and persistence after restart.
The player verified the Cave and safe-landing changes in game. Existing generated chunks are not drained or regenerated. A future Flooded page will provide an authored water level; see [issue #107](https://git.paddedcell.org/dnorberg/rivencraft/issues/107).
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_id=rivencraft
mod_name=Rivencraft mod_name=Rivencraft
mod_license=LGPL-3.0-or-later mod_license=LGPL-3.0-or-later
mod_version=1.0.1.36-beta.1 mod_version=1.0.1.39-beta.1
@@ -78,7 +78,7 @@ public final class PortAgeCommands {
context, "terrain").toUpperCase(Locale.ROOT)); context, "terrain").toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) { } catch (IllegalArgumentException exception) {
context.getSource().sendFailure(Component.literal( context.getSource().sendFailure(Component.literal(
"Unknown terrain; use standard, amplified, island, flat, or void")); "Unknown terrain; use standard, amplified, island, cave, flat, or void"));
return 0; return 0;
} }
return createAge(context, terrain); return createAge(context, terrain);
@@ -94,18 +94,23 @@ public final class PortAgeCommands {
context, "terrain").toUpperCase(Locale.ROOT)); context, "terrain").toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) { } catch (IllegalArgumentException exception) {
context.getSource().sendFailure(Component.literal( context.getSource().sendFailure(Component.literal(
"Unknown terrain; use flat or void")); "Unknown terrain; use cave, flat, or void"));
return 0; return 0;
} }
if (terrain != PortAgeTerrain.FLAT && terrain != PortAgeTerrain.VOID) { if (terrain != PortAgeTerrain.CAVE
&& terrain != PortAgeTerrain.FLAT
&& terrain != PortAgeTerrain.VOID) {
context.getSource().sendFailure(Component.literal( context.getSource().sendFailure(Component.literal(
"Multi-biome probe supports flat or void only")); "Multi-biome probe supports cave, flat, or void only"));
return 0; return 0;
} }
return createAge(context, terrain, new PortBiomePlan( return createAge(context, terrain, new PortBiomePlan(
PortBiomePlan.Distribution.TILED, java.util.List.of( PortBiomePlan.Distribution.TILED,
Identifier.withDefaultNamespace("plains"), terrain == PortAgeTerrain.CAVE
Identifier.withDefaultNamespace("forest")))); ? java.util.List.of(Identifier.withDefaultNamespace("lush_caves"),
Identifier.withDefaultNamespace("dripstone_caves"))
: java.util.List.of(Identifier.withDefaultNamespace("plains"),
Identifier.withDefaultNamespace("forest"))));
}))))) })))))
.then(Commands.literal("list") .then(Commands.literal("list")
.executes(context -> { .executes(context -> {
@@ -5,6 +5,7 @@ public enum PortAgeTerrain {
STANDARD, STANDARD,
AMPLIFIED, AMPLIFIED,
ISLAND, ISLAND,
CAVE,
FLAT, FLAT,
VOID VOID
} }
@@ -33,6 +33,27 @@ public final class PortAgeTravel {
} }
private static BlockPos findLanding(ServerLevel target) { private static BlockPos findLanding(ServerLevel target) {
if (target.getChunkSource().getGenerator() instanceof net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator noise
&& noise.generatorSettings().value().noiseSettings().equals(target.registryAccess()
.lookupOrThrow(Registries.NOISE_SETTINGS)
.getOrThrow(net.minecraft.world.level.levelgen.NoiseGeneratorSettings.CAVES)
.value().noiseSettings())) {
// The cave roof is not a valid arrival surface. Search down from
// Legacy's Y=64 start, then construct the normal safety platform
// at that height only when no footed opening can be found.
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;
for (int y = Math.min(64, target.getMaxY() - 2); y > target.getMinY(); y--) {
BlockPos foot = new BlockPos(x, y, z);
if (hasFooting(target, foot)) return foot;
}
}
}
}
return makePlatform(target, new BlockPos(0, 64, 0));
}
// A Flat Age has a known surface. Use it directly before a heightmap // 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. // fallback can place a platform in the air above a low superflat world.
FlatLevelGeneratorSettings flatSettings = switch (target.getChunkSource().getGenerator()) { FlatLevelGeneratorSettings flatSettings = switch (target.getChunkSource().getGenerator()) {
@@ -62,10 +83,21 @@ public final class PortAgeTravel {
// origin is ocean or foliage. Final book landing rules are ported later. // 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); int y = Math.max(64, target.getHeight(Heightmap.Types.MOTION_BLOCKING, 0, 0) + 2);
if (y + 2 >= target.getMaxY()) return null; if (y + 2 >= target.getMaxY()) return null;
BlockPos foot = new BlockPos(0, y, 0); return makePlatform(target, new BlockPos(0, y, 0));
}
private static BlockPos makePlatform(ServerLevel target, BlockPos foot) {
if (foot.getY() <= target.getMinY() || foot.getY() + 2 >= target.getMaxY()) return null;
// A carved pocket inside a flooded cave immediately refills. Refuse
// that landing instead of placing the player in submerged spawn space.
if (!target.getFluidState(foot).isEmpty() || !target.getFluidState(foot.above()).isEmpty()) return null;
for (Direction direction : Direction.Plane.HORIZONTAL) {
if (!target.getFluidState(foot.relative(direction)).isEmpty()
|| !target.getFluidState(foot.above().relative(direction)).isEmpty()) return null;
}
for (int x = -1; x <= 1; x++) { for (int x = -1; x <= 1; x++) {
for (int z = -1; z <= 1; z++) { for (int z = -1; z <= 1; z++) {
target.setBlock(new BlockPos(x, y - 1, z), Rivencraft.CRYSTAL_BLOCK.get().defaultBlockState(), 3); target.setBlock(foot.offset(x, -1, z), Rivencraft.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
} }
} }
target.setBlock(foot, Blocks.AIR.defaultBlockState(), 3); target.setBlock(foot, Blocks.AIR.defaultBlockState(), 3);
@@ -76,6 +108,8 @@ public final class PortAgeTravel {
private static boolean hasFooting(ServerLevel target, BlockPos foot) { private static boolean hasFooting(ServerLevel target, BlockPos foot) {
BlockPos floor = foot.below(); BlockPos floor = foot.below();
return target.getBlockState(floor).isFaceSturdy(target, floor, Direction.UP) return target.getBlockState(floor).isFaceSturdy(target, floor, Direction.UP)
&& target.getFluidState(foot).isEmpty()
&& target.getFluidState(foot.above()).isEmpty()
&& target.getBlockState(foot).getCollisionShape(target, foot).isEmpty() && target.getBlockState(foot).getCollisionShape(target, foot).isEmpty()
&& target.getBlockState(foot.above()).getCollisionShape(target, foot.above()).isEmpty(); && target.getBlockState(foot.above()).getCollisionShape(target, foot.above()).isEmpty();
} }
@@ -11,6 +11,7 @@ import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.OptionalLong; import java.util.OptionalLong;
import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.writing.PortRegistryPages;
import net.minecraft.core.registries.Registries; import net.minecraft.core.registries.Registries;
import net.minecraft.core.Holder; import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet; import net.minecraft.core.HolderSet;
@@ -325,7 +326,19 @@ public final class RuntimeAgeBackend {
|| terrain != PortAgeTerrain.STANDARD) { || terrain != PortAgeTerrain.STANDARD) {
if (!(generator instanceof NoiseBasedChunkGenerator noise)) if (!(generator instanceof NoiseBasedChunkGenerator noise))
throw new IllegalStateException("Overworld generator cannot accept authored noise settings"); throw new IllegalStateException("Overworld generator cannot accept authored noise settings");
var source = generator.getBiomeSource(); var source = terrain == PortAgeTerrain.CAVE
? new FixedBiomeSource(server.registryAccess().lookupOrThrow(Registries.BIOME)
.getOrThrow(Biomes.LUSH_CAVES))
: generator.getBiomeSource();
if (terrain == PortAgeTerrain.CAVE && biomePlan != null) {
var biomes = server.registryAccess().lookupOrThrow(Registries.BIOME);
for (Identifier biomeId : biomePlan.biomes()) {
var holder = biomes.get(ResourceKey.create(Registries.BIOME, biomeId))
.orElseThrow(() -> new IllegalArgumentException("Unknown authored biome: " + biomeId));
if (!PortRegistryPages.isCaveBiome(holder))
throw new IllegalArgumentException("Cave terrain requires a Cave or Nether biome: " + biomeId);
}
}
if (biomePlan != null && biomePlan.distribution() != PortBiomePlan.Distribution.NATIVE) { if (biomePlan != null && biomePlan.distribution() != PortBiomePlan.Distribution.NATIVE) {
source = authoredBiomeSource(server, biomePlan, seed); source = authoredBiomeSource(server, biomePlan, seed);
} }
@@ -335,6 +348,7 @@ public final class RuntimeAgeBackend {
.getOrThrow(NoiseGeneratorSettings.AMPLIFIED); .getOrThrow(NoiseGeneratorSettings.AMPLIFIED);
case ISLAND -> server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS) case ISLAND -> server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS)
.getOrThrow(NoiseGeneratorSettings.FLOATING_ISLANDS); .getOrThrow(NoiseGeneratorSettings.FLOATING_ISLANDS);
case CAVE -> dryCaveSettings(server);
case FLAT, VOID -> throw new IllegalStateException("Flat terrain should use FlatLevelSource"); case FLAT, VOID -> throw new IllegalStateException("Flat terrain should use FlatLevelSource");
}; };
generator = new NoiseBasedChunkGenerator(source, settings); generator = new NoiseBasedChunkGenerator(source, settings);
@@ -369,4 +383,14 @@ public final class RuntimeAgeBackend {
throw exception; throw exception;
} }
} }
private static Holder<NoiseGeneratorSettings> dryCaveSettings(MinecraftServer server) {
NoiseGeneratorSettings caves = server.registryAccess().lookupOrThrow(Registries.NOISE_SETTINGS)
.getOrThrow(NoiseGeneratorSettings.CAVES).value();
return Holder.direct(new NoiseGeneratorSettings(
caves.noiseSettings(), caves.defaultBlock(), Blocks.AIR.defaultBlockState(),
caves.noiseRouter(), caves.materialRule(), caves.spawnTarget(),
caves.noiseSettings().minY() - 1, caves.disableMobGeneration(), Optional.empty(),
caves.useLegacyRandomSource(), caves.debugFunctions()));
}
} }
@@ -23,6 +23,8 @@ public final class PortAuthoredWriting {
Rivencraft.MOD_ID, "terrain/flat"); Rivencraft.MOD_ID, "terrain/flat");
private static final Identifier VOID_TERRAIN = Identifier.fromNamespaceAndPath( private static final Identifier VOID_TERRAIN = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "terrain/void"); Rivencraft.MOD_ID, "terrain/void");
private static final Identifier CAVE_TERRAIN = Identifier.fromNamespaceAndPath(
Rivencraft.MOD_ID, "terrain/cave");
private PortAuthoredWriting() {} private PortAuthoredWriting() {}
@@ -60,7 +62,7 @@ public final class PortAuthoredWriting {
private static boolean isTerrain(Identifier page) { private static boolean isTerrain(Identifier page) {
return page.equals(STANDARD_TERRAIN) || page.equals(AMPLIFIED_TERRAIN) return page.equals(STANDARD_TERRAIN) || page.equals(AMPLIFIED_TERRAIN)
|| page.equals(ISLAND_TERRAIN) || page.equals(FLAT_TERRAIN) || page.equals(ISLAND_TERRAIN) || page.equals(FLAT_TERRAIN)
|| page.equals(VOID_TERRAIN); || page.equals(VOID_TERRAIN) || page.equals(CAVE_TERRAIN);
} }
private static PortBiomePlan.Distribution controller(Identifier page) { private static PortBiomePlan.Distribution controller(Identifier page) {
@@ -85,6 +87,7 @@ public final class PortAuthoredWriting {
if (pages.contains(ISLAND_TERRAIN)) return PortAgeTerrain.ISLAND; if (pages.contains(ISLAND_TERRAIN)) return PortAgeTerrain.ISLAND;
if (pages.contains(FLAT_TERRAIN)) return PortAgeTerrain.FLAT; if (pages.contains(FLAT_TERRAIN)) return PortAgeTerrain.FLAT;
if (pages.contains(VOID_TERRAIN)) return PortAgeTerrain.VOID; if (pages.contains(VOID_TERRAIN)) return PortAgeTerrain.VOID;
if (pages.contains(CAVE_TERRAIN)) return PortAgeTerrain.CAVE;
return PortAgeTerrain.STANDARD; return PortAgeTerrain.STANDARD;
} }
@@ -94,14 +97,17 @@ public final class PortAuthoredWriting {
"Unsupported terrain or biome-page combination in this port slice"); "Unsupported terrain or biome-page combination in this port slice");
List<Identifier> biomes = new ArrayList<>(); List<Identifier> biomes = new ArrayList<>();
PortBiomePlan.Distribution distribution = null; PortBiomePlan.Distribution distribution = null;
boolean caveTerrain = pages.contains(CAVE_TERRAIN);
for (Identifier page : pages) { for (Identifier page : pages) {
if (isTerrain(page)) continue; if (isTerrain(page)) continue;
PortBiomePlan.Distribution selected = controller(page); PortBiomePlan.Distribution selected = controller(page);
if (selected != null) { distribution = selected; continue; } if (selected != null) { distribution = selected; continue; }
Identifier biome = PortRegistryPages.biomeId(page); Identifier biome = PortRegistryPages.biomeId(page);
var registry = registries.lookupOrThrow(Registries.BIOME); var registry = registries.lookupOrThrow(Registries.BIOME);
if (registry.get(ResourceKey.create(Registries.BIOME, biome)).isEmpty()) var holder = registry.get(ResourceKey.create(Registries.BIOME, biome))
throw new IllegalArgumentException("Unknown authored biome: " + biome); .orElseThrow(() -> new IllegalArgumentException("Unknown authored biome: " + biome));
if (caveTerrain && !PortRegistryPages.isCaveBiome(holder))
throw new IllegalArgumentException("Cave terrain requires a Cave or Nether biome: " + biome);
biomes.add(biome); biomes.add(biome);
} }
if (distribution == PortBiomePlan.Distribution.NATIVE) if (distribution == PortBiomePlan.Distribution.NATIVE)
@@ -67,7 +67,7 @@ public final class PortRegistryPages {
public static List<ItemStack> biomePages(HolderLookup.Provider registries, boolean caves) { public static List<ItemStack> biomePages(HolderLookup.Provider registries, boolean caves) {
return registries.lookupOrThrow(Registries.BIOME).listElements() return registries.lookupOrThrow(Registries.BIOME).listElements()
.filter(holder -> !holder.key().identifier().equals(DEFERRED_PLACEHOLDER)) .filter(holder -> !holder.key().identifier().equals(DEFERRED_PLACEHOLDER))
.filter(holder -> isCave(holder) == caves) .filter(holder -> isCaveBiome(holder) == caves)
.map(holder -> PageItem.createSymbolPage(biomePageId(holder.key().identifier()))) .map(holder -> PageItem.createSymbolPage(biomePageId(holder.key().identifier())))
.sorted((left, right) -> left.get(Rivencraft.SYMBOL_PAGE_ID.get()).toString() .sorted((left, right) -> left.get(Rivencraft.SYMBOL_PAGE_ID.get()).toString()
.compareTo(right.get(Rivencraft.SYMBOL_PAGE_ID.get()).toString())) .compareTo(right.get(Rivencraft.SYMBOL_PAGE_ID.get()).toString()))
@@ -81,7 +81,7 @@ public final class PortRegistryPages {
.map(id -> PageItem.createSymbolPage(blockPageId(id))).toList(); .map(id -> PageItem.createSymbolPage(blockPageId(id))).toList();
} }
private static boolean isCave(net.minecraft.core.Holder.Reference<net.minecraft.world.level.biome.Biome> holder) { public static boolean isCaveBiome(net.minecraft.core.Holder.Reference<net.minecraft.world.level.biome.Biome> holder) {
String path = holder.key().identifier().getPath(); String path = holder.key().identifier().getPath();
return holder.is(CAVE_BIOMES) || holder.is(BiomeTags.IS_NETHER) return holder.is(CAVE_BIOMES) || holder.is(BiomeTags.IS_NETHER)
|| path.equals("deep_dark") || path.endsWith("_caves"); || path.equals("deep_dark") || path.endsWith("_caves");
@@ -0,0 +1,8 @@
{
"replace": false,
"values": [
"minecraft:lush_caves",
"minecraft:dripstone_caves",
"minecraft:deep_dark"
]
}
+66 -9
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Publish a Rivencraft JAR to Modrinth and/or CurseForge. """Publish a Rivencraft JAR to Gitea, Modrinth, and/or CurseForge.
Preview is the default. Tokens live in the Secret Service keyring, never in git. Preview is the default. Tokens live in the Secret Service keyring, never in git.
""" """
@@ -19,8 +19,9 @@ import urllib.request
MODRINTH_PROJECT = "GH2xfZJ8" MODRINTH_PROJECT = "GH2xfZJ8"
CURSEFORGE_PROJECT = "1766326" CURSEFORGE_PROJECT = "1766326"
GITEA_API = "https://git.paddedcell.org/api/v1/repos/dnorberg/rivencraft"
USER_AGENT = "RivencraftReleaseTool/1.0 (https://git.paddedcell.org/dnorberg/rivencraft)" USER_AGENT = "RivencraftReleaseTool/1.0 (https://git.paddedcell.org/dnorberg/rivencraft)"
SITES = ("modrinth", "curseforge") SITES = ("gitea", "modrinth", "curseforge")
def token_for(site: str) -> str: def token_for(site: str) -> str:
@@ -59,7 +60,13 @@ def store_token(site: str) -> None:
def check_token(site: str) -> None: def check_token(site: str) -> None:
token = token_for(site) token = token_for(site)
if site == "modrinth": if site == "gitea":
account = request_json("https://git.paddedcell.org/api/v1/token", token=f"token {token}")
scopes = account.get("scopes", [])
if "write:repository" not in scopes and "all" not in scopes:
raise RuntimeError("Gitea token is valid but lacks write:repository scope")
print(f"Gitea API access works for {account['user']['login']} (write:repository)")
elif site == "modrinth":
try: try:
account = request_json("https://api.modrinth.com/v2/user", token=token) account = request_json("https://api.modrinth.com/v2/user", token=token)
except RuntimeError as error: except RuntimeError as error:
@@ -81,7 +88,7 @@ def check_token(site: str) -> None:
def request_json(url: str, *, token: str | None = None, token_header: str = "Authorization", def request_json(url: str, *, token: str | None = None, token_header: str = "Authorization",
payload: bytes | None = None, content_type: str | None = None, payload: bytes | None = None, content_type: str | None = None,
method: str = "GET") -> dict | list: method: str = "GET", not_found_ok: bool = False) -> dict | list | None:
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"} headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
if token: if token:
headers[token_header] = token headers[token_header] = token
@@ -92,12 +99,14 @@ def request_json(url: str, *, token: str | None = None, token_header: str = "Aut
with urllib.request.urlopen(request, timeout=120) as response: with urllib.request.urlopen(request, timeout=120) as response:
return json.load(response) return json.load(response)
except urllib.error.HTTPError as error: except urllib.error.HTTPError as error:
if error.code == 404 and not_found_ok:
return None
# Error bodies can contain server details, but never echo tokens. # Error bodies can contain server details, but never echo tokens.
detail = error.read(600).decode("utf-8", "replace") detail = error.read(600).decode("utf-8", "replace")
raise RuntimeError(f"{url}: HTTP {error.code}: {detail}") from None raise RuntimeError(f"{url}: HTTP {error.code}: {detail}") from None
def multipart(fields: dict[str, str], jar: Path) -> tuple[bytes, str]: def multipart(fields: dict[str, str], jar: Path, *, file_field: str = "file") -> tuple[bytes, str]:
boundary = "rivencraft-" + secrets.token_hex(16) boundary = "rivencraft-" + secrets.token_hex(16)
chunks = [] chunks = []
for name, value in fields.items(): for name, value in fields.items():
@@ -108,7 +117,7 @@ def multipart(fields: dict[str, str], jar: Path) -> tuple[bytes, str]:
filename = jar.name.replace('"', "") filename = jar.name.replace('"', "")
mime = mimetypes.guess_type(filename)[0] or "application/java-archive" mime = mimetypes.guess_type(filename)[0] or "application/java-archive"
chunks.extend(( chunks.extend((
f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\nContent-Type: {mime}\r\n\r\n".encode(), f"--{boundary}\r\nContent-Disposition: form-data; name=\"{file_field}\"; filename=\"{filename}\"\r\nContent-Type: {mime}\r\n\r\n".encode(),
jar.read_bytes(), b"\r\n", f"--{boundary}--\r\n".encode(), jar.read_bytes(), b"\r\n", f"--{boundary}--\r\n".encode(),
)) ))
return b"".join(chunks), f"multipart/form-data; boundary={boundary}" return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
@@ -140,7 +149,6 @@ def release_metadata(args: argparse.Namespace) -> tuple[dict, dict]:
"gameVersionNames": [args.minecraft, "NeoForge", "Client", "Server", f"Java {args.java}"], "gameVersionNames": [args.minecraft, "NeoForge", "Client", "Server", f"Java {args.java}"],
"releaseType": args.channel, "releaseType": args.channel,
"isMarkedForManualRelease": False, "isMarkedForManualRelease": False,
"relations": {"projects": []},
} }
return modrinth, curseforge return modrinth, curseforge
@@ -172,6 +180,53 @@ def publish_curseforge(metadata: dict, jar: Path) -> None:
print(f"CurseForge: https://www.curseforge.com/minecraft/mc-mods/rivencraft/files/{result['id']} (pending approval)") print(f"CurseForge: https://www.curseforge.com/minecraft/mc-mods/rivencraft/files/{result['id']} (pending approval)")
def publish_gitea(args: argparse.Namespace) -> None:
base_version, suffix = args.version.split("-", 1)
if suffix != f"{args.channel}.1":
raise RuntimeError("Gitea tag convention requires a version ending in the selected channel and .1")
tag = f"V{base_version}-mc{args.minecraft}-{suffix}"
# The release must refer to a tag already pushed by the normal Git workflow.
remote_tag = subprocess.run(
["git", "ls-remote", "origin", f"refs/tags/{tag}"],
text=True, capture_output=True, check=True,
)
if not remote_tag.stdout.strip():
raise RuntimeError(f"Push the Gitea tag {tag} before publishing")
token = f"token {token_for('gitea')}"
release_url = f"{GITEA_API}/releases/tags/{tag}"
release = request_json(release_url, token=token, not_found_ok=True)
notes = args.changelog.read_text(encoding="utf-8")
title = f"Rivencraft {args.minecraft} {args.channel} {args.version}"
if release is None:
metadata = {
"tag_name": tag,
"target_commitish": "main",
"name": title,
"body": notes,
"draft": False,
"prerelease": args.channel != "release",
}
release = request_json(
f"{GITEA_API}/releases", token=token,
payload=json.dumps(metadata).encode(), content_type="application/json", method="POST",
)
elif release.get("name") != title or release.get("body", "").strip() != notes.strip():
raise RuntimeError(f"Gitea release {tag} exists with different notes or title; inspect it before retrying")
assets = release.get("assets", [])
if any(asset.get("name") == args.jar.name for asset in assets):
raise RuntimeError(f"Gitea release {tag} already has {args.jar.name}")
body, content_type = multipart({}, args.jar, file_field="attachment")
attachment = request_json(
f"{GITEA_API}/releases/{release['id']}/assets", token=token,
payload=body, content_type=content_type, method="POST",
)
if attachment.get("name") != args.jar.name or attachment.get("size") != args.jar.stat().st_size:
raise RuntimeError("Gitea returned unexpected attachment metadata; inspect the release")
print(f"Gitea: {release['html_url']}")
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
@@ -187,7 +242,7 @@ def main() -> None:
publish.add_argument("--minecraft", required=True) publish.add_argument("--minecraft", required=True)
publish.add_argument("--java", required=True) publish.add_argument("--java", required=True)
publish.add_argument("--channel", choices=("alpha", "beta", "release"), required=True) publish.add_argument("--channel", choices=("alpha", "beta", "release"), required=True)
publish.add_argument("--site", choices=(*SITES, "both"), default="both") publish.add_argument("--site", choices=(*SITES, "both", "all"), default="all")
publish.add_argument("--publish", action="store_true", help="Actually upload; omitted by default") publish.add_argument("--publish", action="store_true", help="Actually upload; omitted by default")
args = parser.parse_args() args = parser.parse_args()
if args.command == "store-token": if args.command == "store-token":
@@ -205,13 +260,15 @@ def main() -> None:
if args.jar.name != f"rivencraft-{args.minecraft}-{args.version}.jar": if args.jar.name != f"rivencraft-{args.minecraft}-{args.version}.jar":
raise RuntimeError("JAR filename does not match Minecraft and release versions") raise RuntimeError("JAR filename does not match Minecraft and release versions")
modrinth, curseforge = release_metadata(args) modrinth, curseforge = release_metadata(args)
sites = SITES if args.site == "both" else (args.site,) sites = SITES if args.site == "all" else (("modrinth", "curseforge") if args.site == "both" else (args.site,))
print(f"JAR: {args.jar.resolve()} ({args.jar.stat().st_size} bytes)") 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"Release: {args.version} | {args.channel} | Minecraft {args.minecraft} | NeoForge | Java {args.java}")
print(f"Destinations: {', '.join(sites)}") print(f"Destinations: {', '.join(sites)}")
if not args.publish: if not args.publish:
print("PREVIEW ONLY: add --publish to upload") print("PREVIEW ONLY: add --publish to upload")
return return
if "gitea" in sites:
publish_gitea(args)
if "modrinth" in sites: if "modrinth" in sites:
publish_modrinth(modrinth, args.jar) publish_modrinth(modrinth, args.jar)
if "curseforge" in sites: if "curseforge" in sites:
+14 -1
View File
@@ -71,8 +71,10 @@ send_and_wait 'rivencraft_port age create smoke_alpha 123456789' 'Created rivenc
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_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_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 smoke_void 13579 void' 'Created rivencraft:age/smoke_void with seed 13579 terrain=VOID'
send_and_wait 'rivencraft_port age create smoke_cave 112233 cave' 'Created rivencraft:age/smoke_cave with seed 112233 terrain=CAVE'
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_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' send_and_wait 'rivencraft_port age create_multi smoke_void_multi 13580 void' 'Created rivencraft:age/smoke_void_multi with seed 13580 terrain=VOID'
send_and_wait 'rivencraft_port age create_multi smoke_cave_multi 112234 cave' 'Created rivencraft:age/smoke_cave_multi with seed 112234 terrain=CAVE'
# The index must reach disk while the server is still running. A graceful # The index must reach disk while the server is still running. A graceful
# shutdown would hide the interrupted-save regression this checks. # shutdown would hide the interrupted-save regression this checks.
index_file="$server_dir/world/dimensions/minecraft/overworld/data/rivencraft/runtime_ages.dat" index_file="$server_dir/world/dimensions/minecraft/overworld/data/rivencraft/runtime_ages.dat"
@@ -84,18 +86,24 @@ 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_beta'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_flat' 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_void'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_cave'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_flat_multi' gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_flat_multi'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_void_multi' gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_void_multi'
gzip -dc "$index_file" | strings | grep -Fq 'rivencraft:age/smoke_cave_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_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_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_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_void' 'rivencraft:age/smoke_void seed=13579 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_cave' 'rivencraft:age/smoke_cave seed=112233 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_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 sample smoke_void_multi' 'rivencraft:age/smoke_void_multi seed=13580 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_cave_multi' 'rivencraft:age/smoke_cave_multi seed=112234 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_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_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_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_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_cave run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_cave to be force loaded'
send_and_wait 'execute in rivencraft:age/smoke_cave_multi run forceload add 0 0' 'Marked chunk [0, 0] in rivencraft:age/smoke_cave_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 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_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_void if block 0 0 0 minecraft:air run say Void remains empty' 'Void remains empty'
@@ -105,6 +113,8 @@ send_and_wait 'execute in rivencraft:age/smoke_void_multi if block 0 0 0 minecra
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 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_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' send_and_wait 'rivencraft_port age biome smoke_void_multi 384 128' 'rivencraft:age/smoke_void_multi biome at 384,128=minecraft:forest'
send_and_wait 'rivencraft_port age biome smoke_cave_multi 128 128' 'rivencraft:age/smoke_cave_multi biome at 128,128=minecraft:lush_caves'
send_and_wait 'rivencraft_port age biome smoke_cave_multi 384 128' 'rivencraft:age/smoke_cave_multi biome at 384,128=minecraft:dripstone_caves'
alpha_before=$(grep -F 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//') 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=//') 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=//') flat_before=$(grep -F 'rivencraft:age/smoke_flat seed=24680 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
@@ -125,10 +135,13 @@ send_and_wait 'rivencraft_port age sample smoke_alpha' 'rivencraft:age/smoke_alp
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_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_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_void' 'rivencraft:age/smoke_void seed=13579 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_cave' 'rivencraft:age/smoke_cave seed=112233 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_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 sample smoke_void_multi' 'rivencraft:age/smoke_void_multi seed=13580 chunkOffset=0 terrain='
send_and_wait 'rivencraft_port age sample smoke_cave_multi' 'rivencraft:age/smoke_cave_multi seed=112234 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_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' send_and_wait 'rivencraft_port age biome smoke_void_multi 384 128' 'rivencraft:age/smoke_void_multi biome at 384,128=minecraft:forest'
send_and_wait 'rivencraft_port age biome smoke_cave_multi 384 128' 'rivencraft:age/smoke_cave_multi biome at 384,128=minecraft:dripstone_caves'
alpha_after=$(grep -F 'rivencraft:age/smoke_alpha seed=123456789 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//') 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=//') 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=//') flat_after=$(grep -F 'rivencraft:age/smoke_flat seed=24680 chunkOffset=0 terrain=' "$log_file" | tail -1 | sed 's/.*terrain=//')
@@ -144,4 +157,4 @@ void_after=$(grep -F 'rivencraft:age/smoke_void seed=13579 chunkOffset=0 terrain
exit 1 exit 1
} }
stop_server stop_server
printf '26.3 dedicated-server smoke passed: Standard, Flat and Void Ages persisted across restart.\n' printf '26.3 dedicated-server smoke passed: Standard, Flat, Void and Cave Ages persisted across restart.\n'