Files
hytale-gravity-flip/src/main/java/com/mythlane/gravityflip/physics/GravityApplier.java
T
kayjaydee ef3f398c55 feat(03-01): add GravityApplier with native PhysicsValues.invertedGravity toggle (Task 1)
- 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)
2026-04-23 10:31:43 +02:00

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);
}
}