ef3f398c55
- Service GravityApplier qui toggle PhysicsValues.invertedGravity via CommandBuffer.replaceComponent - Thread-safe tracker ConcurrentHashMap.newKeySet<UUID> pour dedup entre ticks - Second pass O(N) pour restaurer la gravité à la sortie de région (trade-off v1 documenté) - Pure diff static helper + hooks package-private pour tests unitaires sans runtime Hytale - 6 tests verts (diff + tracker semantics)
189 lines
8.8 KiB
Java
189 lines
8.8 KiB
Java
package com.mythlane.gravityflip.physics;
|
|
|
|
import com.hypixel.hytale.component.ComponentType;
|
|
import com.hypixel.hytale.component.Ref;
|
|
import com.hypixel.hytale.component.Store;
|
|
import com.hypixel.hytale.server.core.entity.UUIDComponent;
|
|
import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent;
|
|
import com.hypixel.hytale.server.core.modules.physics.component.PhysicsValues;
|
|
import com.hypixel.hytale.server.core.universe.world.World;
|
|
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
|
import com.mythlane.gravityflip.region.GravityFlipRegion;
|
|
import com.mythlane.gravityflip.region.RegionSnapshot;
|
|
|
|
import java.util.Collection;
|
|
import java.util.Collections;
|
|
import java.util.HashSet;
|
|
import java.util.Set;
|
|
import java.util.UUID;
|
|
import java.util.concurrent.ConcurrentHashMap;
|
|
import java.util.function.Consumer;
|
|
|
|
/**
|
|
* Tick-driven service that toggles the native {@code PhysicsValues.invertedGravity} flag on every
|
|
* entity present in an enabled {@link GravityFlipRegion}. Mutations are queued via
|
|
* {@code CommandBuffer.replaceComponent(...)} inside a {@code Store.forEachEntityParallel(...)}
|
|
* lambda — the ECS engine commits them on the main thread after the parallel pass.
|
|
*
|
|
* <p>Dedup: a thread-safe {@link Set}{@code <UUID>} tracks entities flipped at the previous tick.
|
|
* Entities that left an active region are restored to {@code invertedGravity=false} in a second
|
|
* pass (trade-off documented in the plan — second O(N) scan accepted v1).
|
|
*/
|
|
public final class GravityApplier {
|
|
// Lazy ComponentType holders — pattern identique à RegionRegistry.transform()
|
|
// (évite static-init Hytale PluginBase pendant les tests).
|
|
private static volatile ComponentType<EntityStore, PhysicsValues> physicsType;
|
|
private static volatile ComponentType<EntityStore, UUIDComponent> uuidType;
|
|
private static volatile ComponentType<EntityStore, TransformComponent> transformType;
|
|
|
|
private static ComponentType<EntityStore, PhysicsValues> physicsType() {
|
|
ComponentType<EntityStore, PhysicsValues> t = physicsType;
|
|
if (t == null) {
|
|
synchronized (GravityApplier.class) {
|
|
t = physicsType;
|
|
if (t == null) { t = PhysicsValues.getComponentType(); physicsType = t; }
|
|
}
|
|
}
|
|
return t;
|
|
}
|
|
private static ComponentType<EntityStore, UUIDComponent> uuidType() {
|
|
ComponentType<EntityStore, UUIDComponent> t = uuidType;
|
|
if (t == null) {
|
|
synchronized (GravityApplier.class) {
|
|
t = uuidType;
|
|
if (t == null) { t = UUIDComponent.getComponentType(); uuidType = t; }
|
|
}
|
|
}
|
|
return t;
|
|
}
|
|
private static ComponentType<EntityStore, TransformComponent> transformType() {
|
|
ComponentType<EntityStore, TransformComponent> t = transformType;
|
|
if (t == null) {
|
|
synchronized (GravityApplier.class) {
|
|
t = transformType;
|
|
if (t == null) { t = TransformComponent.getComponentType(); transformType = t; }
|
|
}
|
|
}
|
|
return t;
|
|
}
|
|
|
|
// THREADING: écrit/lu depuis les workers ECS via forEachEntityParallel → ConcurrentHashMap.newKeySet obligatoire.
|
|
private final Set<UUID> previouslyInverted = ConcurrentHashMap.newKeySet();
|
|
private final Consumer<Throwable> errorHandler;
|
|
|
|
public GravityApplier(Consumer<Throwable> errorHandler) {
|
|
this.errorHandler = errorHandler == null ? t -> {} : errorHandler;
|
|
}
|
|
|
|
/** Tick entry point. NO-OP si world ou snapshot est null. */
|
|
public void apply(World world, RegionSnapshot snapshot) {
|
|
if (world == null || snapshot == null) return;
|
|
Collection<GravityFlipRegion> enabledRegions = snapshot.byRegion().keySet();
|
|
// Stratégie figée (cf. WARNING 1 résolu) : on accepte la duplication du containsPosition
|
|
// (cf. must_haves trade-off) plutôt que de coupler à un nouveau contrat RegionSnapshot
|
|
// qui exposerait des UUIDs préformés. Coût : 2 pass O(N) sur le store par tick.
|
|
|
|
try {
|
|
Store<EntityStore> store = world.getEntityStore().getStore();
|
|
ComponentType<EntityStore, PhysicsValues> PHYST = physicsType();
|
|
ComponentType<EntityStore, UUIDComponent> UUIDT = uuidType();
|
|
ComponentType<EntityStore, TransformComponent> TT = transformType();
|
|
|
|
// PASS 1 — pour chaque entité avec PhysicsValues : si dans une région activée, queue le flip ON
|
|
// via cmdBuf.replaceComponent ET enregistre l'UUID dans currentlyInRegion (thread-safe).
|
|
// THREADING: lambda sur worker ECS → currentlyInRegion = ConcurrentHashMap.newKeySet ; toutes les
|
|
// mutations PhysicsValues passent par cmdBuf (jamais replaceValues direct).
|
|
Set<UUID> currentlyInRegion = ConcurrentHashMap.newKeySet();
|
|
store.forEachEntityParallel(PHYST, (index, chunk, cmdBuf) -> {
|
|
TransformComponent t;
|
|
UUIDComponent uc;
|
|
PhysicsValues v;
|
|
try {
|
|
t = chunk.getComponent(index, TT);
|
|
uc = chunk.getComponent(index, UUIDT);
|
|
v = chunk.getComponent(index, PHYST);
|
|
} catch (Throwable ignored) { return; }
|
|
if (t == null || uc == null || v == null) return;
|
|
|
|
com.hypixel.hytale.math.vector.Vector3d pos = t.getPosition();
|
|
double x = pos.x, y = pos.y, z = pos.z;
|
|
|
|
boolean inAnyRegion = false;
|
|
for (GravityFlipRegion r : enabledRegions) {
|
|
if (r.asBox().containsPosition(x, y, z)) { inAnyRegion = true; break; }
|
|
}
|
|
if (!inAnyRegion) return;
|
|
|
|
UUID u = uc.getUuid();
|
|
currentlyInRegion.add(u);
|
|
if (!v.isInvertedGravity()) {
|
|
Ref<EntityStore> ref = chunk.getReferenceTo(index);
|
|
cmdBuf.replaceComponent(ref, PHYST,
|
|
new PhysicsValues(v.getMass(), v.getDragCoefficient(), true));
|
|
}
|
|
});
|
|
|
|
// PASS 2 — restore : pour chaque UUID dans previouslyInverted \ currentlyInRegion,
|
|
// re-localiser l'entité (second scan) et queue le flip OFF via cmdBuf.
|
|
// Trade-off perf accepté v1 — voir SUMMARY follow-up.
|
|
Set<UUID> toRestore = ConcurrentHashMap.newKeySet();
|
|
toRestore.addAll(previouslyInverted);
|
|
toRestore.removeAll(currentlyInRegion);
|
|
if (!toRestore.isEmpty()) {
|
|
store.forEachEntityParallel(PHYST, (index, chunk, cmdBuf) -> {
|
|
UUIDComponent uc;
|
|
PhysicsValues v;
|
|
try {
|
|
uc = chunk.getComponent(index, UUIDT);
|
|
v = chunk.getComponent(index, PHYST);
|
|
} catch (Throwable ignored) { return; }
|
|
if (uc == null || v == null) return;
|
|
if (toRestore.contains(uc.getUuid()) && v.isInvertedGravity()) {
|
|
Ref<EntityStore> ref = chunk.getReferenceTo(index);
|
|
cmdBuf.replaceComponent(ref, PHYST,
|
|
new PhysicsValues(v.getMass(), v.getDragCoefficient(), false));
|
|
}
|
|
});
|
|
}
|
|
|
|
// Update tracker — ces ops sont sur le tick thread après la fin du pass parallel.
|
|
// ConcurrentHashMap.newKeySet supporte clear()/addAll() concurrents safely.
|
|
previouslyInverted.clear();
|
|
previouslyInverted.addAll(currentlyInRegion);
|
|
} catch (Throwable th) {
|
|
errorHandler.accept(th);
|
|
}
|
|
}
|
|
|
|
/** Pure data-diff utilitaire pour tests unitaires (pas de runtime Hytale). */
|
|
public static DiffResult diff(Set<UUID> previous, Set<UUID> current) {
|
|
Set<UUID> toFlip = new HashSet<>(current);
|
|
toFlip.removeAll(previous);
|
|
Set<UUID> toRestore = new HashSet<>(previous);
|
|
toRestore.removeAll(current);
|
|
return new DiffResult(toFlip, toRestore);
|
|
}
|
|
|
|
public static final class DiffResult {
|
|
public final Set<UUID> toFlip;
|
|
public final Set<UUID> toRestore;
|
|
DiffResult(Set<UUID> f, Set<UUID> r) { this.toFlip = f; this.toRestore = r; }
|
|
}
|
|
|
|
// ---- Test hooks (package-private) ----
|
|
|
|
/** Vue immuable du tracker — pour tests unitaires sémantiques (résolution WARNING 2). */
|
|
Set<UUID> previouslyInvertedView() {
|
|
return Collections.unmodifiableSet(previouslyInverted);
|
|
}
|
|
|
|
/**
|
|
* Force une valeur du tracker hors runtime ECS — pour tester la sémantique sans dépendre de World/Store.
|
|
* Package-private : NE PAS appeler depuis le code de production.
|
|
*/
|
|
void __updateTrackerForTest(Set<UUID> newState) {
|
|
previouslyInverted.clear();
|
|
previouslyInverted.addAll(newState);
|
|
}
|
|
}
|