Skip to content

Commit eb4e808

Browse files
committed
feat: allow sublevels to traverse dimensions
1 parent 76b67ea commit eb4e808

12 files changed

Lines changed: 692 additions & 19 deletions

File tree

common/src/main/java/dev/ryanhcode/sable/api/sublevel/ServerSubLevelContainer.java

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,13 +139,39 @@ public void removeSubLevel(final int x, final int z, final SubLevelRemovalReason
139139

140140
super.removeSubLevel(x, z, reason);
141141

142-
if (reason == SubLevelRemovalReason.REMOVED) {
142+
if (reason.clearsOccupancy()) {
143143
final ServerLevel level = this.getLevel();
144144
SubLevelOccupancySavedData.getOrLoad(level).setDirty();
145145
this.holdingChunkMap.queueDeletion(subLevel);
146146
}
147147
}
148148

149+
/**
150+
* Moves loading-ticket ownership to a replacement sub-level in another container.
151+
*/
152+
@ApiStatus.Internal
153+
public void transferTicketsTo(
154+
final ServerSubLevel source,
155+
final ServerSubLevelContainer destinationContainer,
156+
final ServerSubLevel destination) {
157+
final UUID uuid = source.getUniqueId();
158+
final SubLevelTicketInfo info = this.allTickets.remove(uuid);
159+
this.activeTickets.remove(source);
160+
161+
if (info == null) {
162+
return;
163+
}
164+
165+
info.setPointer(null);
166+
destinationContainer.allTickets.put(uuid, info);
167+
if (!info.tickets().isEmpty()) {
168+
destinationContainer.activeTickets.put(destination, new ObjectArraySet<>(info.tickets()));
169+
}
170+
171+
SubLevelTicketsSavedData.getOrLoad(this.getLevel()).setDirty();
172+
SubLevelTicketsSavedData.getOrLoad(destinationContainer.getLevel()).setDirty();
173+
}
174+
149175
@Override
150176
protected SubLevel createSubLevel(final int globalPlotX, final int globalPlotZ, final Pose3d pose, final UUID uuid) {
151177
final ServerLevel level = this.getLevel();

common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,13 +230,24 @@ public int getIndex(final int x, final int z) {
230230
* @return the allocated plot
231231
*/
232232
public SubLevel allocateNewSubLevel(final Pose3d pose) {
233+
return this.allocateNewSubLevel(UUID.randomUUID(), pose);
234+
}
235+
236+
/**
237+
* Allocates the first free plot for a sub-level with an existing persistent ID.
238+
*
239+
* @param uuid the persistent sub-level ID
240+
* @param pose the initial pose
241+
* @return the allocated sub-level
242+
*/
243+
public SubLevel allocateNewSubLevel(final UUID uuid, final Pose3d pose) {
233244
final Vector2i firstEmptyPlot = this.getFirstEmptyPlot();
234245

235246
if (firstEmptyPlot == null) {
236247
throw new IllegalStateException("No empty plots left in the plotgrid");
237248
}
238249

239-
return this.allocateSubLevel(UUID.randomUUID(), firstEmptyPlot.x, firstEmptyPlot.y, pose);
250+
return this.allocateSubLevel(uuid, firstEmptyPlot.x, firstEmptyPlot.y, pose);
240251
}
241252

242253
/**
@@ -492,7 +503,7 @@ public void removeSubLevel(final int x, final int z, final SubLevelRemovalReason
492503
this.allSubLevels.remove(subLevel);
493504
this.subLevelsByUUID.remove(subLevel.getUniqueId());
494505

495-
if (reason == SubLevelRemovalReason.REMOVED) {
506+
if (reason.clearsOccupancy()) {
496507
this.getOccupancy().clear(index);
497508
}
498509
}
@@ -529,6 +540,14 @@ public void removeSubLevel(final SubLevel subLevel, final SubLevelRemovalReason
529540
return this.subLevelsByUUID.get(uuid);
530541
}
531542

543+
/**
544+
* Notifies this container's observers that one sub-level instance replaced another.
545+
*/
546+
@ApiStatus.Internal
547+
public void notifySubLevelTransferred(final SubLevel source, final SubLevel destination) {
548+
this.observers.forEach(observer -> observer.onSubLevelTransferred(source, destination));
549+
}
550+
532551
/**
533552
* The occupancy of the plotgrid, including loaded and unloaded plots
534553
*/

common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelObserver.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@ default void onSubLevelAdded(final SubLevel subLevel) {
2424
default void onSubLevelRemoved(final SubLevel subLevel, final SubLevelRemovalReason reason) {
2525
}
2626

27+
/**
28+
* Called after a server sub-level has moved between level containers.
29+
* Implementations must replace references to {@code source} with {@code destination}.
30+
*
31+
* @param source the removed source instance
32+
* @param destination the replacement instance in the destination level
33+
*/
34+
default void onSubLevelTransferred(final SubLevel source, final SubLevel destination) {
35+
}
36+
2737
/**
2838
* Called every tick for each {@link SubLevelContainer}.
2939
*

common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelTicketLoadingSystem.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public void onSubLevelRemoved(final SubLevel subLevel, final SubLevelRemovalReas
4242
if (info != null) {
4343
info.setPointer(serverSubLevel.getLastSerializationPointer());
4444
}
45-
} else if (reason == SubLevelRemovalReason.REMOVED) {
45+
} else if (reason == SubLevelRemovalReason.REMOVED || reason == SubLevelRemovalReason.TRANSFERRED) {
4646
this.container.allTickets.remove(uuid);
4747
this.container.activeTickets.remove(serverSubLevel);
4848
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package dev.ryanhcode.sable.api.sublevel;
2+
3+
import dev.ryanhcode.sable.sublevel.ServerSubLevel;
4+
5+
import java.util.Map;
6+
import java.util.UUID;
7+
8+
/**
9+
* The replacement instances produced by a successful cross-level transfer.
10+
* Persistent UUIDs are preserved, but runtime IDs and Java object identities change.
11+
*
12+
* @param root replacement for the requested root sub-level
13+
* @param replacements all replacements indexed by persistent UUID
14+
*/
15+
public record SubLevelTransferResult(ServerSubLevel root, Map<UUID, ServerSubLevel> replacements) {
16+
public SubLevelTransferResult {
17+
replacements = Map.copyOf(replacements);
18+
}
19+
}
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
package dev.ryanhcode.sable.api.sublevel;
2+
3+
import dev.ryanhcode.sable.api.SubLevelHelper;
4+
import dev.ryanhcode.sable.companion.math.Pose3d;
5+
import dev.ryanhcode.sable.companion.math.Pose3dc;
6+
import dev.ryanhcode.sable.sublevel.ServerSubLevel;
7+
import dev.ryanhcode.sable.sublevel.SubLevel;
8+
import dev.ryanhcode.sable.sublevel.storage.SubLevelRemovalReason;
9+
import dev.ryanhcode.sable.sublevel.storage.serialization.SubLevelData;
10+
import dev.ryanhcode.sable.sublevel.storage.serialization.SubLevelSerializer;
11+
import dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem;
12+
import dev.ryanhcode.sable.sublevel.tracking_points.SubLevelTrackingPointSavedData;
13+
import net.minecraft.server.MinecraftServer;
14+
import net.minecraft.server.level.ServerLevel;
15+
import net.minecraft.world.entity.Entity;
16+
import net.minecraft.world.level.portal.DimensionTransition;
17+
import net.minecraft.world.phys.Vec3;
18+
import org.joml.Quaterniond;
19+
import org.joml.Vector3d;
20+
21+
import java.util.ArrayList;
22+
import java.util.LinkedHashMap;
23+
import java.util.List;
24+
import java.util.Map;
25+
import java.util.UUID;
26+
27+
/**
28+
* Atomically replaces a group of loaded server sub-levels with equivalent instances in another level.
29+
*/
30+
public final class SubLevelTransferService {
31+
private SubLevelTransferService() {
32+
}
33+
34+
/**
35+
* Transfers a sub-level and its complete loading-dependency chain to another level.
36+
* The destination root pose defines the rigid transform applied to every body in the chain.
37+
* This method must run on the server thread and outside a physics step.
38+
*
39+
* @param root root sub-level to transfer
40+
* @param destinationLevel destination parent level
41+
* @param destinationRootPose desired pose of the root in the destination level
42+
* @return all replacement sub-level instances
43+
*/
44+
public static SubLevelTransferResult transfer(
45+
final ServerSubLevel root,
46+
final ServerLevel destinationLevel,
47+
final Pose3dc destinationRootPose) {
48+
validateRequest(root, destinationLevel);
49+
50+
final ServerLevel sourceLevel = root.getLevel();
51+
final ServerSubLevelContainer sourceContainer = requireContainer(sourceLevel);
52+
final ServerSubLevelContainer destinationContainer = requireContainer(destinationLevel);
53+
final List<ServerSubLevel> sources = collectSources(root, sourceLevel, destinationContainer);
54+
final List<UUID> dependencyIds = sources.stream().map(SubLevel::getUniqueId).toList();
55+
final Map<UUID, SubLevelData> snapshots = new LinkedHashMap<>();
56+
final Map<UUID, Pose3d> destinationPoses = new LinkedHashMap<>();
57+
58+
final Pose3dc sourceRootPose = root.logicalPose();
59+
final Quaterniond worldRotation = new Quaterniond(destinationRootPose.orientation())
60+
.mul(new Quaterniond(sourceRootPose.orientation()).conjugate());
61+
62+
for (final ServerSubLevel source : sources) {
63+
snapshots.put(source.getUniqueId(), SubLevelSerializer.toData(source, dependencyIds));
64+
destinationPoses.put(source.getUniqueId(), transformPose(
65+
source.logicalPose(), sourceRootPose, destinationRootPose, worldRotation));
66+
}
67+
68+
final Map<UUID, ServerSubLevel> replacements = new LinkedHashMap<>();
69+
final List<TransferredEntity> transferredEntities = new ArrayList<>();
70+
try {
71+
for (final ServerSubLevel source : sources) {
72+
final UUID uuid = source.getUniqueId();
73+
final ServerSubLevel replacement = SubLevelSerializer.fullyLoadForTransfer(
74+
destinationLevel,
75+
snapshots.get(uuid),
76+
destinationPoses.get(uuid),
77+
worldRotation);
78+
if (replacement == null) {
79+
throw new IllegalStateException("Unable to allocate destination sub-level " + uuid);
80+
}
81+
replacements.put(uuid, replacement);
82+
}
83+
transferPlotEntities(sources, replacements, destinationLevel, transferredEntities);
84+
} catch (final RuntimeException exception) {
85+
rollbackEntities(transferredEntities);
86+
rollback(destinationContainer, replacements);
87+
throw exception;
88+
}
89+
90+
for (final ServerSubLevel source : sources) {
91+
sourceContainer.transferTicketsTo(source, destinationContainer, replacements.get(source.getUniqueId()));
92+
SubLevelTrackingPointSavedData.transferSubLevelPoints(source, replacements.get(source.getUniqueId()));
93+
}
94+
for (final ServerSubLevel source : sources) {
95+
sourceContainer.removeSubLevel(source, SubLevelRemovalReason.TRANSFERRED);
96+
}
97+
for (final ServerSubLevel source : sources) {
98+
final ServerSubLevel replacement = replacements.get(source.getUniqueId());
99+
sourceContainer.notifySubLevelTransferred(source, replacement);
100+
destinationContainer.notifySubLevelTransferred(source, replacement);
101+
}
102+
103+
return new SubLevelTransferResult(replacements.get(root.getUniqueId()), replacements);
104+
}
105+
106+
private static void validateRequest(final ServerSubLevel root, final ServerLevel destinationLevel) {
107+
if (root.isRemoved()) {
108+
throw new IllegalArgumentException("Cannot transfer a removed sub-level");
109+
}
110+
111+
final ServerLevel sourceLevel = root.getLevel();
112+
final MinecraftServer server = sourceLevel.getServer();
113+
if (server != destinationLevel.getServer()) {
114+
throw new IllegalArgumentException("Source and destination levels belong to different servers");
115+
}
116+
if (sourceLevel == destinationLevel) {
117+
throw new IllegalArgumentException("Source and destination levels must be different");
118+
}
119+
if (!server.isSameThread()) {
120+
throw new IllegalStateException("Sub-level transfer must run on the server thread");
121+
}
122+
if (SubLevelPhysicsSystem.IN_PHYSICS_STEP) {
123+
throw new IllegalStateException("Sub-level transfer cannot run during a physics step");
124+
}
125+
}
126+
127+
private static ServerSubLevelContainer requireContainer(final ServerLevel level) {
128+
final ServerSubLevelContainer container = SubLevelContainer.getContainer(level);
129+
if (container == null) {
130+
throw new IllegalStateException("Level " + level.dimension().location() + " has no sub-level container");
131+
}
132+
return container;
133+
}
134+
135+
private static List<ServerSubLevel> collectSources(
136+
final ServerSubLevel root,
137+
final ServerLevel sourceLevel,
138+
final ServerSubLevelContainer destinationContainer) {
139+
final List<ServerSubLevel> sources = new ArrayList<>();
140+
for (final ServerSubLevel source : SubLevelHelper.getLoadingDependencyChain(root)) {
141+
if (source.getLevel() != sourceLevel) {
142+
throw new IllegalStateException("A loading dependency already belongs to another level: " + source.getUniqueId());
143+
}
144+
if (destinationContainer.getSubLevel(source.getUniqueId()) != null) {
145+
throw new IllegalStateException("Destination already contains sub-level " + source.getUniqueId());
146+
}
147+
sources.add(source);
148+
}
149+
return sources;
150+
}
151+
152+
private static Pose3d transformPose(
153+
final Pose3dc source,
154+
final Pose3dc sourceRoot,
155+
final Pose3dc destinationRoot,
156+
final Quaterniond worldRotation) {
157+
final Vector3d transformedPosition = sourceRoot.transformPositionInverse(new Vector3d(source.position()));
158+
destinationRoot.transformPosition(transformedPosition);
159+
160+
final Pose3d transformed = new Pose3d(source);
161+
transformed.position().set(transformedPosition);
162+
transformed.orientation().set(worldRotation).mul(source.orientation());
163+
return transformed;
164+
}
165+
166+
private static void rollback(
167+
final ServerSubLevelContainer destinationContainer,
168+
final Map<UUID, ServerSubLevel> replacements) {
169+
final List<ServerSubLevel> reverseOrder = new ArrayList<>(replacements.values());
170+
for (int i = reverseOrder.size() - 1; i >= 0; i--) {
171+
final ServerSubLevel replacement = reverseOrder.get(i);
172+
if (!replacement.isRemoved()) {
173+
destinationContainer.removeSubLevel(replacement, SubLevelRemovalReason.REMOVED);
174+
}
175+
}
176+
}
177+
178+
private static void transferPlotEntities(
179+
final List<ServerSubLevel> sources,
180+
final Map<UUID, ServerSubLevel> replacements,
181+
final ServerLevel destinationLevel,
182+
final List<TransferredEntity> transferredEntities) {
183+
for (final ServerSubLevel source : sources) {
184+
final ServerSubLevel replacement = replacements.get(source.getUniqueId());
185+
final BlockPosOffset offset = BlockPosOffset.between(
186+
source.getPlot().getCenterBlock(), replacement.getPlot().getCenterBlock());
187+
188+
for (final Entity entity : source.getPlot().collectRootEntities()) {
189+
final Vec3 sourcePosition = entity.position();
190+
final Vec3 sourceVelocity = entity.getDeltaMovement();
191+
final float sourceYRot = entity.getYRot();
192+
final float sourceXRot = entity.getXRot();
193+
final Vec3 destinationPosition = sourcePosition.add(offset.x(), offset.y(), offset.z());
194+
final Entity destinationEntity = entity.changeDimension(new DimensionTransition(
195+
destinationLevel,
196+
destinationPosition,
197+
sourceVelocity,
198+
sourceYRot,
199+
sourceXRot,
200+
DimensionTransition.DO_NOTHING));
201+
if (destinationEntity == null) {
202+
throw new IllegalStateException("Entity " + entity.getUUID() + " rejected sub-level transfer");
203+
}
204+
transferredEntities.add(new TransferredEntity(
205+
destinationEntity,
206+
source.getLevel(),
207+
sourcePosition,
208+
sourceVelocity,
209+
sourceYRot,
210+
sourceXRot));
211+
}
212+
}
213+
}
214+
215+
private static void rollbackEntities(final List<TransferredEntity> transferredEntities) {
216+
for (int i = transferredEntities.size() - 1; i >= 0; i--) {
217+
final TransferredEntity transfer = transferredEntities.get(i);
218+
transfer.entity().changeDimension(new DimensionTransition(
219+
transfer.sourceLevel(),
220+
transfer.sourcePosition(),
221+
transfer.sourceVelocity(),
222+
transfer.yRot(),
223+
transfer.xRot(),
224+
DimensionTransition.DO_NOTHING));
225+
}
226+
}
227+
228+
private record BlockPosOffset(int x, int y, int z) {
229+
private static BlockPosOffset between(
230+
final net.minecraft.core.BlockPos source,
231+
final net.minecraft.core.BlockPos destination) {
232+
return new BlockPosOffset(
233+
destination.getX() - source.getX(),
234+
destination.getY() - source.getY(),
235+
destination.getZ() - source.getZ());
236+
}
237+
}
238+
239+
private record TransferredEntity(
240+
Entity entity,
241+
ServerLevel sourceLevel,
242+
Vec3 sourcePosition,
243+
Vec3 sourceVelocity,
244+
float yRot,
245+
float xRot) {
246+
}
247+
}

0 commit comments

Comments
 (0)