/*
* Aphelion
* Copyright (c) 2013 Joris van der Wel
*
* This file is part of Aphelion
*
* Aphelion is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, version 3 of the License.
*
* Aphelion 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 Affero General Public License
* along with Aphelion. If not, see <http://www.gnu.org/licenses/>.
*
* In addition, the following supplemental terms apply, based on section 7 of
* the GNU Affero General Public License (version 3):
* a) Preservation of all legal notices and author attributions
* b) Prohibition of misrepresentation of the origin of this material, and
* modified versions are required to be marked in reasonable ways as
* different from the original version (for example by appending a copyright notice).
*
* Linking this library statically or dynamically with other modules is making a
* combined work based on this library. Thus, the terms and conditions of the
* GNU Affero General Public License cover the whole combination.
*
* As a special exception, the copyright holders of this library give you
* permission to link this library with independent modules to produce an
* executable, regardless of the license terms of these independent modules,
* and to copy and distribute the resulting executable under terms of your
* choice, provided that you also meet, for each linked independent module,
* the terms and conditions of the license of that module. An independent
* module is a module which is not derived from or based on this library.
*/
package aphelion.client.net;
import aphelion.shared.event.ClockSource;
import aphelion.shared.swissarmyknife.ThreadSafe;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/** This object provides clock synchronization with a server.
* The client should send multiple time requests during the connection
* phase and periodically during gameplay (but never when downloading).
*
* A time request consists of the local timestamp (such as System.nanoTime()).
* The server must reply with a response as soon as possible containing the
* timestamp given by the client unmodified and its own timestamp.
*
* These values are used to estimate latency and calculate a clock difference.
* (The technique that is used to do this is listed below.)
*
* The technique that is used is optimized for TCP unlike protocols like
* NTP which require UDP. It does this by collecting many samples and discarding
* samples that are most likely the result of a retransmission.
*
* The time sync is repeated periodically during gameplay in combination with
* removing very old entries to mitigate any clock drift between the server and
* clients.
*
* Zachary Booth Simpson. A Stream-based Time Synchronization Technique For
* Networked Computer Games, 2000.
* http://www.mine-control.com/zack/timesync/timesync.html
*
* @author Joris
*/
public class ClockSync implements ClockSource
{
private final int entryLimit;
private final List<Entry> entries = new ArrayList<>(); // sorted
private volatile boolean hasResponse = false;
private volatile long offset;
public ClockSync(int entryLimit)
{
this.entryLimit = entryLimit;
}
/** Register a response from the server.
*
* @param receivedAt The nano time at which the response was received
* @param clientRequestTime When was the request sent?
* @param serverNanoTime What (nano) time value did the server send us
*/
@ThreadSafe
public synchronized void addResponse(long receivedAt, long clientRequestTime, long serverNanoTime)
{
long latency = (receivedAt - clientRequestTime) / 2;
long timeOffset = serverNanoTime - receivedAt + latency;
Entry entry = new Entry(timeOffset, System.nanoTime());
int index = Collections.binarySearch(entries, entry);
if (index < 0)
{
index = -(index + 1);
}
entries.add(index, entry);
while(entries.size() >= entryLimit)
{
removeOldest();
}
offset = calculateOffset(); // ofset is only set so that the getters do not need to lock
hasResponse = true; // set me AFTER setting offset to gaurantee thread safety
}
private void removeOldest()
{
long now = System.nanoTime();
if (entries.isEmpty())
{
return;
}
// compare ages (instead of using the smallest value) so that nanoTime wraparound is handled properly
int oldestIndex = 0;
long oldestAge = now - entries.get(oldestIndex).addedAt;
for (int a = 1; a < entries.size(); ++a)
{
Entry timeOffset = entries.get(a);
long age = now - timeOffset.addedAt;
if (age > oldestAge)
{
oldestAge = age;
oldestIndex = a;
}
}
entries.remove(oldestIndex);
}
private long calculateOffset()
{
double mean = calculateMean();
double deviation = calculateStandardDeviation(mean);
double median = calculateMedian();
double total = 0;
long values = 0;
for (Entry timeOffset : entries)
{
if (Math.abs(timeOffset.timeOffset - median) <= deviation)
{
total += timeOffset.timeOffset;
++values;
}
}
if (values == 0)
{
return 0;
}
return (long) (total / values);
}
private double calculateMean()
{
double total = 0;
long values = 0;
for (Entry timeOffset : entries)
{
total += timeOffset.timeOffset;
++values;
}
if (values == 0)
{
return 0;
}
return total / values;
}
private double calculateStandardDeviation(double mean)
{
double total = 0;
long values = 0;
for (Entry timeOffset : entries)
{
total += Math.pow(timeOffset.timeOffset - mean, 2);
++values;
}
if (values == 0)
{
return 0;
}
return Math.sqrt(total / values);
}
private double calculateMedian()
{
int halfSize = entries.size() / 2;
if (entries.size() % 2 == 0)
{
double total = entries.get(halfSize - 1).timeOffset;
total += entries.get(halfSize).timeOffset;
return total / 2;
}
else
{
return entries.get(halfSize).timeOffset;
}
}
@ThreadSafe
public boolean hasResponse()
{
return hasResponse;
}
@ThreadSafe
public long getOffset()
{
if (!hasResponse())
{
throw new IllegalStateException("Atleast one response must have been collected");
}
return offset;
}
@Override
@ThreadSafe
public long nanoTime()
{
return System.nanoTime() + getOffset();
}
private static class Entry implements Comparable<Entry>
{
final long timeOffset;
final long addedAt;
Entry(long timeOffset, long addedAt)
{
this.timeOffset = timeOffset;
this.addedAt = addedAt;
}
@Override
public int compareTo(Entry o)
{
return Long.compare(timeOffset, o.timeOffset);
}
@Override
public boolean equals(Object obj)
{
if (obj instanceof Entry)
{
return timeOffset == ((Entry)obj).timeOffset;
}
return false;
}
@Override
public int hashCode()
{
int hash = 7;
hash = 31 * hash + (int) (this.timeOffset ^ (this.timeOffset >>> 32));
return hash;
}
}
}