From a9a8b3cdd5646c5ee26c6a25d8a59368c7dc4d64 Mon Sep 17 00:00:00 2001 From: Dustin Norberg Date: Fri, 9 Oct 2026 12:56:30 -0400 Subject: [PATCH] Port bounded colored decay and align Legacy defaults (#68) --- docs/AGE_CLOUD_PLATFORM.md | 7 + docs/AGE_PROFILES.md | 2 +- docs/INSTABILITY_DECAY.md | 13 ++ docs/INSTABILITY_RUNTIME.md | 4 +- docs/WRITING_DATA.md | 6 +- docs/WRITING_GRAMMAR.md | 2 +- gradle.properties | 2 +- .../net/dnorberg/rivencraft/Rivencraft.java | 2 + .../rivencraft/block/CloudPlatformBlock.java | 36 ++++ .../dnorberg/rivencraft/block/DecayBlock.java | 46 ++++ .../rivencraft/book/AgeDecayGameTests.java | 87 ++++++++ .../rivencraft/book/AgeDecayProviders.java | 202 ++++++++++++++++++ .../rivencraft/book/AgeDecaySavedData.java | 81 +++++++ .../rivencraft/book/AgeDimensionService.java | 3 +- .../book/CloudPlatformGameTests.java | 27 +++ .../book/TerrainWritingGameTests.java | 4 +- .../rivencraft/client/ClientEvents.java | 1 + .../command/RivencraftCommands.java | 40 +++- .../rivencraft/config/RivencraftConfig.java | 14 +- .../gametest/BookPageCapacityGameTests.java | 68 ++++++ .../rivencraft/gametest/LinkingGameTests.java | 6 +- .../rivencraft/menu/BookBinderMenu.java | 10 +- .../rivencraft/menu/NotebookMenu.java | 8 +- .../rivencraft/registry/ModBlocks.java | 8 + .../rivencraft/writing/NotebookContents.java | 4 +- .../rivencraft/writing/WritingGrammar.java | 2 +- .../blockstates/cloud_platform.json | 1 + .../assets/rivencraft/blockstates/decay.json | 8 + .../assets/rivencraft/lang/en_us.json | 2 + .../models/block/cloud_platform.json | 1 + .../rivencraft/models/block/decay_blue.json | 1 + .../rivencraft/models/block/decay_purple.json | 1 + .../rivencraft/models/block/decay_red.json | 1 + .../rivencraft/models/block/decay_white.json | 1 + .../textures/block/cloud_platform.png | Bin 0 -> 868 bytes .../rivencraft/textures/block/decay_blue.png | Bin 0 -> 270 bytes .../textures/block/decay_purple.png | Bin 0 -> 271 bytes .../rivencraft/textures/block/decay_red.png | Bin 0 -> 272 bytes .../rivencraft/textures/block/decay_white.png | Bin 0 -> 237 bytes .../tags/block/decay_protected.json | 24 +++ 40 files changed, 698 insertions(+), 27 deletions(-) create mode 100644 docs/AGE_CLOUD_PLATFORM.md create mode 100644 docs/INSTABILITY_DECAY.md create mode 100644 src/main/java/net/dnorberg/rivencraft/block/CloudPlatformBlock.java create mode 100644 src/main/java/net/dnorberg/rivencraft/block/DecayBlock.java create mode 100644 src/main/java/net/dnorberg/rivencraft/book/AgeDecayGameTests.java create mode 100644 src/main/java/net/dnorberg/rivencraft/book/AgeDecayProviders.java create mode 100644 src/main/java/net/dnorberg/rivencraft/book/AgeDecaySavedData.java create mode 100644 src/main/java/net/dnorberg/rivencraft/book/CloudPlatformGameTests.java create mode 100644 src/main/java/net/dnorberg/rivencraft/gametest/BookPageCapacityGameTests.java create mode 100644 src/main/resources/assets/rivencraft/blockstates/cloud_platform.json create mode 100644 src/main/resources/assets/rivencraft/blockstates/decay.json create mode 100644 src/main/resources/assets/rivencraft/models/block/cloud_platform.json create mode 100644 src/main/resources/assets/rivencraft/models/block/decay_blue.json create mode 100644 src/main/resources/assets/rivencraft/models/block/decay_purple.json create mode 100644 src/main/resources/assets/rivencraft/models/block/decay_red.json create mode 100644 src/main/resources/assets/rivencraft/models/block/decay_white.json create mode 100644 src/main/resources/assets/rivencraft/textures/block/cloud_platform.png create mode 100644 src/main/resources/assets/rivencraft/textures/block/decay_blue.png create mode 100644 src/main/resources/assets/rivencraft/textures/block/decay_purple.png create mode 100644 src/main/resources/assets/rivencraft/textures/block/decay_red.png create mode 100644 src/main/resources/assets/rivencraft/textures/block/decay_white.png create mode 100644 src/main/resources/data/rivencraft/tags/block/decay_protected.json diff --git a/docs/AGE_CLOUD_PLATFORM.md b/docs/AGE_CLOUD_PLATFORM.md new file mode 100644 index 0000000..b9a0b1f --- /dev/null +++ b/docs/AGE_CLOUD_PLATFORM.md @@ -0,0 +1,7 @@ +# Age arrival cloud platform + +Newly created Ages now place a 5×5 solid cloud platform instead of cobblestone at their saved spawn. The block has full collision, passes skylight, renders translucently, has no loot table, and cannot be obtained with pick-block. Existing Ages retain their already-placed platform; this change does not overwrite a player's saved world. + +The block texture is `assets/rivencraft/textures/block/cloud_platform.png`, verified at exactly 16×16 RGBA. Legacy's supplied assets contain no cloud-block texture, so this one was generated with the built-in image tool, then reduced by nearest-neighbor sampling. The final prompt requested a seamless Minecraft block-face pixel-art tile with 2–3 chunky white, icy-blue, and powder-blue cloud swirls, genuine transparent holes between them, strong 16×16-scale contrast, and no background or text. + +The separate [decay provider](INSTABILITY_DECAY.md) protects the saved spawn area by position, so its protection remains in force regardless of the platform's material. diff --git a/docs/AGE_PROFILES.md b/docs/AGE_PROFILES.md index 7fc6899..8ebb7ab 100644 --- a/docs/AGE_PROFILES.md +++ b/docs/AGE_PROFILES.md @@ -199,7 +199,7 @@ eligible biomes for Tiled/Grid and Tiny–Huge. The eligible pool comes from the terrain's biome preset, or `#rivencraft:cave_biomes` for Cave terrain, so future biome integrations can expand it without changing the count rule. Single uses one; Native uses its preset biome pool. This random-age maximum does not truncate -authored pages: the Binder's 27 symbol-page positions bound an authored book. +authored pages: the Binder's 252 symbol-page positions bound an authored book. Legacy's Binder itself had no explicit page-count check; this remains a fixed-slot storage ceiling. Native cannot be paired with explicit biome pages. Legacy filled Tiny–Huge to three; Rivencraft deliberately uses two so a pair of written biome pages stays exactly as written. diff --git a/docs/INSTABILITY_DECAY.md b/docs/INSTABILITY_DECAY.md new file mode 100644 index 0000000..4f47593 --- /dev/null +++ b/docs/INSTABILITY_DECAY.md @@ -0,0 +1,13 @@ +# Colored decay (Milestone 6, issue #68) + +Legacy defines seven decay block-state colors and has a texture/model for every one: black, red, green, blue, purple, yellow, and white. That does not mean all seven are active instability hazards. Its active provider deck registers blue, purple, red, and white. Black has a decay handler and provider class, but its provider registration is commented out; green and yellow have visual states without registered spread handlers. Rivencraft currently implements the four active provider colors. Their 16×16 textures are exact copies of the local Mystcraft 1.12.2-0.13.7.06 assets at `assets/mystcraft/textures/blocks/decay_{blue,purple,red,white}.png`. Legacy's `blockdecay.json` and `blockdecay_*.json` map these to cube-all models; Rivencraft uses the same mapping. + +The local Legacy bytecode (`EffectDecayBasic`, `DecayHandlerSpreading`, and the four color handlers) seeds decay in a chunk, then spreads to six neighbors. Blue prefers softer blocks, red uses explosion resistance, purple is slower, and white converts almost anything and damages entities on contact. Rivencraft preserves the four colors, six-neighbor growth, relative conversion tendencies, and white contact damage. It adapts the exact difficulty formulas to modern block hardness and safety checks; fluids are not replaced, and provider attempts are deterministic from the Age seed and persisted attempt counter. + +Decay requires both `instability.world_mutation=true` and `instability_decay.enabled=true` in the server config. Both now default to true, matching Legacy's enabled-by-default Age instability and provider registration. Existing config files retain their saved values, including `false`; change them explicitly to opt in after updating an existing installation. At most `instability_decay.max_attempts_per_call` neighbors (default four) are examined, each within radius one, and at most one block can be replaced per provider run. The shared Age action budget may lower that further. Only chunks already loaded and supplied by the scheduler are touched; no cross-dimension or force-loading path exists. A per-Age `rivencraft_decay` saved data file stores a bounded frontier (1024 positions per color) and attempt counter. Disabling decay stops seeding, spreading, and white contact damage; existing blocks remain so re-enabling can resume. + +Decay rejects the saved Age spawn and its nearby 5×5 arrival platform (with a two-block margin), block entities, fluids, unbreakable blocks, vanilla portal/command/structure infrastructure, blocks in `#minecraft:portals`, all non-decay Rivencraft blocks, and blocks in `#rivencraft:decay_protected`. Data packs can append other mods' protected blocks to that tag. A fake-player break event is posted before replacement, and a placement event after it; a cancellation prevents or restores the change. As with other fake-player automation, protection mods need to handle NeoForge block events for this safeguard to apply. + +GameTests cover persistence, frontier deduplication, selected-chunk limits, and Overworld isolation. A real-client check should enable both settings in a disposable Age, observe growth, leave/reload that Age, then disable decay and verify growth stops. Also test a claim-protected block and a block entity if a protection mod is installed. + +For current test builds, an operator standing in an Age can use `/rivencraft age decay pulse white 32` (or `blue`, `purple`, `red`). This performs at most 32 budgeted provider calls in the player's already-loaded chunk and reports how many blocks converted. It does not edit the saved Age plan or instability score. Repeat the command to observe spread, leave/reload the Age to check persistence, then disable decay and confirm the command refuses to act. This test hook remains useful because the provisional authored-page scoring does not yet provide a reliable route to the Legacy eating deck's 15,000-point gate. diff --git a/docs/INSTABILITY_RUNTIME.md b/docs/INSTABILITY_RUNTIME.md index 00c3069..2ea899b 100644 --- a/docs/INSTABILITY_RUNTIME.md +++ b/docs/INSTABILITY_RUNTIME.md @@ -4,6 +4,6 @@ Each new Age stores an `InstabilityPlan` beside its frozen `Instability` score. The saved provider IDs are independent of installed handlers. A missing optional handler leaves its ID and level intact and is skipped at runtime. A syntactically invalid saved ID or unsupported plan version fails loading explicitly; an unknown but valid ID is preserved and logged once when encountered. Disabling instability or world mutation never rewrites a saved score or provider plan. Saves from #64 with a frozen score but no provider plan are selected once from that score and Age seed, then marked dirty for persistence. Older pre-score Ages remain neutral and inert. -The server scheduler runs only for loaded `rivencraft:age/*` levels with a player present and an Age definition. It uses only chunks already loaded near those players, never requests a chunk, and rotates among eligible Ages when the per-tick cap is reached. Configuration bounds the cooldown (minimum 20 ticks), active Ages, handlers and actions per Age, effective provider level, and examined loaded chunks. A handler must claim a shared action permit before each effect. A throwing handler is logged and quarantined temporarily without stopping the server. World-mutating handlers are disabled by default. These limits are guards for upcoming providers, not proof that an arbitrary third-party handler cannot misuse world APIs. +The server scheduler runs only for loaded `rivencraft:age/*` levels with a player present and an Age definition. It uses only chunks already loaded near those players, never requests a chunk, and rotates among eligible Ages when the per-tick cap is reached. Configuration bounds the cooldown (minimum 20 ticks), active Ages, handlers and actions per Age, effective provider level, and examined loaded chunks. A handler must claim a shared action permit before each effect. A throwing handler is logged and quarantined temporarily without stopping the server. World-mutating handlers are enabled by default to match Legacy; the setting can still disable them. These limits are guards for upcoming providers, not proof that an arbitrary third-party handler cannot misuse world APIs. -Issue #67 registers the non-world-mutating potion handlers; see [status-effect policy](INSTABILITY_STATUS_EFFECTS.md). World hazards remain for #68–#70. The runtime rejects Overworld and preview dimension IDs; its player requirement excludes unattended snapshot chunk loaders. GameTests cover deterministic selection, save round-trips, score-only migration, unknown IDs, scope checks, fair admission, and action caps. +Issue #67 registers the non-world-mutating potion handlers; see [status-effect policy](INSTABILITY_STATUS_EFFECTS.md). Issue #68 adds configurable [colored decay](INSTABILITY_DECAY.md), enabled by default for newly generated configs. Other world hazards remain for #69–#70. The runtime rejects Overworld and preview dimension IDs; its player requirement excludes unattended snapshot chunk loaders. GameTests cover deterministic selection, save round-trips, score-only migration, unknown IDs, scope checks, fair admission, and action caps. diff --git a/docs/WRITING_DATA.md b/docs/WRITING_DATA.md index de9dbd7..9537704 100644 --- a/docs/WRITING_DATA.md +++ b/docs/WRITING_DATA.md @@ -87,10 +87,10 @@ placement. The surface draws the native 30×40 portrait page region from Legacy's book UI atlas; symbols are layered at a uniform scale, while the square item models remain unchanged in inventories. -A Notebook holds 27 ordered positions. Its pages, names, gaps, and unresolved +A Notebook holds 252 ordered positions. Its pages, names, gaps, and unresolved IDs are saved on the item, so moving the Notebook between an inventory, Desk, and Binder does not rearrange them. A loose Page in a Desk tab exposes just one -position; a Notebook exposes all 27. The Desk has 88 persistent collection +position; a Notebook exposes all 252. The Desk has 88 persistent collection tabs, shown four at a time. Use the up/down tab controls to move by four, put paper and Ink Vials in their input slots, select a stored page, and take the copy from the virtual output. Taking it spends one paper and the configured @@ -100,7 +100,7 @@ copying. Moving a page within the Desk or dragging it into the inventory removes the original only when the destination accepts it. The Book Binder takes a leather or empty-Notebook cover, an Age title, one Link -Panel, and optionally up to 27 ordered written Pages. A filled Notebook can be +Panel, and optionally up to 252 ordered written Pages. A filled Notebook can be imported at a chosen strip position; the empty Notebook remains recoverable. The preview result is virtual. Taking the completed Descriptive Book commits the inputs once, creates its Age identity, and saves schema-versioned diff --git a/docs/WRITING_GRAMMAR.md b/docs/WRITING_GRAMMAR.md index c1695ac..9b14006 100644 --- a/docs/WRITING_GRAMMAR.md +++ b/docs/WRITING_GRAMMAR.md @@ -2,7 +2,7 @@ `WritingGrammar` interprets the saved `OrderedWriting` on the server using only `SymbolCatalog`'s schema-v2 semantics. Client display metadata, page names, and supplied translations never determine an Age's meaning. The parser is pure: it returns immutable parsed entries and indexed diagnostics without changing a book or its pages. -New Binder writing has at most one Link Panel plus 27 symbol pages. The Link Panel must be first. A Link-Panel-only book, or an older book with no writing component, keeps the existing random Age path. Any authored supported symbol makes the book a partial or complete grammar. From 0.4.3.0, the server compiles it into a persistent Age plan before consuming Binder inputs or generating chunks. +New Binder writing has at most one Link Panel plus 252 symbol pages (the current fixed-slot save-format ceiling, not a Legacy gameplay limit). The Link Panel must be first. A Link-Panel-only book, or an older book with no writing component, keeps the existing random Age path. Any authored supported symbol makes the book a partial or complete grammar. From 0.4.3.0, the server compiles it into a persistent Age plan before consuming Binder inputs or generating chunks. The supported order follows the representable subset of the [Legacy grammar audit](LEGACY_AGE_GRAMMAR_AUDIT.md): terrain; zero or more biome pages; biome controller; weather; season; lighting; sky effects; zero or more period or phase modifiers; one celestial page; then zero or more features, each optionally preceded by a compatible material page. Biome pages precede the controller that consumes them, and celestial modifiers precede the celestial page. Terrain, controller, weather, season, lighting, sky, and celestial are single-choice categories; identical repeats are duplicates and differing values conflict. Repeated modifiers, biome pages, feature pages, and material pages are intentional and retain their order. An unpaired material page receives a seeded compatible feature during compilation. Angle modifiers remain recognized in the schema but are rejected at binding until a sky renderer can apply sun tilt. diff --git a/gradle.properties b/gradle.properties index 1551d9e..efe6b31 100644 --- a/gradle.properties +++ b/gradle.properties @@ -36,7 +36,7 @@ mod_name=Rivencraft # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=LGPL-3.0-or-later # The mod version. Rivencraft uses the x.y.z.b policy documented in README.md. -mod_version=0.5.5.0 +mod_version=0.5.6.4 immersive_portals_version=6.0.7 terrablender_version=4.1.0.8 serene_seasons_version_id=B6nTjh4b diff --git a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java index 17af4c7..2ec0965 100644 --- a/src/main/java/net/dnorberg/rivencraft/Rivencraft.java +++ b/src/main/java/net/dnorberg/rivencraft/Rivencraft.java @@ -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); diff --git a/src/main/java/net/dnorberg/rivencraft/block/CloudPlatformBlock.java b/src/main/java/net/dnorberg/rivencraft/block/CloudPlatformBlock.java new file mode 100644 index 0000000..b7d6c28 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/block/CloudPlatformBlock.java @@ -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; + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/block/DecayBlock.java b/src/main/java/net/dnorberg/rivencraft/block/DecayBlock.java new file mode 100644 index 0000000..33dfe6c --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/block/DecayBlock.java @@ -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 = EnumProperty.create("color", Color.class); + + public DecayBlock(Properties properties) { + super(properties); + registerDefaultState(stateDefinition.any().setValue(COLOR, Color.BLUE)); + } + + @Override + protected void createBlockStateDefinition(StateDefinition.Builder 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); + } + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeDecayGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/AgeDecayGameTests.java new file mode 100644 index 0000000..b738802 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeDecayGameTests.java @@ -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(); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeDecayProviders.java b/src/main/java/net/dnorberg/rivencraft/book/AgeDecayProviders.java new file mode 100644 index 0000000..8240606 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeDecayProviders.java @@ -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 PROTECTED = TagKey.create(Registries.BLOCK, + ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "decay_protected")); + private static final Map 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 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 chunks, + List 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 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); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeDecaySavedData.java b/src/main/java/net/dnorberg/rivencraft/book/AgeDecaySavedData.java new file mode 100644 index 0000000..fa94f6e --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeDecaySavedData.java @@ -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 FACTORY = new Factory<>(AgeDecaySavedData::new, + AgeDecaySavedData::load, DataFixTypes.SAVED_DATA_MAP_DATA); + private final EnumMap> 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 frontier(DecayBlock.Color color) { + return frontiers.get(color); + } + + public void add(DecayBlock.Color color, BlockPos pos) { + List 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; + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java b/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java index d624fa9..b5f8794 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java @@ -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); } diff --git a/src/main/java/net/dnorberg/rivencraft/book/CloudPlatformGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/CloudPlatformGameTests.java new file mode 100644 index 0000000..0fd72f4 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/book/CloudPlatformGameTests.java @@ -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(); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/book/TerrainWritingGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/TerrainWritingGameTests.java index 9dc1e9e..23c82cb 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/TerrainWritingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/book/TerrainWritingGameTests.java @@ -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"); diff --git a/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java b/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java index 49a6a72..ea196ec 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java +++ b/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java @@ -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); diff --git a/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java b/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java index 57c3b48..5d33707 100644 --- a/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java +++ b/src/main/java/net/dnorberg/rivencraft/command/RivencraftCommands.java @@ -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; + } } diff --git a/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java b/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java index e74bb9b..adac4ee 100644 --- a/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java +++ b/src/main/java/net/dnorberg/rivencraft/config/RivencraftConfig.java @@ -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(); } } diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/BookPageCapacityGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/BookPageCapacityGameTests.java new file mode 100644 index 0000000..4a0c5b1 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/gametest/BookPageCapacityGameTests.java @@ -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 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(); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java index 0aaff80..7d92e1a 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java @@ -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(), diff --git a/src/main/java/net/dnorberg/rivencraft/menu/BookBinderMenu.java b/src/main/java/net/dnorberg/rivencraft/menu/BookBinderMenu.java index 350716d..473f635 100644 --- a/src/main/java/net/dnorberg/rivencraft/menu/BookBinderMenu.java +++ b/src/main/java/net/dnorberg/rivencraft/menu/BookBinderMenu.java @@ -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; diff --git a/src/main/java/net/dnorberg/rivencraft/menu/NotebookMenu.java b/src/main/java/net/dnorberg/rivencraft/menu/NotebookMenu.java index 03ba298..bdedd00 100644 --- a/src/main/java/net/dnorberg/rivencraft/menu/NotebookMenu.java +++ b/src/main/java/net/dnorberg/rivencraft/menu/NotebookMenu.java @@ -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++) { diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModBlocks.java b/src/main/java/net/dnorberg/rivencraft/registry/ModBlocks.java index d4d442c..ff2fc97 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModBlocks.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModBlocks.java @@ -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 STAR_FISSURE = BLOCKS.registerBlock("star_fissure", StarFissureBlock::new, BlockBehaviour.Properties.ofFullCopy(Blocks.END_PORTAL) .lightLevel(state -> 6)); + public static final DeferredBlock DECAY = BLOCKS.registerBlock("decay", + DecayBlock::new, BlockBehaviour.Properties.ofFullCopy(Blocks.STONE) + .strength(5.0F, 10.0F).noLootTable()); + public static final DeferredBlock CLOUD_PLATFORM = BLOCKS.registerBlock("cloud_platform", + CloudPlatformBlock::new, BlockBehaviour.Properties.ofFullCopy(Blocks.WHITE_STAINED_GLASS) + .strength(1.5F).noOcclusion().noLootTable()); private ModBlocks() { } diff --git a/src/main/java/net/dnorberg/rivencraft/writing/NotebookContents.java b/src/main/java/net/dnorberg/rivencraft/writing/NotebookContents.java index e1249bc..71abb83 100644 --- a/src/main/java/net/dnorberg/rivencraft/writing/NotebookContents.java +++ b/src/main/java/net/dnorberg/rivencraft/writing/NotebookContents.java @@ -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 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 CODEC = ItemStack.OPTIONAL_CODEC.listOf() .validate(NotebookContents::validate) diff --git a/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java b/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java index 5910792..65425a8 100644 --- a/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java +++ b/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java @@ -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 MATERIAL_FEATURES = Set.of("rocks", "surface_lakes", "deep_lakes", "lava_lakes", "floating_islands", "tendrils", "spheres", "spikes", "obelisks"); diff --git a/src/main/resources/assets/rivencraft/blockstates/cloud_platform.json b/src/main/resources/assets/rivencraft/blockstates/cloud_platform.json new file mode 100644 index 0000000..e041218 --- /dev/null +++ b/src/main/resources/assets/rivencraft/blockstates/cloud_platform.json @@ -0,0 +1 @@ +{"variants":{"":{"model":"rivencraft:block/cloud_platform"}}} diff --git a/src/main/resources/assets/rivencraft/blockstates/decay.json b/src/main/resources/assets/rivencraft/blockstates/decay.json new file mode 100644 index 0000000..1d32da7 --- /dev/null +++ b/src/main/resources/assets/rivencraft/blockstates/decay.json @@ -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" } + } +} diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index dfc5661..58eed14 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -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.", diff --git a/src/main/resources/assets/rivencraft/models/block/cloud_platform.json b/src/main/resources/assets/rivencraft/models/block/cloud_platform.json new file mode 100644 index 0000000..afca4d3 --- /dev/null +++ b/src/main/resources/assets/rivencraft/models/block/cloud_platform.json @@ -0,0 +1 @@ +{"parent":"minecraft:block/cube_all","render_type":"minecraft:translucent","textures":{"all":"rivencraft:block/cloud_platform"}} diff --git a/src/main/resources/assets/rivencraft/models/block/decay_blue.json b/src/main/resources/assets/rivencraft/models/block/decay_blue.json new file mode 100644 index 0000000..1fada54 --- /dev/null +++ b/src/main/resources/assets/rivencraft/models/block/decay_blue.json @@ -0,0 +1 @@ +{"parent":"minecraft:block/cube_all","textures":{"all":"rivencraft:block/decay_blue"}} diff --git a/src/main/resources/assets/rivencraft/models/block/decay_purple.json b/src/main/resources/assets/rivencraft/models/block/decay_purple.json new file mode 100644 index 0000000..ad3e8b3 --- /dev/null +++ b/src/main/resources/assets/rivencraft/models/block/decay_purple.json @@ -0,0 +1 @@ +{"parent":"minecraft:block/cube_all","textures":{"all":"rivencraft:block/decay_purple"}} diff --git a/src/main/resources/assets/rivencraft/models/block/decay_red.json b/src/main/resources/assets/rivencraft/models/block/decay_red.json new file mode 100644 index 0000000..0e1972d --- /dev/null +++ b/src/main/resources/assets/rivencraft/models/block/decay_red.json @@ -0,0 +1 @@ +{"parent":"minecraft:block/cube_all","textures":{"all":"rivencraft:block/decay_red"}} diff --git a/src/main/resources/assets/rivencraft/models/block/decay_white.json b/src/main/resources/assets/rivencraft/models/block/decay_white.json new file mode 100644 index 0000000..8208daf --- /dev/null +++ b/src/main/resources/assets/rivencraft/models/block/decay_white.json @@ -0,0 +1 @@ +{"parent":"minecraft:block/cube_all","textures":{"all":"rivencraft:block/decay_white"}} diff --git a/src/main/resources/assets/rivencraft/textures/block/cloud_platform.png b/src/main/resources/assets/rivencraft/textures/block/cloud_platform.png new file mode 100644 index 0000000000000000000000000000000000000000..d0623a49be7b531a0c7535b76754f2def33e3ef9 GIT binary patch literal 868 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`3dtTpz6=aiY77hwEes65fI|H(?D8gRi>Fdh=ii?v&T63k! zaYdkzYKdz^NlIc#s#S7PDv)9@GB7gJH8jvQunaMWn9-t1TnrN%Sf_J`#^FcvOT7j*f|m>2WI$G@M^d{?pJ zq#t-S%!*hEEZ(lcFjnQ`Qo)-a)ml)d^}%${8JJ* z=i|q{mf=bdr=`txojI%V{Iuuk3=a$CB~w* z5?w(q(t#KJZ(AO8maUF!v(>j`II^ZaI3~n{aGCyi{x0vmUUj+z58d! zL9Z>x=gps?r4*Ij{^^Ws=Cay&ub0(*vjiA~^jl_TmoPsz2{*p%rhd61+Dm`M#^u{~ yNg5id-aEJT8lU>U!p*Zc?(mYV4(wOk@xQ4mC2)HP+eu&=WbkzLb6Mw<&;$S$HD9Ly literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/rivencraft/textures/block/decay_blue.png b/src/main/resources/assets/rivencraft/textures/block/decay_blue.png new file mode 100644 index 0000000000000000000000000000000000000000..d493b7257ca284fc9bd53062ef64fcc621879043 GIT binary patch literal 270 zcmV+p0rCEcP)4*=?msw)lZ!YA4$2iPI6R3VZI9!~CGT?YC7G%2Fuhc( zoS)v4R4a>d`>>(SpL~qlhXFKQ>t7Z?ZGqKibC)v!ky51W`!@i3R4ZQ7DD}YGvl3Hw zn(w{J^U`z~&Q8yXo53;!2yR_-P&1Ds%(D7o50y UI)}+)M*si-07*qoM6N<$f@kD$-v9sr literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/rivencraft/textures/block/decay_purple.png b/src/main/resources/assets/rivencraft/textures/block/decay_purple.png new file mode 100644 index 0000000000000000000000000000000000000000..821329ce729da0bf74c496f8791f963dd4b066ff GIT binary patch literal 271 zcmV+q0r38bP)>ke$jPhh=sQrS&muvSzk2k)N+|+Y&Vi(=&sRxXt<+GcekduqfuzwBYgs6~f9%El ze(30zS%XLO$`YjcCg#;Kk<`rDu!KR(D%zUj!VS>oD_ZT5;muQRJ##;;%UXqQ0AE&* VRF?^^1cm?r002ovPDHLkV1n`3cd`Hg literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/rivencraft/textures/block/decay_red.png b/src/main/resources/assets/rivencraft/textures/block/decay_red.png new file mode 100644 index 0000000000000000000000000000000000000000..d87e683144f8e9a3db114acb1a60067704120da0 GIT binary patch literal 272 zcmV+r0q_2aP)>(SFFwZY!vOlJ^Dm1QYJtOhcheVuTBTM=^9KMuO{u)fD)qqI zvr4FF$F Wy?SyohWAkb0000ZYU&%R$aJ}J$$KGEYT z&5Ys3Fth3R7{iTWGuzHJRywWiToDt