Port bounded colored decay and align Legacy defaults (#68)
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-10-09 12:56:30 -04:00
1 parent 066ef2b758
commit a9a8b3cdd5
40 files changed
+698 -27

No files matched your search

@@ -23,6 +23,7 @@ import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.dnorberg.rivencraft.book.AgeWeatherService;
import net.dnorberg.rivencraft.book.AgeInstabilityRuntime;
import net.dnorberg.rivencraft.book.AgeStatusEffectProviders;
import net.dnorberg.rivencraft.book.AgeDecayProviders;
import net.dnorberg.rivencraft.book.AgeRuntimeProfiles;
import net.dnorberg.rivencraft.writing.SymbolCatalog;
import net.neoforged.bus.api.IEventBus;
@@ -40,6 +41,7 @@ public final class Rivencraft {
public Rivencraft(IEventBus modEventBus, ModContainer modContainer) {
AgeDimensionService.register();
AgeStatusEffectProviders.register();
AgeDecayProviders.register();
modContainer.registerConfig(ModConfig.Type.SERVER, RivencraftConfig.SPEC);
modContainer.registerConfig(ModConfig.Type.CLIENT, RivencraftClientConfig.SPEC);
modEventBus.addListener(ModNetworking::register);
@@ -0,0 +1,36 @@
package net.dnorberg.rivencraft.block;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.LevelReader;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
/** Solid footing for Age arrivals, rendered as a translucent cloud. */
public final class CloudPlatformBlock extends Block {
public CloudPlatformBlock(Properties properties) {
super(properties);
}
@Override
public ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state) {
return ItemStack.EMPTY;
}
@Override
protected boolean skipRendering(BlockState state, BlockState adjacent, Direction side) {
return adjacent.is(this) || super.skipRendering(state, adjacent, side);
}
@Override
protected boolean propagatesSkylightDown(BlockState state, BlockGetter level, BlockPos pos) {
return true;
}
@Override
protected int getLightBlock(BlockState state, BlockGetter level, BlockPos pos) {
return 0;
}
}
@@ -0,0 +1,46 @@
package net.dnorberg.rivencraft.block;
import net.dnorberg.rivencraft.book.AgeInstabilityRuntime;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.minecraft.core.BlockPos;
import net.minecraft.util.StringRepresentable;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.EnumProperty;
/** The four colored decay variants selected by the current instability deck. */
public final class DecayBlock extends Block {
public static final EnumProperty<Color> COLOR = EnumProperty.create("color", Color.class);
public DecayBlock(Properties properties) {
super(properties);
registerDefaultState(stateDefinition.any().setValue(COLOR, Color.BLUE));
}
@Override
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(COLOR);
}
@Override
public void stepOn(Level level, BlockPos pos, BlockState state, Entity entity) {
if (!level.isClientSide && state.getValue(COLOR) == Color.WHITE
&& RivencraftConfig.decayEnabled() && RivencraftConfig.instabilityWorldMutation()
&& AgeInstabilityRuntime.isAgeDimension(level.dimension().location())) {
entity.hurt(level.damageSources().magic(), 1.0F);
}
super.stepOn(level, pos, state, entity);
}
public enum Color implements StringRepresentable {
BLUE, PURPLE, RED, WHITE;
@Override
public String getSerializedName() {
return name().toLowerCase(java.util.Locale.ROOT);
}
}
}
@@ -0,0 +1,87 @@
package net.dnorberg.rivencraft.book;
import java.util.List;
import java.util.UUID;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.DecayBlock;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.level.ChunkPos;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class AgeDecayGameTests {
private AgeDecayGameTests() {}
@GameTest(template = "empty")
public static void frontierRoundTripsWithoutDuplicates(GameTestHelper helper) {
AgeDecaySavedData data = new AgeDecaySavedData();
BlockPos first = new BlockPos(3, 70, 5);
data.add(DecayBlock.Color.BLUE, first);
data.add(DecayBlock.Color.BLUE, first);
data.add(DecayBlock.Color.WHITE, new BlockPos(-4, 64, 9));
helper.assertValueEqual(data.frontier(DecayBlock.Color.BLUE).size(), 1,
"One frontier block must not be stored twice");
helper.assertValueEqual(data.nextAttempt(), 0L, "First attempt starts at zero");
AgeDecaySavedData restored = AgeDecaySavedData.load(
data.save(new CompoundTag(), helper.getLevel().registryAccess()),
helper.getLevel().registryAccess());
helper.assertValueEqual(restored.nextAttempt(), 1L, "Attempt sequence survives reload");
helper.assertValueEqual(restored.frontier(DecayBlock.Color.BLUE).getFirst(), first,
"Blue frontier survives reload");
helper.assertValueEqual(restored.frontier(DecayBlock.Color.WHITE).size(), 1,
"White frontier survives reload independently");
restored.remove(DecayBlock.Color.BLUE, first);
helper.assertTrue(restored.frontier(DecayBlock.Color.BLUE).isEmpty(),
"Removed frontier is not scheduled for further spread");
helper.succeed();
}
@GameTest(template = "empty")
public static void targetMustRemainInChosenLoadedChunk(GameTestHelper helper) {
BlockPos center = helper.absolutePos(new BlockPos(1, 1, 1));
ChunkPos chunk = new ChunkPos(center);
helper.assertTrue(AgeDecayProviders.isLoadedTarget(helper.getLevel(), List.of(chunk), center),
"An already-loaded target inside the selected chunk is eligible");
BlockPos outside = new BlockPos((chunk.x + 1) << 4, center.getY(), center.getZ());
helper.assertTrue(!AgeDecayProviders.isLoadedTarget(helper.getLevel(), List.of(chunk), outside),
"Spread cannot cross into a chunk absent from this Age run");
helper.succeed();
}
@GameTest(template = "empty")
public static void overworldCannotRunDecayProvider(GameTestHelper helper) {
UUID ageId = UUID.randomUUID();
AgeDefinition age = new AgeDefinition(ageId, "Decay isolation", 100L, AgeGenerationProfiles.STANDARD,
AgeDefinition.CURRENT_GENERATION_VERSION, 0L, AgeDefinition.dimensionId(ageId), null,
AgeTraits.LEGACY_DEFAULTS);
AgeInstabilityRuntime.Budget budget = new AgeInstabilityRuntime.Budget(1);
var context = new AgeInstabilityRuntime.Context(helper.getLevel(), age,
List.of(new ChunkPos(helper.absolutePos(new BlockPos(1, 1, 1)))),
helper.getLevel().getServer().getTickCount(), budget);
AgeDecayProviders.apply(context, ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "decayblue"), 1);
helper.assertValueEqual(budget.remaining(), 1, "Overworld decay cannot claim an Age action");
helper.succeed();
}
@GameTest(template = "empty")
public static void savedSpawnAndPlatformAreProtected(GameTestHelper helper) {
UUID ageId = UUID.randomUUID();
BlockPos spawn = new BlockPos(0, 65, 0);
AgeDefinition age = new AgeDefinition(ageId, "Protected arrival", 100L, AgeGenerationProfiles.STANDARD,
AgeDefinition.CURRENT_GENERATION_VERSION, 0L, AgeDefinition.dimensionId(ageId), spawn,
AgeTraits.LEGACY_DEFAULTS);
helper.assertTrue(AgeDecayProviders.isSpawnProtected(age, spawn.below().offset(2, 0, 2)),
"Arrival platform must not decay");
helper.assertTrue(AgeDecayProviders.isSpawnProtected(age, spawn.offset(4, 0, 4)),
"The protective margin must not decay");
helper.assertTrue(!AgeDecayProviders.isSpawnProtected(age, spawn.offset(5, 0, 0)),
"Spawn protection is bounded");
helper.succeed();
}
}
@@ -0,0 +1,202 @@
package net.dnorberg.rivencraft.book;
import java.util.List;
import java.util.Map;
import java.util.Random;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.DecayBlock;
import net.dnorberg.rivencraft.config.RivencraftConfig;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.tags.BlockTags;
import net.minecraft.tags.TagKey;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.levelgen.Heightmap;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.common.util.BlockSnapshot;
import net.neoforged.neoforge.common.util.FakePlayerFactory;
import net.neoforged.neoforge.event.level.BlockEvent;
/** Bounded, per-Age decay seeding and six-direction Legacy-style spreading. */
public final class AgeDecayProviders {
public static final TagKey<Block> PROTECTED = TagKey.create(Registries.BLOCK,
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "decay_protected"));
private static final Map<ResourceLocation, DecayBlock.Color> COLORS = Map.of(
id("decayblue"), DecayBlock.Color.BLUE,
id("decaypurple"), DecayBlock.Color.PURPLE,
id("decayred"), DecayBlock.Color.RED,
id("decaywhite"), DecayBlock.Color.WHITE);
private static final Direction[] DIRECTIONS = Direction.values();
private AgeDecayProviders() {}
public static void register() {
COLORS.keySet().forEach(id -> AgeInstabilityRuntime.register(id, true, AgeDecayProviders::apply));
}
/** Operator test hook; never changes the Age's saved instability score or provider plan. */
public static int pulseForTesting(ServerPlayer player, DecayBlock.Color color, int count) {
ServerLevel level = player.serverLevel();
if (!RivencraftConfig.decayEnabled() || !RivencraftConfig.instabilityWorldMutation()
|| !AgeInstabilityRuntime.isAgeDimension(level.dimension().location())
|| !level.getChunkSource().hasChunk(player.chunkPosition().x, player.chunkPosition().z)) return -1;
AgeDefinition age = AgeRegistrySavedData.get(level).findByDimension(level.dimension().location()).orElse(null);
if (age == null) return -1;
ResourceLocation id = id("decay" + color.getSerializedName());
int changed = 0;
for (int i = 0; i < Math.min(count, 32); i++) {
AgeInstabilityRuntime.Budget budget = new AgeInstabilityRuntime.Budget(1);
apply(new AgeInstabilityRuntime.Context(level, age, List.of(player.chunkPosition()),
level.getServer().getTickCount(), budget), id, 1);
changed += 1 - budget.remaining();
}
return changed;
}
static void apply(AgeInstabilityRuntime.Context context, ResourceLocation id, int level) {
if (!RivencraftConfig.decayEnabled() || !RivencraftConfig.instabilityWorldMutation()
|| !AgeInstabilityRuntime.isAgeDimension(context.level().dimension().location())
|| !context.age().dimensionId().equals(context.level().dimension().location())
|| context.loadedChunks().isEmpty()) return;
DecayBlock.Color color = COLORS.get(id);
if (color == null) return;
AgeDecaySavedData data = AgeDecaySavedData.get(context.level());
Random random = new Random(context.age().seed() ^ data.nextAttempt() * 0x9E3779B97F4A7C15L
^ id.hashCode());
List<BlockPos> frontier = data.frontier(color);
BlockPos source = selectLoadedSource(context.level(), context.loadedChunks(), frontier, random);
if (source == null) {
seed(context, color, data, random);
return;
}
BlockState sourceState = context.level().getBlockState(source);
if (!sourceState.is(ModBlocks.DECAY.get()) || sourceState.getValue(DecayBlock.COLOR) != color) {
data.remove(color, source);
return;
}
for (int attempt = 0; attempt < RivencraftConfig.decayMaxAttempts(); attempt++) {
BlockPos target = source.relative(DIRECTIONS[random.nextInt(DIRECTIONS.length)]);
if (!isLoadedTarget(context.level(), context.loadedChunks(), target)
|| isSpawnProtected(context.age(), target)) continue;
BlockState prior = context.level().getBlockState(target);
if (!canReplace(context.level(), target, prior, color)) continue;
int difficulty = conversionDifficulty(context.level(), target, prior, color);
if (random.nextInt(difficulty) != 0) continue;
if (replace(context, target, prior, color)) data.add(color, target);
return;
}
}
private static void seed(AgeInstabilityRuntime.Context context, DecayBlock.Color color,
AgeDecaySavedData data, Random random) {
ChunkPos chunk = context.loadedChunks().get(random.nextInt(context.loadedChunks().size()));
int x = chunk.getMinBlockX() + random.nextInt(16);
int z = chunk.getMinBlockZ() + random.nextInt(16);
BlockPos pos = new BlockPos(x,
context.level().getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z) - 1, z);
if (!isLoadedTarget(context.level(), context.loadedChunks(), pos)
|| isSpawnProtected(context.age(), pos)) return;
BlockState prior = context.level().getBlockState(pos);
if (canReplace(context.level(), pos, prior, color) && replace(context, pos, prior, color)) {
data.add(color, pos);
}
}
private static BlockPos selectLoadedSource(ServerLevel level, List<ChunkPos> chunks,
List<BlockPos> frontier, Random random) {
if (frontier.isEmpty()) return null;
int start = random.nextInt(frontier.size());
for (int i = 0; i < Math.min(frontier.size(), 16); i++) {
BlockPos pos = frontier.get((start + i) % frontier.size());
if (isLoadedTarget(level, chunks, pos)) return pos;
}
return null;
}
static boolean isLoadedTarget(ServerLevel level, List<ChunkPos> chunks, BlockPos pos) {
return level.isInWorldBounds(pos) && level.getChunkSource().hasChunk(pos.getX() >> 4, pos.getZ() >> 4)
&& chunks.contains(new ChunkPos(pos));
}
static boolean isSpawnProtected(AgeDefinition age, BlockPos pos) {
BlockPos spawn = age.spawn();
return spawn != null && Math.abs(pos.getX() - spawn.getX()) <= 4
&& Math.abs(pos.getZ() - spawn.getZ()) <= 4
&& pos.getY() >= spawn.getY() - 2 && pos.getY() <= spawn.getY() + 4;
}
static boolean canReplace(ServerLevel level, BlockPos pos, BlockState prior, DecayBlock.Color color) {
if (!AgeInstabilityRuntime.isAgeDimension(level.dimension().location())
|| !level.isInWorldBounds(pos) || !level.getChunkSource().hasChunk(pos.getX() >> 4, pos.getZ() >> 4)
|| prior.is(PROTECTED) || prior.is(BlockTags.PORTALS)
|| prior.hasBlockEntity() || level.getBlockEntity(pos) != null
|| prior.getDestroySpeed(level, pos) < 0 || !prior.getFluidState().isEmpty()
|| isCritical(prior)) return false;
return !prior.is(ModBlocks.DECAY.get()) || prior.getValue(DecayBlock.COLOR) != color;
}
private static boolean isCritical(BlockState prior) {
return prior.is(Blocks.BEDROCK) || prior.is(Blocks.BARRIER)
|| prior.is(Blocks.END_PORTAL) || prior.is(Blocks.END_PORTAL_FRAME)
|| prior.is(Blocks.END_GATEWAY) || prior.is(Blocks.NETHER_PORTAL)
|| prior.is(Blocks.COMMAND_BLOCK) || prior.is(Blocks.CHAIN_COMMAND_BLOCK)
|| prior.is(Blocks.REPEATING_COMMAND_BLOCK) || prior.is(Blocks.STRUCTURE_BLOCK)
|| prior.is(Blocks.JIGSAW) || prior.is(Blocks.LIGHT)
|| prior.is(Blocks.REINFORCED_DEEPSLATE)
|| prior.getBlock() != ModBlocks.DECAY.get()
&& net.minecraft.core.registries.BuiltInRegistries.BLOCK.getKey(prior.getBlock())
.getNamespace().equals(Rivencraft.MOD_ID);
}
static int conversionDifficulty(ServerLevel level, BlockPos pos, BlockState state, DecayBlock.Color color) {
if (state.isAir()) return switch (color) {
case BLUE, RED -> 20;
case PURPLE -> 8;
case WHITE -> 50;
};
if (state.is(ModBlocks.DECAY.get())) return color == DecayBlock.Color.WHITE ? 1 : 5;
float hardness = Math.min(1000.0F, Math.max(0.0F, state.getDestroySpeed(level, pos)));
return switch (color) {
case BLUE -> Math.max(1, (int) hardness * 2);
case RED -> Math.max(1, (int) hardness);
case PURPLE -> Math.max(1, ((int) hardness + 1) * 10);
case WHITE -> 1;
};
}
private static boolean replace(AgeInstabilityRuntime.Context context, BlockPos pos, BlockState prior,
DecayBlock.Color color) {
ServerLevel level = context.level();
// A fake player gives claim/protection listeners the Age and exact block position.
var actor = FakePlayerFactory.getMinecraft(level);
actor.setPos(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5);
if (!prior.isAir()) {
var breakEvent = new BlockEvent.BreakEvent(level, pos, prior, actor);
NeoForge.EVENT_BUS.post(breakEvent);
if (breakEvent.isCanceled()) return false;
}
if (level.getBlockState(pos) != prior || !context.budget().claim()) return false;
var snapshot = BlockSnapshot.create(level.dimension(), level, pos);
BlockState decay = ModBlocks.DECAY.get().defaultBlockState().setValue(DecayBlock.COLOR, color);
if (!level.setBlock(pos, decay, 3)) return false;
var placeEvent = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor);
NeoForge.EVENT_BUS.post(placeEvent);
if (placeEvent.isCanceled()) {
snapshot.restore();
return false;
}
return true;
}
private static ResourceLocation id(String path) {
return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path);
}
}
@@ -0,0 +1,81 @@
package net.dnorberg.rivencraft.book;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import net.dnorberg.rivencraft.block.DecayBlock;
import net.minecraft.core.BlockPos;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.datafix.DataFixTypes;
import net.minecraft.world.level.saveddata.SavedData;
/** Small, per-Age frontier and deterministic attempt counter; never stores chunk references. */
public final class AgeDecaySavedData extends SavedData {
private static final int MAX_FRONTIER_PER_COLOR = 1024;
private static final Factory<AgeDecaySavedData> FACTORY = new Factory<>(AgeDecaySavedData::new,
AgeDecaySavedData::load, DataFixTypes.SAVED_DATA_MAP_DATA);
private final EnumMap<DecayBlock.Color, List<BlockPos>> frontiers = new EnumMap<>(DecayBlock.Color.class);
private long attempts;
public AgeDecaySavedData() {
for (DecayBlock.Color color : DecayBlock.Color.values()) frontiers.put(color, new ArrayList<>());
}
public static AgeDecaySavedData get(ServerLevel level) {
return level.getDataStorage().computeIfAbsent(FACTORY, "rivencraft_decay");
}
public long nextAttempt() {
setDirty();
return attempts++;
}
public List<BlockPos> frontier(DecayBlock.Color color) {
return frontiers.get(color);
}
public void add(DecayBlock.Color color, BlockPos pos) {
List<BlockPos> frontier = frontiers.get(color);
if (frontier.contains(pos)) return;
if (frontier.size() >= MAX_FRONTIER_PER_COLOR) frontier.remove(0);
frontier.add(pos.immutable());
setDirty();
}
public void remove(DecayBlock.Color color, BlockPos pos) {
if (frontiers.get(color).remove(pos)) setDirty();
}
@Override
public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
tag.putLong("Attempts", attempts);
for (DecayBlock.Color color : DecayBlock.Color.values()) {
ListTag positions = new ListTag();
for (BlockPos pos : frontiers.get(color)) {
CompoundTag entry = new CompoundTag();
entry.putInt("X", pos.getX());
entry.putInt("Y", pos.getY());
entry.putInt("Z", pos.getZ());
positions.add(entry);
}
tag.put(color.getSerializedName(), positions);
}
return tag;
}
static AgeDecaySavedData load(CompoundTag tag, HolderLookup.Provider registries) {
AgeDecaySavedData data = new AgeDecaySavedData();
data.attempts = Math.max(0L, tag.getLong("Attempts"));
for (DecayBlock.Color color : DecayBlock.Color.values()) {
ListTag positions = tag.getList(color.getSerializedName(), CompoundTag.TAG_COMPOUND);
for (int index = 0; index < Math.min(positions.size(), MAX_FRONTIER_PER_COLOR); index++) {
CompoundTag entry = positions.getCompound(index);
data.frontiers.get(color).add(new BlockPos(entry.getInt("X"), entry.getInt("Y"), entry.getInt("Z")));
}
}
return data;
}
}
@@ -13,6 +13,7 @@ import java.util.Collections;
import java.util.Comparator;
import java.util.Random;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
import net.dnorberg.rivencraft.compat.SereneSeasonsCompatibility;
import net.dnorberg.rivencraft.compat.TerraBlenderBiomes;
@@ -228,7 +229,7 @@ public final class AgeDimensionService {
for (int x = -PLATFORM_RADIUS; x <= PLATFORM_RADIUS; x++) {
for (int z = -PLATFORM_RADIUS; z <= PLATFORM_RADIUS; z++) {
BlockPos floor = floorCenter.offset(x, 0, z);
level.setBlock(floor, Blocks.COBBLESTONE.defaultBlockState(), 2);
level.setBlock(floor, ModBlocks.CLOUD_PLATFORM.get().defaultBlockState(), 2);
for (int y = 1; y <= 4; y++) {
level.setBlock(floor.above(y), Blocks.AIR.defaultBlockState(), 2);
}
@@ -0,0 +1,27 @@
package net.dnorberg.rivencraft.book;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class CloudPlatformGameTests {
private CloudPlatformGameTests() {}
@GameTest(template = "empty")
public static void cloudIsSolidFootingWithoutAnObtainableItem(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
var state = ModBlocks.CLOUD_PLATFORM.get().defaultBlockState();
helper.getLevel().setBlock(pos, state, 2);
helper.assertTrue(!state.getCollisionShape(helper.getLevel(), pos).isEmpty(),
"Cloud platform must support a standing player");
helper.assertTrue(ModBlocks.CLOUD_PLATFORM.get().getCloneItemStack(helper.getLevel(), pos, state).isEmpty(),
"Cloud platform must not provide a pick-block item");
helper.succeed();
}
}
@@ -1,5 +1,7 @@
package net.dnorberg.rivencraft.book;
import net.dnorberg.rivencraft.registry.ModBlocks;
import com.google.gson.JsonParser;
import java.util.List;
import java.util.UUID;
@@ -250,7 +252,7 @@ public final class TerrainWritingGameTests {
helper.assertTrue(level != null, "The written Age must create a runtime dimension");
assertGenerator(helper, level, age.plan().runtimeProfile());
BlockPos spawn = target.location().pos();
helper.assertTrue(level.getBlockState(spawn.below()).is(Blocks.COBBLESTONE)
helper.assertTrue(level.getBlockState(spawn.below()).is(ModBlocks.CLOUD_PLATFORM.get())
&& level.getBlockState(spawn).isAir()
&& level.getBlockState(spawn.above()).isAir(),
"Every written terrain family must retain a safe arrival platform");
@@ -77,6 +77,7 @@ public final class ClientEvents {
ItemBlockRenderTypes.setRenderLayer(ModBlocks.MEDIUM_CRYSTAL_BUD.get(), RenderType.cutout());
ItemBlockRenderTypes.setRenderLayer(ModBlocks.LARGE_CRYSTAL_BUD.get(), RenderType.cutout());
ItemBlockRenderTypes.setRenderLayer(ModBlocks.CRYSTAL_CLUSTER.get(), RenderType.cutout());
ItemBlockRenderTypes.setRenderLayer(ModBlocks.CLOUD_PLATFORM.get(), RenderType.translucent());
});
NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickItem);
NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickBlock);
@@ -1,7 +1,10 @@
package net.dnorberg.rivencraft.command;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import net.dnorberg.rivencraft.block.DecayBlock;
import net.dnorberg.rivencraft.book.AgeDefinition;
import net.dnorberg.rivencraft.book.AgeDecayProviders;
import net.dnorberg.rivencraft.book.AgeDimensionService;
import net.dnorberg.rivencraft.book.AgeRegistrySavedData;
import net.minecraft.commands.Commands;
@@ -30,7 +33,20 @@ public final class RivencraftCommands {
.executes(context -> unloadAge(
context.getSource(),
StringArgumentType.getString(context, "dimension")
))))));
))))
.then(Commands.literal("decay")
.then(Commands.literal("pulse")
.then(Commands.argument("color", StringArgumentType.word())
.suggests((context, builder) -> {
for (DecayBlock.Color color : DecayBlock.Color.values()) {
builder.suggest(color.getSerializedName());
}
return builder.buildFuture();
})
.then(Commands.argument("attempts", IntegerArgumentType.integer(1, 32))
.executes(context -> pulseDecay(context.getSource(),
StringArgumentType.getString(context, "color"),
IntegerArgumentType.getInteger(context, "attempts")))))))));
}
private static int listAges(net.minecraft.commands.CommandSourceStack source) {
@@ -80,4 +96,26 @@ public final class RivencraftCommands {
}
};
}
private static int pulseDecay(net.minecraft.commands.CommandSourceStack source, String colorName, int attempts) {
DecayBlock.Color color;
try {
color = DecayBlock.Color.valueOf(colorName.toUpperCase(java.util.Locale.ROOT));
} catch (IllegalArgumentException exception) {
source.sendFailure(Component.literal("Unknown decay color: " + colorName));
return 0;
}
if (source.getPlayer() == null) {
source.sendFailure(Component.literal("Run this command as a player standing in an Age."));
return 0;
}
int changed = AgeDecayProviders.pulseForTesting(source.getPlayer(), color, attempts);
if (changed < 0) {
source.sendFailure(Component.literal("Enter a loaded Age and enable instability.world_mutation and instability_decay.enabled first."));
return 0;
}
source.sendSuccess(() -> Component.literal("Decay " + colorName + ": " + changed
+ " block(s) converted in " + attempts + " bounded attempt(s)."), true);
return changed;
}
}
@@ -33,6 +33,8 @@ public final class RivencraftConfig {
private static final ModConfigSpec.IntValue STATUS_EFFECTS_MAX_ACTIVE;
private static final ModConfigSpec.IntValue STATUS_EFFECTS_MAX_CANDIDATES;
private static final ModConfigSpec.IntValue STATUS_EFFECTS_MAX_DURATION;
private static final ModConfigSpec.BooleanValue DECAY_ENABLED;
private static final ModConfigSpec.IntValue DECAY_MAX_ATTEMPTS;
static {
ModConfigSpec.Builder builder = new ModConfigSpec.Builder();
@@ -58,8 +60,8 @@ public final class RivencraftConfig {
builder.push("instability");
INSTABILITY_ENABLED = builder.comment("Run selected instability providers in loaded Ages. Disabling does not alter saved plans.")
.define("enabled", true);
INSTABILITY_WORLD_MUTATION = builder.comment("Allow providers to change blocks or create explosions. Off until world hazards are reviewed.")
.define("world_mutation", false);
INSTABILITY_WORLD_MUTATION = builder.comment("Allow selected instability providers to change blocks or create explosions, as in Legacy.")
.define("world_mutation", true);
INSTABILITY_COOLDOWN = builder.comment("Minimum ticks between instability runs for each loaded Age.")
.defineInRange("cooldown_ticks", 20, 20, 1200);
INSTABILITY_MAX_AGES = builder.comment("Maximum loaded Ages processed in one server tick.")
@@ -89,6 +91,12 @@ public final class RivencraftConfig {
STATUS_EFFECTS_MAX_DURATION = builder.comment("Maximum duration in ticks for one instability status effect.")
.defineInRange("max_duration_ticks", 200, 20, 200);
builder.pop();
builder.push("instability_decay");
DECAY_ENABLED = builder.comment("Allow destructive colored decay inside loaded Ages, as in Legacy. Also requires instability.world_mutation.")
.define("enabled", true);
DECAY_MAX_ATTEMPTS = builder.comment("Maximum six-neighbor decay conversion attempts per provider call; at most one block changes.")
.defineInRange("max_attempts_per_call", 4, 1, 6);
builder.pop();
SPEC = builder.build();
}
@@ -126,4 +134,6 @@ public final class RivencraftConfig {
public static int statusEffectsMaxActive() { return STATUS_EFFECTS_MAX_ACTIVE.get(); }
public static int statusEffectsMaxCandidates() { return STATUS_EFFECTS_MAX_CANDIDATES.get(); }
public static int statusEffectsMaxDuration() { return STATUS_EFFECTS_MAX_DURATION.get(); }
public static boolean decayEnabled() { return DECAY_ENABLED.get(); }
public static int decayMaxAttempts() { return DECAY_MAX_ATTEMPTS.get(); }
}
@@ -0,0 +1,68 @@
package net.dnorberg.rivencraft.gametest;
import java.util.ArrayList;
import java.util.List;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity;
import net.dnorberg.rivencraft.item.PageItem;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.registry.ModItems;
import net.dnorberg.rivencraft.writing.NotebookContents;
import net.dnorberg.rivencraft.writing.SymbolReference;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
@GameTestHolder(Rivencraft.MOD_ID)
@PrefixGameTestTemplate(false)
public final class BookPageCapacityGameTests {
private BookPageCapacityGameTests() {}
private static ItemStack page(String path) {
return PageItem.createSymbolPage(new SymbolReference(
ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path)));
}
@GameTest(template = "empty")
public static void binderAndNotebookRetainPagesBeyondOldLimit(GameTestHelper helper) {
BlockPos pos = helper.absolutePos(new BlockPos(1, 1, 1));
helper.getLevel().setBlock(pos, ModBlocks.BOOK_BINDER.get().defaultBlockState(), 3);
BookBinderBlockEntity binder = (BookBinderBlockEntity) helper.getLevel().getBlockEntity(pos);
binder.setTitle("Long Age");
binder.setItem(BookBinderBlockEntity.COVER, new ItemStack(Items.LEATHER));
binder.setItem(BookBinderBlockEntity.LINK_PANEL, PageItem.createLinkPanelPage());
for (int index = 0; index < 32; index++) {
helper.assertTrue(binder.insertPage(index, page("terrain/standard")),
"Binder must accept page " + (index + 1));
}
binder.setItem(BookBinderBlockEntity.SIZE - 1, page("weather/normal"));
BookBinderBlockEntity restored = new BookBinderBlockEntity(pos, binder.getBlockState());
restored.setLevel(helper.getLevel());
restored.loadWithComponents(binder.saveWithoutMetadata(helper.getLevel().registryAccess()),
helper.getLevel().registryAccess());
helper.assertValueEqual(restored.nextPageIndex(), BookBinderBlockEntity.PAGE_COUNT,
"Binder must retain its highest valid page position after reload");
helper.assertTrue(restored.getItem(BookBinderBlockEntity.FIRST_PAGE + 31).is(ModItems.PAGE.get()),
"Page 32 must survive reload");
helper.assertTrue(restored.getItem(BookBinderBlockEntity.SIZE - 1).is(ModItems.PAGE.get()),
"The final fixed-slot page must survive reload");
helper.assertValueEqual(restored.writingGrammar().symbols().size(), 33,
"Grammar must retain all authored pages");
List<ItemStack> notebookPages = new ArrayList<>();
for (int index = 0; index < NotebookContents.MAX_PAGES; index++) notebookPages.add(ItemStack.EMPTY);
notebookPages.set(31, page("terrain/standard"));
notebookPages.set(NotebookContents.MAX_PAGES - 1, page("weather/normal"));
NotebookContents notebook = new NotebookContents(notebookPages);
helper.assertValueEqual(notebook.pages().size(), NotebookContents.MAX_PAGES,
"Notebook must retain its highest valid page position");
helper.assertValueEqual(notebook.pageCount(), 2,
"Sparse Notebook contents must retain both pages");
helper.succeed();
}
}
@@ -1,5 +1,7 @@
package net.dnorberg.rivencraft.gametest;
import net.dnorberg.rivencraft.registry.ModBlocks;
import net.dnorberg.rivencraft.Rivencraft;
import net.dnorberg.rivencraft.book.AgeDefinition;
import net.dnorberg.rivencraft.book.AgeBiomeSelection;
@@ -234,7 +236,7 @@ public final class LinkingGameTests {
helper.assertValueEqual(ageLevel.getSeed(), definition.seed(),
"The runtime dimension seed must match the persisted Age definition");
BlockPos spawn = target.location().pos();
helper.assertTrue(ageLevel.getBlockState(spawn.below()).is(Blocks.COBBLESTONE),
helper.assertTrue(ageLevel.getBlockState(spawn.below()).is(ModBlocks.CLOUD_PLATFORM.get()),
"The generated Age must have a cobblestone arrival platform");
helper.assertTrue(ageLevel.getBlockState(spawn).isAir() && ageLevel.getBlockState(spawn.above()).isAir(),
"The generated Age arrival must have clear headroom");
@@ -285,7 +287,7 @@ public final class LinkingGameTests {
var ageLevel = player.getServer().getLevel(destination.location().dimension());
helper.assertTrue(ageLevel != null, profile + " must create a loaded runtime dimension");
BlockPos spawn = destination.location().pos();
helper.assertTrue(ageLevel != null && ageLevel.getBlockState(spawn.below()).is(Blocks.COBBLESTONE),
helper.assertTrue(ageLevel != null && ageLevel.getBlockState(spawn.below()).is(ModBlocks.CLOUD_PLATFORM.get()),
profile + " must create a solid arrival platform");
helper.assertTrue(ageLevel != null && ageLevel.getBlockState(spawn).isAir()
&& ageLevel.getBlockState(spawn.above()).isAir(),
@@ -23,11 +23,11 @@ import net.minecraft.world.item.Items;
/** Fixed, ordered binder slots and a server-owned virtual result. */
public final class BookBinderMenu extends AbstractContainerMenu {
public static final int RESULT = BookBinderBlockEntity.SIZE;
public static final int SELECT_BASE = 100;
public static final int MOVE_PREVIOUS = 200;
public static final int MOVE_NEXT = 201;
public static final int IMPORT_NOTEBOOK = 202;
public static final int STRIP_CLICK_BASE = 300;
public static final int SELECT_BASE = 1000;
public static final int MOVE_PREVIOUS = 2000;
public static final int MOVE_NEXT = 2001;
public static final int IMPORT_NOTEBOOK = 2002;
public static final int STRIP_CLICK_BASE = 3000;
private static final int PLAYER_START = RESULT + 1;
private final BookBinderBlockEntity binder;
@@ -40,11 +40,9 @@ public final class NotebookMenu extends AbstractContainerMenu {
this.openedHotbarSlot = inventory.selected;
this.contents = new NotebookContainer(inventory, hand);
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) {
// The client draws these pages on a filtered, scrollable surface instead.
addSlot(new PageSlot(contents, column + row * 9, -1000, -1000));
}
for (int page = 0; page < NOTEBOOK_SLOTS; page++) {
// The client draws these pages on a filtered, scrollable surface instead.
addSlot(new PageSlot(contents, page, -1000, -1000));
}
for (int row = 0; row < 3; row++) {
for (int column = 0; column < 9; column++) {
@@ -7,6 +7,8 @@ import net.dnorberg.rivencraft.block.WritingDeskBlock;
import net.dnorberg.rivencraft.block.InkMixerBlock;
import net.dnorberg.rivencraft.block.BuddingCrystalBlock;
import net.dnorberg.rivencraft.block.StarFissureBlock;
import net.dnorberg.rivencraft.block.DecayBlock;
import net.dnorberg.rivencraft.block.CloudPlatformBlock;
import net.minecraft.world.level.block.AmethystClusterBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
@@ -56,6 +58,12 @@ public final class ModBlocks {
public static final DeferredBlock<StarFissureBlock> STAR_FISSURE = BLOCKS.registerBlock("star_fissure",
StarFissureBlock::new, BlockBehaviour.Properties.ofFullCopy(Blocks.END_PORTAL)
.lightLevel(state -> 6));
public static final DeferredBlock<DecayBlock> DECAY = BLOCKS.registerBlock("decay",
DecayBlock::new, BlockBehaviour.Properties.ofFullCopy(Blocks.STONE)
.strength(5.0F, 10.0F).noLootTable());
public static final DeferredBlock<CloudPlatformBlock> CLOUD_PLATFORM = BLOCKS.registerBlock("cloud_platform",
CloudPlatformBlock::new, BlockBehaviour.Properties.ofFullCopy(Blocks.WHITE_STAINED_GLASS)
.strength(1.5F).noOcclusion().noLootTable());
private ModBlocks() {
}
@@ -13,7 +13,9 @@ import net.minecraft.world.item.ItemStack;
/** A bounded, position-preserving collection of Page stacks stored on a Notebook item. */
public record NotebookContents(List<ItemStack> pages) {
public static final int MAX_PAGES = 27;
// Legacy did not impose a book-page cap. Our fixed-slot binder persists slot IDs
// as unsigned bytes; 252 page positions (after its three input slots) fit safely.
public static final int MAX_PAGES = 252;
public static final NotebookContents EMPTY = new NotebookContents(List.of());
public static final Codec<NotebookContents> CODEC = ItemStack.OPTIONAL_CODEC.listOf()
.validate(NotebookContents::validate)
@@ -14,7 +14,7 @@ import net.minecraft.resources.ResourceLocation;
/** Server-owned interpretation of ordered writing before Age-plan compilation. */
public final class WritingGrammar {
// A newly assembled book has one Link Panel plus at most 27 page positions.
// A newly assembled book has one Link Panel plus the binder's page positions.
public static final int MAX_NEW_ENTRIES = NotebookContents.MAX_PAGES + 1;
private static final Set<String> MATERIAL_FEATURES = Set.of("rocks", "surface_lakes", "deep_lakes",
"lava_lakes", "floating_islands", "tendrils", "spheres", "spikes", "obelisks");
@@ -0,0 +1 @@
{"variants":{"":{"model":"rivencraft:block/cloud_platform"}}}
@@ -0,0 +1,8 @@
{
"variants": {
"color=blue": { "model": "rivencraft:block/decay_blue" },
"color=purple": { "model": "rivencraft:block/decay_purple" },
"color=red": { "model": "rivencraft:block/decay_red" },
"color=white": { "model": "rivencraft:block/decay_white" }
}
}
@@ -24,6 +24,8 @@
"block.rivencraft.book_binder": "Book Binder",
"container.rivencraft.book_binder": "Book Binder",
"block.rivencraft.ink_mixer": "Ink Mixer",
"block.rivencraft.decay": "Decay",
"block.rivencraft.cloud_platform": "Cloud Platform",
"container.rivencraft.ink_mixer": "Ink Mixer",
"screen.rivencraft.ink_mixer.ink": "Ink: %s/%s",
"screen.rivencraft.ink_mixer.additive_hint": "Click the basin with an additive; right-click adds one.",
@@ -0,0 +1 @@
{"parent":"minecraft:block/cube_all","render_type":"minecraft:translucent","textures":{"all":"rivencraft:block/cloud_platform"}}
@@ -0,0 +1 @@
{"parent":"minecraft:block/cube_all","textures":{"all":"rivencraft:block/decay_blue"}}
@@ -0,0 +1 @@
{"parent":"minecraft:block/cube_all","textures":{"all":"rivencraft:block/decay_purple"}}
@@ -0,0 +1 @@
{"parent":"minecraft:block/cube_all","textures":{"all":"rivencraft:block/decay_red"}}
@@ -0,0 +1 @@
{"parent":"minecraft:block/cube_all","textures":{"all":"rivencraft:block/decay_white"}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 868 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 B

@@ -0,0 +1,24 @@
{
"replace": false,
"values": [
"minecraft:bedrock",
"minecraft:barrier",
"minecraft:end_portal",
"minecraft:end_portal_frame",
"minecraft:end_gateway",
"minecraft:nether_portal",
"minecraft:reinforced_deepslate",
"minecraft:structure_block",
"minecraft:jigsaw",
"minecraft:command_block",
"minecraft:chain_command_block",
"minecraft:repeating_command_block",
"minecraft:light",
"rivencraft:book_stand",
"rivencraft:writing_desk",
"rivencraft:book_binder",
"rivencraft:ink_mixer",
"rivencraft:star_fissure",
"rivencraft:cloud_platform"
]
}