Public Access
This commit is contained in:
1 parent
350d137d9e
commit
4a87c9fae6
18 files changed
+439
-117
No files matched your search
@@ -76,6 +76,7 @@ public final class Rivencraft {
|
||||
NeoForge.EVENT_BUS.addListener(AgeCelestialAngleSync::onPlayerLoggedIn);
|
||||
NeoForge.EVENT_BUS.addListener(AgeCelestialAngleSync::onPlayerChangedDimension);
|
||||
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onPlayerLoggedOut);
|
||||
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onServerTickPost);
|
||||
NeoForge.EVENT_BUS.addListener(AgeLifecycleEvents::onServerStopping);
|
||||
NeoForge.EVENT_BUS.addListener(AgeInstabilityRuntime::onServerStopping);
|
||||
NeoForge.EVENT_BUS.addListener(AgeEnvironmentalProviders::onServerStopping);
|
||||
|
||||
@@ -104,13 +104,18 @@ public final class BookReceptacleBlock extends BaseEntityBlock {
|
||||
}
|
||||
holder.setBook(stack.copyWithCount(1));
|
||||
holder.setDestination(destination);
|
||||
if (!CrystalPortalNetwork.activate(server, pos, plan)) {
|
||||
if (!CrystalPortalNetwork.activate(server, pos, plan, player)) {
|
||||
holder.takeBook();
|
||||
return ItemInteractionResult.FAIL;
|
||||
}
|
||||
if (!player.getAbilities().instabuild) stack.shrink(1);
|
||||
if (net.neoforged.fml.ModList.get().isLoaded("immersive_portals_core"))
|
||||
net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.activate(server, pos, plan, destination);
|
||||
if (net.neoforged.fml.ModList.get().isLoaded("immersive_portals_core")) {
|
||||
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);
|
||||
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,
|
||||
BlockPos neighborPos, boolean 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ 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.nbt.Tag;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.util.RandomSource;
|
||||
@@ -35,7 +34,6 @@ public final class CrystalPortalBlock extends BaseEntityBlock {
|
||||
// Retained so portal blocks already saved with this state still deserialize.
|
||||
public static final BooleanProperty LIVE_VIEW = BooleanProperty.create("live_view");
|
||||
public static final EnumProperty<Direction.Axis> AXIS = EnumProperty.create("axis", Direction.Axis.class);
|
||||
private static final String COOLDOWN = "RivencraftCrystalPortalCooldown";
|
||||
|
||||
public CrystalPortalBlock(Properties properties) {
|
||||
super(properties);
|
||||
@@ -46,10 +44,9 @@ public final class CrystalPortalBlock extends BaseEntityBlock {
|
||||
builder.add(LIVE_VIEW, AXIS);
|
||||
}
|
||||
@Override protected RenderShape getRenderShape(BlockState state) {
|
||||
// The live-view flag can lag behind portal-entity creation or fail to
|
||||
// synchronize for an existing frame. Never draw the fallback texture
|
||||
// when Immersive Portals owns the view for this installation.
|
||||
return ModList.get().isLoaded("immersive_portals_core")
|
||||
// Keep the animated fallback until the optional destination view has
|
||||
// actually been established, including after a world restart.
|
||||
return ModList.get().isLoaded("immersive_portals_core") && state.getValue(LIVE_VIEW)
|
||||
? RenderShape.INVISIBLE : RenderShape.MODEL;
|
||||
}
|
||||
@Override public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
|
||||
@@ -60,19 +57,13 @@ public final class CrystalPortalBlock extends BaseEntityBlock {
|
||||
@Override protected void entityInside(BlockState state, Level level, BlockPos pos, Entity entity) {
|
||||
if (!(level instanceof ServerLevel server) || !(entity instanceof ServerPlayer player)) return;
|
||||
long now = server.getServer().getTickCount();
|
||||
long previous = player.getPersistentData().contains(COOLDOWN, Tag.TAG_LONG)
|
||||
? player.getPersistentData().getLong(COOLDOWN) : -1;
|
||||
if (previous > now) {
|
||||
player.getPersistentData().remove(COOLDOWN);
|
||||
previous = -1;
|
||||
}
|
||||
if (PortalTravelCooldown.blocked(now, previous)) return;
|
||||
if (PortalTravelCooldown.blocked(player, now)) return;
|
||||
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|
||||
|| portal.receptacle() == null || !level.hasChunkAt(portal.receptacle())
|
||||
|| !(level.getBlockEntity(portal.receptacle()) instanceof BookReceptacleBlockEntity holder)) return;
|
||||
ItemStack book = holder.getBook();
|
||||
if (book.isEmpty()) return;
|
||||
player.getPersistentData().putLong(COOLDOWN, now);
|
||||
PortalTravelCooldown.mark(player, now);
|
||||
if (book.is(ModItems.LINKING_BOOK.get())) LinkingBookItem.usePortalBook(player, book);
|
||||
else if (book.is(ModItems.DESCRIPTIVE_BOOK.get())) DescriptiveBookItem.usePortalBook(player, book);
|
||||
}
|
||||
@@ -80,15 +71,17 @@ public final class CrystalPortalBlock extends BaseEntityBlock {
|
||||
@Override protected void neighborChanged(BlockState state, Level level, BlockPos pos, Block neighbor,
|
||||
BlockPos neighborPos, boolean 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) {
|
||||
if (!(level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal)
|
||||
|| portal.receptacle() == null || !CrystalPortalNetwork.validCell(level, pos, portal.receptacle())) {
|
||||
level.removeBlock(pos, false);
|
||||
return;
|
||||
}
|
||||
level.scheduleTick(pos, this, 100);
|
||||
// Ignore validation ticks saved by older builds. Frame edits are
|
||||
// handled by neighborChanged, and the receptacle maintains the view.
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
|
||||
private static final String DESTINATION_TAG = "Destination";
|
||||
private ItemStack book = ItemStack.EMPTY;
|
||||
private LinkTarget destination;
|
||||
private boolean restoreView = true;
|
||||
|
||||
public BookReceptacleBlockEntity(BlockPos pos, BlockState state) {
|
||||
super(ModBlockEntities.BOOK_RECEPTACLE.get(), pos, state);
|
||||
@@ -62,6 +63,7 @@ public final class BookReceptacleBlockEntity extends BlockEntity {
|
||||
: ItemStack.EMPTY;
|
||||
destination = tag.contains(DESTINATION_TAG)
|
||||
? LinkTarget.CODEC.parse(NbtOps.INSTANCE, tag.get(DESTINATION_TAG)).result().orElse(null) : null;
|
||||
restoreView = true;
|
||||
}
|
||||
|
||||
@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,
|
||||
BookReceptacleBlockEntity holder) {
|
||||
if (!(world instanceof ServerLevel level)) return;
|
||||
if (level.getGameTime() % 200 != 0 || holder.book.isEmpty()) return;
|
||||
CrystalPortalNetwork.reconcile(level, pos);
|
||||
if (holder.destination == null || !ModList.get().isLoaded("immersive_portals_core")) return;
|
||||
if (!net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.exists(level, pos)) {
|
||||
CrystalPortalNetwork.Plan plan = CrystalPortalNetwork.plan(level, pos);
|
||||
if (plan.valid()) net.dnorberg.rivencraft.compat.ImmersivePortalsCrystalView.activate(
|
||||
level, pos, plan, holder.destination);
|
||||
if (holder.book.isEmpty() || (!holder.restoreView && level.getGameTime() % 200 != 0)) return;
|
||||
// Startup maintenance must never turn a saved portal off while its
|
||||
// chunks and block entities are still arriving. Structural edits are
|
||||
// validated by the portal blocks' neighbor updates instead.
|
||||
if (holder.restoreView) CrystalPortalNetwork.recoverSavedOwners(level, pos);
|
||||
CrystalPortalNetwork.Plan plan = CrystalPortalNetwork.plan(level, pos);
|
||||
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
|
||||
protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
|
||||
super.loadAdditional(tag, registries);
|
||||
receptacle = tag.contains(SOURCE_TAG, CompoundTag.TAG_COMPOUND)
|
||||
? net.minecraft.nbt.NbtUtils.readBlockPos(tag, SOURCE_TAG).orElse(null) : null;
|
||||
receptacle = net.minecraft.nbt.NbtUtils.readBlockPos(tag, SOURCE_TAG).orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -29,7 +29,12 @@ public final class LinkingService {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -56,28 +61,29 @@ public final class LinkingService {
|
||||
Vec3 position = destination.get();
|
||||
Vec3 momentum = player.getDeltaMovement();
|
||||
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);
|
||||
player.resetFallDistance();
|
||||
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) {
|
||||
ServerLevel destinationLevel = player.getServer().getLevel(target.location().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;
|
||||
Vec3 momentum = player.getDeltaMovement();
|
||||
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);
|
||||
player.resetFallDistance();
|
||||
return LinkResult.SUCCESS;
|
||||
}
|
||||
|
||||
private static LinkResult travel(ServerPlayer player, LinkTarget target, boolean requiresFooting,
|
||||
LinkProperties properties) {
|
||||
LinkProperties properties, boolean portal) {
|
||||
ServerLevel destinationLevel = player.getServer().getLevel(target.location().dimension());
|
||||
if (destinationLevel == null) {
|
||||
return LinkResult.MISSING_DIMENSION;
|
||||
@@ -85,6 +91,7 @@ public final class LinkingService {
|
||||
if (destinationLevel == player.level() && !properties.has(LinkProperties.INTRA_LINKING))
|
||||
return LinkResult.INTRA_LINKING_REQUIRED;
|
||||
|
||||
boolean instant = !portal || destinationChunksLoaded(destinationLevel, target.location().pos());
|
||||
Optional<Vec3> destination = resolveDestination(destinationLevel, target.location().pos(), requiresFooting);
|
||||
if (destination.isEmpty()) {
|
||||
return LinkResult.NO_SAFE_DESTINATION;
|
||||
@@ -93,7 +100,7 @@ public final class LinkingService {
|
||||
Vec3 position = destination.get();
|
||||
Vec3 momentum = player.getDeltaMovement();
|
||||
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);
|
||||
player.resetFallDistance();
|
||||
return LinkResult.SUCCESS;
|
||||
@@ -115,22 +122,36 @@ public final class LinkingService {
|
||||
ServerPlayer player,
|
||||
ServerLevel destinationLevel,
|
||||
Vec3 position,
|
||||
LinkTarget target
|
||||
LinkTarget target,
|
||||
boolean instant
|
||||
) {
|
||||
if (ModList.get().isLoaded("immersive_portals_core")) {
|
||||
ImmersivePortalsChunkLoaders.close(player);
|
||||
ImmersivePortalsChunkLoaders.teleport(
|
||||
player, destinationLevel, position, target.yaw(), target.pitch());
|
||||
} else {
|
||||
player.teleportTo(
|
||||
destinationLevel,
|
||||
position.x,
|
||||
position.y,
|
||||
position.z,
|
||||
target.yaw(),
|
||||
target.pitch()
|
||||
);
|
||||
if (instant) {
|
||||
ImmersivePortalsChunkLoaders.teleport(
|
||||
player, destinationLevel, position, target.yaw(), target.pitch());
|
||||
return;
|
||||
}
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -16,7 +16,10 @@ import qouteall.q_misc_util.dimension.DimensionIntId;
|
||||
|
||||
/** Isolated server bridge; this class is loaded only when Immersive Portals is installed. */
|
||||
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() {
|
||||
}
|
||||
@@ -35,29 +38,40 @@ public final class ImmersivePortalsChunkLoaders {
|
||||
|
||||
ChunkLoader loader = new ChunkLoader(level.dimension(), centerChunkX, centerChunkZ, 8);
|
||||
PortalAPI.addChunkLoaderForPlayer(player, loader);
|
||||
LOADERS.put(player, loader);
|
||||
LOADERS.put(player, new PreviewLoader(loader, player.getServer().getTickCount() + MAX_PREVIEW_TICKS));
|
||||
ImmPtlChunkTracking.immediatelyUpdateForPlayer(player);
|
||||
}
|
||||
|
||||
public static synchronized void close(ServerPlayer player) {
|
||||
ChunkLoader loader = LOADERS.remove(player);
|
||||
if (loader != null) {
|
||||
PortalAPI.removeChunkLoaderForPlayer(player, loader);
|
||||
PreviewLoader preview = LOADERS.remove(player);
|
||||
if (preview != null) {
|
||||
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) {
|
||||
LOADERS.entrySet().removeIf(entry -> {
|
||||
if (!entry.getValue().dimension().equals(dimension)) {
|
||||
if (!entry.getValue().loader().dimension().equals(dimension)) {
|
||||
return false;
|
||||
}
|
||||
PortalAPI.removeChunkLoaderForPlayer(entry.getKey(), entry.getValue());
|
||||
PortalAPI.removeChunkLoaderForPlayer(entry.getKey(), entry.getValue().loader());
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public static synchronized void closeAll() {
|
||||
LOADERS.forEach(PortalAPI::removeChunkLoaderForPlayer);
|
||||
LOADERS.forEach((player, preview) -> PortalAPI.removeChunkLoaderForPlayer(player, preview.loader()));
|
||||
LOADERS.clear();
|
||||
}
|
||||
|
||||
|
||||
@@ -23,12 +23,12 @@ public final class ImmersivePortalsCrystalView {
|
||||
private ImmersivePortalsCrystalView() {}
|
||||
|
||||
public static void activate(ServerLevel source, BlockPos receptacle,
|
||||
CrystalPortalNetwork.Plan plan, LinkTarget target) {
|
||||
CrystalPortalNetwork.Plan plan, LinkTarget target, boolean requireFooting) {
|
||||
remove(source, receptacle);
|
||||
ServerLevel destinationLevel = source.getServer().getLevel(target.location().dimension());
|
||||
if (destinationLevel == null) return;
|
||||
// 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;
|
||||
Vec3 arrivalEye = arrival.get().add(0, 1.62, 0);
|
||||
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();
|
||||
}
|
||||
|
||||
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);
|
||||
return !source.getEntitiesOfClass(Portal.class,
|
||||
var views = source.getEntitiesOfClass(Portal.class,
|
||||
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,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package net.dnorberg.rivencraft.event;
|
||||
|
||||
import net.dnorberg.rivencraft.compat.ImmersivePortalsChunkLoaders;
|
||||
import net.dnorberg.rivencraft.portal.PortalTravelCooldown;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.neoforged.fml.ModList;
|
||||
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
||||
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. */
|
||||
public final class AgeLifecycleEvents {
|
||||
@@ -12,6 +14,7 @@ public final class AgeLifecycleEvents {
|
||||
}
|
||||
|
||||
public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) {
|
||||
if (event.getEntity() instanceof ServerPlayer player) PortalTravelCooldown.forget(player);
|
||||
if (event.getEntity() instanceof ServerPlayer player
|
||||
&& ModList.get().isLoaded("immersive_portals_core")) {
|
||||
ImmersivePortalsChunkLoaders.close(player);
|
||||
@@ -19,8 +22,14 @@ public final class AgeLifecycleEvents {
|
||||
}
|
||||
|
||||
public static void onServerStopping(ServerStoppingEvent event) {
|
||||
PortalTravelCooldown.clear();
|
||||
if (ModList.get().isLoaded("immersive_portals_core")) {
|
||||
ImmersivePortalsChunkLoaders.closeAll();
|
||||
}
|
||||
}
|
||||
|
||||
public static void onServerTickPost(ServerTickEvent.Post event) {
|
||||
if (ModList.get().isLoaded("immersive_portals_core"))
|
||||
ImmersivePortalsChunkLoaders.expire(event.getServer());
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,10 @@ import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.GlobalPos;
|
||||
import net.minecraft.gametest.framework.GameTest;
|
||||
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.level.block.Blocks;
|
||||
import net.neoforged.neoforge.gametest.GameTestHolder;
|
||||
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;
|
||||
|
||||
@@ -29,14 +32,17 @@ public final class CrystalPortalGameTests {
|
||||
public static void completeFrameActivatesAndReleasesBook(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) 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)),
|
||||
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);
|
||||
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");
|
||||
BookReceptacleBlockEntity holder = (BookReceptacleBlockEntity) level.getBlockEntity(holderPos);
|
||||
holder.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
|
||||
@@ -44,12 +50,34 @@ public final class CrystalPortalGameTests {
|
||||
holder.setDestination(destination);
|
||||
BookReceptacleBlockEntity restored = new BookReceptacleBlockEntity(holderPos, holder.getBlockState());
|
||||
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,
|
||||
"The portal destination must survive a save/reload cycle");
|
||||
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, plan), "Portal placement must succeed");
|
||||
BlockPos center = helper.absolutePos(new BlockPos(1, 2, 1));
|
||||
helper.assertTrue(level.getBlockEntity(center) instanceof CrystalPortalBlockEntity portal
|
||||
&& 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);
|
||||
@@ -57,6 +85,33 @@ public final class CrystalPortalGameTests {
|
||||
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) {
|
||||
return helper.absolutePos(new BlockPos(1, 2, 1));
|
||||
}
|
||||
@@ -65,7 +120,10 @@ public final class CrystalPortalGameTests {
|
||||
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) continue;
|
||||
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);
|
||||
}
|
||||
@@ -74,7 +132,7 @@ public final class CrystalPortalGameTests {
|
||||
.setValue(BookReceptacleBlock.FACING, Direction.NORTH), 3);
|
||||
((BookReceptacleBlockEntity) level.getBlockEntity(holderPos))
|
||||
.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
|
||||
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, CrystalPortalNetwork.plan(level, holderPos)),
|
||||
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,
|
||||
@@ -86,7 +144,10 @@ public final class CrystalPortalGameTests {
|
||||
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) continue;
|
||||
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);
|
||||
}
|
||||
@@ -95,7 +156,7 @@ public final class CrystalPortalGameTests {
|
||||
.setValue(BookReceptacleBlock.FACING, Direction.UP), 3);
|
||||
((BookReceptacleBlockEntity) level.getBlockEntity(holderPos))
|
||||
.setBook(new ItemStack(ModItems.LINKING_BOOK.get()));
|
||||
helper.assertTrue(CrystalPortalNetwork.activate(level, holderPos, CrystalPortalNetwork.plan(level, holderPos)),
|
||||
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,
|
||||
@@ -111,7 +172,7 @@ public final class CrystalPortalGameTests {
|
||||
ModBlocks.CRYSTAL_BLOCK.get().defaultBlockState(), 3);
|
||||
level.setBlock(holderPos, ModBlocks.BOOK_RECEPTACLE.get().defaultBlockState()
|
||||
.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");
|
||||
helper.succeed();
|
||||
}
|
||||
@@ -120,28 +181,89 @@ public final class CrystalPortalGameTests {
|
||||
public static void multiCellOpeningHasOneContinuousView(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
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)),
|
||||
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);
|
||||
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");
|
||||
((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,
|
||||
"Both cells must share one coherent Immersive Portals view");
|
||||
helper.succeed();
|
||||
}
|
||||
|
||||
@GameTest(template = "empty")
|
||||
public static void savedCooldownFromPreviousServerCannotBlockPortal(GameTestHelper helper) {
|
||||
helper.assertTrue(!PortalTravelCooldown.blocked(5, -1),
|
||||
"A player with no prior portal use must link immediately");
|
||||
helper.assertTrue(!PortalTravelCooldown.blocked(5, 12000),
|
||||
"A saved tick from the previous server session must not block travel");
|
||||
helper.assertTrue(PortalTravelCooldown.blocked(105, 100),
|
||||
"A recent portal use in the current session must still prevent loops");
|
||||
public static void largeOpeningSurvivesAtomicActivation(GameTestHelper helper) {
|
||||
var level = helper.getLevel();
|
||||
for (int x = 0; x < 5; x++) for (int y = 1; y < 6; y++) {
|
||||
if (x > 0 && x < 4 && y > 1 && y < 5) {
|
||||
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()));
|
||||
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();
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,9 @@ public final class LinkingBookItem extends Item {
|
||||
Level sourceLevel = player.level();
|
||||
BlockPos sourcePosition = player.blockPosition();
|
||||
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) {
|
||||
sourceLevel.playSound(null, sourcePosition, (portal ? ModSounds.LINK_PORTAL : ModSounds.LINK).get(), SoundSource.PLAYERS,
|
||||
0.8F, sourceLevel.random.nextFloat() * 0.2F + 0.9F);
|
||||
|
||||
@@ -14,8 +14,12 @@ import net.dnorberg.rivencraft.registry.ModBlocks;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
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.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. */
|
||||
public final class CrystalPortalNetwork {
|
||||
@@ -32,14 +36,22 @@ public final class 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 enum CellStatus { VALID, INVALID, WAITING_FOR_CHUNKS }
|
||||
|
||||
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);
|
||||
if (!holder.is(ModBlocks.BOOK_RECEPTACLE.get())) return new Plan(List.of());
|
||||
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());
|
||||
|
||||
Set<BlockPos> crystals = new HashSet<>();
|
||||
@@ -48,7 +60,7 @@ public final class CrystalPortalNetwork {
|
||||
while (!pendingCrystals.isEmpty()) {
|
||||
BlockPos pos = pendingCrystals.removeFirst();
|
||||
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 (atBoundary(base, pos)) return new Plan(List.of());
|
||||
if (crystals.size() > MAX_CRYSTALS) return new Plan(List.of());
|
||||
@@ -62,8 +74,9 @@ public final class CrystalPortalNetwork {
|
||||
while (!candidates.isEmpty()) {
|
||||
BlockPos pos = candidates.removeFirst();
|
||||
queued.remove(pos);
|
||||
if (!level.isInWorldBounds(pos) || !level.hasChunkAt(pos)) return new Plan(List.of());
|
||||
if (portals.contains(pos) || !mayFill(level, pos, receptacle)) continue;
|
||||
if (!level.isInWorldBounds(pos)) return new Plan(List.of());
|
||||
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;
|
||||
portals.add(pos);
|
||||
if (portals.size() > MAX_PORTALS) return new Plan(List.of());
|
||||
@@ -97,6 +110,28 @@ public final class CrystalPortalNetwork {
|
||||
}
|
||||
|
||||
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)
|
||||
|| holder.getBook().isEmpty()) return false;
|
||||
List<BlockPos> placed = new ArrayList<>();
|
||||
@@ -106,28 +141,39 @@ public final class CrystalPortalNetwork {
|
||||
return false;
|
||||
}
|
||||
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);
|
||||
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);
|
||||
}
|
||||
if (level.getBlockEntity(pos) instanceof CrystalPortalBlockEntity portal) {
|
||||
portal.setReceptacle(receptacle);
|
||||
level.scheduleTick(pos, ModBlocks.CRYSTAL_PORTAL.get(), 40);
|
||||
} else {
|
||||
rollback(level, placed);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// All cells are now present, so the same bracing rule used for the
|
||||
// Immersive Portals surface can orient the two-sided fallback model.
|
||||
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)
|
||||
level.setBlock(pos, state.setValue(CrystalPortalBlock.AXIS, axis), 2);
|
||||
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;
|
||||
@@ -148,13 +194,24 @@ public final class CrystalPortalNetwork {
|
||||
}
|
||||
|
||||
/** 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);
|
||||
if (!plan.valid()) {
|
||||
shutdown(level, receptacle);
|
||||
return;
|
||||
// Neighboring chunks can still be loading when the receptacle first
|
||||
// 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());
|
||||
// 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),
|
||||
receptacle.offset(CLEANUP_RADIUS, CLEANUP_RADIUS, CLEANUP_RADIUS))) {
|
||||
if (!level.hasChunkAt(pos)) continue;
|
||||
@@ -162,29 +219,64 @@ public final class CrystalPortalNetwork {
|
||||
&& receptacle.equals(portal.receptacle()) && !wanted.contains(pos))
|
||||
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
|
||||
|| Math.abs(receptacle.getY() - pos.getY()) > CLEANUP_RADIUS
|
||||
|| Math.abs(receptacle.getZ() - pos.getZ()) > CLEANUP_RADIUS
|
||||
|| !level.hasChunkAt(receptacle)
|
||||
|| !(level.getBlockEntity(receptacle) instanceof BookReceptacleBlockEntity holder)
|
||||
|| holder.getBook().isEmpty()) return false;
|
||||
|| Math.abs(receptacle.getZ() - pos.getZ()) > CLEANUP_RADIUS) return CellStatus.INVALID;
|
||||
if (!level.hasChunkAt(receptacle)) return CellStatus.WAITING_FOR_CHUNKS;
|
||||
BlockState state = level.getBlockState(receptacle);
|
||||
if (!state.is(ModBlocks.BOOK_RECEPTACLE.get()) || !level.getBlockState(receptacle.relative(
|
||||
state.getValue(BookReceptacleBlock.FACING).getOpposite())).is(ModBlocks.CRYSTAL_BLOCK.get()))
|
||||
return false;
|
||||
if (!state.is(ModBlocks.BOOK_RECEPTACLE.get())) return CellStatus.INVALID;
|
||||
BlockPos base = receptacle.relative(state.getValue(BookReceptacleBlock.FACING).getOpposite());
|
||||
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()) {
|
||||
// Quick tension check before the bounded network/path revalidation.
|
||||
if (!axisBraced(level, pos, axis)) continue;
|
||||
int other = 0;
|
||||
for (Direction.Axis alternative : Direction.Axis.values())
|
||||
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) {
|
||||
@@ -205,11 +297,15 @@ public final class CrystalPortalNetwork {
|
||||
}
|
||||
|
||||
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;
|
||||
BlockState state = level.getBlockState(pos);
|
||||
return state.isAir() || state.is(ModBlocks.CRYSTAL_PORTAL.get())
|
||||
&& 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) {
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
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 static final long TICKS = 40;
|
||||
private static final Map<ServerPlayer, Long> LAST_USE = new WeakHashMap<>();
|
||||
|
||||
private PortalTravelCooldown() {}
|
||||
|
||||
public static boolean blocked(long now, long previous) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,9 @@
|
||||
"rivencraft:book_binder",
|
||||
"rivencraft:ink_mixer",
|
||||
"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