diff --git a/src/main/java/com/mythlane/gravityflip/physics/GravityApplier.java b/src/main/java/com/mythlane/gravityflip/physics/GravityApplier.java
index eb7a375..bca39fb 100644
--- a/src/main/java/com/mythlane/gravityflip/physics/GravityApplier.java
+++ b/src/main/java/com/mythlane/gravityflip/physics/GravityApplier.java
@@ -1,13 +1,21 @@
package com.mythlane.gravityflip.physics;
+import com.hypixel.hytale.component.ArchetypeChunk;
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.entity.entities.Player;
+import com.hypixel.hytale.server.core.entity.entities.player.movement.MovementManager;
+import com.hypixel.hytale.server.core.io.PacketHandler;
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.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.World;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
+import com.hypixel.hytale.server.npc.entities.NPCEntity;
+import com.hypixel.hytale.server.npc.movement.controllers.MotionController;
+import com.hypixel.hytale.server.npc.role.Role;
import com.mythlane.gravityflip.region.GravityFlipRegion;
import com.mythlane.gravityflip.region.RegionSnapshot;
@@ -25,6 +33,15 @@ import java.util.function.Consumer;
* {@code CommandBuffer.replaceComponent(...)} inside a {@code Store.forEachEntityParallel(...)}
* lambda — the ECS engine commits them on the main thread after the parallel pass.
*
+ *
Phase 03-02: in addition to the ECS toggle (still required for items, consumed by
+ * {@code ItemPrePhysicsSystem}), we wake up the per-entity cached movement settings:
+ *
+ * - Players: {@code MovementManager.setDefaultSettings + applyDefaultSettings + update(packetHandler)}
+ * — sends the {@code UpdateMovementSettings} packet (ID 110) to the client.
+ * - NPCs: {@code Role.getActiveMotionController().updatePhysicsValues(PhysicsValues)}
+ * — re-applies {@code MovementManager.MASTER_DEFAULT.apply} on the cached settings.
+ *
+ *
* Dedup: a thread-safe {@link Set}{@code } 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).
@@ -35,6 +52,10 @@ public final class GravityApplier {
private static volatile ComponentType physicsType;
private static volatile ComponentType uuidType;
private static volatile ComponentType transformType;
+ private static volatile ComponentType movementManagerType;
+ private static volatile ComponentType playerRefType;
+ private static volatile ComponentType playerType;
+ private static volatile ComponentType npcEntityType;
private static ComponentType physicsType() {
ComponentType t = physicsType;
@@ -66,6 +87,46 @@ public final class GravityApplier {
}
return t;
}
+ private static ComponentType movementManagerType() {
+ ComponentType t = movementManagerType;
+ if (t == null) {
+ synchronized (GravityApplier.class) {
+ t = movementManagerType;
+ if (t == null) { t = MovementManager.getComponentType(); movementManagerType = t; }
+ }
+ }
+ return t;
+ }
+ private static ComponentType playerRefType() {
+ ComponentType t = playerRefType;
+ if (t == null) {
+ synchronized (GravityApplier.class) {
+ t = playerRefType;
+ if (t == null) { t = PlayerRef.getComponentType(); playerRefType = t; }
+ }
+ }
+ return t;
+ }
+ private static ComponentType playerType() {
+ ComponentType t = playerType;
+ if (t == null) {
+ synchronized (GravityApplier.class) {
+ t = playerType;
+ if (t == null) { t = Player.getComponentType(); playerType = t; }
+ }
+ }
+ return t;
+ }
+ private static ComponentType npcEntityType() {
+ ComponentType t = npcEntityType;
+ if (t == null) {
+ synchronized (GravityApplier.class) {
+ t = npcEntityType;
+ if (t == null) { t = NPCEntity.getComponentType(); npcEntityType = t; }
+ }
+ }
+ return t;
+ }
// THREADING: écrit/lu depuis les workers ECS via forEachEntityParallel → ConcurrentHashMap.newKeySet obligatoire.
private final Set previouslyInverted = ConcurrentHashMap.newKeySet();
@@ -75,6 +136,35 @@ public final class GravityApplier {
this.errorHandler = errorHandler == null ? t -> {} : errorHandler;
}
+ /**
+ * Construit un nouveau {@link PhysicsValues} en copiant mass/drag de la source et en fixant
+ * {@code invertedGravity=target}. Pure data — pas d'effet de bord.
+ *
+ * NOTE test : le constructeur statique de {@link PhysicsValues} dépend du runtime Hytale
+ * (ModuleRegistry / PluginBase) et échoue hors serveur. La décomposition pure est exposée par
+ * {@link #buildFlaggedDecision(double, double, boolean)} pour les tests unitaires — Rule 3
+ * fallback documenté dans le plan 03-02 (section Task 1 action).
+ */
+ static PhysicsValues buildPhysicsValuesWithFlag(PhysicsValues source, boolean target) {
+ FlaggedDecision d = buildFlaggedDecision(source.getMass(), source.getDragCoefficient(), target);
+ return new PhysicsValues(d.mass, d.drag, d.invertedGravity);
+ }
+
+ /** Pure-data seam pour tests unitaires (aucune dépendance sur PhysicsValues). */
+ static FlaggedDecision buildFlaggedDecision(double mass, double drag, boolean target) {
+ return new FlaggedDecision(mass, drag, target);
+ }
+
+ /** Holder pure-data pour la décomposition testable de {@link #buildPhysicsValuesWithFlag}. */
+ static final class FlaggedDecision {
+ final double mass;
+ final double drag;
+ final boolean invertedGravity;
+ FlaggedDecision(double mass, double drag, boolean invertedGravity) {
+ this.mass = mass; this.drag = drag; this.invertedGravity = invertedGravity;
+ }
+ }
+
/** Tick entry point. NO-OP si world ou snapshot est null. */
public void apply(World world, RegionSnapshot snapshot) {
if (world == null || snapshot == null) return;
@@ -88,20 +178,19 @@ public final class GravityApplier {
private void applyOnWorldThread(World world, RegionSnapshot snapshot) {
Collection 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 store = world.getEntityStore().getStore();
ComponentType PHYST = physicsType();
ComponentType UUIDT = uuidType();
ComponentType TT = transformType();
+ ComponentType MMT = movementManagerType();
+ ComponentType PRT = playerRefType();
+ ComponentType PLT = playerType();
+ ComponentType NPCT = npcEntityType();
// 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).
+ // via cmdBuf.replaceComponent ET wake-up MovementManager / MotionController.
Set currentlyInRegion = ConcurrentHashMap.newKeySet();
store.forEachEntityParallel(PHYST, (index, chunk, cmdBuf) -> {
TransformComponent t;
@@ -125,16 +214,21 @@ public final class GravityApplier {
UUID u = uc.getUuid();
currentlyInRegion.add(u);
+
+ // --- existant (plan 03-01) : flip ECS native ---
if (!v.isInvertedGravity()) {
Ref ref = chunk.getReferenceTo(index);
cmdBuf.replaceComponent(ref, PHYST,
new PhysicsValues(v.getMass(), v.getDragCoefficient(), true));
}
+
+ // --- NOUVEAU (plan 03-02) : wake-up joueur/NPC dans le même lambda ---
+ wakePlayerOrNpc(chunk, index, v, true, MMT, PRT, PLT, NPCT);
});
// 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.
+ // re-localiser l'entité (second scan) et queue le flip OFF via cmdBuf +
+ // wake-up joueur/NPC avec flag=false.
Set toRestore = ConcurrentHashMap.newKeySet();
toRestore.addAll(previouslyInverted);
toRestore.removeAll(currentlyInRegion);
@@ -147,16 +241,20 @@ public final class GravityApplier {
v = chunk.getComponent(index, PHYST);
} catch (Throwable ignored) { return; }
if (uc == null || v == null) return;
- if (toRestore.contains(uc.getUuid()) && v.isInvertedGravity()) {
+ if (!toRestore.contains(uc.getUuid())) return;
+
+ if (v.isInvertedGravity()) {
Ref ref = chunk.getReferenceTo(index);
cmdBuf.replaceComponent(ref, PHYST,
new PhysicsValues(v.getMass(), v.getDragCoefficient(), false));
}
+
+ // --- NOUVEAU (plan 03-02) : wake-up joueur/NPC avec flag=false ---
+ wakePlayerOrNpc(chunk, index, v, false, MMT, PRT, PLT, NPCT);
});
}
// 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) {
@@ -164,6 +262,63 @@ public final class GravityApplier {
}
}
+ /**
+ * Wake-up dans le MÊME lambda parallèle :
+ * - joueur (MovementManager + PlayerRef + Player) → setDefaultSettings + applyDefaultSettings + update(ph)
+ * - NPC (NPCEntity avec role non-null et active controller non-null) → updatePhysicsValues(targetValues)
+ * - sinon (items, autres) : no-op (le flip cmdBuf.replaceComponent du pass 1 suffit)
+ *
+ * On NE relit PAS le PhysicsValues via l'ECS — le cmdBuf n'est pas encore commit. On construit
+ * localement le PhysicsValues cible via {@link #buildPhysicsValuesWithFlag}.
+ */
+ private void wakePlayerOrNpc(
+ ArchetypeChunk chunk, int index,
+ PhysicsValues sourceValues, boolean targetFlag,
+ ComponentType MMT,
+ ComponentType PRT,
+ ComponentType PLT,
+ ComponentType NPCT) {
+ PhysicsValues targetValues = buildPhysicsValuesWithFlag(sourceValues, targetFlag);
+
+ // --- Branche joueur ---
+ MovementManager mm = null;
+ try { mm = chunk.getComponent(index, MMT); } catch (Throwable ignored) {}
+ if (mm != null) {
+ PlayerRef pr = null; Player pl = null;
+ try { pr = chunk.getComponent(index, PRT); } catch (Throwable ignored) {}
+ try { pl = chunk.getComponent(index, PLT); } catch (Throwable ignored) {}
+ if (pr != null && pl != null) {
+ try {
+ mm.setDefaultSettings(mm.getDefaultSettings(), targetValues, pl.getGameMode());
+ mm.applyDefaultSettings();
+ PacketHandler ph = pr.getPacketHandler();
+ mm.update(ph);
+ } catch (Throwable th) {
+ errorHandler.accept(th);
+ }
+ return; // un joueur n'est pas un NPC — court-circuit
+ }
+ }
+
+ // --- Branche NPC ---
+ NPCEntity npc = null;
+ try { npc = chunk.getComponent(index, NPCT); } catch (Throwable ignored) {}
+ if (npc != null) {
+ try {
+ Role role = npc.getRole();
+ if (role != null) {
+ MotionController active = role.getActiveMotionController();
+ if (active != null) {
+ active.updatePhysicsValues(targetValues);
+ }
+ }
+ } catch (Throwable th) {
+ errorHandler.accept(th);
+ }
+ }
+ // sinon : item / autre — pas de wake-up, le cmdBuf.replaceComponent du pass 1 suffit
+ }
+
/** Pure data-diff utilitaire pour tests unitaires (pas de runtime Hytale). */
public static DiffResult diff(Set previous, Set current) {
Set toFlip = new HashSet<>(current);
diff --git a/src/test/java/com/mythlane/gravityflip/physics/GravityApplierDiffTest.java b/src/test/java/com/mythlane/gravityflip/physics/GravityApplierDiffTest.java
index 1e8306f..2ea7063 100644
--- a/src/test/java/com/mythlane/gravityflip/physics/GravityApplierDiffTest.java
+++ b/src/test/java/com/mythlane/gravityflip/physics/GravityApplierDiffTest.java
@@ -77,4 +77,26 @@ class GravityApplierDiffTest {
Set view = applier.previouslyInvertedView();
assertThrows(UnsupportedOperationException.class, () -> view.add(UUID.randomUUID()));
}
+
+ // NOTE (Rule 3 deviation — Plan 03-02) : les tests suivants ciblent la seam pure
+ // `buildFlaggedDecision(double, double, boolean)` au lieu de `buildPhysicsValuesWithFlag`
+ // parce que le static init de `PhysicsValues` déclenche un `ExceptionInInitializerError`
+ // hors runtime Hytale (dépendance ModuleRegistry). La décomposition pure garantit la
+ // sémantique attendue (mass/drag préservés, flag = target) sans couplage ECS.
+
+ @Test
+ void buildFlaggedDecisionPreservesMassAndDrag() {
+ GravityApplier.FlaggedDecision out = GravityApplier.buildFlaggedDecision(1.5, 0.7, true);
+ assertEquals(1.5, out.mass, 1e-9);
+ assertEquals(0.7, out.drag, 1e-9);
+ assertTrue(out.invertedGravity);
+ }
+
+ @Test
+ void buildFlaggedDecisionIsIdempotentWhenAlreadyTarget() {
+ GravityApplier.FlaggedDecision out = GravityApplier.buildFlaggedDecision(2.0, 0.3, true);
+ assertEquals(2.0, out.mass, 1e-9);
+ assertEquals(0.3, out.drag, 1e-9);
+ assertTrue(out.invertedGravity);
+ }
}