Core/Movement: Add time synchronisation (Needs tested)
Port From (https://github.com/TrinityCore/TrinityCore/commit/c19a4db1c12b8864d6c486ee8e2f0e058fb4155a)
This commit is contained in:
@@ -0,0 +1,424 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2012-2020 CypherCore <http://github.com/CypherCore>
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Framework.Collections
|
||||||
|
{
|
||||||
|
/// <inheritdoc/>
|
||||||
|
/// <summary>
|
||||||
|
/// Circular buffer.
|
||||||
|
///
|
||||||
|
/// When writing to a full buffer:
|
||||||
|
/// PushBack -> removes this[0] / Front()
|
||||||
|
/// PushFront -> removes this[Size-1] / Back()
|
||||||
|
///
|
||||||
|
/// this implementation is inspired by
|
||||||
|
/// http://www.boost.org/doc/libs/1_53_0/libs/circular_buffer/doc/circular_buffer.html
|
||||||
|
/// because I liked their interface.
|
||||||
|
/// </summary>
|
||||||
|
public class CircularBuffer<T> : IEnumerable<T>
|
||||||
|
{
|
||||||
|
private readonly T[] _buffer;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The _start. Index of the first element in buffer.
|
||||||
|
/// </summary>
|
||||||
|
private int _start;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The _end. Index after the last element in the buffer.
|
||||||
|
/// </summary>
|
||||||
|
private int _end;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The _size. Buffer size.
|
||||||
|
/// </summary>
|
||||||
|
private int _size;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="CircularBuffer{T}"/> class.
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name='capacity'>
|
||||||
|
/// Buffer capacity. Must be positive.
|
||||||
|
/// </param>
|
||||||
|
public CircularBuffer(int capacity)
|
||||||
|
: this(capacity, new T[] { })
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="CircularBuffer{T}"/> class.
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name='capacity'>
|
||||||
|
/// Buffer capacity. Must be positive.
|
||||||
|
/// </param>
|
||||||
|
/// <param name='items'>
|
||||||
|
/// Items to fill buffer with. Items length must be less than capacity.
|
||||||
|
/// Suggestion: use Skip(x).Take(y).ToArray() to build this argument from
|
||||||
|
/// any enumerable.
|
||||||
|
/// </param>
|
||||||
|
public CircularBuffer(int capacity, T[] items)
|
||||||
|
{
|
||||||
|
if (capacity < 1)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"Circular buffer cannot have negative or zero capacity.", nameof(capacity));
|
||||||
|
}
|
||||||
|
if (items == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(items));
|
||||||
|
}
|
||||||
|
if (items.Length > capacity)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"Too many items to fit circular buffer", nameof(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
_buffer = new T[capacity];
|
||||||
|
|
||||||
|
Array.Copy(items, _buffer, items.Length);
|
||||||
|
_size = items.Length;
|
||||||
|
|
||||||
|
_start = 0;
|
||||||
|
_end = _size == capacity ? 0 : _size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum capacity of the buffer. Elements pushed into the buffer after
|
||||||
|
/// maximum capacity is reached (IsFull = true), will remove an element.
|
||||||
|
/// </summary>
|
||||||
|
public int Capacity { get { return _buffer.Length; } }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Boolean indicating if Circular is at full capacity.
|
||||||
|
/// Adding more elements when the buffer is full will
|
||||||
|
/// cause elements to be removed from the other end
|
||||||
|
/// of the buffer.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsFull
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return Size == Capacity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True if has no elements.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEmpty
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return Size == 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Current buffer size (the number of elements that the buffer has).
|
||||||
|
/// </summary>
|
||||||
|
public int Size { get { return _size; } }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Element at the front of the buffer - this[0].
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The value of the element of type T at the front of the buffer.</returns>
|
||||||
|
public T Front()
|
||||||
|
{
|
||||||
|
ThrowIfEmpty();
|
||||||
|
return _buffer[_start];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Element at the back of the buffer - this[Size - 1].
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The value of the element of type T at the back of the buffer.</returns>
|
||||||
|
public T Back()
|
||||||
|
{
|
||||||
|
ThrowIfEmpty();
|
||||||
|
return _buffer[(_end != 0 ? _end : Capacity) - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Index access to elements in buffer.
|
||||||
|
/// Index does not loop around like when adding elements,
|
||||||
|
/// valid interval is [0;Size[
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="index">Index of element to access.</param>
|
||||||
|
/// <exception cref="IndexOutOfRangeException">Thrown when index is outside of [; Size[ interval.</exception>
|
||||||
|
public T this[int index]
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (IsEmpty)
|
||||||
|
{
|
||||||
|
throw new IndexOutOfRangeException(string.Format("Cannot access index {0}. Buffer is empty", index));
|
||||||
|
}
|
||||||
|
if (index >= _size)
|
||||||
|
{
|
||||||
|
throw new IndexOutOfRangeException(string.Format("Cannot access index {0}. Buffer size is {1}", index, _size));
|
||||||
|
}
|
||||||
|
int actualIndex = InternalIndex(index);
|
||||||
|
return _buffer[actualIndex];
|
||||||
|
}
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (IsEmpty)
|
||||||
|
{
|
||||||
|
throw new IndexOutOfRangeException(string.Format("Cannot access index {0}. Buffer is empty", index));
|
||||||
|
}
|
||||||
|
if (index >= _size)
|
||||||
|
{
|
||||||
|
throw new IndexOutOfRangeException(string.Format("Cannot access index {0}. Buffer size is {1}", index, _size));
|
||||||
|
}
|
||||||
|
int actualIndex = InternalIndex(index);
|
||||||
|
_buffer[actualIndex] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pushes a new element to the back of the buffer. Back()/this[Size-1]
|
||||||
|
/// will now return this element.
|
||||||
|
///
|
||||||
|
/// When the buffer is full, the element at Front()/this[0] will be
|
||||||
|
/// popped to allow for this new element to fit.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">Item to push to the back of the buffer</param>
|
||||||
|
public void PushBack(T item)
|
||||||
|
{
|
||||||
|
if (IsFull)
|
||||||
|
{
|
||||||
|
_buffer[_end] = item;
|
||||||
|
Increment(ref _end);
|
||||||
|
_start = _end;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_buffer[_end] = item;
|
||||||
|
Increment(ref _end);
|
||||||
|
++_size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pushes a new element to the front of the buffer. Front()/this[0]
|
||||||
|
/// will now return this element.
|
||||||
|
///
|
||||||
|
/// When the buffer is full, the element at Back()/this[Size-1] will be
|
||||||
|
/// popped to allow for this new element to fit.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">Item to push to the front of the buffer</param>
|
||||||
|
public void PushFront(T item)
|
||||||
|
{
|
||||||
|
if (IsFull)
|
||||||
|
{
|
||||||
|
Decrement(ref _start);
|
||||||
|
_end = _start;
|
||||||
|
_buffer[_start] = item;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Decrement(ref _start);
|
||||||
|
_buffer[_start] = item;
|
||||||
|
++_size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the element at the back of the buffer. Decreasing the
|
||||||
|
/// Buffer size by 1.
|
||||||
|
/// </summary>
|
||||||
|
public void PopBack()
|
||||||
|
{
|
||||||
|
ThrowIfEmpty("Cannot take elements from an empty buffer.");
|
||||||
|
Decrement(ref _end);
|
||||||
|
_buffer[_end] = default(T);
|
||||||
|
--_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the element at the front of the buffer. Decreasing the
|
||||||
|
/// Buffer size by 1.
|
||||||
|
/// </summary>
|
||||||
|
public void PopFront()
|
||||||
|
{
|
||||||
|
ThrowIfEmpty("Cannot take elements from an empty buffer.");
|
||||||
|
_buffer[_start] = default(T);
|
||||||
|
Increment(ref _start);
|
||||||
|
--_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copies the buffer contents to an array, according to the logical
|
||||||
|
/// contents of the buffer (i.e. independent of the internal
|
||||||
|
/// order/contents)
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A new array with a copy of the buffer contents.</returns>
|
||||||
|
public T[] ToArray()
|
||||||
|
{
|
||||||
|
T[] newArray = new T[Size];
|
||||||
|
int newArrayOffset = 0;
|
||||||
|
var segments = ToArraySegments();
|
||||||
|
foreach (ArraySegment<T> segment in segments)
|
||||||
|
{
|
||||||
|
Array.Copy(segment.Array, segment.Offset, newArray, newArrayOffset, segment.Count);
|
||||||
|
newArrayOffset += segment.Count;
|
||||||
|
}
|
||||||
|
return newArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the contents of the buffer as 2 ArraySegments.
|
||||||
|
/// Respects the logical contents of the buffer, where
|
||||||
|
/// each segment and items in each segment are ordered
|
||||||
|
/// according to insertion.
|
||||||
|
///
|
||||||
|
/// Fast: does not copy the array elements.
|
||||||
|
/// Useful for methods like <c>Send(IList<ArraySegment<Byte>>)</c>.
|
||||||
|
///
|
||||||
|
/// <remarks>Segments may be empty.</remarks>
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>An IList with 2 segments corresponding to the buffer content.</returns>
|
||||||
|
public IList<ArraySegment<T>> ToArraySegments()
|
||||||
|
{
|
||||||
|
return new[] { ArrayOne(), ArrayTwo() };
|
||||||
|
}
|
||||||
|
|
||||||
|
#region IEnumerable<T> implementation
|
||||||
|
/// <summary>
|
||||||
|
/// Returns an enumerator that iterates through this buffer.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>An enumerator that can be used to iterate this collection.</returns>
|
||||||
|
public IEnumerator<T> GetEnumerator()
|
||||||
|
{
|
||||||
|
var segments = ToArraySegments();
|
||||||
|
foreach (ArraySegment<T> segment in segments)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < segment.Count; i++)
|
||||||
|
{
|
||||||
|
yield return segment.Array[segment.Offset + i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
#region IEnumerable implementation
|
||||||
|
IEnumerator IEnumerable.GetEnumerator()
|
||||||
|
{
|
||||||
|
return GetEnumerator();
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
private void ThrowIfEmpty(string message = "Cannot access an empty buffer.")
|
||||||
|
{
|
||||||
|
if (IsEmpty)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Increments the provided index variable by one, wrapping
|
||||||
|
/// around if necessary.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="index"></param>
|
||||||
|
private void Increment(ref int index)
|
||||||
|
{
|
||||||
|
if (++index == Capacity)
|
||||||
|
{
|
||||||
|
index = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decrements the provided index variable by one, wrapping
|
||||||
|
/// around if necessary.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="index"></param>
|
||||||
|
private void Decrement(ref int index)
|
||||||
|
{
|
||||||
|
if (index == 0)
|
||||||
|
{
|
||||||
|
index = Capacity;
|
||||||
|
}
|
||||||
|
index--;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts the index in the argument to an index in <code>_buffer</code>
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// The transformed index.
|
||||||
|
/// </returns>
|
||||||
|
/// <param name='index'>
|
||||||
|
/// External index.
|
||||||
|
/// </param>
|
||||||
|
private int InternalIndex(int index)
|
||||||
|
{
|
||||||
|
return _start + (index < (Capacity - _start) ? index : index - Capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// doing ArrayOne and ArrayTwo methods returning ArraySegment<T> as seen here:
|
||||||
|
// http://www.boost.org/doc/libs/1_37_0/libs/circular_buffer/doc/circular_buffer.html#classboost_1_1circular__buffer_1957cccdcb0c4ef7d80a34a990065818d
|
||||||
|
// http://www.boost.org/doc/libs/1_37_0/libs/circular_buffer/doc/circular_buffer.html#classboost_1_1circular__buffer_1f5081a54afbc2dfc1a7fb20329df7d5b
|
||||||
|
// should help a lot with the code.
|
||||||
|
|
||||||
|
#region Array items easy access.
|
||||||
|
// The array is composed by at most two non-contiguous segments,
|
||||||
|
// the next two methods allow easy access to those.
|
||||||
|
|
||||||
|
private ArraySegment<T> ArrayOne()
|
||||||
|
{
|
||||||
|
if (IsEmpty)
|
||||||
|
{
|
||||||
|
return new ArraySegment<T>(new T[0]);
|
||||||
|
}
|
||||||
|
else if (_start < _end)
|
||||||
|
{
|
||||||
|
return new ArraySegment<T>(_buffer, _start, _end - _start);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return new ArraySegment<T>(_buffer, _start, _buffer.Length - _start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ArraySegment<T> ArrayTwo()
|
||||||
|
{
|
||||||
|
if (IsEmpty)
|
||||||
|
{
|
||||||
|
return new ArraySegment<T>(new T[0]);
|
||||||
|
}
|
||||||
|
else if (_start < _end)
|
||||||
|
{
|
||||||
|
return new ArraySegment<T>(_buffer, _end, 0);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return new ArraySegment<T>(_buffer, 0, _end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
using Framework.Constants;
|
using Framework.Constants;
|
||||||
using Framework.GameMath;
|
using Framework.GameMath;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
|
||||||
public static class MathFunctions
|
public static class MathFunctions
|
||||||
@@ -269,6 +270,22 @@ public static class MathFunctions
|
|||||||
return (ushort)((byte)l | (ushort)h << 8);
|
return (ushort)((byte)l | (ushort)h << 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static double Variance(this IEnumerable<uint> source)
|
||||||
|
{
|
||||||
|
int n = 0;
|
||||||
|
double mean = 0;
|
||||||
|
double M2 = 0;
|
||||||
|
|
||||||
|
foreach (var x in source)
|
||||||
|
{
|
||||||
|
n = n + 1;
|
||||||
|
double delta = x - mean;
|
||||||
|
mean = mean + delta / n;
|
||||||
|
M2 += delta * (x - mean);
|
||||||
|
}
|
||||||
|
return M2 / (n - 1);
|
||||||
|
}
|
||||||
|
|
||||||
//3d math
|
//3d math
|
||||||
public static Box toWorldSpace(Matrix4x4 rotation, Vector3 translation, Box box)
|
public static Box toWorldSpace(Matrix4x4 rotation, Vector3 translation, Box box)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ public static class Time
|
|||||||
return newMSTime - oldMSTime;
|
return newMSTime - oldMSTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static uint GetMSTimeDiff(uint oldMSTime, DateTime newTime)
|
||||||
|
{
|
||||||
|
uint newMSTime = (uint)(newTime - ApplicationStartTime).TotalMilliseconds;
|
||||||
|
return GetMSTimeDiff(oldMSTime, newMSTime);
|
||||||
|
}
|
||||||
|
|
||||||
public static uint GetMSTimeDiffToNow(uint oldMSTime)
|
public static uint GetMSTimeDiffToNow(uint oldMSTime)
|
||||||
{
|
{
|
||||||
var newMSTime = GetMSTime();
|
var newMSTime = GetMSTime();
|
||||||
|
|||||||
@@ -212,10 +212,6 @@ namespace Game.Entities
|
|||||||
// only changed for direct client control (possess, vehicle etc.), not stuff you control using pet commands
|
// only changed for direct client control (possess, vehicle etc.), not stuff you control using pet commands
|
||||||
public Unit m_unitMovedByMe;
|
public Unit m_unitMovedByMe;
|
||||||
Team m_team;
|
Team m_team;
|
||||||
public Stack<uint> m_timeSyncQueue = new();
|
|
||||||
uint m_timeSyncTimer;
|
|
||||||
public uint m_timeSyncClient;
|
|
||||||
public uint m_timeSyncServer;
|
|
||||||
ReputationMgr reputationMgr;
|
ReputationMgr reputationMgr;
|
||||||
QuestObjectiveCriteriaManager m_questObjectiveCriteriaMgr;
|
QuestObjectiveCriteriaManager m_questObjectiveCriteriaMgr;
|
||||||
public AtLoginFlags atLoginFlags;
|
public AtLoginFlags atLoginFlags;
|
||||||
|
|||||||
@@ -74,8 +74,6 @@ namespace Game.Entities
|
|||||||
m_logintime = GameTime.GetGameTime();
|
m_logintime = GameTime.GetGameTime();
|
||||||
m_Last_tick = m_logintime;
|
m_Last_tick = m_logintime;
|
||||||
|
|
||||||
m_timeSyncServer = GameTime.GetGameTimeMS();
|
|
||||||
|
|
||||||
m_dungeonDifficulty = Difficulty.Normal;
|
m_dungeonDifficulty = Difficulty.Normal;
|
||||||
m_raidDifficulty = Difficulty.NormalRaid;
|
m_raidDifficulty = Difficulty.NormalRaid;
|
||||||
m_legacyRaidDifficulty = Difficulty.Raid10N;
|
m_legacyRaidDifficulty = Difficulty.Raid10N;
|
||||||
@@ -533,13 +531,6 @@ namespace Game.Entities
|
|||||||
else
|
else
|
||||||
m_zoneUpdateTimer -= diff;
|
m_zoneUpdateTimer -= diff;
|
||||||
}
|
}
|
||||||
if (m_timeSyncTimer > 0 && !IsBeingTeleportedFar())
|
|
||||||
{
|
|
||||||
if (diff >= m_timeSyncTimer)
|
|
||||||
SendTimeSync();
|
|
||||||
else
|
|
||||||
m_timeSyncTimer -= diff;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (IsAlive())
|
if (IsAlive())
|
||||||
{
|
{
|
||||||
@@ -848,29 +839,6 @@ namespace Game.Entities
|
|||||||
Session.SendPacket(data);
|
Session.SendPacket(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
//Time
|
|
||||||
void ResetTimeSync()
|
|
||||||
{
|
|
||||||
m_timeSyncTimer = 0;
|
|
||||||
m_timeSyncClient = 0;
|
|
||||||
m_timeSyncServer = GameTime.GetGameTimeMS();
|
|
||||||
}
|
|
||||||
void SendTimeSync()
|
|
||||||
{
|
|
||||||
m_timeSyncQueue.Push(m_movementCounter++);
|
|
||||||
|
|
||||||
TimeSyncRequest packet = new();
|
|
||||||
packet.SequenceIndex = m_timeSyncQueue.Last();
|
|
||||||
SendPacket(packet);
|
|
||||||
|
|
||||||
// Schedule next sync in 10 sec
|
|
||||||
m_timeSyncTimer = 10000;
|
|
||||||
m_timeSyncServer = GameTime.GetGameTimeMS();
|
|
||||||
|
|
||||||
if (m_timeSyncQueue.Count > 3)
|
|
||||||
Log.outError(LogFilter.Network, "Not received CMSG_TIME_SYNC_RESP for over 30 seconds from player {0} ({1}), possible cheater", GetGUID().ToString(), GetName());
|
|
||||||
}
|
|
||||||
|
|
||||||
public DeclinedName GetDeclinedNames() { return _declinedname; }
|
public DeclinedName GetDeclinedNames() { return _declinedname; }
|
||||||
|
|
||||||
public void CreateGarrison(uint garrSiteId)
|
public void CreateGarrison(uint garrSiteId)
|
||||||
@@ -5460,10 +5428,10 @@ namespace Game.Entities
|
|||||||
if (!m_teleport_options.HasAnyFlag(TeleportToOptions.Seamless))
|
if (!m_teleport_options.HasAnyFlag(TeleportToOptions.Seamless))
|
||||||
{
|
{
|
||||||
m_movementCounter = 0;
|
m_movementCounter = 0;
|
||||||
ResetTimeSync();
|
GetSession().ResetTimeSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
SendTimeSync();
|
GetSession().SendTimeSync();
|
||||||
|
|
||||||
GetSocial().SendSocialList(this, SocialFlag.All);
|
GetSocial().SendSocialList(this, SocialFlag.All);
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ using Game.Networking;
|
|||||||
using Game.Networking.Packets;
|
using Game.Networking.Packets;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
namespace Game
|
namespace Game
|
||||||
{
|
{
|
||||||
@@ -140,14 +141,8 @@ namespace Game
|
|||||||
if (opcode == ClientOpcodes.MoveFallLand || opcode == ClientOpcodes.MoveStartSwim || opcode == ClientOpcodes.MoveSetFly)
|
if (opcode == ClientOpcodes.MoveFallLand || opcode == ClientOpcodes.MoveStartSwim || opcode == ClientOpcodes.MoveSetFly)
|
||||||
mover.RemoveAurasWithInterruptFlags(SpellAuraInterruptFlags.LandingOrFlight); // Parachutes
|
mover.RemoveAurasWithInterruptFlags(SpellAuraInterruptFlags.LandingOrFlight); // Parachutes
|
||||||
|
|
||||||
uint mstime = GameTime.GetGameTimeMS();
|
|
||||||
|
|
||||||
if (m_clientTimeDelay == 0)
|
|
||||||
m_clientTimeDelay = mstime - movementInfo.Time;
|
|
||||||
|
|
||||||
movementInfo.Time = movementInfo.Time + m_clientTimeDelay;
|
|
||||||
|
|
||||||
movementInfo.Guid = mover.GetGUID();
|
movementInfo.Guid = mover.GetGUID();
|
||||||
|
movementInfo.Time = AdjustClientMovementTime(movementInfo.Time);
|
||||||
mover.m_movementInfo = movementInfo;
|
mover.m_movementInfo = movementInfo;
|
||||||
|
|
||||||
// Some vehicles allow the passenger to turn by himself
|
// Some vehicles allow the passenger to turn by himself
|
||||||
@@ -205,7 +200,7 @@ namespace Game
|
|||||||
plrMover.RemovePlayerFlag(PlayerFlags.IsOutOfBounds);
|
plrMover.RemovePlayerFlag(PlayerFlags.IsOutOfBounds);
|
||||||
|
|
||||||
if (opcode == ClientOpcodes.MoveJump)
|
if (opcode == ClientOpcodes.MoveJump)
|
||||||
{
|
{
|
||||||
plrMover.RemoveAurasWithInterruptFlags(SpellAuraInterruptFlags2.Jump); // Mind Control
|
plrMover.RemoveAurasWithInterruptFlags(SpellAuraInterruptFlags2.Jump); // Mind Control
|
||||||
Unit.ProcSkillsAndAuras(plrMover, null, ProcFlags.Jump, ProcFlags.None, ProcFlagsSpellType.MaskAll, ProcFlagsSpellPhase.None, ProcFlagsHit.None, null, null, null);
|
Unit.ProcSkillsAndAuras(plrMover, null, ProcFlags.Jump, ProcFlags.None, ProcFlagsSpellType.MaskAll, ProcFlagsSpellPhase.None, ProcFlagsHit.None, null, null, null);
|
||||||
}
|
}
|
||||||
@@ -347,7 +342,7 @@ namespace Game
|
|||||||
|
|
||||||
// resurrect character at enter into instance where his corpse exist after add to map
|
// resurrect character at enter into instance where his corpse exist after add to map
|
||||||
if (mapEntry.IsDungeon() && !player.IsAlive())
|
if (mapEntry.IsDungeon() && !player.IsAlive())
|
||||||
{
|
{
|
||||||
if (player.GetCorpseLocation().GetMapId() == mapEntry.Id)
|
if (player.GetCorpseLocation().GetMapId() == mapEntry.Id)
|
||||||
{
|
{
|
||||||
player.ResurrectPlayer(0.5f, false);
|
player.ResurrectPlayer(0.5f, false);
|
||||||
@@ -619,7 +614,7 @@ namespace Game
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
moveApplyMovementForceAck.Ack.Status.Time += m_clientTimeDelay;
|
moveApplyMovementForceAck.Ack.Status.Time = AdjustClientMovementTime(moveApplyMovementForceAck.Ack.Status.Time);
|
||||||
|
|
||||||
MoveUpdateApplyMovementForce updateApplyMovementForce = new();
|
MoveUpdateApplyMovementForce updateApplyMovementForce = new();
|
||||||
updateApplyMovementForce.Status = moveApplyMovementForceAck.Ack.Status;
|
updateApplyMovementForce.Status = moveApplyMovementForceAck.Ack.Status;
|
||||||
@@ -641,7 +636,7 @@ namespace Game
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
moveRemoveMovementForceAck.Ack.Status.Time += m_clientTimeDelay;
|
moveRemoveMovementForceAck.Ack.Status.Time = AdjustClientMovementTime(moveRemoveMovementForceAck.Ack.Status.Time);
|
||||||
|
|
||||||
MoveUpdateRemoveMovementForce updateRemoveMovementForce = new();
|
MoveUpdateRemoveMovementForce updateRemoveMovementForce = new();
|
||||||
updateRemoveMovementForce.Status = moveRemoveMovementForceAck.Ack.Status;
|
updateRemoveMovementForce.Status = moveRemoveMovementForceAck.Ack.Status;
|
||||||
@@ -683,7 +678,7 @@ namespace Game
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setModMovementForceMagnitudeAck.Ack.Status.Time += m_clientTimeDelay;
|
setModMovementForceMagnitudeAck.Ack.Status.Time = AdjustClientMovementTime(setModMovementForceMagnitudeAck.Ack.Status.Time);
|
||||||
|
|
||||||
MoveUpdateSpeed updateModMovementForceMagnitude = new(ServerOpcodes.MoveUpdateModMovementForceMagnitude);
|
MoveUpdateSpeed updateModMovementForceMagnitude = new(ServerOpcodes.MoveUpdateModMovementForceMagnitude);
|
||||||
updateModMovementForceMagnitude.Status = setModMovementForceMagnitudeAck.Ack.Status;
|
updateModMovementForceMagnitude.Status = setModMovementForceMagnitudeAck.Ack.Status;
|
||||||
@@ -759,5 +754,74 @@ namespace Game
|
|||||||
if (GetPlayer().pvpInfo.IsHostile)
|
if (GetPlayer().pvpInfo.IsHostile)
|
||||||
GetPlayer().CastSpell(GetPlayer(), 2479, true);
|
GetPlayer().CastSpell(GetPlayer(), 2479, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[WorldPacketHandler(ClientOpcodes.TimeSyncResponse, Processing = PacketProcessing.ThreadSafe)]
|
||||||
|
void HandleTimeSyncResponse(TimeSyncResponse timeSyncResponse)
|
||||||
|
{
|
||||||
|
if (!_pendingTimeSyncRequests.ContainsKey(timeSyncResponse.SequenceIndex))
|
||||||
|
return;
|
||||||
|
|
||||||
|
uint serverTimeAtSent = _pendingTimeSyncRequests.LookupByKey(timeSyncResponse.SequenceIndex);
|
||||||
|
_pendingTimeSyncRequests.Remove(timeSyncResponse.SequenceIndex);
|
||||||
|
|
||||||
|
// time it took for the request to travel to the client, for the client to process it and reply and for response to travel back to the server.
|
||||||
|
// we are going to make 2 assumptions:
|
||||||
|
// 1) we assume that the request processing time equals 0.
|
||||||
|
// 2) we assume that the packet took as much time to travel from server to client than it took to travel from client to server.
|
||||||
|
uint roundTripDuration = Time.GetMSTimeDiff(serverTimeAtSent, timeSyncResponse.GetReceivedTime());
|
||||||
|
uint lagDelay = roundTripDuration / 2;
|
||||||
|
|
||||||
|
/*
|
||||||
|
clockDelta = serverTime - clientTime
|
||||||
|
where
|
||||||
|
serverTime: time that was displayed on the clock of the SERVER at the moment when the client processed the SMSG_TIME_SYNC_REQUEST packet.
|
||||||
|
clientTime: time that was displayed on the clock of the CLIENT at the moment when the client processed the SMSG_TIME_SYNC_REQUEST packet.
|
||||||
|
|
||||||
|
Once clockDelta has been computed, we can compute the time of an event on server clock when we know the time of that same event on the client clock,
|
||||||
|
using the following relation:
|
||||||
|
serverTime = clockDelta + clientTime
|
||||||
|
*/
|
||||||
|
long clockDelta = (long)(serverTimeAtSent + lagDelay) - (long)timeSyncResponse.ClientTime;
|
||||||
|
_timeSyncClockDeltaQueue.PushFront(Tuple.Create(clockDelta, roundTripDuration));
|
||||||
|
ComputeNewClockDelta();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ComputeNewClockDelta()
|
||||||
|
{
|
||||||
|
// implementation of the technique described here: https://web.archive.org/web/20180430214420/http://www.mine-control.com/zack/timesync/timesync.html
|
||||||
|
// to reduce the skew induced by dropped TCP packets that get resent.
|
||||||
|
|
||||||
|
//accumulator_set < uint32, features < tag::mean, tag::median, tag::variance(lazy) > > latencyAccumulator;
|
||||||
|
List<uint> latencyList = new();
|
||||||
|
foreach (var pair in _timeSyncClockDeltaQueue)
|
||||||
|
latencyList.Add(pair.Item2);
|
||||||
|
|
||||||
|
uint latencyMedian = (uint)Math.Round(latencyList.Average(p => p));//median(latencyAccumulator));
|
||||||
|
uint latencyStandardDeviation = (uint)Math.Round(Math.Sqrt(latencyList.Variance()));//variance(latencyAccumulator)));
|
||||||
|
|
||||||
|
//accumulator_set<long, features<tag::mean>> clockDeltasAfterFiltering;
|
||||||
|
List<long> clockDeltasAfterFiltering = new();
|
||||||
|
uint sampleSizeAfterFiltering = 0;
|
||||||
|
foreach (var pair in _timeSyncClockDeltaQueue)
|
||||||
|
{
|
||||||
|
if (pair.Item2 < latencyStandardDeviation + latencyMedian)
|
||||||
|
{
|
||||||
|
clockDeltasAfterFiltering.Add(pair.Item1);
|
||||||
|
sampleSizeAfterFiltering++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sampleSizeAfterFiltering != 0)
|
||||||
|
{
|
||||||
|
long meanClockDelta = (long)(Math.Round(clockDeltasAfterFiltering.Average()));
|
||||||
|
if (Math.Abs(meanClockDelta - _timeSyncClockDelta) > 25)
|
||||||
|
_timeSyncClockDelta = meanClockDelta;
|
||||||
|
}
|
||||||
|
else if (_timeSyncClockDelta == 0)
|
||||||
|
{
|
||||||
|
var back = _timeSyncClockDeltaQueue.Back();
|
||||||
|
_timeSyncClockDelta = back.Item1;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,24 +31,5 @@ namespace Game
|
|||||||
response.Time = GameTime.GetGameTime();
|
response.Time = GameTime.GetGameTime();
|
||||||
SendPacket(response);
|
SendPacket(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
[WorldPacketHandler(ClientOpcodes.TimeSyncResponse, Processing = PacketProcessing.Inplace)]
|
|
||||||
void HandleTimeSyncResponse(TimeSyncResponse packet)
|
|
||||||
{
|
|
||||||
Log.outDebug(LogFilter.Network, "CMSG_TIME_SYNC_RESP");
|
|
||||||
|
|
||||||
if (packet.SequenceIndex != _player.m_timeSyncQueue.FirstOrDefault())
|
|
||||||
Log.outError(LogFilter.Network, "Wrong time sync counter from player {0} (cheater?)", _player.GetName());
|
|
||||||
|
|
||||||
Log.outDebug(LogFilter.Network, "Time sync received: counter {0}, client ticks {1}, time since last sync {2}", packet.SequenceIndex, packet.ClientTime, packet.ClientTime - _player.m_timeSyncClient);
|
|
||||||
|
|
||||||
uint ourTicks = packet.ClientTime + (GameTime.GetGameTimeMS() - _player.m_timeSyncServer);
|
|
||||||
|
|
||||||
// diff should be small
|
|
||||||
Log.outDebug(LogFilter.Network, "Our ticks: {0}, diff {1}, latency {2}", ourTicks, ourTicks - packet.ClientTime, GetLatency());
|
|
||||||
|
|
||||||
_player.m_timeSyncClient = packet.ClientTime;
|
|
||||||
_player.m_timeSyncQueue.Pop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,7 +220,11 @@ namespace Game.Networking
|
|||||||
|
|
||||||
public uint GetOpcode() { return opcode; }
|
public uint GetOpcode() { return opcode; }
|
||||||
|
|
||||||
|
public DateTime GetReceivedTime() { return m_receivedTime; }
|
||||||
|
public void SetReceiveTime(DateTime receivedTime) { m_receivedTime = receivedTime; }
|
||||||
|
|
||||||
uint opcode;
|
uint opcode;
|
||||||
|
DateTime m_receivedTime; // only set for a specific set of opcodes, for performance reasons.
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PacketHeader
|
public class PacketHeader
|
||||||
|
|||||||
@@ -275,6 +275,8 @@ namespace Game.Networking.Packets
|
|||||||
ClientTime = _worldPacket.ReadUInt32();
|
ClientTime = _worldPacket.ReadUInt32();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public DateTime GetReceivedTime() { return _worldPacket.GetReceivedTime(); }
|
||||||
|
|
||||||
public uint ClientTime; // Client ticks in ms
|
public uint ClientTime; // Client ticks in ms
|
||||||
public uint SequenceIndex; // Same index as in request
|
public uint SequenceIndex; // Same index as in request
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -340,6 +340,9 @@ namespace Game.Networking
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opcode == ClientOpcodes.TimeSyncResponse)
|
||||||
|
packet.SetReceiveTime(DateTime.Now);
|
||||||
|
|
||||||
// Our Idle timer will reset on any non PING opcodes on login screen, allowing us to catch people idling.
|
// Our Idle timer will reset on any non PING opcodes on login screen, allowing us to catch people idling.
|
||||||
_worldSession.ResetTimeOutTime(false);
|
_worldSession.ResetTimeOutTime(false);
|
||||||
|
|
||||||
@@ -812,10 +815,7 @@ namespace Game.Networking
|
|||||||
lock (_worldSessionLock)
|
lock (_worldSessionLock)
|
||||||
{
|
{
|
||||||
if (_worldSession != null)
|
if (_worldSession != null)
|
||||||
{
|
|
||||||
_worldSession.SetLatency(ping.Latency);
|
_worldSession.SetLatency(ping.Latency);
|
||||||
_worldSession.ResetClientTimeDelay();
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Log.outError(LogFilter.Network, "WorldSocket:HandlePing: peer sent CMSG_PING, but is not authenticated or got recently kicked, address = {0}", GetRemoteIpAddress());
|
Log.outError(LogFilter.Network, "WorldSocket:HandlePing: peer sent CMSG_PING, but is not authenticated or got recently kicked, address = {0}", GetRemoteIpAddress());
|
||||||
|
|||||||
@@ -15,8 +15,10 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
using Framework.Collections;
|
||||||
using Framework.Constants;
|
using Framework.Constants;
|
||||||
using Framework.Database;
|
using Framework.Database;
|
||||||
|
using Framework.Realm;
|
||||||
using Game.Accounts;
|
using Game.Accounts;
|
||||||
using Game.BattleGrounds;
|
using Game.BattleGrounds;
|
||||||
using Game.BattlePets;
|
using Game.BattlePets;
|
||||||
@@ -32,7 +34,6 @@ using System.IO;
|
|||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Framework.Realm;
|
|
||||||
|
|
||||||
namespace Game
|
namespace Game
|
||||||
{
|
{
|
||||||
@@ -331,6 +332,18 @@ namespace Game
|
|||||||
if (m_Socket[(int)ConnectionType.Realm] != null && m_Socket[(int)ConnectionType.Realm].IsOpen() && _warden != null)
|
if (m_Socket[(int)ConnectionType.Realm] != null && m_Socket[(int)ConnectionType.Realm].IsOpen() && _warden != null)
|
||||||
_warden.Update();
|
_warden.Update();
|
||||||
|
|
||||||
|
if (!updater.ProcessUnsafe()) // <=> updater is of type MapSessionFilter
|
||||||
|
{
|
||||||
|
// Send time sync packet every 10s.
|
||||||
|
if (_timeSyncTimer > 0)
|
||||||
|
{
|
||||||
|
if (diff >= _timeSyncTimer)
|
||||||
|
SendTimeSync();
|
||||||
|
else
|
||||||
|
_timeSyncTimer -= diff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ProcessQueryCallbacks();
|
ProcessQueryCallbacks();
|
||||||
|
|
||||||
if (updater.ProcessUnsafe())
|
if (updater.ProcessUnsafe())
|
||||||
@@ -828,12 +841,42 @@ namespace Game
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ResetTimeSync()
|
||||||
|
{
|
||||||
|
_timeSyncNextCounter = 0;
|
||||||
|
_pendingTimeSyncRequests.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SendTimeSync()
|
||||||
|
{
|
||||||
|
TimeSyncRequest timeSyncRequest = new();
|
||||||
|
timeSyncRequest.SequenceIndex = _timeSyncNextCounter;
|
||||||
|
SendPacket(timeSyncRequest);
|
||||||
|
|
||||||
|
_pendingTimeSyncRequests[_timeSyncNextCounter] = Time.GetMSTime();
|
||||||
|
|
||||||
|
// Schedule next sync in 10 sec (except for the 2 first packets, which are spaced by only 5s)
|
||||||
|
_timeSyncTimer = _timeSyncNextCounter == 0 ? 5000 : 10000u;
|
||||||
|
_timeSyncNextCounter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint AdjustClientMovementTime(uint time)
|
||||||
|
{
|
||||||
|
long movementTime = (long)time + _timeSyncClockDelta;
|
||||||
|
if (_timeSyncClockDelta == 0 || movementTime < 0 || movementTime > 0xFFFFFFFF)
|
||||||
|
{
|
||||||
|
Log.outWarn(LogFilter.Misc, "The computed movement time using clockDelta is erronous. Using fallback instead");
|
||||||
|
return GameTime.GetGameTimeMS();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
return (uint)movementTime;
|
||||||
|
}
|
||||||
|
|
||||||
public Locale GetSessionDbcLocale() { return m_sessionDbcLocale; }
|
public Locale GetSessionDbcLocale() { return m_sessionDbcLocale; }
|
||||||
public Locale GetSessionDbLocaleIndex() { return m_sessionDbLocaleIndex; }
|
public Locale GetSessionDbLocaleIndex() { return m_sessionDbLocaleIndex; }
|
||||||
|
|
||||||
public uint GetLatency() { return m_latency; }
|
public uint GetLatency() { return m_latency; }
|
||||||
public void SetLatency(uint latency) { m_latency = latency; }
|
public void SetLatency(uint latency) { m_latency = latency; }
|
||||||
public void ResetClientTimeDelay() { m_clientTimeDelay = 0; }
|
|
||||||
public void ResetTimeOutTime(bool onlyActive)
|
public void ResetTimeOutTime(bool onlyActive)
|
||||||
{
|
{
|
||||||
if (GetPlayer())
|
if (GetPlayer())
|
||||||
@@ -893,7 +936,6 @@ namespace Game
|
|||||||
Locale m_sessionDbcLocale;
|
Locale m_sessionDbcLocale;
|
||||||
Locale m_sessionDbLocaleIndex;
|
Locale m_sessionDbLocaleIndex;
|
||||||
uint m_latency;
|
uint m_latency;
|
||||||
uint m_clientTimeDelay;
|
|
||||||
AccountData[] _accountData = new AccountData[(int)AccountDataTypes.Max];
|
AccountData[] _accountData = new AccountData[(int)AccountDataTypes.Max];
|
||||||
uint[] tutorials = new uint[SharedConst.MaxAccountTutorialValues];
|
uint[] tutorials = new uint[SharedConst.MaxAccountTutorialValues];
|
||||||
TutorialsFlag tutorialsChanged;
|
TutorialsFlag tutorialsChanged;
|
||||||
@@ -916,6 +958,13 @@ namespace Game
|
|||||||
|
|
||||||
ObjectGuid m_currentBankerGUID;
|
ObjectGuid m_currentBankerGUID;
|
||||||
|
|
||||||
|
CircularBuffer<Tuple<long, uint>> _timeSyncClockDeltaQueue = new(6); // first member: clockDelta. Second member: latency of the packet exchange that was used to compute that clockDelta.
|
||||||
|
long _timeSyncClockDelta;
|
||||||
|
|
||||||
|
Dictionary<uint, uint> _pendingTimeSyncRequests = new(); // key: counter. value: server time when packet with that counter was sent.
|
||||||
|
uint _timeSyncNextCounter;
|
||||||
|
uint _timeSyncTimer;
|
||||||
|
|
||||||
CollectionMgr _collectionMgr;
|
CollectionMgr _collectionMgr;
|
||||||
|
|
||||||
ConnectToKey _instanceConnectKey;
|
ConnectToKey _instanceConnectKey;
|
||||||
|
|||||||
Reference in New Issue
Block a user