diff --git a/docs/ASSET_PROVENANCE.md b/docs/ASSET_PROVENANCE.md index d39ce21..98172bb 100644 --- a/docs/ASSET_PROVENANCE.md +++ b/docs/ASSET_PROVENANCE.md @@ -28,6 +28,7 @@ project's GNU LGPL v3 license. Rivencraft itself uses the same license. | `assets/rivencraft/textures/block/crystal_block.png`, `budding_crystal.png` | `assets/mystcraft/textures/blocks/crystal.png` | Legacy 0.13.7.06 local extraction | Exact 16×16 blue tile reused for both solid crystal blocks | | `assets/rivencraft/textures/block/{small,medium,large}_crystal_bud.png`, `crystal_cluster.png` | Minecraft 1.21.1 `textures/block/{small,medium,large}_amethyst_bud.png`, `amethyst_cluster.png` | Minecraft 1.21.1 | Original 16×16 alpha masks and silhouettes preserved; hue shifted toward Legacy blue, with brighter saturation; exported as 8-bit RGBA (not indexed PNG) | | `assets/rivencraft/textures/item/crystal_shard.png` | Original sprite generated with OpenAI ImageGen from the Legacy crystal palette | 2026-10-05 | Cropped and nearest-neighbor scaled to 16×16 | +| `assets/rivencraft/sounds/linking/link-fissure.ogg` | `assets/mystcraft/sounds/linking/link-fissure.ogg` | Legacy 0.13.7.06 local extraction | Namespace/path rename only; plays when a Star Fissure returns an entity home | Mystcraft Legacy identifies XCompWiz as its project developer and includes the full LGPL v3 text in its root `LICENSE` file. Rivencraft's root `LICENSE` diff --git a/docs/LEGACY_AGE_GRAMMAR_AUDIT.md b/docs/LEGACY_AGE_GRAMMAR_AUDIT.md index 9e9964f..bef992f 100644 --- a/docs/LEGACY_AGE_GRAMMAR_AUDIT.md +++ b/docs/LEGACY_AGE_GRAMMAR_AUDIT.md @@ -18,7 +18,7 @@ Legacy does **not** reject a book just because it omits terrain, biome, weather, | `WeatherNorm`, `Fast`, `Slow`, `Off`, `On`, `Cloudy`, `Rain`, `Snow`, `Storm` | `AgeTraits.Weather` has matching names except Legacy `On` maps to `ALWAYS` | Compile weather semantics separately from biome selection. `Snow` must remain deferred until optional season integration; do not silently substitute another weather mode. | | Suns, moons, starfields; angle, phase, period, color/gradient/sunset modifiers | One `celestialPeriod`, `celestialAngle`, `celestialPhase` on `AgeTraits` | Compile only the representable single-cycle subset first. Multiple celestial bodies, independent periods, custom stars, gradients and sunsets need a richer definition and renderer, not flattening into one value. | | Sky/fog/cloud/grass/foliage/water colors, natural variants, horizon, rainbow | Existing sky/dimension visuals and preview | Require an explicit modern client/biome-color design; metadata-only symbol pages must not pretend to change rendering. | -| Caves, ores, huge trees, lakes, floating islands, tendrils, skylands, spheres, spikes, crystals; vanilla villages, strongholds, mineshafts, dungeons, fortresses, obelisks, fissures | Current cave profile, authored Floating Islands/Tendrils/Skylands/Spheres/Spikes/Obelisks/Crystal Formations and Dense Ores, and page-gated vanilla Dungeons/Mineshafts/Nether Fortresses/Strongholds/Villages | The five Legacy vanilla structure symbols gate Minecraft 1.21.1 generation. Dungeons use monster-room placement; the others use vanilla structure sets. Obelisks use a bespoke 4×4 plinth and 2×12×2 shaft with safe modern placement. Crystal Formations retain Legacy's blue tile and one-in-fifteen attempt rate but use new budding, shard-producing mounds. Fissures and exact 1.12 structure geometry remain separate work. | +| Caves, ores, huge trees, lakes, floating islands, tendrils, skylands, spheres, spikes, crystals; vanilla villages, strongholds, mineshafts, dungeons, fortresses, obelisks, fissures | Current cave profile, authored Floating Islands/Tendrils/Skylands/Spheres/Spikes/Obelisks/Crystal Formations/Star Fissures and Dense Ores, and page-gated vanilla Dungeons/Mineshafts/Nether Fortresses/Strongholds/Villages | The five Legacy vanilla structure symbols gate Minecraft 1.21.1 generation. Dungeons use monster-room placement; the others use vanilla structure sets. Obelisks use a bespoke 4×4 plinth and 2×12×2 shaft with safe modern placement. Crystal Formations retain Legacy's blue tile and one-in-fifteen attempt rate but use new budding, shard-producing mounds. Star Fissures reproduce Legacy's spawn-chunk footprint, open-to-sky columns, home link, and sound. Their renderer uses the modern End Portal pipeline in place of Legacy's retired fixed-function OpenGL calls. Exact 1.12 geometry for other structures remains separate work. | | `EnvAccel`, explosions, lightning, meteors, scorching; PvP and dummy suppression pages | No equivalent Age-definition effect | Defer environmental hazards/instability effects to milestone 6, PvP rule to a separately scoped server policy; retain authored IDs without fake behavior. | The Legacy symbol list and page names come from [ModSymbols](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L129-L240) and [SymbolRules](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/SymbolRules.java#L19-L137). Rivencraft's [catalog](../src/main/java/net/dnorberg/rivencraft/writing/SymbolCatalog.java) currently holds display metadata only; its built-in set is just standard terrain, native biomes, normal weather, normal lighting, normal time, and caves. [OrderedWriting](../src/main/java/net/dnorberg/rivencraft/writing/OrderedWriting.java) preserves page order, but no compiler yet connects it to [AgeTraits](../src/main/java/net/dnorberg/rivencraft/book/AgeTraits.java) or [Age profiles](AGE_PROFILES.md). diff --git a/docs/WRITING_GRAMMAR.md b/docs/WRITING_GRAMMAR.md index e0c2b68..5716746 100644 --- a/docs/WRITING_GRAMMAR.md +++ b/docs/WRITING_GRAMMAR.md @@ -20,4 +20,6 @@ Material pages are generated for registered full-collision, non-fluid blocks and Crystal Formations are an authored, material-free feature on exposed solid terrain, including Flat terrain; Cave and Void reject the page. Like Legacy, one in fifteen eligible chunks attempts a formation, with the site fixed by the Age seed and chunk coordinates. The modern formation is a compact mound with budding hearts instead of Legacy's long crystal lines. Budding blocks grow through small, medium, and large buds into clusters using vanilla amethyst timing and sounds. Non-silk cluster harvest yields Crystal Shards; four shards craft one Crystal Block. These blocks and shards use 16×16 textures: Legacy's crystal tile for solids and hue-shifted vanilla amethyst sprites for buds and clusters. A page-free Age can also randomly select Crystal Formations; an authored page always retains its exact choice in the saved plan. +Star Fissures are a material-free feature on every terrain profile, including Cave and Void. The fissure is generated once beside the first resolved spawn, using Legacy's spawn-chunk offset and 10–17 paired, irregular X-span rows running along +Z. Each portal tile is placed at y=0 and its column is opened to the sky, as in Legacy. For modern terrain with a surface below y=0, the portal uses the minimum build height plus one instead; empty Void columns keep y=0. Its non-solid portal links to the Overworld spawn with the Legacy fissure sound; the animated starfield is drawn with Minecraft's current End Portal renderer, which uses the same end-sky/end-portal art as Legacy's custom renderer. Random Ages sample Legacy's default small-feature grammar for fissure selection: the stop/one/repeat branches have rank-derived weights 1:1:3, and the fissure has weight 1 of 9 among its small-feature rules. The remainder of random feature selection still follows Rivencraft's current pipeline, rather than running Legacy's entire Age grammar. + The Rivencraft symbol-page creative tabs read a generated index of bundled symbol definitions and group Pages by each definition's category, except biome-controller roles, which appear in a separate Biome Generation tab rather than the Biome tab. Adding another valid JSON symbol under `src/main/resources/data/rivencraft/rivencraft/symbols/` includes its page in the matching category tab on the next build; a new bundled category receives its own tab. Registry-derived Block and Fluid Pages have separate tabs and both appear in optional JEI. Link Pages have a separate tab. Blank Pages appear in neither the creative tabs nor JEI's ingredient list. The server catalog still decides whether a page is valid for writing. External server data-pack symbols are not included in this bundled index. diff --git a/gradle.properties b/gradle.properties index a5e05ec..67204b0 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.4.18.2 +mod_version=0.4.19.1 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/block/StarFissureBlock.java b/src/main/java/net/dnorberg/rivencraft/block/StarFissureBlock.java new file mode 100644 index 0000000..dad67a4 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/block/StarFissureBlock.java @@ -0,0 +1,84 @@ +package net.dnorberg.rivencraft.block; + +import com.mojang.serialization.MapCodec; +import net.dnorberg.rivencraft.block.entity.StarFissureBlockEntity; +import net.dnorberg.rivencraft.book.LinkingService; +import net.dnorberg.rivencraft.registry.ModSounds; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.LevelReader; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Portal; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockBehaviour; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.material.Fluid; +import net.minecraft.world.level.portal.DimensionTransition; +import net.minecraft.world.phys.Vec3; +import net.minecraft.world.phys.shapes.BooleanOp; +import net.minecraft.world.phys.shapes.CollisionContext; +import net.minecraft.world.phys.shapes.Shapes; +import net.minecraft.world.phys.shapes.VoxelShape; + +/** A starry, non-obtainable portal at the base of a generated fissure. */ +public final class StarFissureBlock extends BaseEntityBlock implements Portal { + public static final MapCodec CODEC = simpleCodec(StarFissureBlock::new); + private static final VoxelShape SHAPE = Block.box(0, 0, 0, 16, 1.6, 16); + + public StarFissureBlock(BlockBehaviour.Properties properties) { + super(properties); + } + + @Override + protected MapCodec codec() { + return CODEC; + } + + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new StarFissureBlockEntity(pos, state); + } + + @Override + protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext context) { + return SHAPE; + } + + @Override + protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity) { + if (entity.canUsePortal(false) && Shapes.joinIsNotEmpty( + Shapes.create(entity.getBoundingBox().move(-pos.getX(), -pos.getY(), -pos.getZ())), + state.getShape(level, pos), BooleanOp.AND)) { + entity.setAsInsidePortal(this, pos); + } + } + + @Override + public DimensionTransition getPortalDestination(ServerLevel level, Entity entity, BlockPos pos) { + if (level.dimension() == Level.OVERWORLD) return null; + ServerLevel overworld = level.getServer().overworld(); + if (overworld == null) return null; + Vec3 destination = LinkingService.resolveDestination(overworld, overworld.getSharedSpawnPos(), true) + .orElse(overworld.getSharedSpawnPos().getBottomCenter()); + level.playSound(null, pos, ModSounds.LINK_FISSURE.get(), SoundSource.BLOCKS, 1.0F, 1.0F); + return new DimensionTransition(overworld, destination, + entity.getDeltaMovement(), entity.getYRot(), entity.getXRot(), + DimensionTransition.PLACE_PORTAL_TICKET); + } + + @Override + public ItemStack getCloneItemStack(LevelReader level, BlockPos pos, BlockState state) { + return ItemStack.EMPTY; + } + + @Override + protected boolean canBeReplaced(BlockState state, Fluid fluid) { + return false; + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/block/entity/StarFissureBlockEntity.java b/src/main/java/net/dnorberg/rivencraft/block/entity/StarFissureBlockEntity.java new file mode 100644 index 0000000..b5e4f7d --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/block/entity/StarFissureBlockEntity.java @@ -0,0 +1,13 @@ +package net.dnorberg.rivencraft.block.entity; + +import net.dnorberg.rivencraft.registry.ModBlockEntities; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.entity.TheEndPortalBlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +/** Reuses Minecraft's animated starfield renderer with a valid Rivencraft block-entity type. */ +public final class StarFissureBlockEntity extends TheEndPortalBlockEntity { + public StarFissureBlockEntity(BlockPos pos, BlockState state) { + super(ModBlockEntities.STAR_FISSURE.get(), pos, state); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java b/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java index 2c2d2e7..1a41e00 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeDimensionService.java @@ -39,6 +39,7 @@ import net.minecraft.world.level.biome.MultiNoiseBiomeSource; import net.dnorberg.rivencraft.world.ScaledBiomeSource; import net.dnorberg.rivencraft.world.AgeFlatChunkGenerator; import net.dnorberg.rivencraft.world.AgeFeatureChunkGenerator; +import net.dnorberg.rivencraft.world.StarFissureFeature; import net.minecraft.world.level.chunk.ChunkGenerator; import net.minecraft.world.level.dimension.BuiltinDimensionTypes; import net.minecraft.world.level.dimension.DimensionType; @@ -98,6 +99,12 @@ public final class AgeDimensionService { if (spawn == null) { applyInitialCelestialPhase(level, definition); spawn = createSafeSpawn(level, definition); + if (AgeFeatureSelection.resolved(definition.plan()).stream() + .anyMatch(choice -> choice.id().equals(AgeFeatureSelection.STAR_FISSURES)) + || definition.randomFeatures().stream() + .anyMatch(choice -> choice.id().equals(AgeFeatureSelection.STAR_FISSURES))) { + StarFissureFeature.generate(level, definition.seed(), spawn); + } definition = AgeRegistrySavedData.get(level).setSpawn(definition.id(), spawn); } return Optional.of(new LinkTarget( diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java b/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java index bf7efb3..7f7d5ce 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java @@ -32,7 +32,9 @@ public final class AgeFeatureSelection { private static final List FLUID_FEATURES = List.of( id("surface_lakes"), id("deep_lakes"), id("lava_lakes")); private static final long RANDOM_SKYLANDS_SALT = 0x534B594C414E4453L; + private static final long RANDOM_STAR_FISSURES_SALT = 0x535441524752414DL; public static final ResourceLocation CRYSTAL_FORMATIONS = id("crystal_formations"); + public static final ResourceLocation STAR_FISSURES = id("star_fissures"); private AgeFeatureSelection() {} @@ -176,12 +178,33 @@ public final class AgeFeatureSelection { choices.add(new Choice(id("skylands"), Optional.empty())); if (error(CRYSTAL_FORMATIONS, profile, registries) == null && random.nextInt(10) == 0) choices.add(new Choice(CRYSTAL_FORMATIONS, Optional.empty())); + if (randomStarFissure(ageSeed)) + choices.add(new Choice(STAR_FISSURES, Optional.empty())); AgeBiomeSelection biomes = AgeBiomeSelection.compile(WritingGrammar.parse(OrderedWriting.EMPTY), AgeTraits.select(ageSeed), profile, ageSeed, registries); choices.addAll(AgeStructureSelection.randomChoices(ageSeed, profile, biomes, registries)); return List.copyOf(choices); } + /** + * Legacy's default FeatureSmalls0 grammar: stop without a feature (rank 4), + * emit one (rank 4), or emit one and repeat (rank 2). Its rank-derived + * weights are 1:1:3. Within FEATURE_SMALL, the Star Fissure rule has weight + * 1 of 9: three rank-3 choices have weight 1 each and two rank-1 lake + * choices have weight 3 each. Other small features remain selected by this + * port's existing random feature pipeline; only the fissure result is used. + */ + private static boolean randomStarFissure(long ageSeed) { + Random random = new Random(mix(ageSeed ^ RANDOM_STAR_FISSURES_SALT)); + for (int step = 0; step < 27; step++) { + int branch = random.nextInt(5); + if (branch == 0) return false; + if (random.nextInt(9) == 0) return true; + if (branch == 1) return false; + } + return false; + } + private static List shuffledMaterials(long ageSeed, ResourceLocation feature) { var category = solidFeature(feature) ? BlockSymbolPages.CATEGORY : BlockSymbolPages.FLUID_CATEGORY; @@ -212,6 +235,7 @@ public final class AgeFeatureSelection { return "Feature " + choice + " needs exposed solid ground, not Cave or Void terrain"; return null; } + if (choice.equals(STAR_FISSURES)) return null; if (skylands(choice)) return null; if (tendrils(choice)) return null; if (spheres(choice)) return null; diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgePersistenceGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/AgePersistenceGameTests.java index 2d76122..b697972 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgePersistenceGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgePersistenceGameTests.java @@ -79,11 +79,13 @@ public final class AgePersistenceGameTests { "A random Age may roll at most two distinct structure features"); for (AgeFeatureSelection.Choice choice : age.randomFeatures()) { helper.assertTrue(AgeFeatureSelection.skylands(choice.id()) + || choice.id().equals(AgeFeatureSelection.CRYSTAL_FORMATIONS) + || choice.id().equals(AgeFeatureSelection.STAR_FISSURES) || AgeStructureSelection.feature(choice.id()) ? choice.material().isEmpty() : choice.material().isPresent() && AgeFeatureSelection.materialError(choice) == null, - "Random material features need valid blocks or fluids; Skylands and structures need none"); + "Random material features need valid blocks or fluids; material-free features need none"); } } AgeRegistrySavedData registry = AgeRegistrySavedData.get(helper.getLevel()); diff --git a/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java index 366c88c..e1b51ff 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java @@ -40,7 +40,8 @@ public final class FeatureWritingGameTests { "feature/bastions", "feature/buried_treasure", "feature/shipwrecks", "feature/ocean_ruins", "feature/pillager_outposts", "feature/ruined_portals", "feature/nether_fossils", "feature/ancient_cities", "feature/trail_ruins", - "feature/dense_ores", "feature/obelisks", "feature/crystal_formations"); + "feature/dense_ores", "feature/obelisks", "feature/crystal_formations", + "feature/star_fissures"); helper.assertTrue(PageItem.bundledCreativeSymbols().containsAll(pages), "Supported feature pages must appear in the creative and JEI symbol feed"); for (String path : pages) { @@ -74,6 +75,7 @@ public final class FeatureWritingGameTests { || AgeFeatureSelection.spikes(choice.id()) || AgeFeatureSelection.obelisks(choice.id()) || choice.id().equals(AgeFeatureSelection.CRYSTAL_FORMATIONS) + || choice.id().equals(AgeFeatureSelection.STAR_FISSURES) || choice.id().equals(AgeDenseOres.ID)) continue; var placement = AgeFeatureSelection.placedFeature(choice.id()); helper.assertTrue(registries.lookupOrThrow(Registries.PLACED_FEATURE) @@ -187,8 +189,9 @@ public final class FeatureWritingGameTests { helper.assertTrue(random.stream().allMatch(choice -> AgeFeatureSelection.tendrils(choice.id()) || AgeFeatureSelection.spheres(choice.id()) || AgeFeatureSelection.skylands(choice.id()) + || choice.id().equals(AgeFeatureSelection.STAR_FISSURES) || AgeStructureSelection.feature(choice.id())), - "Cave random Ages must not choose incompatible surface features"); + "Cave random Ages must only choose compatible features, including Star Fissures"); if (random.stream().anyMatch(choice -> AgeFeatureSelection.tendrils(choice.id()))) found = true; } helper.assertTrue(found, "A page-free random Cave Age must be able to roll Tendrils"); diff --git a/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java b/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java index b84bfd1..49a6a72 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java +++ b/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java @@ -35,6 +35,7 @@ public final class ClientEvents { public static void registerRenderers(EntityRenderersEvent.RegisterRenderers event) { event.registerBlockEntityRenderer(ModBlockEntities.BOOK_STAND.get(), BookStandRenderer::new); event.registerBlockEntityRenderer(ModBlockEntities.WRITING_DESK.get(), WritingDeskRenderer::new); + event.registerBlockEntityRenderer(ModBlockEntities.STAR_FISSURE.get(), StarFissureRenderer::new); } @SubscribeEvent diff --git a/src/main/java/net/dnorberg/rivencraft/client/StarFissureRenderer.java b/src/main/java/net/dnorberg/rivencraft/client/StarFissureRenderer.java new file mode 100644 index 0000000..6396188 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/client/StarFissureRenderer.java @@ -0,0 +1,22 @@ +package net.dnorberg.rivencraft.client; + +import net.dnorberg.rivencraft.block.entity.StarFissureBlockEntity; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.client.renderer.blockentity.TheEndPortalRenderer; + +/** Uses the modern end-portal starfield shader at Legacy's full-block face positions. */ +public final class StarFissureRenderer extends TheEndPortalRenderer { + public StarFissureRenderer(BlockEntityRendererProvider.Context context) { + super(context); + } + + @Override + protected float getOffsetUp() { + return 0.0F; + } + + @Override + protected float getOffsetDown() { + return 1.0F; + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModBlockEntities.java b/src/main/java/net/dnorberg/rivencraft/registry/ModBlockEntities.java index 9efb719..0239541 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModBlockEntities.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModBlockEntities.java @@ -5,6 +5,7 @@ import net.dnorberg.rivencraft.block.entity.BookStandBlockEntity; import net.dnorberg.rivencraft.block.entity.BookBinderBlockEntity; import net.dnorberg.rivencraft.block.entity.WritingDeskBlockEntity; import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity; +import net.dnorberg.rivencraft.block.entity.StarFissureBlockEntity; import net.minecraft.core.registries.Registries; import net.minecraft.world.level.block.entity.BlockEntityType; import net.neoforged.bus.api.IEventBus; @@ -28,6 +29,9 @@ public final class ModBlockEntities { public static final DeferredHolder, BlockEntityType> INK_MIXER = BLOCK_ENTITIES.register("ink_mixer", () -> BlockEntityType.Builder .of(InkMixerBlockEntity::new, ModBlocks.INK_MIXER.get()).build(null)); + public static final DeferredHolder, BlockEntityType> STAR_FISSURE = + BLOCK_ENTITIES.register("star_fissure", () -> BlockEntityType.Builder + .of(StarFissureBlockEntity::new, ModBlocks.STAR_FISSURE.get()).build(null)); private ModBlockEntities() { } diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModBlocks.java b/src/main/java/net/dnorberg/rivencraft/registry/ModBlocks.java index 03cab35..d4d442c 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModBlocks.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModBlocks.java @@ -6,6 +6,7 @@ import net.dnorberg.rivencraft.block.BookBinderBlock; 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.minecraft.world.level.block.AmethystClusterBlock; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; @@ -52,6 +53,9 @@ public final class ModBlocks { public static final DeferredBlock SMALL_CRYSTAL_BUD = BLOCKS.registerBlock("small_crystal_bud", properties -> new AmethystClusterBlock(3.0F, 4.0F, properties), BlockBehaviour.Properties.ofFullCopy(Blocks.SMALL_AMETHYST_BUD)); + public static final DeferredBlock STAR_FISSURE = BLOCKS.registerBlock("star_fissure", + StarFissureBlock::new, BlockBehaviour.Properties.ofFullCopy(Blocks.END_PORTAL) + .lightLevel(state -> 6)); private ModBlocks() { } diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModSounds.java b/src/main/java/net/dnorberg/rivencraft/registry/ModSounds.java index 05798ff..6c5d7c9 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModSounds.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModSounds.java @@ -14,6 +14,7 @@ public final class ModSounds { DeferredRegister.create(Registries.SOUND_EVENT, Rivencraft.MOD_ID); public static final DeferredHolder LINK = register("linking.link"); + public static final DeferredHolder LINK_FISSURE = register("linking.link-fissure"); private ModSounds() { } diff --git a/src/main/java/net/dnorberg/rivencraft/world/StarFissureFeature.java b/src/main/java/net/dnorberg/rivencraft/world/StarFissureFeature.java new file mode 100644 index 0000000..35f2458 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/world/StarFissureFeature.java @@ -0,0 +1,77 @@ +package net.dnorberg.rivencraft.world; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import net.dnorberg.rivencraft.registry.ModBlocks; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.levelgen.Heightmap; + +/** + * Ports the footprint and carving of Mystcraft's WorldGenMystStarFissure. + * The original is LGPL-3.0, copyright XCompWiz and Mystcraft contributors. + */ +public final class StarFissureFeature { + private static final long SALT = 0x5354415246495353L; + + private StarFissureFeature() {} + + /** Legacy chooses a point 8–23 blocks inside/after the spawn chunk, then grows the rift along +Z. */ + public static List footprint(long ageSeed, BlockPos spawn) { + long chunkX = spawn.getX() >> 4; + long chunkZ = spawn.getZ() >> 4; + Random random = new Random(ageSeed ^ SALT ^ chunkX ^ (chunkZ << 32)); + int originX = ((int) chunkX << 4) + random.nextInt(16) + 8; + int originZ = ((int) chunkZ << 4) + random.nextInt(16) + 8; + int length = random.nextInt(8) + 10; + int[][] intervals = new int[length][2]; + List columns = new ArrayList<>(); + addRow(columns, originX, originZ, 0, 0, 0); + for (int row = 1; row < length; row++) { + int range = length + Math.min(row, length - row) + 1; + int right = (random.nextInt(range) - (range >> 1) + intervals[row - 1][1]) / 2; + int left = (random.nextInt(range) - (range >> 1) + intervals[row - 1][0]) / 2; + if (left > right) { + int swap = left; + left = right; + right = swap; + } + left = Math.min(left, intervals[row - 1][1]); + right = Math.max(right, intervals[row - 1][0]); + intervals[row][0] = left; + intervals[row][1] = right; + addRow(columns, originX, originZ, row, left, right); + } + return List.copyOf(columns); + } + + private static void addRow(List columns, int originX, int originZ, int row, int left, int right) { + for (int x = left; x <= right; x++) { + columns.add(new BlockPos(originX + x, 0, originZ + row * 2)); + columns.add(new BlockPos(originX + x, 0, originZ + row * 2 + 1)); + } + } + + public static void generate(ServerLevel level, long ageSeed, BlockPos spawn) { + List columns = footprint(ageSeed, spawn); + BlockPos first = columns.getFirst(); + // 1.12 worlds began at y=0. Modern superflat can sit below it, so use the + // modern world floor there. Empty Void columns keep Legacy's y=0 portal. + int surface = level.getHeight(Heightmap.Types.WORLD_SURFACE, first.getX(), first.getZ()); + int minY = level.getMinBuildHeight(); + int floorY = surface > minY && surface <= 0 ? minY + 1 : Math.max(0, minY + 1); + BlockPos.MutableBlockPos mutable = new BlockPos.MutableBlockPos(); + for (BlockPos column : columns) { + int top = level.getHeight(Heightmap.Types.WORLD_SURFACE, column.getX(), column.getZ()); + level.setBlock(mutable.set(column.getX(), floorY, column.getZ()), + ModBlocks.STAR_FISSURE.get().defaultBlockState(), 2); + // Legacy clears all blocks above each portal tile to the sky. Heightmap + // bounds the same work without iterating already-empty world height. + for (int y = floorY + 1; y < top; y++) { + level.setBlock(mutable.set(column.getX(), y, column.getZ()), Blocks.AIR.defaultBlockState(), 2); + } + } + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/world/StarFissureGameTests.java b/src/main/java/net/dnorberg/rivencraft/world/StarFissureGameTests.java new file mode 100644 index 0000000..9cdfb7f --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/world/StarFissureGameTests.java @@ -0,0 +1,103 @@ +package net.dnorberg.rivencraft.world; + +import java.util.List; +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.book.AgeFeatureSelection; +import net.dnorberg.rivencraft.book.AgeGenerationPlan; +import net.dnorberg.rivencraft.book.AgeGenerationProfiles; +import net.dnorberg.rivencraft.book.AgeRuntimeProfiles; +import net.dnorberg.rivencraft.book.AgeTraits; +import net.dnorberg.rivencraft.item.PageItem; +import net.dnorberg.rivencraft.registry.ModBlocks; +import net.dnorberg.rivencraft.writing.OrderedWriting; +import net.dnorberg.rivencraft.writing.SymbolReference; +import net.dnorberg.rivencraft.writing.WritingEntry; +import net.minecraft.core.BlockPos; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.resources.ResourceLocation; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; + +/** Pure selection and footprint checks; runtime carving is left to the in-game visual check. */ +@GameTestHolder(Rivencraft.MOD_ID) +@PrefixGameTestTemplate(false) +public final class StarFissureGameTests { + private StarFissureGameTests() {} + + @GameTest(template = "empty") + public static void authoredFissureIsWritableAndSaved(GameTestHelper helper) { + var registries = helper.getLevel().registryAccess(); + helper.assertTrue(PageItem.bundledCreativeSymbols().contains("feature/star_fissures"), + "Star Fissures must appear in the creative and JEI page feed"); + helper.assertTrue(PageItem.isUsableWriting(PageItem.createBundledCreativePage("feature/star_fissures")), + "The page must be usable in an authored book"); + OrderedWriting writing = new OrderedWriting(List.of(WritingEntry.linkPanel(), + page("terrain/standard"), page("feature/star_fissures"))); + AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 58L, AgeGenerationProfiles.STANDARD, + AgeTraits.select(58L), registries); + var resolved = AgeFeatureSelection.resolved(plan); + helper.assertValueEqual(resolved.size(), 1, "One authored fissure must remain one feature choice"); + helper.assertValueEqual(resolved.getFirst().id(), AgeFeatureSelection.STAR_FISSURES, + "The authored feature must be a Star Fissure"); + helper.assertTrue(resolved.getFirst().material().isEmpty(), "Star Fissures consume no material page"); + helper.assertValueEqual(AgeFeatureSelection.resolved(AgeGenerationPlan.load(plan.save())), resolved, + "The feature must survive Age-plan save and load"); + helper.assertTrue(ModBlocks.STAR_FISSURE.get().defaultBlockState() != null, + "The non-obtainable star portal block must be registered"); + for (String terrain : List.of("standard", "amplified", "island", "flat", "cave", "void")) + helper.assertTrue(AgeFeatureSelection.error(AgeFeatureSelection.STAR_FISSURES, + AgeRuntimeProfiles.resolve(id(terrain)), registries) == null, + "Legacy fissures can appear on " + terrain + " terrain"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void randomFissuresAndSpawnFootprintAreStable(GameTestHelper helper) { + var registries = helper.getLevel().registryAccess(); + int selected = 0; + for (long seed = 0; seed < 128; seed++) { + boolean standard = hasRandomFissure(seed, AgeGenerationProfiles.STANDARD, registries); + if (standard) selected++; + for (String terrain : List.of("cave", "void")) + helper.assertValueEqual(hasRandomFissure(seed, id(terrain), registries), standard, + "Legacy small-feature selection must not depend on terrain"); + } + helper.assertTrue(selected > 0 && selected < 128, + "The weighted Legacy grammar should select fissures in some, not all, random Ages"); + BlockPos spawn = new BlockPos(0, 64, 0); + var footprint = StarFissureFeature.footprint(58L, spawn); + helper.assertValueEqual(footprint, StarFissureFeature.footprint(58L, spawn), + "The Age seed and spawn must reproduce the same fissure"); + int minZ = footprint.stream().mapToInt(BlockPos::getZ).min().orElseThrow(); + int maxZ = footprint.stream().mapToInt(BlockPos::getZ).max().orElseThrow(); + helper.assertTrue(minZ >= 8 && minZ <= 23, + "Legacy starts 8–23 blocks past the spawn chunk's Z edge"); + helper.assertTrue(maxZ - minZ + 1 >= 20 && maxZ - minZ + 1 <= 34, + "Legacy's rift spans 10–17 paired Z rows"); + for (int z = minZ; z <= maxZ; z++) { + int row = z; + var xs = footprint.stream().filter(pos -> pos.getZ() == row) + .mapToInt(BlockPos::getX).sorted().toArray(); + helper.assertTrue(xs.length > 0, "Every Z row must contain portal tiles"); + for (int i = 1; i < xs.length; i++) + helper.assertValueEqual(xs[i], xs[i - 1] + 1, + "Each jagged Legacy row remains contiguous"); + } + helper.succeed(); + } + + private static ResourceLocation id(String path) { + return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path); + } + + private static boolean hasRandomFissure(long seed, ResourceLocation terrain, + net.minecraft.core.RegistryAccess registries) { + return AgeFeatureSelection.randomChoices(seed, AgeRuntimeProfiles.resolve(terrain), registries).stream() + .anyMatch(choice -> choice.id().equals(AgeFeatureSelection.STAR_FISSURES)); + } + + private static WritingEntry page(String path) { + return WritingEntry.symbol(new SymbolReference(id(path))); + } +} diff --git a/src/main/resources/assets/rivencraft/blockstates/star_fissure.json b/src/main/resources/assets/rivencraft/blockstates/star_fissure.json new file mode 100644 index 0000000..ae26b92 --- /dev/null +++ b/src/main/resources/assets/rivencraft/blockstates/star_fissure.json @@ -0,0 +1 @@ +{"variants":{"":{"model":"rivencraft:block/star_fissure"}}} diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index f233777..6e5b4ef 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -289,6 +289,9 @@ "symbol.rivencraft.feature.obelisks.description": "Add rare tall obelisks on exposed solid ground, with a random or selected block material.", "symbol.rivencraft.feature.crystal_formations": "Crystal Formations", "symbol.rivencraft.feature.crystal_formations.description": "Add blue crystal formations whose budding blocks grow harvestable clusters.", + "symbol.rivencraft.feature.star_fissures": "Star Fissures", + "symbol.rivencraft.feature.star_fissures.description": "Open a starry fissure near this Age's spawn that links back to the Overworld.", + "block.rivencraft.star_fissure": "Star Fissure", "block.rivencraft.crystal_block": "Crystal Block", "block.rivencraft.budding_crystal": "Budding Crystal", "block.rivencraft.small_crystal_bud": "Small Crystal Bud", diff --git a/src/main/resources/assets/rivencraft/models/block/star_fissure.json b/src/main/resources/assets/rivencraft/models/block/star_fissure.json new file mode 100644 index 0000000..908d864 --- /dev/null +++ b/src/main/resources/assets/rivencraft/models/block/star_fissure.json @@ -0,0 +1 @@ +{"parent":"minecraft:block/end_portal"} diff --git a/src/main/resources/assets/rivencraft/sounds.json b/src/main/resources/assets/rivencraft/sounds.json index 2929317..6751342 100644 --- a/src/main/resources/assets/rivencraft/sounds.json +++ b/src/main/resources/assets/rivencraft/sounds.json @@ -6,5 +6,9 @@ "name": "rivencraft:linking/link" } ] + }, + "linking.link-fissure": { + "category": "player", + "sounds": [{"name": "rivencraft:linking/link-fissure"}] } } diff --git a/src/main/resources/assets/rivencraft/sounds/linking/link-fissure.ogg b/src/main/resources/assets/rivencraft/sounds/linking/link-fissure.ogg new file mode 100644 index 0000000..23e7cbb Binary files /dev/null and b/src/main/resources/assets/rivencraft/sounds/linking/link-fissure.ogg differ diff --git a/src/main/resources/data/rivencraft/rivencraft/symbols/feature/star_fissures.json b/src/main/resources/data/rivencraft/rivencraft/symbols/feature/star_fissures.json new file mode 100644 index 0000000..3db5309 --- /dev/null +++ b/src/main/resources/data/rivencraft/rivencraft/symbols/feature/star_fissures.json @@ -0,0 +1,8 @@ +{ + "schema_version": 2, + "name": "symbol.rivencraft.feature.star_fissures", + "description": "symbol.rivencraft.feature.star_fissures.description", + "category": "rivencraft:feature", + "semantics": { "role": "feature", "value": "rivencraft:star_fissures" }, + "poem": "Star Fissure World Home" +}