feat(03-02): wake-up MovementManager + MotionController on region flip
- 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)
This commit is contained in:
@@ -1,13 +1,21 @@
|
|||||||
package com.mythlane.gravityflip.physics;
|
package com.mythlane.gravityflip.physics;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.component.ArchetypeChunk;
|
||||||
import com.hypixel.hytale.component.ComponentType;
|
import com.hypixel.hytale.component.ComponentType;
|
||||||
import com.hypixel.hytale.component.Ref;
|
import com.hypixel.hytale.component.Ref;
|
||||||
import com.hypixel.hytale.component.Store;
|
import com.hypixel.hytale.component.Store;
|
||||||
import com.hypixel.hytale.server.core.entity.UUIDComponent;
|
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.entity.component.TransformComponent;
|
||||||
import com.hypixel.hytale.server.core.modules.physics.component.PhysicsValues;
|
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.World;
|
||||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
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.GravityFlipRegion;
|
||||||
import com.mythlane.gravityflip.region.RegionSnapshot;
|
import com.mythlane.gravityflip.region.RegionSnapshot;
|
||||||
|
|
||||||
@@ -25,6 +33,15 @@ import java.util.function.Consumer;
|
|||||||
* {@code CommandBuffer.replaceComponent(...)} inside a {@code Store.forEachEntityParallel(...)}
|
* {@code CommandBuffer.replaceComponent(...)} inside a {@code Store.forEachEntityParallel(...)}
|
||||||
* lambda — the ECS engine commits them on the main thread after the parallel pass.
|
* lambda — the ECS engine commits them on the main thread after the parallel pass.
|
||||||
*
|
*
|
||||||
|
* <p>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:
|
||||||
|
* <ul>
|
||||||
|
* <li>Players: {@code MovementManager.setDefaultSettings + applyDefaultSettings + update(packetHandler)}
|
||||||
|
* — sends the {@code UpdateMovementSettings} packet (ID 110) to the client.</li>
|
||||||
|
* <li>NPCs: {@code Role.getActiveMotionController().updatePhysicsValues(PhysicsValues)}
|
||||||
|
* — re-applies {@code MovementManager.MASTER_DEFAULT.apply} on the cached settings.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
* <p>Dedup: a thread-safe {@link Set}{@code <UUID>} tracks entities flipped at the previous tick.
|
* <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
|
* 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).
|
* 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<EntityStore, PhysicsValues> physicsType;
|
private static volatile ComponentType<EntityStore, PhysicsValues> physicsType;
|
||||||
private static volatile ComponentType<EntityStore, UUIDComponent> uuidType;
|
private static volatile ComponentType<EntityStore, UUIDComponent> uuidType;
|
||||||
private static volatile ComponentType<EntityStore, TransformComponent> transformType;
|
private static volatile ComponentType<EntityStore, TransformComponent> transformType;
|
||||||
|
private static volatile ComponentType<EntityStore, MovementManager> movementManagerType;
|
||||||
|
private static volatile ComponentType<EntityStore, PlayerRef> playerRefType;
|
||||||
|
private static volatile ComponentType<EntityStore, Player> playerType;
|
||||||
|
private static volatile ComponentType<EntityStore, NPCEntity> npcEntityType;
|
||||||
|
|
||||||
private static ComponentType<EntityStore, PhysicsValues> physicsType() {
|
private static ComponentType<EntityStore, PhysicsValues> physicsType() {
|
||||||
ComponentType<EntityStore, PhysicsValues> t = physicsType;
|
ComponentType<EntityStore, PhysicsValues> t = physicsType;
|
||||||
@@ -66,6 +87,46 @@ public final class GravityApplier {
|
|||||||
}
|
}
|
||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
private static ComponentType<EntityStore, MovementManager> movementManagerType() {
|
||||||
|
ComponentType<EntityStore, MovementManager> t = movementManagerType;
|
||||||
|
if (t == null) {
|
||||||
|
synchronized (GravityApplier.class) {
|
||||||
|
t = movementManagerType;
|
||||||
|
if (t == null) { t = MovementManager.getComponentType(); movementManagerType = t; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
private static ComponentType<EntityStore, PlayerRef> playerRefType() {
|
||||||
|
ComponentType<EntityStore, PlayerRef> t = playerRefType;
|
||||||
|
if (t == null) {
|
||||||
|
synchronized (GravityApplier.class) {
|
||||||
|
t = playerRefType;
|
||||||
|
if (t == null) { t = PlayerRef.getComponentType(); playerRefType = t; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
private static ComponentType<EntityStore, Player> playerType() {
|
||||||
|
ComponentType<EntityStore, Player> t = playerType;
|
||||||
|
if (t == null) {
|
||||||
|
synchronized (GravityApplier.class) {
|
||||||
|
t = playerType;
|
||||||
|
if (t == null) { t = Player.getComponentType(); playerType = t; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
private static ComponentType<EntityStore, NPCEntity> npcEntityType() {
|
||||||
|
ComponentType<EntityStore, NPCEntity> 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.
|
// THREADING: écrit/lu depuis les workers ECS via forEachEntityParallel → ConcurrentHashMap.newKeySet obligatoire.
|
||||||
private final Set<UUID> previouslyInverted = ConcurrentHashMap.newKeySet();
|
private final Set<UUID> previouslyInverted = ConcurrentHashMap.newKeySet();
|
||||||
@@ -75,6 +136,35 @@ public final class GravityApplier {
|
|||||||
this.errorHandler = errorHandler == null ? t -> {} : errorHandler;
|
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.
|
||||||
|
*
|
||||||
|
* <p>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. */
|
/** Tick entry point. NO-OP si world ou snapshot est null. */
|
||||||
public void apply(World world, RegionSnapshot snapshot) {
|
public void apply(World world, RegionSnapshot snapshot) {
|
||||||
if (world == null || snapshot == null) return;
|
if (world == null || snapshot == null) return;
|
||||||
@@ -88,20 +178,19 @@ public final class GravityApplier {
|
|||||||
|
|
||||||
private void applyOnWorldThread(World world, RegionSnapshot snapshot) {
|
private void applyOnWorldThread(World world, RegionSnapshot snapshot) {
|
||||||
Collection<GravityFlipRegion> enabledRegions = snapshot.byRegion().keySet();
|
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 {
|
try {
|
||||||
Store<EntityStore> store = world.getEntityStore().getStore();
|
Store<EntityStore> store = world.getEntityStore().getStore();
|
||||||
ComponentType<EntityStore, PhysicsValues> PHYST = physicsType();
|
ComponentType<EntityStore, PhysicsValues> PHYST = physicsType();
|
||||||
ComponentType<EntityStore, UUIDComponent> UUIDT = uuidType();
|
ComponentType<EntityStore, UUIDComponent> UUIDT = uuidType();
|
||||||
ComponentType<EntityStore, TransformComponent> TT = transformType();
|
ComponentType<EntityStore, TransformComponent> TT = transformType();
|
||||||
|
ComponentType<EntityStore, MovementManager> MMT = movementManagerType();
|
||||||
|
ComponentType<EntityStore, PlayerRef> PRT = playerRefType();
|
||||||
|
ComponentType<EntityStore, Player> PLT = playerType();
|
||||||
|
ComponentType<EntityStore, NPCEntity> NPCT = npcEntityType();
|
||||||
|
|
||||||
// PASS 1 — pour chaque entité avec PhysicsValues : si dans une région activée, queue le flip ON
|
// 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).
|
// via cmdBuf.replaceComponent ET wake-up MovementManager / MotionController.
|
||||||
// THREADING: lambda sur worker ECS → currentlyInRegion = ConcurrentHashMap.newKeySet ; toutes les
|
|
||||||
// mutations PhysicsValues passent par cmdBuf (jamais replaceValues direct).
|
|
||||||
Set<UUID> currentlyInRegion = ConcurrentHashMap.newKeySet();
|
Set<UUID> currentlyInRegion = ConcurrentHashMap.newKeySet();
|
||||||
store.forEachEntityParallel(PHYST, (index, chunk, cmdBuf) -> {
|
store.forEachEntityParallel(PHYST, (index, chunk, cmdBuf) -> {
|
||||||
TransformComponent t;
|
TransformComponent t;
|
||||||
@@ -125,16 +214,21 @@ public final class GravityApplier {
|
|||||||
|
|
||||||
UUID u = uc.getUuid();
|
UUID u = uc.getUuid();
|
||||||
currentlyInRegion.add(u);
|
currentlyInRegion.add(u);
|
||||||
|
|
||||||
|
// --- existant (plan 03-01) : flip ECS native ---
|
||||||
if (!v.isInvertedGravity()) {
|
if (!v.isInvertedGravity()) {
|
||||||
Ref<EntityStore> ref = chunk.getReferenceTo(index);
|
Ref<EntityStore> ref = chunk.getReferenceTo(index);
|
||||||
cmdBuf.replaceComponent(ref, PHYST,
|
cmdBuf.replaceComponent(ref, PHYST,
|
||||||
new PhysicsValues(v.getMass(), v.getDragCoefficient(), true));
|
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,
|
// PASS 2 — restore : pour chaque UUID dans previouslyInverted \ currentlyInRegion,
|
||||||
// re-localiser l'entité (second scan) et queue le flip OFF via cmdBuf.
|
// re-localiser l'entité (second scan) et queue le flip OFF via cmdBuf +
|
||||||
// Trade-off perf accepté v1 — voir SUMMARY follow-up.
|
// wake-up joueur/NPC avec flag=false.
|
||||||
Set<UUID> toRestore = ConcurrentHashMap.newKeySet();
|
Set<UUID> toRestore = ConcurrentHashMap.newKeySet();
|
||||||
toRestore.addAll(previouslyInverted);
|
toRestore.addAll(previouslyInverted);
|
||||||
toRestore.removeAll(currentlyInRegion);
|
toRestore.removeAll(currentlyInRegion);
|
||||||
@@ -147,16 +241,20 @@ public final class GravityApplier {
|
|||||||
v = chunk.getComponent(index, PHYST);
|
v = chunk.getComponent(index, PHYST);
|
||||||
} catch (Throwable ignored) { return; }
|
} catch (Throwable ignored) { return; }
|
||||||
if (uc == null || v == null) return;
|
if (uc == null || v == null) return;
|
||||||
if (toRestore.contains(uc.getUuid()) && v.isInvertedGravity()) {
|
if (!toRestore.contains(uc.getUuid())) return;
|
||||||
|
|
||||||
|
if (v.isInvertedGravity()) {
|
||||||
Ref<EntityStore> ref = chunk.getReferenceTo(index);
|
Ref<EntityStore> ref = chunk.getReferenceTo(index);
|
||||||
cmdBuf.replaceComponent(ref, PHYST,
|
cmdBuf.replaceComponent(ref, PHYST,
|
||||||
new PhysicsValues(v.getMass(), v.getDragCoefficient(), false));
|
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.
|
// 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.clear();
|
||||||
previouslyInverted.addAll(currentlyInRegion);
|
previouslyInverted.addAll(currentlyInRegion);
|
||||||
} catch (Throwable th) {
|
} 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)
|
||||||
|
*
|
||||||
|
* <p>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<EntityStore> chunk, int index,
|
||||||
|
PhysicsValues sourceValues, boolean targetFlag,
|
||||||
|
ComponentType<EntityStore, MovementManager> MMT,
|
||||||
|
ComponentType<EntityStore, PlayerRef> PRT,
|
||||||
|
ComponentType<EntityStore, Player> PLT,
|
||||||
|
ComponentType<EntityStore, NPCEntity> 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). */
|
/** Pure data-diff utilitaire pour tests unitaires (pas de runtime Hytale). */
|
||||||
public static DiffResult diff(Set<UUID> previous, Set<UUID> current) {
|
public static DiffResult diff(Set<UUID> previous, Set<UUID> current) {
|
||||||
Set<UUID> toFlip = new HashSet<>(current);
|
Set<UUID> toFlip = new HashSet<>(current);
|
||||||
|
|||||||
@@ -77,4 +77,26 @@ class GravityApplierDiffTest {
|
|||||||
Set<UUID> view = applier.previouslyInvertedView();
|
Set<UUID> view = applier.previouslyInvertedView();
|
||||||
assertThrows(UnsupportedOperationException.class, () -> view.add(UUID.randomUUID()));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user