/* * 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.net.game.NetworkedActor; import aphelion.shared.net.game.GameProtoListener; import aphelion.shared.net.game.GameProtocolConnection; import aphelion.shared.net.WS_CLOSE_STATUS; import aphelion.shared.net.protobuf.GameC2S; import aphelion.shared.net.protobuf.GameC2S.Authenticate; import aphelion.shared.net.protobuf.GameC2S.ConnectionReady; import aphelion.shared.net.protobuf.GameC2S.TimeRequest; import aphelion.shared.net.protobuf.GameOperation; import aphelion.shared.net.protobuf.GameS2C; import aphelion.shared.net.protobuf.GameS2C.AuthenticateResponse; import aphelion.shared.physics.PhysicsEnvironment; import aphelion.shared.physics.valueobjects.PhysicsMovement; import aphelion.shared.physics.valueobjects.PhysicsShipPosition; import aphelion.shared.physics.WEAPON_SLOT; import aphelion.shared.swissarmyknife.SwissArmyKnife; import aphelion.shared.event.ClockSource; import aphelion.shared.event.TickEvent; import aphelion.shared.event.TickedEventLoop; import aphelion.shared.net.COMMAND_SOURCE; import aphelion.shared.net.game.ActorListener; import aphelion.shared.resource.Asset; import aphelion.shared.resource.AssetCache; import aphelion.shared.resource.LocalUserStorage; import aphelion.shared.swissarmyknife.RollingHistory; import com.google.protobuf.ByteString; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; import java.util.*; import java.util.logging.Level; import java.util.logging.Logger; /** * * @author Joris */ public class NetworkedGame implements GameProtoListener, TickEvent { private static final Logger log = Logger.getLogger("aphelion.net"); /** How often to send our queued move operations to the server. (in ticks) */ private static final int SEND_MOVE_DELAY = 5; /** How many move operations to send that were already sent previously. * If, for example, SEND_MOVE_DELAY is 5 and SEND_MOVE_OVERLAP is 10, a * maximum of 15 move operations will be sent every 5 ticks. * And each move operation will be sent a total of 3 times. */ private static final int SEND_MOVE_OVERLAP = 10; /** How often to sync the clock (in nanoseconds). * The clock sync is also used to measure round trip latency. */ private static final long CLOCKSYNC_DELAY = 30 * 1000_000_000L; private final TickedEventLoop loop; private final URL httpServer; private PhysicsEnvironment physicsEnv; private GameProtocolConnection gameConn; private ClockSync clockSync; private int initial_timeSync_count = 0; private final AssetCache assetCache; private final List<Asset> assets = new ArrayList<>(); public String mapResource; public final List<String> gameConfigResources = new ArrayList<>(); public final List<String> niftyGuiResources = new ArrayList<>(); private final ArrayList<ActorListener> actorListeners = new ArrayList<>(4); private final Map<Integer, NetworkedActor> actors = new HashMap<>(); private final ArrayList<OperationDroppedListener> operationDroppedListeners = new ArrayList<>(4); private String nickname; private STATE state; private int myPid = -1; /** The tick difference between the local PhysicsEnvironment ticks and the servers PhysicsEnvironment ticks. */ private long sendMove_lastSent_tick; // physicsEnv.getTick() private RollingHistory<PhysicsMovement> sendMove_history; // physicsEnv.getTick() private long clockSync_lastSync_nano; private long clockSync_lastRTT_nano; /** This list is used to queue any physics operations we receive before ArenaSync has been received. */ private LinkedList<GameS2C.S2C> arenaSyncOperationQueue; private final HashSet<Integer> unknownActorRemove = new HashSet<>(); // Assumes PIDs are unique. ActorNew is never called twice with the same PID. private WS_CLOSE_STATUS disconnect_code; // null if unknown private String disconnect_reason; // null if unknown private AuthenticateResponse.ERROR auth_error; private String auth_error_desc; private static enum STATE { // the order in this enum is also the order the states will move through. ESTABLISHING (1), // busy establishing sockets and getting a session token ESTABLISHED (2), // we now have a session token and atleast 1 websocket SEND_AUTHENTICATE (3), WAIT_AUTHENTICATE (4), INITIAL_TIME_SYNC (5), // Busy with the initial time sync INITIAL_TIME_SYNC_DONE (6), SEND_CONNECTION_READY (7), WAIT_FOR_ARENALOAD (8), RECEIVED_ARENALOAD (9), ARENA_LOADING (10), // we are loading the map, etc SEND_ARENA_LOADED (11), WAIT_FOR_ARENASYNC (12), // server is busy sending us arena sync data RECEIVED_ARENASYNC (13), READY (14), // ready to play the game AUTH_FAILED (20), DISCONNECTED (21); int sequence; private STATE(int sequence) { this.sequence = sequence; } public boolean hasOccurred(STATE other) { return this.sequence >= other.sequence; } } public NetworkedGame(TickedEventLoop loop, URL httpServer, String nickname) { this.loop = loop; this.httpServer = httpServer; this.nickname = nickname; loop.addTickEvent(this); nextState(STATE.ESTABLISHING); try { assetCache = new AssetCache(new LocalUserStorage("assets")); } catch (IOException ex) { // Should not fail throw new Error(ex); } } public NetworkedActor getActor(int pid) { return actors.get(pid); } public void addActorListener(ActorListener listener, boolean playback) { this.actorListeners.add(listener); if (playback) { for (NetworkedActor actor : this.actors.values()) { listener.newActor(actor); } } } public void addOperationDroppedLIstener(OperationDroppedListener listener) { operationDroppedListeners.add(listener); } private void fireOperationDroppedListeners(long tick, int pid, Object messsage) { for (OperationDroppedListener listener : operationDroppedListeners) { listener.operationDropped(tick, pid, messsage); } } public void arenaLoaded(PhysicsEnvironment physicsEnv) { if (this.state != STATE.ARENA_LOADING) { throw new IllegalStateException(); } if (physicsEnv.getTick() != 0) { throw new IllegalStateException(); } this.physicsEnv = physicsEnv; nextState(STATE.SEND_ARENA_LOADED); } public boolean isConnecting() { // all states before WAIT_FOR_ARENALOAD count as "connecting" // DISCONNECTED should return false! return !state.hasOccurred(STATE.ARENA_LOADING); } public boolean hasArenaSynced() { return state.hasOccurred(STATE.RECEIVED_ARENASYNC); } public boolean isReady() { return state == STATE.READY; } public boolean isDisconnected() { // no way out of this state, you should create a new object return state == STATE.DISCONNECTED; } public WS_CLOSE_STATUS getDisconnectCode() { return disconnect_code; } public String getDisconnectReason() { return disconnect_reason; } public AuthenticateResponse.ERROR getAuthError() { return auth_error; } public String getAuthErrorDesc() { return auth_error_desc; } public GameProtocolConnection getGameConn() { return gameConn; } public ClockSource getSyncedClockSource() { return clockSync; } private void nextState(STATE newState) { if (this.state == newState) { throw new IllegalStateException(); } this.state = newState; GameC2S.C2S.Builder c2s; switch (state) { case ESTABLISHING: disconnect_code = null; disconnect_reason = null; auth_error = null; auth_error_desc = null; break; case ESTABLISHED: nextState(STATE.SEND_AUTHENTICATE); break; case SEND_AUTHENTICATE: c2s = GameC2S.C2S.newBuilder(); Authenticate.Builder auth = c2s.addAuthenticateBuilder(); auth.setAuthMethod(Authenticate.AUTH_METHOD.NONE); auth.setNickname(nickname); gameConn.send(c2s); nextState(STATE.WAIT_AUTHENTICATE); break; case WAIT_AUTHENTICATE: break; case INITIAL_TIME_SYNC: sendTimeSync(); break; case INITIAL_TIME_SYNC_DONE: nextState(STATE.SEND_CONNECTION_READY); break; case SEND_CONNECTION_READY: c2s = GameC2S.C2S.newBuilder(); ConnectionReady.Builder readyRequest = c2s.addConnectionReadyBuilder(); gameConn.send(c2s); nextState(STATE.WAIT_FOR_ARENALOAD); break; case WAIT_FOR_ARENALOAD: break; case RECEIVED_ARENALOAD: // handled by GameLoop nextState(STATE.ARENA_LOADING); break; case ARENA_LOADING: break; case SEND_ARENA_LOADED: c2s = GameC2S.C2S.newBuilder(); GameC2S.ArenaLoaded.Builder loadedRequest = c2s.addArenaLoadedBuilder(); gameConn.send(c2s); nextState(STATE.WAIT_FOR_ARENASYNC); break; case WAIT_FOR_ARENASYNC: arenaSyncOperationQueue = new LinkedList<>(); break; case RECEIVED_ARENASYNC: for (GameS2C.S2C s2c : arenaSyncOperationQueue) { parseOperation(s2c); } arenaSyncOperationQueue = null; nextState(STATE.READY); break; case READY: break; case AUTH_FAILED: gameConn.requestClose(WS_CLOSE_STATUS.NORMAL); break; case DISCONNECTED: break; } } @Override public void gameEstablishFailure(WS_CLOSE_STATUS code, String reason) { nextState(STATE.DISCONNECTED); disconnect_code = code; disconnect_reason = reason; } @Override public void gameNewClient(GameProtocolConnection game) { if (isDisconnected()) { log.log(Level.WARNING, "Received gameNewClient while in a disconnected state"); return; } this.gameConn = game; clockSync = new ClockSync(30); // limit of 30 means a half hour of history at most. nextState(STATE.ESTABLISHED); } @Override public void gameRemovedClient(GameProtocolConnection game) { game = null; nextState(STATE.DISCONNECTED); } @Override public void gameNewConnection(GameProtocolConnection game) { } @Override public void gameDropConnection(GameProtocolConnection game, WS_CLOSE_STATUS code, String reason) { } @Override public void gameS2CMessage(GameProtocolConnection game, GameS2C.S2C s2c, long receivedAt) { if (isDisconnected()) { log.log(Level.WARNING, "Received a message while in a disconnected state"); return; } if (game != this.gameConn) { log.log(Level.SEVERE, "Received a message for an invalid GameProtocol (session token). Ignoring message"); assert false; return; } for (GameS2C.AuthenticateResponse msg : s2c.getAuthenticateResponseList()) { if (state != STATE.WAIT_AUTHENTICATE) { log.log(Level.SEVERE, "Received AuthenticateResponse in an invalid state"); return; } if (msg.getError() == AuthenticateResponse.ERROR.OK) { log.log(Level.INFO, "Authenticate OK"); nextState(STATE.INITIAL_TIME_SYNC); } else { auth_error = msg.getError(); auth_error_desc = msg.getErrorDescription(); log.log(Level.WARNING, "Authentication failed {0}: {1}", new Object[]{auth_error, auth_error_desc}); nextState(STATE.AUTH_FAILED); } } for (GameS2C.TimeResponse msg : s2c.getTimeResponseList()) { parseTimeResponse(msg, receivedAt); } for (GameS2C.ArenaLoad msg : s2c.getArenaLoadList()) { log.log(Level.INFO, "Received ArenaLoad"); if (state != STATE.WAIT_FOR_ARENALOAD) { log.log(Level.SEVERE, "Received ArenaLoad in an invalid state"); return; } this.mapResource = msg.getMap(); this.gameConfigResources.clear(); this.gameConfigResources.addAll(msg.getGameGonfigList()); this.niftyGuiResources.addAll(msg.getNiftyGuiList()); for (GameS2C.ResourceRequirement req : msg.getResourceRequirementList()) { try { assets.add(new Asset(assetCache, httpServer, req)); } catch (MalformedURLException ex) { log.log(Level.SEVERE, "Received a malformed url from the server", ex); this.disconnect_code = WS_CLOSE_STATUS.MALFORMED_PACKET; this.disconnect_reason = "Received a malformed url from the server"; this.gameConn.requestClose(disconnect_code, disconnect_reason); nextState(STATE.DISCONNECTED); return; } } nextState(STATE.RECEIVED_ARENALOAD); } for (GameS2C.ArenaSync msg : s2c.getArenaSyncList()) { log.log(Level.INFO, "Received ArenaSync"); // should only receive this message once (for now) this.myPid = msg.getYourPid(); // The current server time long serverNow = clockSync.nanoTime(); // how many ticks ago was the ArenSync message sent by the server? (rounded up) long tickLatency = SwissArmyKnife.divideCeil(serverNow - msg.getCurrentNanoTime(), loop.TICK); // At what tick was our event loop when the server sent that message? long loopTick = loop.currentTick() - tickLatency; // make sure our loop is synchronized to the servers time value loop.synchronize(msg.getCurrentNanoTime(), loopTick); // At what tick is the server now? (loop.synchronize will wait or fast forward ahead to compensate for latency) physicsEnv.skipForward(msg.getCurrentTicks() + tickLatency); physicsEnv.actorNew(msg.getCurrentTicks(), myPid, msg.getYourSeed(), msg.getShip()); NetworkedActor actor = new NetworkedActor(myPid, true, msg.getName()); this.actors.put(actor.pid, actor); nextState(STATE.RECEIVED_ARENASYNC); for (ActorListener listener : actorListeners) { listener.newActor(actor); } } if (s2c.getActorNewCount() > 0 || s2c.getActorSyncCount() > 0 || s2c.getActorModificationCount() > 0 || s2c.getActorRemoveCount() > 0 || s2c.getActorWarpCount() > 0 || s2c.getActorMoveCount() > 0 || s2c.getActorWeaponCount() > 0 || s2c.getWeaponSyncCount() > 0 ) { if (state == STATE.WAIT_FOR_ARENASYNC) { // special case // An operation might arrive before arenasync // so queue them. // It would be possible to notify the server with another completion message // (something like ARENASYNC_DONE), however that would only move the queue // onto the server. arenaSyncOperationQueue.add(s2c); } else if (state == STATE.READY) { parseOperation(s2c); } else { log.log(Level.SEVERE, "Received an operation in an invalid state"); } } } @Override public void gameC2SMessage(GameProtocolConnection game, GameC2S.C2S c2s, long receivedAt) { assert false; } private void parseOperation(GameS2C.S2C s2c) { // If implementing a new message type here, also add it to a conditional in gameS2CMessage() for (GameOperation.ActorNew msg : s2c.getActorNewList()) { log.log(Level.INFO, "Received ActorNew {0} {1}", new Object[] { msg.getTick(), msg.getPid()}); physicsEnv.actorNew( msg.getTick(), msg.getPid(), msg.getSeed(), msg.getShip() ); if (unknownActorRemove.contains(msg.getPid())) { // Received ActorNew and ActorRemove out of order unknownActorRemove.remove(msg.getPid()); } else { NetworkedActor actor = new NetworkedActor(msg.getPid(), false, msg.getName()); this.actors.put(actor.pid, actor); for (ActorListener listener : actorListeners) { listener.newActor(actor); } } } for (GameOperation.ActorSync msg : s2c.getActorSyncList()) { physicsEnv.actorSync(msg); } for (GameOperation.ActorModification msg : s2c.getActorModificationList()) { long tick = msg.getTick(); physicsEnv.actorModification(tick, msg.getPid(), msg.getShip()); } for (GameOperation.ActorRemove msg : s2c.getActorRemoveList()) { log.log(Level.INFO, "Received ActorRemove {0} {1}", new Object[] { msg.getTick(), msg.getPid()}); physicsEnv.actorRemove(msg.getTick(), msg.getPid()); NetworkedActor actor = actors.get(msg.getPid()); if (actor == null) { // Received ActorNew and ActorRemove out of order // Assumes PIDs are unique. ActorNew is never called twice with the same PID. unknownActorRemove.add(msg.getPid()); } else { actors.remove(actor.pid); for (ActorListener listener : actorListeners) { listener.removedActor(actor); } } } for (GameOperation.ActorWarp msg : s2c.getActorWarpList()) { //log.log(Level.INFO, "Received ActorWarp {0} {1}", new Object[] { msg.getTick(), msg.getPid()}); boolean valid = physicsEnv.actorWarp( msg.getTick(), msg.getPid(), msg.getHint(), msg.getX(), msg.getY(), msg.getXVel(), msg.getYVel(), msg.getRotation(), msg.hasX(), msg.hasY(), msg.hasXVel(), msg.hasYVel(), msg.hasRotation()); if (!valid) { fireOperationDroppedListeners(msg.getTick(), msg.getPid(), msg); } } for (GameOperation.ActorMove msg : s2c.getActorMoveList()) { //log.log(Level.INFO, "Received ActorMove {0} {1}", new Object[] { msg.getTick(), msg.getPid()}); long tick = msg.getTick(); List<PhysicsMovement> moves = PhysicsMovement.unserializeListLE(msg.getMove().asReadOnlyByteBuffer()); for (PhysicsMovement move : moves) { boolean valid = physicsEnv.actorMove( tick, msg.getPid(), move ); if (!valid) { fireOperationDroppedListeners(tick, msg.getPid(), msg); } ++tick; } } for (GameOperation.ActorWeapon msg : s2c.getActorWeaponList()) { long tick = msg.getTick(); boolean has_hint = msg.hasX() && msg.hasY() && msg.hasXVel() && msg.hasYVel() && msg.hasSnappedRotation(); if (!WEAPON_SLOT.isValidId(msg.getSlot())) { log.log(Level.SEVERE, "Received an ActorWeapon with an invalid slot id {0}", msg.getSlot()); continue; } WEAPON_SLOT slot = WEAPON_SLOT.byId(msg.getSlot()); boolean valid = physicsEnv.actorWeapon( tick, msg.getPid(), slot, has_hint, msg.getX(), msg.getY(), msg.getXVel(), msg.getYVel(), msg.getSnappedRotation()); if (!valid) { fireOperationDroppedListeners(tick, msg.getPid(), msg); } } for (GameOperation.WeaponSync msg : s2c.getWeaponSyncList()) { long tick = msg.getTick(); boolean valid = physicsEnv.weaponSync( tick, msg.getPid(), msg.getWeaponKey(), msg.getProjectilesList().toArray(new GameOperation.WeaponSync.Projectile[0]), msg.getKey()); if (!valid) { fireOperationDroppedListeners(tick, msg.getPid(), msg); } } } @Override public void tick(long tick) { long now = System.nanoTime(); if (sendMove_history != null) { // we might have move operations queued but the caller is not // calling sendMove() anymore sendMove(physicsEnv.getTick(), null); } if (isReady()) { if (now - clockSync_lastSync_nano > CLOCKSYNC_DELAY) { sendTimeSync(); // this method should immediately update lastClockSync } } } /** Available after RECEIVED_ARENASYNC * * @return The pid for the local player */ public int getMyPid() { if (!state.hasOccurred(STATE.RECEIVED_ARENASYNC)) { throw new IllegalStateException(); } return myPid; } public List<Asset> getRequiredAssets() { return Collections.unmodifiableList(assets); } public void sendTimeSync() { GameC2S.C2S.Builder c2s = GameC2S.C2S.newBuilder(); TimeRequest.Builder timeRequest = c2s.addTimeRequestBuilder(); timeRequest.setClientTime(System.nanoTime()); gameConn.send(c2s); clockSync_lastSync_nano = System.nanoTime(); } /** Queue a move to be sent to the server. * Moves into the past, or changing previously sent moves is not allowed. * * @param tick The tick at which the move occurred (based on physicsEnv.getTick() ) * @param move A move. * Null is a special value which is only used to make * sure the queue gets emptied in time (this is called for * every possible tick by NetworkedGame.tick() ). */ public void sendMove(long tick, PhysicsMovement move) { if (sendMove_history == null) { if (move == null || !move.hasEffect()) { return; } sendMove_history = new RollingHistory<>(tick, SEND_MOVE_DELAY + SEND_MOVE_OVERLAP); sendMove_lastSent_tick = tick - 1; } else if (tick < sendMove_history.getOldestTick()) { throw new IllegalStateException(); // Too far into the past } sendMove_history.setHistory(tick, move); if (tick - sendMove_lastSent_tick < SEND_MOVE_DELAY) { return; //queue } // Skip trailing and leading NONE / null moves. // note that this is not just an optimalization! // Trailing NONE's need to be skipped because a // NONE may be overriden at a (slightly) later moment. long first_tick = sendMove_history.getOldestTick(); while (true) { PhysicsMovement m = sendMove_history.get(first_tick); if (m != null && m != PhysicsMovement.NONE) { break; } ++first_tick; if (first_tick > sendMove_history.getMostRecentTick()) { return; // history is completely empty } } long last_tick = sendMove_history.getMostRecentTick(); while (true) { PhysicsMovement m = sendMove_history.get(last_tick); if (m != null && m != PhysicsMovement.NONE) { break; } --last_tick; // should have returned already in the previous loop assert last_tick >= sendMove_history.getOldestTick(); } sendMove_lastSent_tick = tick; GameC2S.C2S.Builder c2s = GameC2S.C2S.newBuilder(); GameOperation.ActorMove.Builder actorMove = c2s.addActorMoveBuilder(); actorMove.setPid(myPid); actorMove.setTick(first_tick); actorMove.setDirect(true); ArrayList<PhysicsMovement> moves = new ArrayList<>((int) (last_tick - first_tick + 1)); for (long t = first_tick; t <= last_tick; ++t) { PhysicsMovement m = sendMove_history.get(t); moves.add(m == null ? PhysicsMovement.NONE : m); } actorMove.setMove(ByteString.copyFrom(PhysicsMovement.serializeListLE(moves))); gameConn.send(c2s); } public void sendActorWeapon(long tick, WEAPON_SLOT weaponSlot, PhysicsShipPosition positionHint) { GameC2S.C2S.Builder c2s = GameC2S.C2S.newBuilder(); GameOperation.ActorWeapon.Builder actorWeapon = c2s.addActorWeaponBuilder(); actorWeapon.setTick(tick); actorWeapon.setPid(myPid); actorWeapon.setSlot(weaponSlot.id); if (positionHint != null && positionHint.set) { actorWeapon.setX(positionHint.x); actorWeapon.setY(positionHint.y); actorWeapon.setXVel(positionHint.x_vel); actorWeapon.setYVel(positionHint.y_vel); actorWeapon.setSnappedRotation(positionHint.rot_snapped); } gameConn.send(c2s); } public long getlastRTTNano() { return clockSync_lastRTT_nano; } private boolean parseTimeResponse_first = true; private void parseTimeResponse(GameS2C.TimeResponse message, long receivedAt) { if (parseTimeResponse_first) { parseTimeResponse_first = false; // ignore the first one due to lazy class loading messing up the results sendTimeSync(); return; } clockSync_lastRTT_nano = receivedAt - message.getClientTime(); clockSync.addResponse(receivedAt, message.getClientTime(), message.getServerTime()); if (log.isLoggable(Level.INFO)) { log.log(Level.INFO, "Received a time response. Request sent {0}ms ago; Offset {1}", new Object[] { (receivedAt - message.getClientTime()) / 1000000d, clockSync.getOffset() }); } if (state == STATE.INITIAL_TIME_SYNC) { if (initial_timeSync_count < 10) { ++initial_timeSync_count; sendTimeSync(); } else { nextState(STATE.INITIAL_TIME_SYNC_DONE); } } } public void sendCommand(COMMAND_SOURCE source, String name, String ... args) { sendCommand(source, name, 0, false, args); } public void sendCommand(COMMAND_SOURCE source, String name, int responseCode, boolean fromGUI, String ... args) { GameC2S.C2S.Builder c2s = GameC2S.C2S.newBuilder(); GameC2S.Command.Builder command = c2s.addCommandBuilder(); command.setSource(source.id); command.setName(name); if (responseCode != 0) { command.setResponseCode(responseCode); } command.addAllArguments(Arrays.asList(args)); gameConn.send(c2s); } }