diff --git a/README.md b/README.md index 341b12d..6a754c0 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,20 @@ JEI is optional; vanilla crafting recipes and all writing interactions work without it. For a development client with JEI, run `./gradlew runClient -PwithJei=true`. +Four Crystal Shards craft one Crystal Block; eight Crystal Blocks around an +empty crafting-grid center make one Book Receptacle. Picking up a shard unlocks +the block recipe, and picking up a block unlocks the Receptacle recipe. The +existing Ink Vial recipe uses two black dyes, a glass bottle, and a water +bucket; the empty bucket stays in the crafting grid. These JSON recipes appear +in JEI when it is installed and can be replaced by a data pack at +`data/rivencraft/recipe/.json`. To test a replacement, use a fresh +world or `/reload`, then check the result in a crafting table and vanilla +Crafter. A vanilla Lectern converts to a Rivencraft Book Stand when a Rivencraft +book or page is placed on it; this in-world interaction has no JEI guide. +With Create installed, Crystal Blocks and Book Receptacles have an optional Ponder scene +showing crystal-frame construction, book activation, and a parrot crossing the portal. Use the Ponder shortcut +while hovering either item; Rivencraft does not require Create to run. + Craft a Book Binder from three iron ingots and five planks. Put leather or an empty Notebook in the left cover slot. Enter a title, then place a Link Panel Page first in the horizontal page strip. Click a page in the strip with a diff --git a/docs/LEGACY_M7_AUDIT.md b/docs/LEGACY_M7_AUDIT.md index 17a3813..929b2de 100644 --- a/docs/LEGACY_M7_AUDIT.md +++ b/docs/LEGACY_M7_AUDIT.md @@ -52,7 +52,7 @@ Legacy's travel path creates no automatic Return Link. Rivencraft currently defa | Area | Legacy evidence and modern boundary | | --- | --- | -| Crafting | [Legacy recipes](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/recipes) specify the Receptacle, Binder, Mixer, Folder, Desk, and other writing blocks. [`RecipeLinkingbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/RecipeLinkingbook.java#L28-L60) combines one Link Panel and leather anywhere in the grid. Rivencraft already has Binder, Mixer, Ink Vial, Notebook, Desk, Crystal Block, and Linking Book recipes. #79 adds only missing recipes/processes; preserve vanilla-Lectern conversion, noncraftable Blank Pages, and optional JEI. | +| Crafting | [Legacy recipes](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/recipes) specify the Receptacle, Binder, Mixer, Folder, Desk, and other writing blocks. [`RecipeLinkingbook`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/RecipeLinkingbook.java#L28-L60) combines one Link Panel and leather anywhere in the grid. Rivencraft has Binder, Mixer, Ink Vial, Notebook, Desk, Crystal Block, Linking Book, and Receptacle recipes. #79 keeps these data-driven and adds recipe-book unlocks for Crystal Block, Receptacle, and Ink Vial. The Legacy potion-based ink variant is omitted: in 1.21.1 a simple potion ingredient does not return its glass bottle, while the existing water-bucket recipe returns its bucket. The Legacy Folder/Portfolio use Rivencraft's Notebook; the old two-part Desk is one item; the old Lectern/Book Stand recipes use a vanilla Lectern converted in-world by adding a Rivencraft book or page. Blank Pages remain noncraftable and Link Panels remain outside the Desk. JEI automatically displays the JSON crafting recipes; its existing optional categories cover Mixer, Desk, and Binder processes. The lectern interaction intentionally has no JEI guide. With Create installed, an optional Ponder scene attached to the Receptacle item demonstrates frame construction and book insertion; the core mod still runs without Create. | | Sounds | [`Sounds`](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/Sounds.java#L8-L26) registers pop, link, disarm, following, intra-link, fissure-link, portal-link, and meteor roar. The matching OGGs are in the local extraction. Rivencraft already carries link, fissure-link, and meteor roar. #80 should reuse and credit available Legacy files before creating alternatives. | | Advancements | [Legacy advancement JSONs](https://github.com/Mystcraft/Mystcraft-Legacy/tree/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/resources/assets/mystcraft/advancements) have inventory milestones for a symbol page, unlinked Linking Book, and Age Book; a Writing Desk trigger; a safe Age-travel trigger; and a hidden Quinn trigger. #81 should use modern server-owned criteria. Quinn has no established Rivencraft equivalent and should be documented or deliberately omitted, not awarded on preview. | diff --git a/gradle.properties b/gradle.properties index d1fd107..f3db079 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.6.3.4 +mod_version=0.6.4.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/block/CrystalPortalBlock.java b/src/main/java/net/dnorberg/rivencraft/block/CrystalPortalBlock.java index 4f0a5c5..17a4a66 100644 --- a/src/main/java/net/dnorberg/rivencraft/block/CrystalPortalBlock.java +++ b/src/main/java/net/dnorberg/rivencraft/block/CrystalPortalBlock.java @@ -3,10 +3,9 @@ package net.dnorberg.rivencraft.block; import com.mojang.serialization.MapCodec; import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity; import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity; -import net.dnorberg.rivencraft.item.DescriptiveBookItem; -import net.dnorberg.rivencraft.item.LinkingBookItem; import net.dnorberg.rivencraft.portal.CrystalPortalNetwork; import net.dnorberg.rivencraft.portal.PortalTravelCooldown; +import net.dnorberg.rivencraft.portal.PortalTravelQueue; import net.dnorberg.rivencraft.registry.ModBlocks; import net.dnorberg.rivencraft.registry.ModItems; import net.minecraft.core.BlockPos; @@ -64,8 +63,7 @@ public final class CrystalPortalBlock extends BaseEntityBlock { ItemStack book = holder.getBook(); if (book.isEmpty()) return; PortalTravelCooldown.mark(player, now); - if (book.is(ModItems.LINKING_BOOK.get())) LinkingBookItem.usePortalBook(player, book); - else if (book.is(ModItems.DESCRIPTIVE_BOOK.get())) DescriptiveBookItem.usePortalBook(player, book); + PortalTravelQueue.enqueue(player, server, pos, portal.receptacle(), book, now); } @Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor, diff --git a/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java b/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java index 1316fc9..274544a 100644 --- a/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java +++ b/src/main/java/net/dnorberg/rivencraft/client/ClientEvents.java @@ -85,6 +85,8 @@ public final class ClientEvents { // the portal cell itself is invisible while its live view is active. if (ModList.get().isLoaded("immersive_portals_core")) ItemBlockRenderTypes.setRenderLayer(ModBlocks.CRYSTAL_BLOCK.get(), RenderType.translucent()); + if (ModList.get().isLoaded("create")) + net.dnorberg.rivencraft.compat.RivencraftPonderGuide.register(); }); NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickItem); NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickBlock); diff --git a/src/main/java/net/dnorberg/rivencraft/compat/RivencraftPonderGuide.java b/src/main/java/net/dnorberg/rivencraft/compat/RivencraftPonderGuide.java new file mode 100644 index 0000000..c33b574 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/compat/RivencraftPonderGuide.java @@ -0,0 +1,108 @@ +package net.dnorberg.rivencraft.compat; + +import net.createmod.ponder.api.PonderPalette; +import net.createmod.ponder.api.registration.PonderPlugin; +import net.createmod.ponder.api.registration.PonderSceneRegistrationHelper; +import net.createmod.ponder.api.scene.SceneBuilder; +import net.createmod.ponder.api.scene.SceneBuildingUtil; +import net.createmod.ponder.foundation.PonderIndex; +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.block.BookReceptacleBlock; +import net.dnorberg.rivencraft.block.CrystalPortalBlock; +import net.dnorberg.rivencraft.registry.ModBlocks; +import net.dnorberg.rivencraft.registry.ModItems; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.Blocks; + +/** Create-only Ponder registration; this class is never loaded without Create. */ +public final class RivencraftPonderGuide implements PonderPlugin { + private static final ResourceLocation DEPOT_TEMPLATE = ResourceLocation.fromNamespaceAndPath("create", "depot"); + + private RivencraftPonderGuide() {} + + public static void register() { + PonderIndex.addPlugin(new RivencraftPonderGuide()); + } + + @Override + public String getModId() { + return Rivencraft.MOD_ID; + } + + @Override + public void registerScenes(PonderSceneRegistrationHelper helper) { + // Create is already present when this plugin is registered. Its small + // depot schematic supplies an emptyable 6×4×6 canvas, so Rivencraft + // does not need to ship a separate binary Ponder template. + helper.forComponents(ModItems.BOOK_RECEPTACLE.getId(), ModItems.CRYSTAL_BLOCK.getId()) + .addStoryBoard(DEPOT_TEMPLATE, RivencraftPonderGuide::crystalPortal); + } + + private static void crystalPortal(SceneBuilder scene, SceneBuildingUtil util) { + scene.title("crystal_portal", "Building a Crystal Portal"); + scene.configureBasePlate(0, 0, 6); + scene.scaleSceneView(0.85F); + scene.showBasePlate(); + scene.world().setBlocks(util.select().fromTo(0, 1, 0, 5, 3, 5), + Blocks.AIR.defaultBlockState(), false); + scene.idle(10); + + BlockPos center = util.grid().at(3, 2, 3); + scene.overlay().showText(65).text("Build a closed crystal frame with an open center.") + .pointAt(util.vector().centerOf(center)).placeNearTarget(); + for (int x = 2; x <= 4; x++) { + for (int y = 1; y <= 3; y++) { + if (x == 3 && y == 2) continue; + BlockPos pos = util.grid().at(x, y, 3); + scene.world().setBlock(pos, ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), false); + scene.world().showSection(util.select().position(pos), Direction.DOWN); + scene.idle(3); + } + } + scene.idle(30); + + BlockPos receptacle = util.grid().at(2, 1, 2); + scene.overlay().showText(70).text("Attach a Book Receptacle to one crystal, facing outward.") + .pointAt(util.vector().centerOf(receptacle)).placeNearTarget(); + scene.world().setBlock(receptacle, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState() + .setValue(BookReceptacleBlock.FACING, Direction.NORTH), false); + scene.world().showSection(util.select().position(receptacle), Direction.DOWN); + scene.idle(75); + + scene.overlay().showText(65).text("Insert a bound Linking or Descriptive Book to activate the portal.") + .colored(PonderPalette.INPUT).pointAt(util.vector().centerOf(receptacle)).placeNearTarget(); + scene.overlay().showControls(util.vector().centerOf(receptacle), + net.createmod.catnip.math.Pointing.RIGHT, 55) + .rightClick().withItem(new ItemStack(ModItems.LINKING_BOOK.get())); + scene.idle(65); + + scene.world().setBlock(center, ModBlocks.CRYSTAL_PORTAL.get().defaultBlockState() + .setValue(CrystalPortalBlock.AXIS, Direction.Axis.Z), false); + scene.world().showSection(util.select().position(center), Direction.DOWN); + scene.effects().indicateSuccess(center); + scene.overlay().showText(80).text("Step through the portal. Remove the book to close it.") + .colored(PonderPalette.OUTPUT).pointAt(util.vector().centerOf(center)).placeNearTarget(); + scene.idle(20); + var parrot = scene.world().createEntity(world -> { + var bird = EntityType.PARROT.create(world); + bird.setNoAi(true); + bird.setPos(3.5, 2.05, 1.5); + bird.setYRot(0); + return bird; + }); + scene.idle(10); + for (int step = 1; step <= 7; step++) { + double z = 1.5 + step * 0.42; + scene.world().modifyEntity(parrot, bird -> bird.setPos(3.5, 2.05, z)); + scene.idle(5); + } + scene.world().modifyEntity(parrot, bird -> bird.discard()); + scene.effects().indicateSuccess(center); + scene.idle(20); + scene.markAsFinished(); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/event/AgeLifecycleEvents.java b/src/main/java/net/dnorberg/rivencraft/event/AgeLifecycleEvents.java index 79dd9e7..932fec8 100644 --- a/src/main/java/net/dnorberg/rivencraft/event/AgeLifecycleEvents.java +++ b/src/main/java/net/dnorberg/rivencraft/event/AgeLifecycleEvents.java @@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.event; import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders; import net.dnorberg.rivencraft.portal.PortalTravelCooldown; +import net.dnorberg.rivencraft.portal.PortalTravelQueue; import net.minecraft.server.level.ServerPlayer; import net.neoforged.fml.ModList; import net.neoforged.neoforge.event.entity.player.PlayerEvent; @@ -14,7 +15,10 @@ public final class AgeLifecycleEvents { } public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) { - if (event.getEntity() instanceof ServerPlayer player) PortalTravelCooldown.forget(player); + if (event.getEntity() instanceof ServerPlayer player) { + PortalTravelCooldown.forget(player); + PortalTravelQueue.forget(player); + } if (event.getEntity() instanceof ServerPlayer player && ModList.get().isLoaded("immersive_portals_core")) { ImmersivePortalsChunkLoaders.close(player); @@ -23,12 +27,14 @@ public final class AgeLifecycleEvents { public static void onServerStopping(ServerStoppingEvent event) { PortalTravelCooldown.clear(); + PortalTravelQueue.clear(); if (ModList.get().isLoaded("immersive_portals_core")) { ImmersivePortalsChunkLoaders.closeAll(); } } public static void onServerTickPost(ServerTickEvent.Post event) { + PortalTravelQueue.process(event.getServer()); if (ModList.get().isLoaded("immersive_portals_core")) ImmersivePortalsChunkLoaders.expire(event.getServer()); } diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/Milestone7CraftingGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/Milestone7CraftingGameTests.java new file mode 100644 index 0000000..ba70ffd --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/gametest/Milestone7CraftingGameTests.java @@ -0,0 +1,105 @@ +package net.dnorberg.rivencraft.gametest; + +import java.util.ArrayList; +import java.util.List; +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.item.PageItem; +import net.dnorberg.rivencraft.registry.ModItems; +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.minecraft.world.item.crafting.CraftingInput; +import net.minecraft.world.item.crafting.RecipeType; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; + +/** Data-driven Milestone 7 crafting checks; every result is obtained from the loaded recipe manager. */ +@GameTestHolder(Rivencraft.MOD_ID) +@PrefixGameTestTemplate(false) +public final class Milestone7CraftingGameTests { + private Milestone7CraftingGameTests() {} + + @GameTest(template = "empty") + public static void crystalProgressionMatchesLegacyCounts(GameTestHelper helper) { + var level = helper.getLevel(); + ItemStack shard = new ItemStack(ModItems.CRYSTAL_SHARD.get()); + CraftingInput crystalInput = CraftingInput.of(2, 2, List.of(shard, shard, shard, shard)); + var crystalRecipe = level.getRecipeManager().getRecipeFor(RecipeType.CRAFTING, crystalInput, level) + .orElseThrow(); + helper.assertValueEqual(crystalRecipe.id(), id("crystal_block"), + "Four shards must craft a Crystal Block"); + ItemStack crystal = crystalRecipe.value().assemble(crystalInput, level.registryAccess()); + helper.assertTrue(crystal.is(ModItems.CRYSTAL_BLOCK.get()) && crystal.getCount() == 1, + "The shard recipe must yield one Crystal Block"); + + List ring = new ArrayList<>(); + for (int slot = 0; slot < 9; slot++) ring.add(slot == 4 ? ItemStack.EMPTY : crystal.copy()); + CraftingInput receptacleInput = CraftingInput.of(3, 3, ring); + var receptacleRecipe = level.getRecipeManager() + .getRecipeFor(RecipeType.CRAFTING, receptacleInput, level).orElseThrow(); + helper.assertValueEqual(receptacleRecipe.id(), id("book_receptacle"), + "The Legacy eight-block ring must craft the Receptacle"); + ItemStack result = receptacleRecipe.value().assemble(receptacleInput, level.registryAccess()); + helper.assertTrue(result.is(ModItems.BOOK_RECEPTACLE.get()) && result.getCount() == 1, + "One ring must yield exactly one Book Receptacle"); + helper.assertTrue(receptacleRecipe.value().getRemainingItems(receptacleInput).stream() + .allMatch(ItemStack::isEmpty), "Crystal crafting must not duplicate source blocks as remainders"); + ring.set(4, crystal.copy()); + helper.assertTrue(level.getRecipeManager().getRecipeFor(RecipeType.CRAFTING, + CraftingInput.of(3, 3, ring), level).isEmpty(), + "A filled center must not match the Book Receptacle ring"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void bucketInkReturnsItsContainer(GameTestHelper helper) { + var level = helper.getLevel(); + ItemStack dye = new ItemStack(Items.BLACK_DYE); + CraftingInput bucketInput = CraftingInput.of(2, 2, List.of( + dye, dye, new ItemStack(Items.GLASS_BOTTLE), new ItemStack(Items.WATER_BUCKET))); + var bucketRecipe = level.getRecipeManager().getRecipeFor(RecipeType.CRAFTING, bucketInput, level) + .orElseThrow(); + helper.assertValueEqual(bucketRecipe.id(), id("ink_vial"), "Bucket ink recipe must remain registered"); + helper.assertTrue(bucketRecipe.value().assemble(bucketInput, level.registryAccess()) + .is(ModItems.INK_VIAL.get()), "Bucket recipe must yield an Ink Vial"); + helper.assertTrue(bucketRecipe.value().getRemainingItems(bucketInput).stream() + .anyMatch(stack -> stack.is(Items.BUCKET)), "The water bucket must return an empty bucket"); + + helper.succeed(); + } + + @GameTest(template = "empty") + public static void recipeUnlocksAreLoadedAndBlankPagesStayUncraftable(GameTestHelper helper) { + var level = helper.getLevel(); + for (String path : List.of("crystal_block", "book_receptacle", "ink_vial")) { + helper.assertTrue(level.getServer().getAdvancements().get(id("recipes/misc/" + path)) != null, + "Recipe unlock must load for " + path); + } + var player = helper.makeMockServerPlayerInLevel(); + var shardUnlock = level.getServer().getAdvancements().get(id("recipes/misc/crystal_block")); + player.getAdvancements().award(shardUnlock, "has_crystal_shard"); + helper.assertTrue(player.getRecipeBook().contains(id("crystal_block")), + "Discovering a shard must unlock the Crystal Block recipe"); + var receptacleUnlock = level.getServer().getAdvancements().get(id("recipes/misc/book_receptacle")); + player.getAdvancements().award(receptacleUnlock, "has_crystal_block"); + helper.assertTrue(player.getRecipeBook().contains(id("book_receptacle")), + "Discovering a Crystal Block must unlock the Receptacle recipe"); + CraftingInput blankInput = CraftingInput.of(2, 2, List.of( + new ItemStack(Items.PAPER), new ItemStack(ModItems.INK_VIAL.get()), + ItemStack.EMPTY, ItemStack.EMPTY)); + helper.assertTrue(level.getRecipeManager().getRecipeFor(RecipeType.CRAFTING, blankInput, level).isEmpty(), + "Blank Pages and Link Panels must not become grid-craftable"); + ItemStack panel = PageItem.createLinkPanelPage(); + CraftingInput linkInput = CraftingInput.of(2, 1, List.of(panel, new ItemStack(Items.LEATHER))); + helper.assertValueEqual(level.getRecipeManager().getRecipeFor(RecipeType.CRAFTING, linkInput, level) + .orElseThrow().id(), id("linking_book"), + "Link Panel crafting remains limited to the established Linking Book recipe"); + helper.succeed(); + } + + private static ResourceLocation id(String path) { + return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/portal/PortalTravelQueue.java b/src/main/java/net/dnorberg/rivencraft/portal/PortalTravelQueue.java new file mode 100644 index 0000000..68da543 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/portal/PortalTravelQueue.java @@ -0,0 +1,64 @@ +package net.dnorberg.rivencraft.portal; + +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity; +import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity; +import net.dnorberg.rivencraft.item.DescriptiveBookItem; +import net.dnorberg.rivencraft.item.LinkingBookItem; +import net.dnorberg.rivencraft.registry.ModItems; +import net.minecraft.core.BlockPos; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; + +/** Defers block-collision travel until the movement packet that entered the portal has finished. */ +public final class PortalTravelQueue { + private static final Map PENDING = new IdentityHashMap<>(); + + private record Pending(ServerLevel source, BlockPos portal, BlockPos receptacle, + ItemStack book, long queuedTick) {} + private record Travel(ServerPlayer player, Pending pending) {} + + private PortalTravelQueue() {} + + public static void enqueue(ServerPlayer player, ServerLevel source, BlockPos portal, + BlockPos receptacle, ItemStack book, long now) { + PENDING.putIfAbsent(player, new Pending(source, portal.immutable(), receptacle.immutable(), + book.copyWithCount(1), now)); + } + + public static void process(MinecraftServer server) { + List ready = new ArrayList<>(); + Iterator> iterator = PENDING.entrySet().iterator(); + while (iterator.hasNext()) { + var entry = iterator.next(); + if (server.getTickCount() <= entry.getValue().queuedTick()) continue; + ready.add(new Travel(entry.getKey(), entry.getValue())); + iterator.remove(); + } + // Travel can itself cause block-collision callbacks, so do not keep an + // iterator over the pending map while changing the player's dimension. + for (Travel travel : ready) { + ServerPlayer player = travel.player(); + Pending pending = travel.pending(); + if (server.getPlayerList().getPlayer(player.getUUID()) != player + || !player.isAlive() || player.serverLevel() != pending.source() + || !(pending.source().getBlockEntity(pending.portal()) instanceof CrystalPortalBlockEntity portal) + || !pending.receptacle().equals(portal.receptacle()) + || !(pending.source().getBlockEntity(pending.receptacle()) instanceof BookReceptacleBlockEntity holder) + || !ItemStack.isSameItemSameComponents(holder.getBook(), pending.book())) continue; + if (pending.book().is(ModItems.LINKING_BOOK.get())) + LinkingBookItem.usePortalBook(player, pending.book()); + else if (pending.book().is(ModItems.DESCRIPTIVE_BOOK.get())) + DescriptiveBookItem.usePortalBook(player, pending.book()); + } + } + + public static void forget(ServerPlayer player) { PENDING.remove(player); } + public static void clear() { PENDING.clear(); } +} diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index b1ae376..91bcdd9 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -341,6 +341,11 @@ "jei.rivencraft.writing.copy": "At a Writing Desk, select a stored Page, add paper and ink, then take the copy. The source Page stays.", "jei.rivencraft.writing.bind": "At a Book Binder, name the Age and add a cover plus Link Panel. Optional symbol Pages follow the panel.", "jei.rivencraft.writing.linking_book": "Craft one Link Panel Page with leather to make an unwritten Linking Book. Open it to name and bind it.", + "rivencraft.ponder.crystal_portal.header": "Building a Crystal Portal", + "rivencraft.ponder.crystal_portal.text_1": "Build a closed crystal frame with an open center.", + "rivencraft.ponder.crystal_portal.text_2": "Attach a Book Receptacle to one crystal, facing outward.", + "rivencraft.ponder.crystal_portal.text_3": "Insert a bound Linking or Descriptive Book to activate the portal.", + "rivencraft.ponder.crystal_portal.text_4": "Step through the portal. Remove the book to close it.", "itemGroup.rivencraft": "Rivencraft", "itemGroup.rivencraft.pages.link": "Rivencraft Link Pages", "itemGroup.rivencraft.pages.terrain": "Rivencraft Terrain Pages", diff --git a/src/main/resources/data/rivencraft/advancement/recipes/misc/book_receptacle.json b/src/main/resources/data/rivencraft/advancement/recipes/misc/book_receptacle.json new file mode 100644 index 0000000..4e0a68e --- /dev/null +++ b/src/main/resources/data/rivencraft/advancement/recipes/misc/book_receptacle.json @@ -0,0 +1,15 @@ +{ + "parent": "minecraft:recipes/root", + "criteria": { + "has_crystal_block": { + "trigger": "minecraft:inventory_changed", + "conditions": {"items": [{"items": "rivencraft:crystal_block"}]} + }, + "has_the_recipe": { + "trigger": "minecraft:recipe_unlocked", + "conditions": {"recipe": "rivencraft:book_receptacle"} + } + }, + "requirements": [["has_crystal_block", "has_the_recipe"]], + "rewards": {"recipes": ["rivencraft:book_receptacle"]} +} diff --git a/src/main/resources/data/rivencraft/advancement/recipes/misc/crystal_block.json b/src/main/resources/data/rivencraft/advancement/recipes/misc/crystal_block.json new file mode 100644 index 0000000..c16f447 --- /dev/null +++ b/src/main/resources/data/rivencraft/advancement/recipes/misc/crystal_block.json @@ -0,0 +1,15 @@ +{ + "parent": "minecraft:recipes/root", + "criteria": { + "has_crystal_shard": { + "trigger": "minecraft:inventory_changed", + "conditions": {"items": [{"items": "rivencraft:crystal_shard"}]} + }, + "has_the_recipe": { + "trigger": "minecraft:recipe_unlocked", + "conditions": {"recipe": "rivencraft:crystal_block"} + } + }, + "requirements": [["has_crystal_shard", "has_the_recipe"]], + "rewards": {"recipes": ["rivencraft:crystal_block"]} +} diff --git a/src/main/resources/data/rivencraft/advancement/recipes/misc/ink_vial.json b/src/main/resources/data/rivencraft/advancement/recipes/misc/ink_vial.json new file mode 100644 index 0000000..cf3de53 --- /dev/null +++ b/src/main/resources/data/rivencraft/advancement/recipes/misc/ink_vial.json @@ -0,0 +1,15 @@ +{ + "parent": "minecraft:recipes/root", + "criteria": { + "has_black_dye": { + "trigger": "minecraft:inventory_changed", + "conditions": {"items": [{"items": "minecraft:black_dye"}]} + }, + "has_the_recipe": { + "trigger": "minecraft:recipe_unlocked", + "conditions": {"recipe": "rivencraft:ink_vial"} + } + }, + "requirements": [["has_black_dye", "has_the_recipe"]], + "rewards": {"recipes": ["rivencraft:ink_vial"]} +} diff --git a/src/main/resources/data/rivencraft/recipe/book_receptacle.json b/src/main/resources/data/rivencraft/recipe/book_receptacle.json index 2cf9009..86e3112 100644 --- a/src/main/resources/data/rivencraft/recipe/book_receptacle.json +++ b/src/main/resources/data/rivencraft/recipe/book_receptacle.json @@ -1 +1,7 @@ -{"type":"minecraft:crafting_shaped","pattern":["CCC","C C","CCC"],"key":{"C":{"item":"rivencraft:crystal_block"}},"result":{"id":"rivencraft:book_receptacle","count":1}} +{ + "type": "minecraft:crafting_shaped", + "category": "misc", + "pattern": ["CCC", "C C", "CCC"], + "key": {"C": {"item": "rivencraft:crystal_block"}}, + "result": {"id": "rivencraft:book_receptacle", "count": 1} +}