Complete Milestone 7 crafting and portal guide (#79)
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-10-19 04:01:03 -04:00
1 parent 4a87c9fae6
commit b154455563
14 files changed
+361 -8

No files matched your search

@@ -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,
@@ -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);
@@ -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<ResourceLocation> 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();
}
}
@@ -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());
}
@@ -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<ItemStack> 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);
}
}
@@ -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<ServerPlayer, Pending> 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<Travel> ready = new ArrayList<>();
Iterator<Map.Entry<ServerPlayer, Pending>> 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(); }
}
@@ -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",
@@ -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"]}
}
@@ -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"]}
}
@@ -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"]}
}
@@ -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}
}