Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b154455563 | ||
|
|
4a87c9fae6 | ||
|
|
350d137d9e |
No files matched your search
@@ -105,6 +105,20 @@ JEI is optional; vanilla crafting
|
|||||||
recipes and all writing interactions work without it. For a development client
|
recipes and all writing interactions work without it. For a development client
|
||||||
with JEI, run `./gradlew runClient -PwithJei=true`.
|
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/<recipe_name>.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
|
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
|
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
|
Page first in the horizontal page strip. Click a page in the strip with a
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 73 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 319 KiB |
@@ -15,9 +15,13 @@ Rivencraft has books, Ages, symbol writing, book stands, a desk, binder, Ink Mix
|
|||||||
|
|
||||||
## Crystal portals: targets for #77–#78
|
## Crystal portals: targets for #77–#78
|
||||||
|
|
||||||
Issue #77 now has a craftable Book Receptacle (eight Crystal Blocks around an empty center), a bounded loaded-chunk crystal-network validator, persistent book holder/source pointers, animated Legacy portal cells, and portal collision routed through the existing Linking/Descriptive Book travel services. A book is only taken after a nonempty frame plan and destination dimension are validated. Removing the book or breaking its support removes the portal; a periodic server repair pass checks the loaded frame after restarts or edits. The Descriptive Book path preserves the configured automatic Return Link policy and link modifiers. Crystal-portal arrival does not generate a landing platform, matching Legacy's external travel semantics. A normal Linking Book still needs Intra Linking for same-dimension travel.
|
Issue #77 now has a craftable Book Receptacle (eight Crystal Blocks around an empty center), a bounded loaded-chunk crystal-network validator, persistent book holder/source pointers, animated Legacy portal cells, and portal collision routed through the existing Linking/Descriptive Book travel services. A book is only taken after a nonempty frame plan and destination dimension are validated. Removing the book or breaking its support removes the portal; portal cells validate on neighboring block changes, like vanilla portals, rather than self-destructing on recurring ticks during chunk loading. The Descriptive Book path preserves the configured automatic Return Link policy and link modifiers. Crystal-portal arrival does not generate a landing platform, matching Legacy's external travel semantics. A normal Linking Book still needs Intra Linking for same-dimension travel.
|
||||||
|
|
||||||
When Immersive Portals is present, render-only, non-teleporting IP portal entities show the book's destination through both faces of each coplanar opening; individual crystal cells are combined into one shaped surface so the destination is not repeated tile by tile. The view's central camera is aligned to the same safe arrival position and yaw/pitch used by book travel (at standing eye height). Perspective still changes naturally as the observer moves in front of a live portal. Each live view is recessed inside the opening; the fallback portal texture is always hidden while IP is installed, and crystal blocks render after the IP view so the frame appears on top. Without IP, the animated Legacy portal texture remains visible. Rivencraft portal-block contact remains the travel authority. This is an optional compile-only integration. The first placement of a Descriptive Book can generate/load its Age to establish a view destination. If the optional view is absent after restart, the holder's periodic pass recreates it. #78 retains exhaustive multiplayer, protection, destination deletion, and chunk-ticket hardening.
|
When Immersive Portals is present, render-only, non-teleporting IP portal entities show the book's destination through both faces of each coplanar opening; individual crystal cells are combined into one shaped surface so the destination is not repeated tile by tile. The view's central camera is aligned to the same safe arrival position and yaw/pitch used by book travel (at standing eye height). Perspective still changes naturally as the observer moves in front of a live portal. Each live view is recessed inside the opening; the animated fallback texture stays visible until the live view is established, and crystal blocks render after the IP view so the frame appears on top. Without IP, the animated Legacy portal texture remains visible. Rivencraft portal-block contact remains the travel authority. This is an optional compile-only integration. The first placement of a Descriptive Book can generate/load its Age to establish a view destination. If the optional view is absent after restart, the holder's maintenance pass recreates it. Portal contact uses IP's instant transfer only if the destination's 3×3 chunk area was already loaded; otherwise it uses vanilla Minecraft's dimension transfer.
|
||||||
|
|
||||||
|
Issue #78 hardening: placing each new portal cell fires a NeoForge entity-place protection event attributed to the player who inserted the book. If any cell is rejected, portal placement rolls back and the book remains with the player. Cells are placed without intermediate neighbor notifications so the frame validator cannot remove the first cells before the whole opening exists. Unattended maintenance never generates missing cells or tears down a saved portal during restart: block-change validation handles local frame damage, while the book remains retrievable. Portal owner positions are NBT integer arrays (not compound tags); the reader now matches that format, live-view state changes preserve the block entity's owner, and an intact saved surface with ownerless cells can recover its pointers from the adjacent book receptacle without placing new blocks. A missing neighboring chunk is treated as unknown, not as a broken frame. Portal cooldowns are per-player and session-only, never saved against a server tick counter that resets on restart. A Descriptive Book portal now searches for existing safe footing without creating a platform; Linking Books retain their intentionally floor-optional destination rule. Missing destination dimensions keep the book retrievable and remove any stale Immersive Portals view. Portal blocks and crystal frames are explicitly decay-protected. Book-GUI preview chunk loaders close on GUI exit, travel, logout, and server stop, with a five-minute fail-safe if a release packet is lost; crystal portals themselves add no Rivencraft chunk tickets.
|
||||||
|
|
||||||
|
Manual #78 matrix (use two players where noted): restart with an active portal and confirm the book, surface, and destination view return once its chunk loads; walk through it immediately and after 10 seconds without reinserting the book. Then unload/reload the Age and repeat. With destination chunks loaded, contact should transfer instantly; with them unloaded, it should use Minecraft's normal dimension transfer while the animated fallback remains visible until the live view is ready. Have both players enter the same portal, return through separately bound books, and confirm each cooldown is independent and neither loops immediately. Test a Descriptive Book destination with unsafe feet/head space and with no existing footing: it must choose another footed spot or refuse travel, never generate a platform. Deny a portal cell with a block-protection mod and confirm activation consumes no book and leaves no partial surface. Break one cell and confirm it does not silently reappear. Remove or unload the destination dimension and confirm no crash or stale live view, while the book can still be retrieved. Open a book preview with Immersive Portals, close it, and confirm its temporary chunk loader is released; an abandoned preview should expire within five minutes. Repeat with Immersive Portals absent to confirm the fallback texture and book travel.
|
||||||
|
|
||||||
Manual #77 check: build a 3×3 vertical Crystal Block ring, put the Receptacle on one frame crystal's outward face, then insert a bound Linking Book. The center should animate and link on contact; the book stays in the Receptacle. Confirm unbound books and incomplete frames do not consume items; empty-hand right-click retrieves the book and clears the surface. Repeat with a Descriptive Book and with Immersive Portals installed: the latter should show the destination live. Restart with the portal active and confirm the surface/view returns; break the support crystal and confirm book/portal cleanup.
|
Manual #77 check: build a 3×3 vertical Crystal Block ring, put the Receptacle on one frame crystal's outward face, then insert a bound Linking Book. The center should animate and link on contact; the book stays in the Receptacle. Confirm unbound books and incomplete frames do not consume items; empty-hand right-click retrieves the book and clears the surface. Repeat with a Descriptive Book and with Immersive Portals installed: the latter should show the destination live. Restart with the portal active and confirm the surface/view returns; break the support crystal and confirm book/portal cleanup.
|
||||||
|
|
||||||
@@ -48,7 +52,7 @@ Legacy's travel path creates no automatic Return Link. Rivencraft currently defa
|
|||||||
|
|
||||||
| Area | Legacy evidence and modern boundary |
|
| 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. |
|
| 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. |
|
| 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. |
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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.
|
# 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
|
mod_license=LGPL-3.0-or-later
|
||||||
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
|
# The mod version. Rivencraft uses the x.y.z.b policy documented in README.md.
|
||||||
mod_version=0.6.2.4
|
mod_version=0.6.4.4
|
||||||
immersive_portals_version=6.0.7
|
immersive_portals_version=6.0.7
|
||||||
terrablender_version=4.1.0.8
|
terrablender_version=4.1.0.8
|
||||||
serene_seasons_version_id=B6nTjh4b
|
serene_seasons_version_id=B6nTjh4b
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ public final class Rivencraft {
|
|||||||
NeoForge.EVENT_BUS.addListener(AgeCelestialAngleSync::onPlayerLoggedIn);
|
NeoForge.EVENT_BUS.addListener(AgeCelestialAngleSync::onPlayerLoggedIn);
|
||||||
NeoForge.EVENT_BUS.addListener(AgeCelestialAngleSync::onPlayerChangedDimension);
|
NeoForge.EVENT_BUS.addListener(AgeCelestialAngleSync::onPlayerChangedDimension);
|
||||||
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onPlayerLoggedOut);
|
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onPlayerLoggedOut);
|
||||||
|
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onServerTickPost);
|
||||||
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onServerStopping);
|
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onServerStopping);
|
||||||
NeoForge.EVENT_BUS.addListener(AgeInstabilityRuntime::onServerStopping);
|
NeoForge.EVENT_BUS.addListener(AgeInstabilityRuntime::onServerStopping);
|
||||||
NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onServerStopping);
|
NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onServerStopping);
|
||||||
|
|||||||
@@ -104,13 +104,18 @@ public final class BookReceptacleBlock extends BaseEntityBlock {
|
|||||||
}
|
}
|
||||||
holder.setBook(stack.copyWithCount(1));
|
holder.setBook(stack.copyWithCount(1));
|
||||||
holder.setDestination(destination);
|
holder.setDestination(destination);
|
||||||
if (!CrystalPortalNetwork.activate(server, pos, plan)) {
|
if (!CrystalPortalNetwork.activate(server, pos, plan, player)) {
|
||||||
holder.takeBook();
|
holder.takeBook();
|
||||||
return ItemInteractionResult.FAIL;
|
return ItemInteractionResult.FAIL;
|
||||||
}
|
}
|
||||||
if (!player.getAbilities().instabuild) stack.shrink(1);
|
if (!player.getAbilities().instabuild) stack.shrink(1);
|
||||||
if (net.neoforged.fml.ModList.get().isLoaded("immersive_portals_core"))
|
if (net.neoforged.fml.ModList.get().isLoaded("immersive_portals_core")) {
|
||||||
net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.activate(server, pos, plan, destination);
|
net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.activate(server, pos, plan, destination,
|
||||||
|
holder.getBook().is(ModItems.DESCRIPTIVE_BOOK.get()));
|
||||||
|
CrystalPortalNetwork.setLiveView(server, plan,
|
||||||
|
net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.complete(
|
||||||
|
server, pos, plan, destination));
|
||||||
|
}
|
||||||
level.playSound(null, pos, SoundEvents.ENCHANTMENT_TABLE_USE, SoundSource.BLOCKS, 1, 1);
|
level.playSound(null, pos, SoundEvents.ENCHANTMENT_TABLE_USE, SoundSource.BLOCKS, 1, 1);
|
||||||
return ItemInteractionResult.SUCCESS;
|
return ItemInteractionResult.SUCCESS;
|
||||||
}
|
}
|
||||||
@@ -132,7 +137,8 @@ public final class BookReceptacleBlock extends BaseEntityBlock {
|
|||||||
@Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor,
|
@Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor,
|
||||||
BlockPos neighborPos, boolean moved) {
|
BlockPos neighborPos, boolean moved) {
|
||||||
super.neighborChanged(state, level, pos, neighbor, neighborPos, moved);
|
super.neighborChanged(state, level, pos, neighbor, neighborPos, moved);
|
||||||
if (!level.isClientSide && !level.getBlockState(pos.relative(state.getValue(FACING).getOpposite()))
|
BlockPos support = pos.relative(state.getValue(FACING).getOpposite());
|
||||||
|
if (!level.isClientSide && level.hasChunkAt(support) && !level.getBlockState(support)
|
||||||
.is(ModBlocks.CRYSTAL_BLOCK.get())) level.destroyBlock(pos, true);
|
.is(ModBlocks.CRYSTAL_BLOCK.get())) level.destroyBlock(pos, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ package net.dnorberg.rivencraft.block;
|
|||||||
import com.mojang.serialization.MapCodec;
|
import com.mojang.serialization.MapCodec;
|
||||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||||
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
|
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.CrystalPortalNetwork;
|
||||||
import net.dnorberg.rivencraft.portal.PortalTravelCooldown;
|
import net.dnorberg.rivencraft.portal.PortalTravelCooldown;
|
||||||
|
import net.dnorberg.rivencraft.portal.PortalTravelQueue;
|
||||||
import net.dnorberg.rivencraft.registry.ModBlocks;
|
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||||
import net.dnorberg.rivencraft.registry.ModItems;
|
import net.dnorberg.rivencraft.registry.ModItems;
|
||||||
import net.minecraft.core.BlockPos;
|
import net.minecraft.core.BlockPos;
|
||||||
import net.minecraft.nbt.Tag;
|
import net.minecraft.core.Direction;
|
||||||
import net.minecraft.server.level.ServerLevel;
|
import net.minecraft.server.level.ServerLevel;
|
||||||
import net.minecraft.server.level.ServerPlayer;
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
import net.minecraft.util.RandomSource;
|
import net.minecraft.util.RandomSource;
|
||||||
@@ -24,6 +23,7 @@ import net.minecraft.world.level.block.entity.BlockEntity;
|
|||||||
import net.minecraft.world.level.block.state.BlockState;
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
import net.minecraft.world.level.block.state.StateDefinition;
|
import net.minecraft.world.level.block.state.StateDefinition;
|
||||||
import net.minecraft.world.level.block.state.properties.BooleanProperty;
|
import net.minecraft.world.level.block.state.properties.BooleanProperty;
|
||||||
|
import net.minecraft.world.level.block.state.properties.EnumProperty;
|
||||||
import net.minecraft.world.level.material.Fluid;
|
import net.minecraft.world.level.material.Fluid;
|
||||||
import net.neoforged.fml.ModList;
|
import net.neoforged.fml.ModList;
|
||||||
|
|
||||||
@@ -32,21 +32,20 @@ public final class CrystalPortalBlock extends BaseEntityBlock {
|
|||||||
public static final MapCodec<CrystalPortalBlock> CODEC = simpleCodec(CrystalPortalBlock::new);
|
public static final MapCodec<CrystalPortalBlock> CODEC = simpleCodec(CrystalPortalBlock::new);
|
||||||
// Retained so portal blocks already saved with this state still deserialize.
|
// Retained so portal blocks already saved with this state still deserialize.
|
||||||
public static final BooleanProperty LIVE_VIEW = BooleanProperty.create("live_view");
|
public static final BooleanProperty LIVE_VIEW = BooleanProperty.create("live_view");
|
||||||
private static final String COOLDOWN = "RivencraftCrystalPortalCooldown";
|
public static final EnumProperty<Direction.Axis> AXIS = EnumProperty.create("axis", Direction.Axis.class);
|
||||||
|
|
||||||
public CrystalPortalBlock(Properties properties) {
|
public CrystalPortalBlock(Properties properties) {
|
||||||
super(properties);
|
super(properties);
|
||||||
registerDefaultState(stateDefinition.any().setValue(LIVE_VIEW, false));
|
registerDefaultState(stateDefinition.any().setValue(LIVE_VIEW, false).setValue(AXIS, Direction.Axis.Z));
|
||||||
}
|
}
|
||||||
@Override protected MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
|
@Override protected MapCodec<? extends BaseEntityBlock> codec() { return CODEC; }
|
||||||
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
|
@Override protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
|
||||||
builder.add(LIVE_VIEW);
|
builder.add(LIVE_VIEW, AXIS);
|
||||||
}
|
}
|
||||||
@Override protected RenderShape getRenderShape(BlockState state) {
|
@Override protected RenderShape getRenderShape(BlockState state) {
|
||||||
// The live-view flag can lag behind portal-entity creation or fail to
|
// Keep the animated fallback until the optional destination view has
|
||||||
// synchronize for an existing frame. Never draw the fallback texture
|
// actually been established, including after a world restart.
|
||||||
// when Immersive Portals owns the view for this installation.
|
return ModList.get().isLoaded("immersive_portals_core") && state.getValue(LIVE_VIEW)
|
||||||
return ModList.get().isLoaded("immersive_portals_core")
|
|
||||||
? RenderShape.INVISIBLE : RenderShape.MODEL;
|
? RenderShape.INVISIBLE : RenderShape.MODEL;
|
||||||
}
|
}
|
||||||
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||||
@@ -57,35 +56,30 @@ public final class CrystalPortalBlock extends BaseEntityBlock {
|
|||||||
@Override protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity) {
|
@Override protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity) {
|
||||||
if (!(level instanceof ServerLevel server) || !(entity instanceof ServerPlayer player)) return;
|
if (!(level instanceof ServerLevel server) || !(entity instanceof ServerPlayer player)) return;
|
||||||
long now = server.getServer().getTickCount();
|
long now = server.getServer().getTickCount();
|
||||||
long previous = player.getPersistentData().contains(COOLDOWN, Tag.TAG_LONG)
|
if (PortalTravelCooldown.blocked(player, now)) return;
|
||||||
? player.getPersistentData().getLong(COOLDOWN) : -1;
|
|
||||||
if (previous > now) {
|
|
||||||
player.getPersistentData().remove(COOLDOWN);
|
|
||||||
previous = -1;
|
|
||||||
}
|
|
||||||
if (PortalTravelCooldown.blocked(now, previous)) return;
|
|
||||||
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|
||||||
|| portal.receptacle() == null || !level.hasChunkAt(portal.receptacle())
|
|| portal.receptacle() == null || !level.hasChunkAt(portal.receptacle())
|
||||||
|| !(level.getBlockEntity(portal.receptacle()) instanceof BookReceptacleBlockEntity holder)) return;
|
|| !(level.getBlockEntity(portal.receptacle()) instanceof BookReceptacleBlockEntity holder)) return;
|
||||||
ItemStack book = holder.getBook();
|
ItemStack book = holder.getBook();
|
||||||
if (book.isEmpty()) return;
|
if (book.isEmpty()) return;
|
||||||
player.getPersistentData().putLong(COOLDOWN, now);
|
PortalTravelCooldown.mark(player, now);
|
||||||
if (book.is(ModItems.LINKING_BOOK.get())) LinkingBookItem.usePortalBook(player, book);
|
PortalTravelQueue.enqueue(player, server, pos, portal.receptacle(), book, now);
|
||||||
else if (book.is(ModItems.DESCRIPTIVE_BOOK.get())) DescriptiveBookItem.usePortalBook(player, book);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor,
|
@Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor,
|
||||||
BlockPos neighborPos, boolean moved) {
|
BlockPos neighborPos, boolean moved) {
|
||||||
super.neighborChanged(state, level, pos, neighbor, neighborPos, moved);
|
super.neighborChanged(state, level, pos, neighbor, neighborPos, moved);
|
||||||
if (level instanceof ServerLevel server) server.scheduleTick(pos, this, 2);
|
if (!(level instanceof ServerLevel server)
|
||||||
|
|| !(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|
||||||
|
|| portal.receptacle() == null) return;
|
||||||
|
// Like a vanilla portal, validate when the frame changes, not on a
|
||||||
|
// repeating timer that can race block entities loading after restart.
|
||||||
|
if (CrystalPortalNetwork.cellStatus(server, pos, portal.receptacle())
|
||||||
|
== CrystalPortalNetwork.CellStatus.INVALID) server.removeBlock(pos, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override protected void tick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) {
|
@Override protected void tick(BlockState state, ServerLevel level, BlockPos pos, RandomSource random) {
|
||||||
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|
// Ignore validation ticks saved by older builds. Frame edits are
|
||||||
|| portal.receptacle() == null || !CrystalPortalNetwork.validCell(level, pos, portal.receptacle())) {
|
// handled by neighborChanged, and the receptacle maintains the view.
|
||||||
level.removeBlock(pos, false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
level.scheduleTick(pos, this, 100);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,7 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
|
|||||||
private static final String DESTINATION_TAG = "Destination";
|
private static final String DESTINATION_TAG = "Destination";
|
||||||
private ItemStack book = ItemStack.EMPTY;
|
private ItemStack book = ItemStack.EMPTY;
|
||||||
private LinkTarget destination;
|
private LinkTarget destination;
|
||||||
|
private boolean restoreView = true;
|
||||||
|
|
||||||
public BookReceptacleBlockEntity(BlockPos pos, BlockState state) {
|
public BookReceptacleBlockEntity(BlockPos pos, BlockState state) {
|
||||||
super(ModBlockEntities.BOOK_RECEPTACLE.get(), pos, state);
|
super(ModBlockEntities.BOOK_RECEPTACLE.get(), pos, state);
|
||||||
@@ -62,6 +63,7 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
|
|||||||
: ItemStack.EMPTY;
|
: ItemStack.EMPTY;
|
||||||
destination = tag.contains(DESTINATION_TAG)
|
destination = tag.contains(DESTINATION_TAG)
|
||||||
? LinkTarget.CODEC.parse(NbtOps.INSTANCE, tag.get(DESTINATION_TAG)).result().orElse(null) : null;
|
? LinkTarget.CODEC.parse(NbtOps.INSTANCE, tag.get(DESTINATION_TAG)).result().orElse(null) : null;
|
||||||
|
restoreView = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -83,13 +85,38 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
|
|||||||
public static void serverTick(net.minecraft.world.level.Level world, BlockPos pos, BlockState state,
|
public static void serverTick(net.minecraft.world.level.Level world, BlockPos pos, BlockState state,
|
||||||
BookReceptacleBlockEntity holder) {
|
BookReceptacleBlockEntity holder) {
|
||||||
if (!(world instanceof ServerLevel level)) return;
|
if (!(world instanceof ServerLevel level)) return;
|
||||||
if (level.getGameTime() % 200 != 0 || holder.book.isEmpty()) return;
|
if (holder.book.isEmpty() || (!holder.restoreView && level.getGameTime() % 200 != 0)) return;
|
||||||
CrystalPortalNetwork.reconcile(level, pos);
|
// Startup maintenance must never turn a saved portal off while its
|
||||||
if (holder.destination == null || !ModList.get().isLoaded("immersive_portals_core")) return;
|
// chunks and block entities are still arriving. Structural edits are
|
||||||
if (!net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.exists(level, pos)) {
|
// validated by the portal blocks' neighbor updates instead.
|
||||||
CrystalPortalNetwork.Plan plan = CrystalPortalNetwork.plan(level, pos);
|
if (holder.restoreView) CrystalPortalNetwork.recoverSavedOwners(level, pos);
|
||||||
if (plan.valid()) net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.activate(
|
CrystalPortalNetwork.Plan plan = CrystalPortalNetwork.plan(level, pos);
|
||||||
level, pos, plan, holder.destination);
|
if (!plan.valid()) return;
|
||||||
|
if (!ModList.get().isLoaded("immersive_portals_core")) {
|
||||||
|
holder.restoreView = false;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (holder.restoreView) CrystalPortalNetwork.setLiveView(level, plan, false);
|
||||||
|
holder.restoreView = false;
|
||||||
|
if (holder.destination == null) {
|
||||||
|
net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.remove(level, pos);
|
||||||
|
CrystalPortalNetwork.setLiveView(level, plan, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (level.getServer().getLevel(holder.destination.location().dimension()) == null) {
|
||||||
|
net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.remove(level, pos);
|
||||||
|
CrystalPortalNetwork.setLiveView(level, plan, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.complete(
|
||||||
|
level, pos, plan, holder.destination)) {
|
||||||
|
CrystalPortalNetwork.setLiveView(level, plan, false);
|
||||||
|
net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.activate(
|
||||||
|
level, pos, plan, holder.destination,
|
||||||
|
holder.book.is(net.dnorberg.rivencraft.registry.ModItems.DESCRIPTIVE_BOOK.get()));
|
||||||
|
}
|
||||||
|
CrystalPortalNetwork.setLiveView(level, plan,
|
||||||
|
net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.complete(
|
||||||
|
level, pos, plan, holder.destination));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,8 +31,7 @@ public final class CrystalPortalBlockEntity extends BlockEntity {
|
|||||||
@Override
|
@Override
|
||||||
protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
|
protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
|
||||||
super.loadAdditional(tag, registries);
|
super.loadAdditional(tag, registries);
|
||||||
receptacle = tag.contains(SOURCE_TAG, CompoundTag.TAG_COMPOUND)
|
receptacle = net.minecraft.nbt.NbtUtils.readBlockPos(tag, SOURCE_TAG).orElse(null);
|
||||||
? net.minecraft.nbt.NbtUtils.readBlockPos(tag, SOURCE_TAG).orElse(null) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ public final class LinkingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static LinkResult travel(ServerPlayer player, LinkTarget target, LinkProperties properties) {
|
public static LinkResult travel(ServerPlayer player, LinkTarget target, LinkProperties properties) {
|
||||||
return travel(player, target, false, properties);
|
return travel(player, target, false, properties, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Portal travel uses the vanilla transfer if its destination is not yet loaded. */
|
||||||
|
public static LinkResult travelPortal(ServerPlayer player, LinkTarget target, LinkProperties properties) {
|
||||||
|
return travel(player, target, false, properties, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Optional<Vec3> resolveDestination(ServerLevel level, BlockPos origin, boolean ageArrival) {
|
public static Optional<Vec3> resolveDestination(ServerLevel level, BlockPos origin, boolean ageArrival) {
|
||||||
@@ -56,28 +61,29 @@ public final class LinkingService {
|
|||||||
Vec3 position = destination.get();
|
Vec3 position = destination.get();
|
||||||
Vec3 momentum = player.getDeltaMovement();
|
Vec3 momentum = player.getDeltaMovement();
|
||||||
if (properties.has(LinkProperties.DISARM)) dropInventoryAtSource(player);
|
if (properties.has(LinkProperties.DISARM)) dropInventoryAtSource(player);
|
||||||
teleport(player, destinationLevel, position, target);
|
teleport(player, destinationLevel, position, target, true);
|
||||||
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) player.setDeltaMovement(momentum);
|
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) player.setDeltaMovement(momentum);
|
||||||
player.resetFallDistance();
|
player.resetFallDistance();
|
||||||
return LinkResult.SUCCESS;
|
return LinkResult.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A descriptive portal uses the book's Age destination without requiring a landing platform. */
|
/** A descriptive portal requires existing footing, but never generates a landing platform. */
|
||||||
public static LinkResult travelDescriptivePortal(ServerPlayer player, LinkTarget target, LinkProperties properties) {
|
public static LinkResult travelDescriptivePortal(ServerPlayer player, LinkTarget target, LinkProperties properties) {
|
||||||
ServerLevel destinationLevel = player.getServer().getLevel(target.location().dimension());
|
ServerLevel destinationLevel = player.getServer().getLevel(target.location().dimension());
|
||||||
if (destinationLevel == null) return LinkResult.MISSING_DIMENSION;
|
if (destinationLevel == null) return LinkResult.MISSING_DIMENSION;
|
||||||
Optional<Vec3> destination = resolveDestination(destinationLevel, target.location().pos(), false);
|
boolean instant = destinationChunksLoaded(destinationLevel, target.location().pos());
|
||||||
|
Optional<Vec3> destination = resolveDestination(destinationLevel, target.location().pos(), true);
|
||||||
if (destination.isEmpty()) return LinkResult.NO_SAFE_DESTINATION;
|
if (destination.isEmpty()) return LinkResult.NO_SAFE_DESTINATION;
|
||||||
Vec3 momentum = player.getDeltaMovement();
|
Vec3 momentum = player.getDeltaMovement();
|
||||||
if (properties.has(LinkProperties.DISARM)) dropInventoryAtSource(player);
|
if (properties.has(LinkProperties.DISARM)) dropInventoryAtSource(player);
|
||||||
teleport(player, destinationLevel, destination.get(), target);
|
teleport(player, destinationLevel, destination.get(), target, instant);
|
||||||
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) player.setDeltaMovement(momentum);
|
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) player.setDeltaMovement(momentum);
|
||||||
player.resetFallDistance();
|
player.resetFallDistance();
|
||||||
return LinkResult.SUCCESS;
|
return LinkResult.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static LinkResult travel(ServerPlayer player, LinkTarget target, boolean requiresFooting,
|
private static LinkResult travel(ServerPlayer player, LinkTarget target, boolean requiresFooting,
|
||||||
LinkProperties properties) {
|
LinkProperties properties, boolean portal) {
|
||||||
ServerLevel destinationLevel = player.getServer().getLevel(target.location().dimension());
|
ServerLevel destinationLevel = player.getServer().getLevel(target.location().dimension());
|
||||||
if (destinationLevel == null) {
|
if (destinationLevel == null) {
|
||||||
return LinkResult.MISSING_DIMENSION;
|
return LinkResult.MISSING_DIMENSION;
|
||||||
@@ -85,6 +91,7 @@ public final class LinkingService {
|
|||||||
if (destinationLevel == player.level() && !properties.has(LinkProperties.INTRA_LINKING))
|
if (destinationLevel == player.level() && !properties.has(LinkProperties.INTRA_LINKING))
|
||||||
return LinkResult.INTRA_LINKING_REQUIRED;
|
return LinkResult.INTRA_LINKING_REQUIRED;
|
||||||
|
|
||||||
|
boolean instant = !portal || destinationChunksLoaded(destinationLevel, target.location().pos());
|
||||||
Optional<Vec3> destination = resolveDestination(destinationLevel, target.location().pos(), requiresFooting);
|
Optional<Vec3> destination = resolveDestination(destinationLevel, target.location().pos(), requiresFooting);
|
||||||
if (destination.isEmpty()) {
|
if (destination.isEmpty()) {
|
||||||
return LinkResult.NO_SAFE_DESTINATION;
|
return LinkResult.NO_SAFE_DESTINATION;
|
||||||
@@ -93,7 +100,7 @@ public final class LinkingService {
|
|||||||
Vec3 position = destination.get();
|
Vec3 position = destination.get();
|
||||||
Vec3 momentum = player.getDeltaMovement();
|
Vec3 momentum = player.getDeltaMovement();
|
||||||
if (properties.has(LinkProperties.DISARM)) dropInventoryAtSource(player);
|
if (properties.has(LinkProperties.DISARM)) dropInventoryAtSource(player);
|
||||||
teleport(player, destinationLevel, position, target);
|
teleport(player, destinationLevel, position, target, instant);
|
||||||
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) player.setDeltaMovement(momentum);
|
if (properties.has(LinkProperties.MAINTAIN_MOMENTUM)) player.setDeltaMovement(momentum);
|
||||||
player.resetFallDistance();
|
player.resetFallDistance();
|
||||||
return LinkResult.SUCCESS;
|
return LinkResult.SUCCESS;
|
||||||
@@ -115,22 +122,36 @@ public final class LinkingService {
|
|||||||
ServerPlayer player,
|
ServerPlayer player,
|
||||||
ServerLevel destinationLevel,
|
ServerLevel destinationLevel,
|
||||||
Vec3 position,
|
Vec3 position,
|
||||||
LinkTarget target
|
LinkTarget target,
|
||||||
|
boolean instant
|
||||||
) {
|
) {
|
||||||
if (ModList.get().isLoaded("immersive_portals_core")) {
|
if (ModList.get().isLoaded("immersive_portals_core")) {
|
||||||
ImmersivePortalsChunkLoaders.close(player);
|
ImmersivePortalsChunkLoaders.close(player);
|
||||||
ImmersivePortalsChunkLoaders.teleport(
|
if (instant) {
|
||||||
player, destinationLevel, position, target.yaw(), target.pitch());
|
ImmersivePortalsChunkLoaders.teleport(
|
||||||
} else {
|
player, destinationLevel, position, target.yaw(), target.pitch());
|
||||||
player.teleportTo(
|
return;
|
||||||
destinationLevel,
|
}
|
||||||
position.x,
|
|
||||||
position.y,
|
|
||||||
position.z,
|
|
||||||
target.yaw(),
|
|
||||||
target.pitch()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
player.teleportTo(
|
||||||
|
destinationLevel,
|
||||||
|
position.x,
|
||||||
|
position.y,
|
||||||
|
position.z,
|
||||||
|
target.yaw(),
|
||||||
|
target.pitch()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean destinationChunksLoaded(ServerLevel level, BlockPos center) {
|
||||||
|
int chunkX = center.getX() >> 4;
|
||||||
|
int chunkZ = center.getZ() >> 4;
|
||||||
|
for (int x = chunkX - 1; x <= chunkX + 1; x++) {
|
||||||
|
for (int z = chunkZ - 1; z <= chunkZ + 1; z++) {
|
||||||
|
if (!level.getChunkSource().hasChunk(x, z)) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Optional<Vec3> findSafeDestination(ServerLevel level, BlockPos origin) {
|
static Optional<Vec3> findSafeDestination(ServerLevel level, BlockPos origin) {
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ public final class ClientEvents {
|
|||||||
// the portal cell itself is invisible while its live view is active.
|
// the portal cell itself is invisible while its live view is active.
|
||||||
if (ModList.get().isLoaded("immersive_portals_core"))
|
if (ModList.get().isLoaded("immersive_portals_core"))
|
||||||
ItemBlockRenderTypes.setRenderLayer(ModBlocks.CRYSTAL_BLOCK.get(), RenderType.translucent());
|
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::onRightClickItem);
|
||||||
NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickBlock);
|
NeoForge.EVENT_BUS.addListener(ClientBookInteractionHandler::onRightClickBlock);
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import qouteall.q_misc_util.dimension.DimensionIntId;
|
|||||||
|
|
||||||
/** Isolated server bridge; this class is loaded only when Immersive Portals is installed. */
|
/** Isolated server bridge; this class is loaded only when Immersive Portals is installed. */
|
||||||
public final class ImmersivePortalsChunkLoaders {
|
public final class ImmersivePortalsChunkLoaders {
|
||||||
private static final Map<ServerPlayer, ChunkLoader> LOADERS = new IdentityHashMap<>();
|
private static final long MAX_PREVIEW_TICKS = 20L * 60 * 5;
|
||||||
|
private static final Map<ServerPlayer, PreviewLoader> LOADERS = new IdentityHashMap<>();
|
||||||
|
|
||||||
|
private record PreviewLoader(ChunkLoader loader, long expiresAt) {}
|
||||||
|
|
||||||
private ImmersivePortalsChunkLoaders() {
|
private ImmersivePortalsChunkLoaders() {
|
||||||
}
|
}
|
||||||
@@ -35,29 +38,40 @@ public final class ImmersivePortalsChunkLoaders {
|
|||||||
|
|
||||||
ChunkLoader loader = new ChunkLoader(level.dimension(), centerChunkX, centerChunkZ, 8);
|
ChunkLoader loader = new ChunkLoader(level.dimension(), centerChunkX, centerChunkZ, 8);
|
||||||
PortalAPI.addChunkLoaderForPlayer(player, loader);
|
PortalAPI.addChunkLoaderForPlayer(player, loader);
|
||||||
LOADERS.put(player, loader);
|
LOADERS.put(player, new PreviewLoader(loader, player.getServer().getTickCount() + MAX_PREVIEW_TICKS));
|
||||||
ImmPtlChunkTracking.immediatelyUpdateForPlayer(player);
|
ImmPtlChunkTracking.immediatelyUpdateForPlayer(player);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static synchronized void close(ServerPlayer player) {
|
public static synchronized void close(ServerPlayer player) {
|
||||||
ChunkLoader loader = LOADERS.remove(player);
|
PreviewLoader preview = LOADERS.remove(player);
|
||||||
if (loader != null) {
|
if (preview != null) {
|
||||||
PortalAPI.removeChunkLoaderForPlayer(player, loader);
|
PortalAPI.removeChunkLoaderForPlayer(player, preview.loader());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fail-safe for a lost close packet: no book preview can hold chunks indefinitely. */
|
||||||
|
public static synchronized void expire(MinecraftServer server) {
|
||||||
|
if (server.getTickCount() % 20 != 0) return;
|
||||||
|
LOADERS.entrySet().removeIf(entry -> {
|
||||||
|
if (server.getTickCount() < entry.getValue().expiresAt()
|
||||||
|
&& server.getLevel(entry.getValue().loader().dimension()) != null) return false;
|
||||||
|
PortalAPI.removeChunkLoaderForPlayer(entry.getKey(), entry.getValue().loader());
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public static synchronized void closeForDimension(ResourceKey<Level> dimension) {
|
public static synchronized void closeForDimension(ResourceKey<Level> dimension) {
|
||||||
LOADERS.entrySet().removeIf(entry -> {
|
LOADERS.entrySet().removeIf(entry -> {
|
||||||
if (!entry.getValue().dimension().equals(dimension)) {
|
if (!entry.getValue().loader().dimension().equals(dimension)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
PortalAPI.removeChunkLoaderForPlayer(entry.getKey(), entry.getValue());
|
PortalAPI.removeChunkLoaderForPlayer(entry.getKey(), entry.getValue().loader());
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static synchronized void closeAll() {
|
public static synchronized void closeAll() {
|
||||||
LOADERS.forEach(PortalAPI::removeChunkLoaderForPlayer);
|
LOADERS.forEach((player, preview) -> PortalAPI.removeChunkLoaderForPlayer(player, preview.loader()));
|
||||||
LOADERS.clear();
|
LOADERS.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ public final class ImmersivePortalsCrystalView {
|
|||||||
private ImmersivePortalsCrystalView() {}
|
private ImmersivePortalsCrystalView() {}
|
||||||
|
|
||||||
public static void activate(ServerLevel source, BlockPos receptacle,
|
public static void activate(ServerLevel source, BlockPos receptacle,
|
||||||
CrystalPortalNetwork.Plan plan, LinkTarget target) {
|
CrystalPortalNetwork.Plan plan, LinkTarget target, boolean requireFooting) {
|
||||||
remove(source, receptacle);
|
remove(source, receptacle);
|
||||||
ServerLevel destinationLevel = source.getServer().getLevel(target.location().dimension());
|
ServerLevel destinationLevel = source.getServer().getLevel(target.location().dimension());
|
||||||
if (destinationLevel == null) return;
|
if (destinationLevel == null) return;
|
||||||
// This is the same safe-location search used by portal book travel.
|
// This is the same safe-location search used by portal book travel.
|
||||||
var arrival = LinkingService.resolveDestination(destinationLevel, target.location().pos(), false);
|
var arrival = LinkingService.resolveDestination(destinationLevel, target.location().pos(), requireFooting);
|
||||||
if (arrival.isEmpty()) return;
|
if (arrival.isEmpty()) return;
|
||||||
Vec3 arrivalEye = arrival.get().add(0, 1.62, 0);
|
Vec3 arrivalEye = arrival.get().add(0, 1.62, 0);
|
||||||
for (CrystalPortalSurfaces.Surface surface : CrystalPortalSurfaces.group(source, plan)) {
|
for (CrystalPortalSurfaces.Surface surface : CrystalPortalSurfaces.group(source, plan)) {
|
||||||
@@ -46,11 +46,15 @@ public final class ImmersivePortalsCrystalView {
|
|||||||
&& (entity.portalTag.equals(tag) || entity.portalTag.equals(oldTag)))) portal.discard();
|
&& (entity.portalTag.equals(tag) || entity.portalTag.equals(oldTag)))) portal.discard();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean exists(ServerLevel source, BlockPos receptacle) {
|
public static boolean complete(ServerLevel source, BlockPos receptacle,
|
||||||
|
CrystalPortalNetwork.Plan plan, LinkTarget target) {
|
||||||
String tag = tag(receptacle);
|
String tag = tag(receptacle);
|
||||||
return !source.getEntitiesOfClass(Portal.class,
|
var views = source.getEntitiesOfClass(Portal.class,
|
||||||
new AABB(receptacle).inflate(CrystalPortalNetwork.MAX_RADIUS + 2),
|
new AABB(receptacle).inflate(CrystalPortalNetwork.MAX_RADIUS + 2),
|
||||||
entity -> tag.equals(entity.portalTag)).isEmpty();
|
entity -> tag.equals(entity.portalTag));
|
||||||
|
int expected = CrystalPortalSurfaces.group(source, plan).size() * 2;
|
||||||
|
return expected > 0 && views.size() == expected && views.stream()
|
||||||
|
.allMatch(view -> target.location().dimension().equals(view.getDestDim()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void spawn(ServerLevel source, BlockPos receptacle, CrystalPortalSurfaces.Surface surface,
|
private static void spawn(ServerLevel source, BlockPos receptacle, CrystalPortalSurfaces.Surface surface,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package net.dnorberg.rivencraft.event;
|
package net.dnorberg.rivencraft.event;
|
||||||
|
|
||||||
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
|
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.minecraft.server.level.ServerPlayer;
|
||||||
import net.neoforged.fml.ModList;
|
import net.neoforged.fml.ModList;
|
||||||
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
||||||
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
|
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
|
||||||
|
import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
||||||
|
|
||||||
/** Server-authoritative cleanup for resources that exist only while a preview is active. */
|
/** Server-authoritative cleanup for resources that exist only while a preview is active. */
|
||||||
public final class AgeLifecycleEvents {
|
public final class AgeLifecycleEvents {
|
||||||
@@ -12,6 +15,10 @@ public final class AgeLifecycleEvents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) {
|
public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) {
|
||||||
|
if (event.getEntity() instanceof ServerPlayer player) {
|
||||||
|
PortalTravelCooldown.forget(player);
|
||||||
|
PortalTravelQueue.forget(player);
|
||||||
|
}
|
||||||
if (event.getEntity() instanceof ServerPlayer player
|
if (event.getEntity() instanceof ServerPlayer player
|
||||||
&& ModList.get().isLoaded("immersive_portals_core")) {
|
&& ModList.get().isLoaded("immersive_portals_core")) {
|
||||||
ImmersivePortalsChunkLoaders.close(player);
|
ImmersivePortalsChunkLoaders.close(player);
|
||||||
@@ -19,8 +26,16 @@ public final class AgeLifecycleEvents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void onServerStopping(ServerStoppingEvent event) {
|
public static void onServerStopping(ServerStoppingEvent event) {
|
||||||
|
PortalTravelCooldown.clear();
|
||||||
|
PortalTravelQueue.clear();
|
||||||
if (ModList.get().isLoaded("immersive_portals_core")) {
|
if (ModList.get().isLoaded("immersive_portals_core")) {
|
||||||
ImmersivePortalsChunkLoaders.closeAll();
|
ImmersivePortalsChunkLoaders.closeAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void onServerTickPost(ServerTickEvent.Post event) {
|
||||||
|
PortalTravelQueue.process(event.getServer());
|
||||||
|
if (ModList.get().isLoaded("immersive_portals_core"))
|
||||||
|
ImmersivePortalsChunkLoaders.expire(event.getServer());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ package net.dnorberg.rivencraft.gametest;
|
|||||||
|
|
||||||
import net.dnorberg.rivencraft.Rivencraft;
|
import net.dnorberg.rivencraft.Rivencraft;
|
||||||
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
|
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
|
||||||
|
import net.dnorberg.rivencraft.block.CrystalPortalBlock;
|
||||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||||
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
|
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
|
||||||
import net.dnorberg.rivencraft.book.LinkTarget;
|
import net.dnorberg.rivencraft.book.LinkTarget;
|
||||||
@@ -15,7 +16,10 @@ import net.minecraft.core.Direction;
|
|||||||
import net.minecraft.core.GlobalPos;
|
import net.minecraft.core.GlobalPos;
|
||||||
import net.minecraft.gametest.framework.GameTest;
|
import net.minecraft.gametest.framework.GameTest;
|
||||||
import net.minecraft.gametest.framework.GameTestHelper;
|
import net.minecraft.gametest.framework.GameTestHelper;
|
||||||
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
import net.minecraft.world.item.ItemStack;
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.level.block.Blocks;
|
||||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||||
|
|
||||||
@@ -28,14 +32,17 @@ public final class CrystalPortalGameTests {
|
|||||||
public static void completeFrameActivatesAndReleasesBook(GameTestHelper helper) {
|
public static void completeFrameActivatesAndReleasesBook(GameTestHelper helper) {
|
||||||
var level = helper.getLevel();
|
var level = helper.getLevel();
|
||||||
for (int x = 0; x < 3; x++) for (int y = 1; y < 4; y++) {
|
for (int x = 0; x < 3; x++) for (int y = 1; y < 4; y++) {
|
||||||
if (x == 1 && y == 2) continue;
|
if (x == 1 && y == 2) {
|
||||||
|
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)), Blocks.AIR.defaultBlockState(), 3);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)),
|
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)),
|
||||||
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
|
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
|
||||||
}
|
}
|
||||||
BlockPos holderPos = helper.absolutePos(new BlockPos(0, 1, 0));
|
BlockPos holderPos = helper.absolutePos(new BlockPos(0, 1, 0));
|
||||||
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
|
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
|
||||||
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
|
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
|
||||||
CrystalPortalNetwork.Plan plan = CrystalPortalNetwork.plan(level, holderPos);
|
CrystalPortalNetwork.Plan plan = planLoaded(level, holderPos);
|
||||||
helper.assertTrue(plan.valid(), "A closed crystal frame must form a portal");
|
helper.assertTrue(plan.valid(), "A closed crystal frame must form a portal");
|
||||||
BookReceptacleBlockEntity holder = (BookReceptacleBlockEntity) level.getBlockEntity(holderPos);
|
BookReceptacleBlockEntity holder = (BookReceptacleBlockEntity) level.getBlockEntity(holderPos);
|
||||||
holder.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
|
holder.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
|
||||||
@@ -43,21 +50,120 @@ public final class CrystalPortalGameTests {
|
|||||||
holder.setDestination(destination);
|
holder.setDestination(destination);
|
||||||
BookReceptacleBlockEntity restored = new BookReceptacleBlockEntity(holderPos, holder.getBlockState());
|
BookReceptacleBlockEntity restored = new BookReceptacleBlockEntity(holderPos, holder.getBlockState());
|
||||||
restored.loadWithComponents(holder.saveWithoutMetadata(level.registryAccess()), level.registryAccess());
|
restored.loadWithComponents(holder.saveWithoutMetadata(level.registryAccess()), level.registryAccess());
|
||||||
|
helper.assertTrue(restored.getBook().is(ModItems.LINKING_BOOK.get()),
|
||||||
|
"The book itself must survive a save/reload cycle");
|
||||||
helper.assertValueEqual(restored.destination(), destination,
|
helper.assertValueEqual(restored.destination(), destination,
|
||||||
"The portal destination must survive a save/reload cycle");
|
"The portal destination must survive a save/reload cycle");
|
||||||
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, plan), "Portal placement must succeed");
|
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, plan), "Portal placement must succeed");
|
||||||
BlockPos center = helper.absolutePos(new BlockPos(1, 2, 1));
|
BlockPos center = helper.absolutePos(new BlockPos(1, 2, 1));
|
||||||
helper.assertTrue(level.getBlockEntity(center) instanceof CrystalPortalBlockEntity portal
|
helper.assertTrue(level.getBlockEntity(center) instanceof CrystalPortalBlockEntity portal
|
||||||
&& holderPos.equals(portal.receptacle()), "The portal must point to its one book holder");
|
&& holderPos.equals(portal.receptacle()), "The portal must point to its one book holder");
|
||||||
|
CrystalPortalBlockEntity savedPortal = (CrystalPortalBlockEntity) level.getBlockEntity(center);
|
||||||
|
CrystalPortalBlockEntity restoredPortal = new CrystalPortalBlockEntity(center, savedPortal.getBlockState());
|
||||||
|
CompoundTag savedPortalTag = savedPortal.saveWithoutMetadata(level.registryAccess());
|
||||||
|
helper.assertTrue(savedPortalTag.contains("Receptacle"), "Portal owner must serialize: " + savedPortalTag);
|
||||||
|
restoredPortal.loadWithComponents(savedPortalTag,
|
||||||
|
level.registryAccess());
|
||||||
|
helper.assertTrue(restoredPortal.receptacle() != null, "Portal owner must deserialize: " + savedPortalTag);
|
||||||
|
helper.assertValueEqual(restoredPortal.receptacle(), holderPos,
|
||||||
|
"The portal-to-receptacle pointer must survive a full chunk save");
|
||||||
|
helper.assertTrue(CrystalPortalNetwork.reconcile(level, holderPos),
|
||||||
|
"A saved portal must recover without duplicating cells");
|
||||||
|
CrystalPortalNetwork.setLiveView(level, plan, true);
|
||||||
|
CrystalPortalBlockEntity livePortal = (CrystalPortalBlockEntity) level.getBlockEntity(center);
|
||||||
|
helper.assertValueEqual(livePortal.receptacle(), holderPos,
|
||||||
|
"Switching to the live view must retain the portal's book holder");
|
||||||
|
helper.assertTrue(livePortal.saveWithoutMetadata(level.registryAccess()).contains("Receptacle"),
|
||||||
|
"The live-view portal must persist its holder pointer to disk");
|
||||||
|
CrystalPortalNetwork.setLiveView(level, plan, false);
|
||||||
|
helper.assertValueEqual(((CrystalPortalBlockEntity) level.getBlockEntity(center)).receptacle(), holderPos,
|
||||||
|
"Switching back to the fallback texture must retain the holder");
|
||||||
|
helper.assertValueEqual(level.getBlockState(center).getValue(CrystalPortalBlock.AXIS), Direction.Axis.Z,
|
||||||
|
"A north/south opening must only show its two player-facing sides");
|
||||||
CrystalPortalNetwork.shutdown(level, holderPos);
|
CrystalPortalNetwork.shutdown(level, holderPos);
|
||||||
helper.assertTrue(level.getBlockState(center).isAir(), "Removing the book must release the portal");
|
helper.assertTrue(level.getBlockState(center).isAir(), "Removing the book must release the portal");
|
||||||
helper.succeed();
|
helper.succeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void backgroundRepairDoesNotRecreateMissingCells(GameTestHelper helper) {
|
||||||
|
var level = helper.getLevel();
|
||||||
|
for (int x = 0; x < 3; x++) for (int y = 1; y < 4; y++) {
|
||||||
|
if (x == 1 && y == 2) {
|
||||||
|
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)), Blocks.AIR.defaultBlockState(), 3);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)),
|
||||||
|
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
|
||||||
|
}
|
||||||
|
BlockPos holderPos = helper.absolutePos(new BlockPos(0, 1, 0));
|
||||||
|
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
|
||||||
|
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
|
||||||
|
BookReceptacleBlockEntity holder = (BookReceptacleBlockEntity) level.getBlockEntity(holderPos);
|
||||||
|
holder.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
|
||||||
|
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, planLoaded(level, holderPos)),
|
||||||
|
"Portal must activate before the repair check");
|
||||||
|
BlockPos center = helper.absolutePos(new BlockPos(1, 2, 1));
|
||||||
|
level.removeBlock(center, false);
|
||||||
|
helper.assertTrue(!CrystalPortalNetwork.reconcile(level, holderPos),
|
||||||
|
"An unattended repair must not place a new cell across a protection boundary");
|
||||||
|
helper.assertTrue(level.getBlockState(center).isAir() && !holder.getBook().isEmpty(),
|
||||||
|
"A broken portal stays shut down while its one stored book remains retrievable");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
private static BlockPos centerTarget(GameTestHelper helper) {
|
private static BlockPos centerTarget(GameTestHelper helper) {
|
||||||
return helper.absolutePos(new BlockPos(1, 2, 1));
|
return helper.absolutePos(new BlockPos(1, 2, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void eastWestOpeningOrientsFallbackSurface(GameTestHelper helper) {
|
||||||
|
var level = helper.getLevel();
|
||||||
|
for (int z = 1; z <= 3; z++) for (int y = 1; y <= 3; y++) {
|
||||||
|
if (z == 2 && y == 2) {
|
||||||
|
level.setBlock(helper.absolutePos(new BlockPos(1, y, z)), Blocks.AIR.defaultBlockState(), 3);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
level.setBlock(helper.absolutePos(new BlockPos(1, y, z)),
|
||||||
|
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
|
||||||
|
}
|
||||||
|
BlockPos holderPos = helper.absolutePos(new BlockPos(1, 1, 0));
|
||||||
|
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
|
||||||
|
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
|
||||||
|
((BookReceptacleBlockEntity) level.getBlockEntity(holderPos))
|
||||||
|
.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
|
||||||
|
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, planLoaded(level, holderPos)),
|
||||||
|
"The east/west crystal opening must activate");
|
||||||
|
helper.assertValueEqual(level.getBlockState(helper.absolutePos(new BlockPos(1, 2, 2)))
|
||||||
|
.getValue(CrystalPortalBlock.AXIS), Direction.Axis.X,
|
||||||
|
"The fallback surface must face east and west, not north and south");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void horizontalOpeningOrientsFallbackSurface(GameTestHelper helper) {
|
||||||
|
var level = helper.getLevel();
|
||||||
|
for (int x = 0; x < 3; x++) for (int z = 0; z < 3; z++) {
|
||||||
|
if (x == 1 && z == 1) {
|
||||||
|
level.setBlock(helper.absolutePos(new BlockPos(x, 1, z)), Blocks.AIR.defaultBlockState(), 3);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
level.setBlock(helper.absolutePos(new BlockPos(x, 1, z)),
|
||||||
|
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
|
||||||
|
}
|
||||||
|
BlockPos holderPos = helper.absolutePos(new BlockPos(0, 2, 1));
|
||||||
|
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
|
||||||
|
.setValue(BookReceptacleBlock.FACING, Direction.UP), 3);
|
||||||
|
((BookReceptacleBlockEntity) level.getBlockEntity(holderPos))
|
||||||
|
.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
|
||||||
|
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, planLoaded(level, holderPos)),
|
||||||
|
"The horizontal crystal opening must activate");
|
||||||
|
helper.assertValueEqual(level.getBlockState(helper.absolutePos(new BlockPos(1, 1, 1)))
|
||||||
|
.getValue(CrystalPortalBlock.AXIS), Direction.Axis.Y,
|
||||||
|
"The fallback surface must face up and down");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
@GameTest(template = "empty")
|
@GameTest(template = "empty")
|
||||||
public static void incompleteFrameCannotActivate(GameTestHelper helper) {
|
public static void incompleteFrameCannotActivate(GameTestHelper helper) {
|
||||||
var level = helper.getLevel();
|
var level = helper.getLevel();
|
||||||
@@ -66,7 +172,7 @@ public final class CrystalPortalGameTests {
|
|||||||
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
|
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
|
||||||
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
|
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
|
||||||
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
|
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
|
||||||
helper.assertTrue(!CrystalPortalNetwork.plan(level, holderPos).valid(),
|
helper.assertTrue(!planLoaded(level, holderPos).valid(),
|
||||||
"An incomplete frame must not take a book or leave portal cells");
|
"An incomplete frame must not take a book or leave portal cells");
|
||||||
helper.succeed();
|
helper.succeed();
|
||||||
}
|
}
|
||||||
@@ -75,28 +181,89 @@ public final class CrystalPortalGameTests {
|
|||||||
public static void multiCellOpeningHasOneContinuousView(GameTestHelper helper) {
|
public static void multiCellOpeningHasOneContinuousView(GameTestHelper helper) {
|
||||||
var level = helper.getLevel();
|
var level = helper.getLevel();
|
||||||
for (int x = 0; x < 3; x++) for (int y = 1; y < 5; y++) {
|
for (int x = 0; x < 3; x++) for (int y = 1; y < 5; y++) {
|
||||||
if (x == 1 && (y == 2 || y == 3)) continue;
|
if (x == 1 && (y == 2 || y == 3)) {
|
||||||
|
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)), Blocks.AIR.defaultBlockState(), 3);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)),
|
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)),
|
||||||
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
|
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
|
||||||
}
|
}
|
||||||
BlockPos holderPos = helper.absolutePos(new BlockPos(0, 1, 0));
|
BlockPos holderPos = helper.absolutePos(new BlockPos(0, 1, 0));
|
||||||
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
|
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
|
||||||
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
|
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
|
||||||
CrystalPortalNetwork.Plan plan = CrystalPortalNetwork.plan(level, holderPos);
|
CrystalPortalNetwork.Plan plan = planLoaded(level, holderPos);
|
||||||
helper.assertValueEqual(plan.portalCells().size(), 2, "The opening contains two portal cells");
|
helper.assertValueEqual(plan.portalCells().size(), 2, "The opening contains two portal cells");
|
||||||
|
((BookReceptacleBlockEntity) level.getBlockEntity(holderPos))
|
||||||
|
.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
|
||||||
|
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, plan),
|
||||||
|
"The two-cell opening must activate before its live-view grouping is inspected");
|
||||||
helper.assertValueEqual(CrystalPortalSurfaces.group(level, plan).size(), 1,
|
helper.assertValueEqual(CrystalPortalSurfaces.group(level, plan).size(), 1,
|
||||||
"Both cells must share one coherent Immersive Portals view");
|
"Both cells must share one coherent Immersive Portals view");
|
||||||
helper.succeed();
|
helper.succeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GameTest(template = "empty")
|
@GameTest(template = "empty")
|
||||||
public static void savedCooldownFromPreviousServerCannotBlockPortal(GameTestHelper helper) {
|
public static void largeOpeningSurvivesAtomicActivation(GameTestHelper helper) {
|
||||||
helper.assertTrue(!PortalTravelCooldown.blocked(5, -1),
|
var level = helper.getLevel();
|
||||||
"A player with no prior portal use must link immediately");
|
for (int x = 0; x < 5; x++) for (int y = 1; y < 6; y++) {
|
||||||
helper.assertTrue(!PortalTravelCooldown.blocked(5, 12000),
|
if (x > 0 && x < 4 && y > 1 && y < 5) {
|
||||||
"A saved tick from the previous server session must not block travel");
|
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)), Blocks.AIR.defaultBlockState(), 3);
|
||||||
helper.assertTrue(PortalTravelCooldown.blocked(105, 100),
|
continue;
|
||||||
"A recent portal use in the current session must still prevent loops");
|
}
|
||||||
|
level.setBlock(helper.absolutePos(new BlockPos(x, y, 1)),
|
||||||
|
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
|
||||||
|
}
|
||||||
|
BlockPos holderPos = helper.absolutePos(new BlockPos(0, 1, 0));
|
||||||
|
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
|
||||||
|
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
|
||||||
|
BookReceptacleBlockEntity holder = (BookReceptacleBlockEntity) level.getBlockEntity(holderPos);
|
||||||
|
holder.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
|
||||||
|
CrystalPortalNetwork.Plan plan = planLoaded(level, holderPos);
|
||||||
|
helper.assertValueEqual(plan.portalCells().size(), 9, "The large frame has a 3×3 opening");
|
||||||
|
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, plan),
|
||||||
|
"The complete opening must activate");
|
||||||
|
for (BlockPos pos : plan.portalCells()) {
|
||||||
|
helper.assertTrue(level.getBlockState(pos).is(ModBlocks.CRYSTAL_PORTAL.get())
|
||||||
|
&& level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
|
||||||
|
&& holderPos.equals(portal.receptacle()),
|
||||||
|
"Activation must retain every cell with the same book holder");
|
||||||
|
}
|
||||||
|
CrystalPortalNetwork.setLiveView(level, plan, true);
|
||||||
|
for (BlockPos pos : plan.portalCells()) {
|
||||||
|
((CrystalPortalBlockEntity) level.getBlockEntity(pos))
|
||||||
|
.loadWithComponents(new CompoundTag(), level.registryAccess());
|
||||||
|
}
|
||||||
|
helper.assertTrue(CrystalPortalNetwork.recoverSavedOwners(level, holderPos),
|
||||||
|
"An intact saved surface can recover its lost holder pointers without placing blocks");
|
||||||
|
for (BlockPos pos : plan.portalCells()) {
|
||||||
|
CrystalPortalBlockEntity portal = (CrystalPortalBlockEntity) level.getBlockEntity(pos);
|
||||||
|
helper.assertValueEqual(portal.receptacle(), holderPos,
|
||||||
|
"Every recovered cell must point to the book holder");
|
||||||
|
}
|
||||||
helper.succeed();
|
helper.succeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GameTest(template = "empty")
|
||||||
|
public static void portalCooldownIsIndependentAndSessionOnly(GameTestHelper helper) {
|
||||||
|
var first = helper.makeMockServerPlayerInLevel();
|
||||||
|
var second = helper.makeMockServerPlayerInLevel();
|
||||||
|
helper.assertTrue(!PortalTravelCooldown.blocked(5, -1),
|
||||||
|
"A player with no prior portal use must link immediately");
|
||||||
|
PortalTravelCooldown.mark(first, 100);
|
||||||
|
helper.assertTrue(PortalTravelCooldown.blocked(first, 105)
|
||||||
|
&& !PortalTravelCooldown.blocked(second, 105),
|
||||||
|
"One player's loop protection must not block another player");
|
||||||
|
PortalTravelCooldown.clear();
|
||||||
|
helper.assertTrue(!PortalTravelCooldown.blocked(first, 105),
|
||||||
|
"A new server session must never inherit a saved portal cooldown");
|
||||||
|
helper.succeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CrystalPortalNetwork.Plan planLoaded(ServerLevel level, BlockPos holderPos) {
|
||||||
|
int chunkX = holderPos.getX() >> 4;
|
||||||
|
int chunkZ = holderPos.getZ() >> 4;
|
||||||
|
for (int x = chunkX - 1; x <= chunkX + 1; x++)
|
||||||
|
for (int z = chunkZ - 1; z <= chunkZ + 1; z++) level.getChunk(x, z);
|
||||||
|
return CrystalPortalNetwork.plan(level, holderPos);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,9 @@ public final class LinkingBookItem extends Item {
|
|||||||
Level sourceLevel = player.level();
|
Level sourceLevel = player.level();
|
||||||
BlockPos sourcePosition = player.blockPosition();
|
BlockPos sourcePosition = player.blockPosition();
|
||||||
LinkProperties properties = stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
LinkProperties properties = stack.getOrDefault(ModDataComponents.LINK_PROPERTIES.get(), LinkProperties.NONE);
|
||||||
LinkingService.LinkResult result = LinkingService.travel(player, target, properties);
|
LinkingService.LinkResult result = portal
|
||||||
|
? LinkingService.travelPortal(player, target, properties)
|
||||||
|
: LinkingService.travel(player, target, properties);
|
||||||
if (result == LinkingService.LinkResult.SUCCESS) {
|
if (result == LinkingService.LinkResult.SUCCESS) {
|
||||||
sourceLevel.playSound(null, sourcePosition, (portal ? ModSounds.LINK_PORTAL : ModSounds.LINK).get(), SoundSource.PLAYERS,
|
sourceLevel.playSound(null, sourcePosition, (portal ? ModSounds.LINK_PORTAL : ModSounds.LINK).get(), SoundSource.PLAYERS,
|
||||||
0.8F, sourceLevel.random.nextFloat() * 0.2F + 0.9F);
|
0.8F, sourceLevel.random.nextFloat() * 0.2F + 0.9F);
|
||||||
|
|||||||
@@ -7,14 +7,19 @@ import java.util.LinkedHashSet;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
|
import net.dnorberg.rivencraft.block.BookReceptacleBlock;
|
||||||
|
import net.dnorberg.rivencraft.block.CrystalPortalBlock;
|
||||||
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
import net.dnorberg.rivencraft.block.entity.BookReceptacleBlockEntity;
|
||||||
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
|
import net.dnorberg.rivencraft.block.entity.CrystalPortalBlockEntity;
|
||||||
import net.dnorberg.rivencraft.registry.ModBlocks;
|
import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||||
import net.minecraft.core.BlockPos;
|
import net.minecraft.core.BlockPos;
|
||||||
import net.minecraft.core.Direction;
|
import net.minecraft.core.Direction;
|
||||||
import net.minecraft.server.level.ServerLevel;
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.world.entity.Entity;
|
||||||
import net.minecraft.world.level.block.Blocks;
|
import net.minecraft.world.level.block.Blocks;
|
||||||
import net.minecraft.world.level.block.state.BlockState;
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import net.neoforged.neoforge.common.NeoForge;
|
||||||
|
import net.neoforged.neoforge.common.util.BlockSnapshot;
|
||||||
|
import net.neoforged.neoforge.event.level.BlockEvent;
|
||||||
|
|
||||||
/** Bounded, loaded-chunk-only adaptation of Legacy's crystal network flood. */
|
/** Bounded, loaded-chunk-only adaptation of Legacy's crystal network flood. */
|
||||||
public final class CrystalPortalNetwork {
|
public final class CrystalPortalNetwork {
|
||||||
@@ -31,14 +36,22 @@ public final class CrystalPortalNetwork {
|
|||||||
|
|
||||||
private CrystalPortalNetwork() {}
|
private CrystalPortalNetwork() {}
|
||||||
|
|
||||||
public record Plan(List<BlockPos> portalCells) {
|
public record Plan(List<BlockPos> portalCells, boolean waitingForChunks) {
|
||||||
|
public Plan(List<BlockPos> portalCells) { this(portalCells, false); }
|
||||||
public boolean valid() { return !portalCells.isEmpty(); }
|
public boolean valid() { return !portalCells.isEmpty(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum CellStatus { VALID, INVALID, WAITING_FOR_CHUNKS }
|
||||||
|
|
||||||
public static Plan plan(ServerLevel level, BlockPos receptacle) {
|
public static Plan plan(ServerLevel level, BlockPos receptacle) {
|
||||||
|
return plan(level, receptacle, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Plan plan(ServerLevel level, BlockPos receptacle, boolean allowUnownedCells) {
|
||||||
BlockState holder = level.getBlockState(receptacle);
|
BlockState holder = level.getBlockState(receptacle);
|
||||||
if (!holder.is(ModBlocks.BOOK_RECEPTACLE.get())) return new Plan(List.of());
|
if (!holder.is(ModBlocks.BOOK_RECEPTACLE.get())) return new Plan(List.of());
|
||||||
BlockPos base = receptacle.relative(holder.getValue(BookReceptacleBlock.FACING).getOpposite());
|
BlockPos base = receptacle.relative(holder.getValue(BookReceptacleBlock.FACING).getOpposite());
|
||||||
|
if (!level.hasChunkAt(base)) return new Plan(List.of(), true);
|
||||||
if (!level.getBlockState(base).is(ModBlocks.CRYSTAL_BLOCK.get())) return new Plan(List.of());
|
if (!level.getBlockState(base).is(ModBlocks.CRYSTAL_BLOCK.get())) return new Plan(List.of());
|
||||||
|
|
||||||
Set<BlockPos> crystals = new HashSet<>();
|
Set<BlockPos> crystals = new HashSet<>();
|
||||||
@@ -47,7 +60,7 @@ public final class CrystalPortalNetwork {
|
|||||||
while (!pendingCrystals.isEmpty()) {
|
while (!pendingCrystals.isEmpty()) {
|
||||||
BlockPos pos = pendingCrystals.removeFirst();
|
BlockPos pos = pendingCrystals.removeFirst();
|
||||||
if (!within(base, pos) || !level.isInWorldBounds(pos)) continue;
|
if (!within(base, pos) || !level.isInWorldBounds(pos)) continue;
|
||||||
if (!level.hasChunkAt(pos)) return new Plan(List.of());
|
if (!level.hasChunkAt(pos)) return new Plan(List.of(), true);
|
||||||
if (!level.getBlockState(pos).is(ModBlocks.CRYSTAL_BLOCK.get()) || !crystals.add(pos)) continue;
|
if (!level.getBlockState(pos).is(ModBlocks.CRYSTAL_BLOCK.get()) || !crystals.add(pos)) continue;
|
||||||
if (atBoundary(base, pos)) return new Plan(List.of());
|
if (atBoundary(base, pos)) return new Plan(List.of());
|
||||||
if (crystals.size() > MAX_CRYSTALS) return new Plan(List.of());
|
if (crystals.size() > MAX_CRYSTALS) return new Plan(List.of());
|
||||||
@@ -61,8 +74,9 @@ public final class CrystalPortalNetwork {
|
|||||||
while (!candidates.isEmpty()) {
|
while (!candidates.isEmpty()) {
|
||||||
BlockPos pos = candidates.removeFirst();
|
BlockPos pos = candidates.removeFirst();
|
||||||
queued.remove(pos);
|
queued.remove(pos);
|
||||||
if (!level.isInWorldBounds(pos) || !level.hasChunkAt(pos)) return new Plan(List.of());
|
if (!level.isInWorldBounds(pos)) return new Plan(List.of());
|
||||||
if (portals.contains(pos) || !mayFill(level, pos, receptacle)) continue;
|
if (!level.hasChunkAt(pos)) return new Plan(List.of(), true);
|
||||||
|
if (portals.contains(pos) || !mayFill(level, pos, receptacle, allowUnownedCells)) continue;
|
||||||
if (adjacentCount(pos, crystals, portals) < 2) continue;
|
if (adjacentCount(pos, crystals, portals) < 2) continue;
|
||||||
portals.add(pos);
|
portals.add(pos);
|
||||||
if (portals.size() > MAX_PORTALS) return new Plan(List.of());
|
if (portals.size() > MAX_PORTALS) return new Plan(List.of());
|
||||||
@@ -96,6 +110,28 @@ public final class CrystalPortalNetwork {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static boolean activate(ServerLevel level, BlockPos receptacle, Plan plan) {
|
public static boolean activate(ServerLevel level, BlockPos receptacle, Plan plan) {
|
||||||
|
return activate(level, receptacle, plan, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Repairs only existing, framed cells whose owner pointer was lost by an older live-view update. */
|
||||||
|
public static boolean recoverSavedOwners(ServerLevel level, BlockPos receptacle) {
|
||||||
|
if (!(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder)
|
||||||
|
|| holder.getBook().isEmpty()) return false;
|
||||||
|
Plan plan = plan(level, receptacle, true);
|
||||||
|
if (!plan.valid()) return false;
|
||||||
|
for (BlockPos pos : plan.portalCells()) {
|
||||||
|
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|
||||||
|
|| portal.receptacle() != null && !receptacle.equals(portal.receptacle())) return false;
|
||||||
|
}
|
||||||
|
for (BlockPos pos : plan.portalCells()) {
|
||||||
|
CrystalPortalBlockEntity portal = (CrystalPortalBlockEntity) level.getBlockEntity(pos);
|
||||||
|
if (portal.receptacle() == null) portal.setReceptacle(receptacle);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The player who inserted the book owns each newly placed portal cell for claim checks. */
|
||||||
|
public static boolean activate(ServerLevel level, BlockPos receptacle, Plan plan, Entity actor) {
|
||||||
if (!plan.valid() || !(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder)
|
if (!plan.valid() || !(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder)
|
||||||
|| holder.getBook().isEmpty()) return false;
|
|| holder.getBook().isEmpty()) return false;
|
||||||
List<BlockPos> placed = new ArrayList<>();
|
List<BlockPos> placed = new ArrayList<>();
|
||||||
@@ -105,20 +141,41 @@ public final class CrystalPortalNetwork {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (level.getBlockState(pos).isAir()) {
|
if (level.getBlockState(pos).isAir()) {
|
||||||
if (!level.setBlock(pos, ModBlocks.CRYSTAL_PORTAL.get().defaultBlockState(), 3)) {
|
BlockSnapshot snapshot = actor == null ? null : BlockSnapshot.create(level.dimension(), level, pos);
|
||||||
|
BlockState prior = level.getBlockState(pos);
|
||||||
|
// Defer neighbor notifications until the entire opening exists.
|
||||||
|
// Otherwise the first cells see an incomplete brace and the
|
||||||
|
// portal's neighbor validator removes them during activation.
|
||||||
|
if (!level.setBlock(pos, ModBlocks.CRYSTAL_PORTAL.get().defaultBlockState(), 2)) {
|
||||||
rollback(level, placed);
|
rollback(level, placed);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (snapshot != null) {
|
||||||
|
var event = new BlockEvent.EntityPlaceEvent(snapshot, prior, actor);
|
||||||
|
NeoForge.EVENT_BUS.post(event);
|
||||||
|
if (event.isCanceled()) {
|
||||||
|
snapshot.restore();
|
||||||
|
rollback(level, placed);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
placed.add(pos);
|
placed.add(pos);
|
||||||
}
|
}
|
||||||
if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal) {
|
if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal) {
|
||||||
portal.setReceptacle(receptacle);
|
portal.setReceptacle(receptacle);
|
||||||
level.scheduleTick(pos, ModBlocks.CRYSTAL_PORTAL.get(), 40);
|
|
||||||
} else {
|
} else {
|
||||||
rollback(level, placed);
|
rollback(level, placed);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
orientSurfaces(level, plan);
|
||||||
|
for (BlockPos pos : plan.portalCells()) {
|
||||||
|
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|
||||||
|
|| !receptacle.equals(portal.receptacle())) {
|
||||||
|
rollback(level, placed);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,13 +194,24 @@ public final class CrystalPortalNetwork {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** One bounded repair pass per receptacle, rather than a full flood per portal cell. */
|
/** One bounded repair pass per receptacle, rather than a full flood per portal cell. */
|
||||||
public static void reconcile(ServerLevel level, BlockPos receptacle) {
|
public static boolean reconcile(ServerLevel level, BlockPos receptacle) {
|
||||||
Plan plan = plan(level, receptacle);
|
Plan plan = plan(level, receptacle);
|
||||||
if (!plan.valid()) {
|
if (!plan.valid()) {
|
||||||
shutdown(level, receptacle);
|
// Neighboring chunks can still be loading when the receptacle first
|
||||||
return;
|
// ticks after a restart. An incomplete view is not a broken frame.
|
||||||
|
if (!plan.waitingForChunks()) shutdown(level, receptacle);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
Set<BlockPos> wanted = new HashSet<>(plan.portalCells());
|
Set<BlockPos> wanted = new HashSet<>(plan.portalCells());
|
||||||
|
// A background pass must not create blocks without the owner present:
|
||||||
|
// that would bypass the placement/protection event used at activation.
|
||||||
|
for (BlockPos pos : wanted) {
|
||||||
|
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|
||||||
|
|| !receptacle.equals(portal.receptacle())) {
|
||||||
|
shutdown(level, receptacle);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
for (BlockPos pos : BlockPos.betweenClosed(receptacle.offset(-CLEANUP_RADIUS, -CLEANUP_RADIUS, -CLEANUP_RADIUS),
|
for (BlockPos pos : BlockPos.betweenClosed(receptacle.offset(-CLEANUP_RADIUS, -CLEANUP_RADIUS, -CLEANUP_RADIUS),
|
||||||
receptacle.offset(CLEANUP_RADIUS, CLEANUP_RADIUS, CLEANUP_RADIUS))) {
|
receptacle.offset(CLEANUP_RADIUS, CLEANUP_RADIUS, CLEANUP_RADIUS))) {
|
||||||
if (!level.hasChunkAt(pos)) continue;
|
if (!level.hasChunkAt(pos)) continue;
|
||||||
@@ -151,29 +219,64 @@ public final class CrystalPortalNetwork {
|
|||||||
&& receptacle.equals(portal.receptacle()) && !wanted.contains(pos))
|
&& receptacle.equals(portal.receptacle()) && !wanted.contains(pos))
|
||||||
level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
|
level.setBlock(pos, Blocks.AIR.defaultBlockState(), 3);
|
||||||
}
|
}
|
||||||
activate(level, receptacle, plan);
|
orientSurfaces(level, plan);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean validCell(ServerLevel level, BlockPos pos, BlockPos receptacle) {
|
private static void orientSurfaces(ServerLevel level, Plan plan) {
|
||||||
|
for (CrystalPortalSurfaces.Surface surface : CrystalPortalSurfaces.group(level, plan)) {
|
||||||
|
for (BlockPos pos : surface.cells()) {
|
||||||
|
BlockState state = level.getBlockState(pos);
|
||||||
|
Direction.Axis axis = surface.normal().getAxis();
|
||||||
|
if (state.getValue(CrystalPortalBlock.AXIS) != axis)
|
||||||
|
updatePortalState(level, pos, state.setValue(CrystalPortalBlock.AXIS, axis));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The vanilla-style surface remains visible until its optional live view is ready. */
|
||||||
|
public static void setLiveView(ServerLevel level, Plan plan, boolean live) {
|
||||||
|
for (BlockPos pos : plan.portalCells()) {
|
||||||
|
BlockState state = level.getBlockState(pos);
|
||||||
|
if (state.is(ModBlocks.CRYSTAL_PORTAL.get()) && state.getValue(CrystalPortalBlock.LIVE_VIEW) != live)
|
||||||
|
updatePortalState(level, pos, state.setValue(CrystalPortalBlock.LIVE_VIEW, live));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void updatePortalState(ServerLevel level, BlockPos pos, BlockState replacement) {
|
||||||
|
BlockPos owner = level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
|
||||||
|
? portal.receptacle() : null;
|
||||||
|
if (!level.setBlock(pos, replacement, 2) || owner == null) return;
|
||||||
|
if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
|
||||||
|
&& !owner.equals(portal.receptacle())) portal.setReceptacle(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CellStatus cellStatus(ServerLevel level, BlockPos pos, BlockPos receptacle) {
|
||||||
if (Math.abs(receptacle.getX() - pos.getX()) > CLEANUP_RADIUS
|
if (Math.abs(receptacle.getX() - pos.getX()) > CLEANUP_RADIUS
|
||||||
|| Math.abs(receptacle.getY() - pos.getY()) > CLEANUP_RADIUS
|
|| Math.abs(receptacle.getY() - pos.getY()) > CLEANUP_RADIUS
|
||||||
|| Math.abs(receptacle.getZ() - pos.getZ()) > CLEANUP_RADIUS
|
|| Math.abs(receptacle.getZ() - pos.getZ()) > CLEANUP_RADIUS) return CellStatus.INVALID;
|
||||||
|| !level.hasChunkAt(receptacle)
|
if (!level.hasChunkAt(receptacle)) return CellStatus.WAITING_FOR_CHUNKS;
|
||||||
|| !(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder)
|
|
||||||
|| holder.getBook().isEmpty()) return false;
|
|
||||||
BlockState state = level.getBlockState(receptacle);
|
BlockState state = level.getBlockState(receptacle);
|
||||||
if (!state.is(ModBlocks.BOOK_RECEPTACLE.get()) || !level.getBlockState(receptacle.relative(
|
if (!state.is(ModBlocks.BOOK_RECEPTACLE.get())) return CellStatus.INVALID;
|
||||||
state.getValue(BookReceptacleBlock.FACING).getOpposite())).is(ModBlocks.CRYSTAL_BLOCK.get()))
|
BlockPos base = receptacle.relative(state.getValue(BookReceptacleBlock.FACING).getOpposite());
|
||||||
return false;
|
if (!level.hasChunkAt(base)) return CellStatus.WAITING_FOR_CHUNKS;
|
||||||
|
if (!level.getBlockState(base).is(ModBlocks.CRYSTAL_BLOCK.get())) return CellStatus.INVALID;
|
||||||
|
if (!(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder))
|
||||||
|
return CellStatus.WAITING_FOR_CHUNKS;
|
||||||
|
if (holder.getBook().isEmpty()) return CellStatus.INVALID;
|
||||||
|
// A neighboring chunk may hold one of the braces. Do not tear down a
|
||||||
|
// persisted portal until all six neighbors are available to inspect.
|
||||||
|
for (Direction direction : Direction.values())
|
||||||
|
if (!level.hasChunkAt(pos.relative(direction))) return CellStatus.WAITING_FOR_CHUNKS;
|
||||||
for (Direction.Axis axis : Direction.Axis.values()) {
|
for (Direction.Axis axis : Direction.Axis.values()) {
|
||||||
// Quick tension check before the bounded network/path revalidation.
|
// Quick tension check before the bounded network/path revalidation.
|
||||||
if (!axisBraced(level, pos, axis)) continue;
|
if (!axisBraced(level, pos, axis)) continue;
|
||||||
int other = 0;
|
int other = 0;
|
||||||
for (Direction.Axis alternative : Direction.Axis.values())
|
for (Direction.Axis alternative : Direction.Axis.values())
|
||||||
if (alternative != axis && axisBraced(level, pos, alternative)) other++;
|
if (alternative != axis && axisBraced(level, pos, alternative)) other++;
|
||||||
if (other > 0) return true;
|
if (other > 0) return CellStatus.VALID;
|
||||||
}
|
}
|
||||||
return false;
|
return CellStatus.INVALID;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean axisBraced(ServerLevel level, BlockPos pos, Direction.Axis axis) {
|
private static boolean axisBraced(ServerLevel level, BlockPos pos, Direction.Axis axis) {
|
||||||
@@ -194,11 +297,15 @@ public final class CrystalPortalNetwork {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static boolean mayFill(ServerLevel level, BlockPos pos, BlockPos receptacle) {
|
private static boolean mayFill(ServerLevel level, BlockPos pos, BlockPos receptacle) {
|
||||||
|
return mayFill(level, pos, receptacle, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean mayFill(ServerLevel level, BlockPos pos, BlockPos receptacle, boolean allowUnowned) {
|
||||||
if (!level.hasChunkAt(pos) || !level.isInWorldBounds(pos)) return false;
|
if (!level.hasChunkAt(pos) || !level.isInWorldBounds(pos)) return false;
|
||||||
BlockState state = level.getBlockState(pos);
|
BlockState state = level.getBlockState(pos);
|
||||||
return state.isAir() || state.is(ModBlocks.CRYSTAL_PORTAL.get())
|
return state.isAir() || state.is(ModBlocks.CRYSTAL_PORTAL.get())
|
||||||
&& level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
|
&& level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal
|
||||||
&& receptacle.equals(portal.receptacle());
|
&& (receptacle.equals(portal.receptacle()) || allowUnowned && portal.receptacle() == null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int adjacentCount(BlockPos pos, Set<BlockPos> crystals, Set<BlockPos> portals) {
|
private static int adjacentCount(BlockPos pos, Set<BlockPos> crystals, Set<BlockPos> portals) {
|
||||||
|
|||||||
@@ -1,12 +1,33 @@
|
|||||||
package net.dnorberg.rivencraft.portal;
|
package net.dnorberg.rivencraft.portal;
|
||||||
|
|
||||||
/** Server tick counters restart with the process; old saved values cannot block new sessions. */
|
import java.util.Map;
|
||||||
|
import java.util.WeakHashMap;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
|
||||||
|
/** Session-only portal cooldowns: tick counters must never be saved with players. */
|
||||||
public final class PortalTravelCooldown {
|
public final class PortalTravelCooldown {
|
||||||
public static final long TICKS = 40;
|
public static final long TICKS = 40;
|
||||||
|
private static final Map<ServerPlayer, Long> LAST_USE = new WeakHashMap<>();
|
||||||
|
|
||||||
private PortalTravelCooldown() {}
|
private PortalTravelCooldown() {}
|
||||||
|
|
||||||
public static boolean blocked(long now, long previous) {
|
public static boolean blocked(long now, long previous) {
|
||||||
return previous >= 0 && now >= previous && now - previous < TICKS;
|
return previous >= 0 && now >= previous && now - previous < TICKS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static boolean blocked(ServerPlayer player, long now) {
|
||||||
|
return blocked(now, LAST_USE.getOrDefault(player, -1L));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void mark(ServerPlayer player, long now) {
|
||||||
|
LAST_USE.put(player, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void forget(ServerPlayer player) {
|
||||||
|
LAST_USE.remove(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clear() {
|
||||||
|
LAST_USE.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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(); }
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
{"variants":{"live_view=false":{"model":"rivencraft:block/crystal_portal"},"live_view=true":{"model":"rivencraft:block/crystal_portal"}}}
|
{"variants":{"axis=x,live_view=false":{"model":"rivencraft:block/crystal_portal","y":90},"axis=x,live_view=true":{"model":"rivencraft:block/crystal_portal","y":90},"axis=y,live_view=false":{"model":"rivencraft:block/crystal_portal","x":90},"axis=y,live_view=true":{"model":"rivencraft:block/crystal_portal","x":90},"axis=z,live_view=false":{"model":"rivencraft:block/crystal_portal"},"axis=z,live_view=true":{"model":"rivencraft:block/crystal_portal"}}}
|
||||||
@@ -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.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.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.",
|
"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": "Rivencraft",
|
||||||
"itemGroup.rivencraft.pages.link": "Rivencraft Link Pages",
|
"itemGroup.rivencraft.pages.link": "Rivencraft Link Pages",
|
||||||
"itemGroup.rivencraft.pages.terrain": "Rivencraft Terrain Pages",
|
"itemGroup.rivencraft.pages.terrain": "Rivencraft Terrain Pages",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"parent":"minecraft:block/cube_all","textures":{"all":"rivencraft:block/crystal_portal"}}
|
{"textures":{"portal":"rivencraft:block/crystal_portal"},"elements":[{"from":[0,0,7.5],"to":[16,16,8.5],"shade":false,"faces":{"north":{"texture":"#portal"},"south":{"texture":"#portal"}}}]}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@
|
|||||||
"rivencraft:book_binder",
|
"rivencraft:book_binder",
|
||||||
"rivencraft:ink_mixer",
|
"rivencraft:ink_mixer",
|
||||||
"rivencraft:star_fissure",
|
"rivencraft:star_fissure",
|
||||||
"rivencraft:cloud_platform"
|
"rivencraft:cloud_platform",
|
||||||
|
"rivencraft:crystal_block",
|
||||||
|
"rivencraft:book_receptacle",
|
||||||
|
"rivencraft:crystal_portal"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user