From 210c93aee7d95d776a206f8242aa770e80665426 Mon Sep 17 00:00:00 2001 From: kayjaydee Date: Thu, 23 Apr 2026 12:29:00 +0200 Subject: [PATCH] feat(03-02): wake-up MovementManager + MotionController on region flip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GravityApplier: branches player (setDefaultSettings + applyDefaultSettings + update) et NPC (Role.getActiveMotionController().updatePhysicsValues) dans les 2 pass - wakePlayerOrNpc() helper appelé depuis forEachEntityParallel (sous world.execute — WorldThread assert OK) - PhysicsValues construit localement via buildPhysicsValuesWithFlag (évite relookup ECS pre-commit cmdBuf) - Seam pure FlaggedDecision extraite pour tests unitaires hors runtime Hytale (Rule 3 — static init PhysicsValues nécessite PluginBase) - 8 tests verts (6 existants + 2 nouveaux sur buildFlaggedDecision) --- .../gravityflip/physics/GravityApplier.java | 175 +++++++++++++++++- .../physics/GravityApplierDiffTest.java | 22 +++ 2 files changed, 187 insertions(+), 10 deletions(-) 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: + *

+ * *

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