/*
* 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.shared.net.game;
import aphelion.shared.event.Workable;
import aphelion.shared.event.WorkerTask;
import aphelion.shared.event.promise.AbstractPromise;
import aphelion.shared.event.promise.PromiseException;
import aphelion.shared.net.PROTOCOL;
import aphelion.shared.net.SessionToken;
import aphelion.shared.net.WS_CLOSE_STATUS;
import aphelion.shared.net.WebSocketTransport;
import aphelion.shared.net.protobuf.GameC2S;
import aphelion.shared.net.protobuf.GameC2S.TimeRequest;
import aphelion.shared.net.protobuf.GameS2C;
import aphelion.shared.swissarmyknife.*;
import com.google.protobuf.CodedOutputStream;
import com.google.protobuf.InvalidProtocolBufferException;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Map;
import java.util.WeakHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Represents a game protocol connection to the client or server.
* Multiple sockets may be used to send the data.
* @author Joris
*/
public class GameProtocolConnection implements Attachable
{
private static final Logger log = Logger.getLogger("aphelion.net");
public static final AttachmentManager attachmentManager = new AttachmentManager();
private AttachmentData attachment = attachmentManager.getNewDataContainer();
private final Workable workable;
private final WebSocketTransport websocketTransport;
public final SessionToken session;
public final boolean server;
private final ArrayList listeners = new ArrayList(4);
private final STATE[] state = {STATE.NONE};
private static enum STATE {NONE, CREATED, DROPPED};
public GameProtocolConnection(Workable workable, WebSocketTransport websocketTransport, SessionToken session, boolean server)
{
this.workable = workable;
this.websocketTransport = websocketTransport;
this.session = session;
this.server = server;
}
public void addListener(GameProtoListener listener)
{
this.listeners.add(listener);
}
public void addListener(GameC2SListener listener)
{
this.listeners.add(listener);
}
public void addListener(GameS2CListener listener)
{
this.listeners.add(listener);
}
@Override
public AttachmentData getAttachments()
{
return attachment;
}
@ThreadSafe
public AbstractPromise send(GameS2C.S2COrBuilder s2c)
{
if (!server)
{
throw new IllegalStateException();
}
// callback is fired when the message has been sent to the socket
return workable.addWorkerTask(new EncodeS2CWork(), s2c);
}
@ThreadSafe
public AbstractPromise send(GameC2S.C2SOrBuilder c2s)
{
if (server)
{
throw new IllegalStateException();
}
return workable.addWorkerTask(new EncodeC2SWork(), c2s);
}
public void requestClose(WS_CLOSE_STATUS code, String message)
{
websocketTransport.closeSession(session, PROTOCOL.GAME, code, message);
}
public void requestClose(WS_CLOSE_STATUS code)
{
requestClose(code, "");
}
@ThreadSafe
public void created()
{
synchronized(state)
{
if (state[0] == STATE.NONE)
{
state[0] = STATE.CREATED;
workable.runOnMain(new CallGameClientListener(listeners, this, 1, null, null));
}
else
{
log.log(Level.WARNING, "created() in an incorrect state {0}", state[0]);
}
}
}
@ThreadSafe
public void removed()
{
synchronized(state)
{
if (state[0] == STATE.CREATED)
{
state[0] = STATE.DROPPED;
workable.runOnMain(new CallGameClientListener(listeners, this, 2, null, null));
}
else
{
log.log(Level.WARNING, "removed() in an incorrect state {0}", state[0]);
}
}
}
/** Immediately parse an incoming message. This is an expensive call
* @param message The nano time at which this message was received
* @param receivedAt The nano time at which this message was received
*/
@ThreadSafe
public void parseMessage(ByteBuffer message, long receivedAt)
{
synchronized(state)
{
if (state[0] != STATE.CREATED)
{
log.log(Level.WARNING, "parseMessage() in an incorrect state {0}", state[0]);
return;
}
}
if (server)
{
try
{
// this method is called from one of the websocket worker threads
GameC2S.C2S c2s = GameC2S.C2S.parseFrom(new ByteArrayInputStream(message.array(), message.position(), message.remaining()));
// parse these immediately as a shortcut to get a better time measurement
// While possible to optimize further, this is not needed.
// With client and server on the same machine (my machine) requests are
// sent and received within 1 millisecond.
for (TimeRequest timeRequest : c2s.getTimeRequestList())
{
GameS2C.S2C.Builder s2c = GameS2C.S2C.newBuilder();
GameS2C.TimeResponse.Builder response = s2c.addTimeResponseBuilder();
response.setClientTime(timeRequest.getClientTime());
response.setServerTime(System.nanoTime());
EncodeS2CWork work = new EncodeS2CWork();
try
{
work.work(s2c);
}
catch (PromiseException ex)
{
log.log(Level.SEVERE, "Unexpected exception", ex);
}
}
// run the callback on the main thread
workable.runOnMain(new CallGameClientListener(listeners, this, c2s, receivedAt));
}
catch (InvalidProtocolBufferException ex)
{
log.log(Level.SEVERE, "Protobuf Exception while parsing a message as a server", ex);
}
catch (IOException ex)
{
log.log(Level.SEVERE, "IOException while parsing a message as a server", ex);
}
}
else
{
try
{
// this method is called from one of the websocket worker threads
GameS2C.S2C s2c = GameS2C.S2C.parseFrom(new ByteArrayInputStream(message.array(), message.position(), message.remaining()));
// run the callback on the main thread
workable.runOnMain(new CallGameClientListener(listeners, this, s2c, receivedAt));
}
catch (InvalidProtocolBufferException ex)
{
log.log(Level.SEVERE, "Protobuf Exception while parsing a message as a client", ex);
}
catch (IOException ex)
{
log.log(Level.SEVERE, "IOException while parsing a message as a client", ex);
}
}
}
@ThreadSafe
public void connectionAdded()
{
workable.runOnMain(new CallGameClientListener(listeners, this, 4, null, null));
}
@ThreadSafe
public void connectionDropped(WS_CLOSE_STATUS drop_code, String drop_reason)
{
workable.runOnMain(new CallGameClientListener(listeners, this, 5, drop_code, drop_reason));
}
// a cache that kicks in when we have to send the same message multiple times.
private static Map<GameS2C.S2COrBuilder, byte[]> s2cCache = Collections.synchronizedMap(new WeakHashMap<GameS2C.S2COrBuilder, byte[]>());
private static Map<GameC2S.C2SOrBuilder, byte[]> c2sCache = Collections.synchronizedMap(new WeakHashMap<GameC2S.C2SOrBuilder, byte[]>());
private class EncodeS2CWork extends WorkerTask<GameS2C.S2COrBuilder, Void>
{
@Override
public Void work(GameS2C.S2COrBuilder s2cOrBuilder) throws PromiseException
{
byte[] result;
result = s2cCache.get(s2cOrBuilder);
if (result != null)
{
result = result.clone();
// Need to clone because websocketTransport needs to reserve a few bytes
}
else
{
GameS2C.S2C s2c;
if (s2cOrBuilder instanceof GameS2C.S2C.Builder)
{
synchronized(s2cOrBuilder)
{
// protobuf does not support concurrent calls to build()
// since it modifies internal state (even though it returns
// a new instance of GameS2C.S2C, which is immutable by API,
// the old object is also no longer mutable by use of Error
// exceptions)
s2c = ((GameS2C.S2C.Builder) s2cOrBuilder).build();
}
}
else
{
s2c = (GameS2C.S2C) s2cOrBuilder;
}
int size = s2c.getSerializedSize();
result = new byte[size + WebSocketTransport.SEND_RESERVEDPREFIX_BYTES];
CodedOutputStream output = CodedOutputStream.newInstance(result, WebSocketTransport.SEND_RESERVEDPREFIX_BYTES, size);
try
{
s2c.writeTo(output);
}
catch (IOException ex)
{
log.log(Level.WARNING, "", ex);
throw new PromiseException(ex);
}
// assert that there are no bytes left
output.checkNoSpaceLeft();
s2cCache.put(s2cOrBuilder, result);
}
try
{
// the websocket library will encode the bytes into websocket frames from this worker thread
websocketTransport.send(session, PROTOCOL.GAME, result);
}
catch (WebSocketTransport.NoSuitableConnection ex)
{
log.log(Level.WARNING, "", ex);
throw new PromiseException(ex);
}
return null; // callback return argument is not used
}
}
private class EncodeC2SWork extends WorkerTask<GameC2S.C2SOrBuilder, Void>
{
@Override
public Void work(GameC2S.C2SOrBuilder c2sOrBuilder) throws PromiseException
{
byte[] result;
result = c2sCache.get(c2sOrBuilder);
if (result != null)
{
result = result.clone();
// Need to clone because websocketTransport needs to reserve a few bytes
}
else
{
GameC2S.C2S c2s;
if (c2sOrBuilder instanceof GameC2S.C2S.Builder)
{
synchronized (c2sOrBuilder)
{
try
{
// special case for a small amount of extra accuracy
if (c2sOrBuilder.getTimeRequestCount() > 0)
{
for (TimeRequest.Builder timeRequest : ((GameC2S.C2S.Builder) c2sOrBuilder).getTimeRequestBuilderList())
{
timeRequest.setClientTime(System.nanoTime());
}
}
}
catch (Error ex)
{
// no longer mutable, leave the time stamp alone
}
c2s = ((GameC2S.C2S.Builder) c2sOrBuilder).build();
}
}
else
{
c2s = (GameC2S.C2S) c2sOrBuilder;
}
int size = c2s.getSerializedSize();
result = new byte[size + WebSocketTransport.SEND_RESERVEDPREFIX_BYTES];
CodedOutputStream output = CodedOutputStream.newInstance(result, WebSocketTransport.SEND_RESERVEDPREFIX_BYTES, size);
try
{
c2s.writeTo(output);
}
catch (IOException ex)
{
log.log(Level.WARNING, "", ex);
throw new PromiseException(ex);
}
// assert that there are no bytes left
output.checkNoSpaceLeft();
c2sCache.put(c2sOrBuilder, result);
}
try
{
// the websocket library will encode the bytes into websocket frames from this worker thread
websocketTransport.send(session, PROTOCOL.GAME, result);
}
catch (WebSocketTransport.NoSuitableConnection ex)
{
log.log(Level.WARNING, "", ex);
throw new PromiseException(ex);
}
return null; // callback return argument is not used
}
}
public static class CallGameClientListener implements Runnable
{
final Iterable listeners;
final GameC2S.C2S c2s;
final GameS2C.S2C s2c;
final GameProtocolConnection conn;
final int what;
final long receivedAt;
final WS_CLOSE_STATUS drop_code;
final String drop_reason;
public CallGameClientListener(Iterable listeners, GameProtocolConnection conn, int what, WS_CLOSE_STATUS drop_code, String drop_reason)
{
this.listeners = listeners;
this.conn = conn;
this.c2s = null;
this.s2c = null;
this.what = what;
this.receivedAt = 0;
this.drop_code = drop_code;
this.drop_reason = drop_reason;
}
// message
public CallGameClientListener(Iterable listeners, GameProtocolConnection conn, GameC2S.C2S c2s, long receivedAt)
{
this.listeners = listeners;
this.conn = conn;
this.c2s = c2s;
this.s2c = null;
this.what = 3;
this.receivedAt = receivedAt;
this.drop_code = null;
this.drop_reason = null;
}
// message
public CallGameClientListener(Iterable listeners, GameProtocolConnection conn, GameS2C.S2C s2c, long receivedAt)
{
this.listeners = listeners;
this.conn = conn;
this.c2s = null;
this.s2c = s2c;
this.what = 3;
this.receivedAt = receivedAt;
this.drop_code = null;
this.drop_reason = null;
}
@Override
public void run()
{
for (Object listener : listeners)
{
switch (what)
{
case 0:
if (listener instanceof GameProtoListener)
{
((GameProtoListener) listener).gameEstablishFailure(drop_code, drop_reason);
}
break;
case 1:
if (listener instanceof GameProtoListener)
{
assert listener instanceof GameC2SListener;
assert listener instanceof GameS2CListener;
((GameProtoListener) listener).gameNewClient(conn);
}
break;
case 2:
if (listener instanceof GameProtoListener)
{
((GameProtoListener) listener).gameRemovedClient(conn);
}
break;
case 3:
if (this.c2s != null)
{
if (listener instanceof GameC2SListener)
{
((GameC2SListener) listener).gameC2SMessage(conn, c2s, receivedAt);
}
}
else
{
assert this.s2c != null;
if (listener instanceof GameS2CListener)
{
((GameS2CListener) listener).gameS2CMessage(conn, s2c, receivedAt);
}
}
break;
case 4:
if (listener instanceof GameProtoListener)
{
((GameProtoListener) listener).gameNewConnection(conn);
}
break;
case 5:
if (listener instanceof GameProtoListener)
{
((GameProtoListener) listener).gameDropConnection(conn, drop_code, drop_reason);
}
break;
default:
assert false;
}
}
}
}
}