package net.sf.colossus.server; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.CharBuffer; import java.nio.channels.SelectionKey; import java.nio.channels.SocketChannel; import java.nio.charset.CharacterCodingException; import java.nio.charset.Charset; import java.nio.charset.CharsetDecoder; import java.nio.charset.CharsetEncoder; import java.util.ArrayList; import java.util.Date; import java.util.LinkedList; import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; import net.sf.colossus.client.IClient; import net.sf.colossus.common.Constants; import net.sf.colossus.game.EntrySide; import net.sf.colossus.game.Legion; import net.sf.colossus.game.Phase; import net.sf.colossus.game.PlayerColor; import net.sf.colossus.game.actions.Recruitment; import net.sf.colossus.game.actions.Summoning; import net.sf.colossus.util.ErrorUtils; import net.sf.colossus.util.InstanceTracker; import net.sf.colossus.util.Split; import net.sf.colossus.variant.BattleHex; import net.sf.colossus.variant.CreatureType; import net.sf.colossus.variant.MasterHex; /** * Holds all data specific to one client connection. * (Earlier this was the class ServerSocketThread, but since changing * to NIO it's not an own thread any more.) * * The code in here is (should be) executed exclusively by the server * thread as reaction to something happening on the selector * - first the client connection being accepted, and then later always * when data from client was received (usually from THIS client, but * there might be other cases). * * @author David Ripton */ final class ClientHandler extends ClientHandlerStub implements IClient { private static final Logger LOGGER = Logger.getLogger(ClientHandler.class .getName()); // If it's a constanct, Eclipse complains so often about dead code... private static boolean _DEBUG_OUTPUT() { return false; } // server is stored in ClientHandlerStub private final SocketChannel socketChannel; private final SelectionKey selectorKey; private int clientVersion = 0; private boolean spectator; private ClientHandler replacedCH = null; private String javaVersion = "not-set-yet"; private String osInfo = "not-set-yet"; private boolean didExplicitDisconnect = false; private boolean withdrawnAlready = false; private int cantSendMessageRepeated = 0; private boolean temporarilyDisconnected = false; private boolean obsolete = false; private String incompleteInput = ""; private String incompleteText = ""; // Charset and encoder: by default according to the property, // fallback US-ASCII private static final String DEFAULT_CHAR_SET = System .getProperty("file.encoding"); private final String CHARSET_NAME = DEFAULT_CHAR_SET != null ? DEFAULT_CHAR_SET : "US-ASCII"; private final Charset charset = Charset.forName(CHARSET_NAME); private final CharsetEncoder encoder = charset.newEncoder(); private final CharsetDecoder decoder = charset.newDecoder(); // sync-when-disconnected stuff private int commitPointCounter = 0; private final static int MAX_KEEP_LINES = 5; private final ArrayList<String> recentlyProcessedLines = new ArrayList<String>( MAX_KEEP_LINES); private long lastPingReplyReceived = -1; private static int MAX_FAKE_MSG_COUNT = 3; private Level fakeMsgLogLevel = Level.WARNING; // Note that the client (SocketClientThread) sends ack every // CLIENT_CTR_ACK_EVERY messages (currently 20) // The two values above and the client value must fit together // that it does not cause a deadlock. ClientHandler(Server server, SocketChannel channel, SelectionKey selKey) { super(server); this.socketChannel = channel; this.selectorKey = selKey; String tempId = "<no name yet #" + (counter++) + ">"; InstanceTracker.register(this, tempId); } public SelectionKey getSelectorKey() { return selectorKey; } public SocketChannel getSocketChannel() { return socketChannel; } // if "isGone" is true, connection to this client is gone // Server uses this to decide whether any nonAI player is // (even if perhaps dead) still connected (= watching). public boolean isGone() { return this.isGone; } @Override protected boolean isStub() { return false; } public boolean isSpectator() { return spectator; } /** * Stores the previous clienthandler, which is now replaced by * us. * @param previous The clienthandler which so far held connection * to that client */ void setReplacedCH(ClientHandler previous) { this.replacedCH = previous; } ClientHandler getReplacedCH() { LOGGER.finest("GET replaced CH for " + getConnectionId() + ": id = " + (replacedCH != null ? "" + replacedCH.getConnectionId() : "NULL")); return this.replacedCH; } public boolean didExplicitDisconnect() { return didExplicitDisconnect; } public void setTemporarilyDisconnected() { temporarilyDisconnected = true; } /* Not really needed at the moment; every reconnect creates a new ClientHandler private void clearTemporarilyDisconnected() { temporarilyDisconnected = false; } */ public boolean isTemporarilyDisconnected() { return temporarilyDisconnected; } public long getMillisSincePingReply() { // Should happen only the very first time this is called. if (lastPingReplyReceived < 0) { return 0; } long now = new Date().getTime(); return now - lastPingReplyReceived; } /** * Server side disposes a client (and informs it about it first) * To be used only for "disposeAllClients()", otherwise setIsGone * reason is misleading. */ @Override public void disposeClient() { // Don't do it again if (isGone) { return; } sendViaChannel(Constants.dispose); setIsGone("Server disposes client (all clients)"); server.queueClientHandlerForChannelChanges(this); server.clientWontConfirmCatchup(this, "Client disposed from server side."); } // Called by Server's select reader public void processInput(ByteBuffer byteBuffer) { try { CharBuffer charBuff = decoder.decode(byteBuffer); String msg = incompleteInput + charBuff.toString(); incompleteInput = ""; incompleteText = ""; String msg1 = msg.replaceAll("\r\n|\r|\n", "\\\\n"); LOGGER.finest("Decoded string is >>>>>" + msg1 + "<<<<<"); int processed = 0; String lines[] = msg.split("\r\n|\n|\r", -1); int len = lines.length; for (int i = 0; i < len; i++) { String line = lines[i]; if (i < len - 1) { LOGGER.finest("before processing cmd '" + line + "'"); List<String> li = Split.split(sep, line); String method = li.remove(0); if (signonName == null && !method.equals(Constants.signOn)) { LOGGER .log(Level.SEVERE, "First packet must be signOn, but it is " + method); } else { String logMessage = "RECEIVD " + getTruncatedPlayerName() + " <- " + line; LOGGER.finer(logMessage); doCallMethodInTryBlock(line, method, li); } LOGGER.finest("after processing line '" + line + "'"); processed++; } else if (i == len - 1 && line.equals("")) { // Received characters ended with newline, producing one // empty string at the end. Perfect. } else { LOGGER.log(Level.FINEST, "last item incomplete, storing it: '" + line + "'"); incompleteInput = line; incompleteText = " (not handled: incomplete input '" + incompleteInput + "')"; } } LOGGER.log(Level.FINEST, "Processed " + processed + " commands" + incompleteText + "."); } catch (CharacterCodingException cce) { LOGGER.log(Level.SEVERE, "CharacterCodingException while reading from channel" + socketChannel, cce); } } private void sendViaChannel(String msg) { sendViaChannelRaw(msg); } @Override protected void flushQueuedContent() { sendViaChannelRaw(null); } @Override protected void commitPoint() { if (supportsReconnect()) { commitPointCounter++; isCommitPoint = true; sendToClient(Constants.commitPoint + sep + commitPointCounter + sep + messageCounter); isCommitPoint = false; } } /** * Remove the messages in redoQueue prior to given commit point * @param confirmedNr Commit point from which we now know that client has * successfully received it */ private void confirmCommitPoint(int confirmedNr) { setBattleRecentlyFinished(false); int found = -1; int size = resendQueue.size(); for (int i = 0; i < size && found == -1; i++) { MessageForClient mfc = resendQueue.get(i); if (mfc.getCommitNumber() == confirmedNr) { found = i; } } if (found != -1) { /* * TODO: If we would subclass ArrayList, could use the more * efficient method removeRange (it is "protected") */ for (int i = 0; i <= found; i++) { MessageForClient mfc = resendQueue.remove(0); historyQueue.add(mfc); } } } @Override protected boolean canHandlePingRequest() { return clientVersion >= IServer.CLIENT_VERSION_UNDERSTANDS_PING; } @Override public boolean supportsReconnect() { return clientVersion >= IServer.CLIENT_VERSION_CAN_RECONNECT; } public boolean canHandleBattleMoveNak() { return clientVersion >= IServer.CLIENT_VERSION_CAN_HANDLE_NAK; } @Override public boolean canHandleAdvancedSync() { return clientVersion >= IServer.CLIENT_VERSION_CAN_HANDLE_NAK; } protected boolean canHandleNewVariantXML() { return clientVersion >= IServer.CLIENT_VERSION_VARIANT_XML_OK; } protected boolean canHandleInactivityTimeout() { return clientVersion >= IServer.CLIENT_VERSION_INACTIVITY_TIMEOUT; } @Override public boolean canHandleExtraRollRequest() { return clientVersion >= IServer.CLIENT_VERSION_REQUEST_ROLL; } @Override public boolean canHandleSuspendRequests() { return clientVersion >= IServer.CLIENT_VERSION_CAN_SUSPEND; } @Override public boolean canHandleChangedValuesOnlyStyle() { return clientVersion >= IServer.CLIENT_VERSION_NEW_PLAYER_INFO; } public void cloneRedoQueue(ClientHandler oldCH) { // Remove the reconnect-related messages resendQueue.clear(); resendQueue.addAll(oldCH.resendQueue); commitPointCounter = oldCH.commitPointCounter; } public void cloneHistoryQueue(ClientHandler oldCH) { // Remove the reconnect-related messages historyQueue.clear(); historyQueue.addAll(oldCH.historyQueue); commitPointCounter = oldCH.commitPointCounter; } @Override protected void enqueueToRedoQueue(int messageNr, String message) { if (supportsReconnect()) { resendQueue.add(new MessageForClient(messageNr, (isCommitPoint ? commitPointCounter : 0), message)); messageCounter++; } } /* private void enqeueExtra(String message) { enqueueForResend(new MessageForClient(0, 0, message)); } */ // private int newCounterHistory = 0; private int newCounterRedo = 0; /* private void reEnqueueHistory(MessageForClient mfc) { prn("Putting to hist queue: " + mfc.getShortenedMessage()); LOGGER.finest("Putting to hist queue: " + mfc.getShortenedMessage()); MessageForClient newOne = new MessageForClient(mfc, newCounterHistory++); historyQueue.add(newOne); } */ private void enqueueForResend(MessageForClient mfc) { // System.out.println("Putting to redo queue: " + mfc.getShortenedMessage()); LOGGER.finest("Putting to redo queue: " + mfc.getShortenedMessage()); MessageForClient newOne = new MessageForClient(mfc, newCounterRedo++); resendQueue.add(newOne); } public void initResendQueueFromStub(ClientHandlerStub stub) { boolean kickPhaseSeen = false; GameServerSide game = server.getGame(); String expectedTurnChangeLine = Constants.setupTurnState + sep + game.getActivePlayer() + sep + game.getTurnNumber(); String expectedSetupPhaseLine = buildExpectedSetupPhaseLine(game); boolean currentTurnReached = false; boolean currentPhaseReached = false; resendQueue.clear(); List<MessageForClient> tempQ = new ArrayList<MessageForClient>(); tempQ.addAll(stub.historyQueue); tempQ.addAll(stub.resendQueue); for (MessageForClient mfc : tempQ) { String method = mfc.getMethod(); if (method.equals(Constants.kickPhase)) { kickPhaseSeen = true; } if (method.equals(Constants.initBoard)) { int maxTurn = server.getGame().getTurnNumber(); String replayOn = Constants.replayOngoing + sep + true + sep + maxTurn; enqueueForResend(new MessageForClient(0, 0, replayOn)); enqueueForResend(mfc); } // skip all the later ones; the initial ones are needed. else if (kickPhaseSeen && method.equals(Constants.updatePlayerInfo)) { // skip } // Versions 20151124 and before cannot handle them, but they // pushed to stub's queue because stub is always new enough... else if (method.equals(Constants.updateChangedValues) && !canHandleChangedValuesOnlyStyle()) { // skip } else if (method.equals(Constants.updateCreatureCount) || method.equals(Constants.tellWhatsHappening)) { // skip } else if (!currentTurnReached && method.equals(Constants.setupTurnState) && mfc.getMessage().equals(expectedTurnChangeLine)) { System.out.println("Reached current turn:" + mfc.getMessage()); currentTurnReached = true; enqueueForResend(mfc); } else if (currentTurnReached && !currentPhaseReached && mfc.getMessage().equals(expectedSetupPhaseLine)) { currentPhaseReached = true; String redoOn = Constants.redoOngoing + sep + false; enqueueForResend(new MessageForClient(0, 0, redoOn)); enqueueForResend(mfc); } else { enqueueForResend(mfc); } } String redoOff = Constants.redoOngoing + sep + false; enqueueForResend(new MessageForClient(0, 0, redoOff)); String replayOff = Constants.replayOngoing + sep + false + sep + 0; enqueueForResend(new MessageForClient(0, 0, replayOff)); LOGGER.fine("Initialized resendQueue from other CH or stub, " + "contains now " + resendQueue.size() + " items!"); } public void initResendQueueFromOther(ClientHandlerStub otherCH) { boolean kickPhaseSeen = false; GameServerSide game = server.getGame(); String expectedTurnChangeLine = Constants.setupTurnState + sep + game.getActivePlayer() + sep + game.getTurnNumber(); String expectedSetupPhaseLine = buildExpectedSetupPhaseLine(game); boolean currentTurnReached = false; boolean currentPhaseReached = false; resendQueue.clear(); List<MessageForClient> tempQ = new ArrayList<MessageForClient>(); tempQ.addAll(otherCH.historyQueue); tempQ.addAll(otherCH.resendQueue); for (MessageForClient mfc : tempQ) { String method = mfc.getMethod(); if (method.equals(Constants.kickPhase)) { kickPhaseSeen = true; } if (currentTurnReached) { // System.out.println("??? " + mfc.getMessage()); } if (method.equals(Constants.initBoard)) { int maxTurn = server.getGame().getTurnNumber(); String replayOn = Constants.replayOngoing + sep + true + sep + maxTurn; enqueueForResend(new MessageForClient(0, 0, replayOn)); enqueueForResend(mfc); } // skip all the later ones; the initial ones are needed. else if (kickPhaseSeen && method.equals(Constants.updatePlayerInfo)) { // skip } else if (method.equals(Constants.updateCreatureCount) || method.equals(Constants.tellWhatsHappening)) { // skip } else if (currentPhaseReached && Constants.isNeededForRedo(method)) { enqueueForResend(mfc); } else if (!currentTurnReached && method.equals(Constants.setupTurnState) && mfc.getMessage().equals(expectedTurnChangeLine)) { currentTurnReached = true; String redoOn = Constants.redoOngoing + sep + true; enqueueForResend(new MessageForClient(0, 0, redoOn)); enqueueForResend(mfc); } else if (currentTurnReached && !currentPhaseReached && mfc.getMessage().equals(expectedSetupPhaseLine)) { currentPhaseReached = true; enqueueForResend(mfc); } else if (Constants.shouldSkipForScratchReconnect(method)) { // Client has no use, or even can't handle those during a reconnect } else { enqueueForResend(mfc); } } String redoOff = Constants.redoOngoing + sep + false; enqueueForResend(new MessageForClient(0, 0, redoOff)); String replayOff = Constants.replayOngoing + sep + false + sep + 0; enqueueForResend(new MessageForClient(0, 0, replayOff)); LOGGER.fine("Initialized resendQueue from other CH or stub, " + "contains now " + resendQueue.size() + " items"); } private String buildExpectedSetupPhaseLine(GameServerSide game) { Phase currentPhase = game.getPhase(); String line; if (currentPhase.equals(Phase.SPLIT)) { line = Constants.setupSplit + sep + game.getActivePlayer() + sep + game.getTurnNumber(); } else if (currentPhase.equals(Phase.MOVE)) { line = Constants.setupMove; } else if (currentPhase.equals(Phase.FIGHT)) { line = Constants.setupFight; } else if (currentPhase.equals(Phase.MUSTER)) { line = Constants.setupMuster; } else if (currentPhase.equals(Phase.INIT)) { line = "dummy"; } else { LOGGER.warning("bogus phase " + currentPhase.name()); line = "dummy2"; } return line; } /* public void initResendQueueFromOther(ClientHandlerStub replacedCH, boolean isPlayer) { LOGGER.fine("Init resend queue from other; old history queue has " + replacedCH.historyQueue.size() + " messages!"); resendQueue.clear(); boolean kickPhaseSeen = false; for (MessageForClient mfc : replacedCH.historyQueue) { String method = mfc.getMethod(); if (method.equals(Constants.kickPhase)) { kickPhaseSeen = true; } if (method.equals(Constants.initBoard)) { int maxTurn = server.getGame().getTurnNumber(); String replayOn = Constants.replayOngoing + sep + true + sep + maxTurn; enqueueForResend(new MessageForClient(0, 0, replayOn)); enqueueForResend(mfc); } else if (kickPhaseSeen && method.equals(Constants.updatePlayerInfo)) { // Only the first updatePlayerInfo is needed to give clients // all initial data. } else if (Constants.shouldSkipForScratchReconnect(method)) { // Client has no use, or even can't handle those } else { enqueueForResend(mfc); } } ArrayList<MessageForClient> leftToDoQueue = new ArrayList<MessageForClient>( 100); for (MessageForClient mfc : replacedCH.resendQueue) { String method = mfc.getMethod(); if (method.equals(Constants.updateCreatureCount) || method.equals(Constants.askConfirmCatchUp) || method.equals(Constants.redoOngoing) || method.equals(Constants.replayOngoing) || method.equals(Constants.appendToConnectionLog)) { // drop this } else { leftToDoQueue.add(mfc); } } if (isPlayer) { redoForPlayer(leftToDoQueue); } else { redoForSpectator(leftToDoQueue); } enqeueExtra(Constants.replayOngoing + sep + false + sep + 0); enqeueExtra(Constants.kickPhase); LOGGER.fine("Initialized redoQueue from previous CH, contains now " + resendQueue.size() + " items!"); } private void redoForPlayer(ArrayList<MessageForClient> leftToDoQueue) { GameServerSide game = server.getGame(); Player player = game.getPlayerByName(playerName); Player activePlayer = game.getActivePlayer(); Engagement eng = game.getEngagement(); if (eng != null && (player.equals(game.getDefender()) || player.equals(game .getAttacker()))) { LOGGER .severe("\n!!!!!!!!!!!!!!!!!\nInvolved into ongoing engagement, not implemented."); System.out .println("\n!!!!!!!!!!!!!!!!!\nInvolved into ongoing engagement, not implemented."); return; } else if (player.equals(activePlayer)) { redoForActivePlayer(leftToDoQueue, game); } else { // ok, that's simple, basically same as spectator enqeueExtra(Constants.redoOngoing + sep + true); redoForSpectator(leftToDoQueue); enqeueExtra(Constants.redoOngoing + sep + false); } } private void redoForActivePlayer( ArrayList<MessageForClient> leftToDoQueue, GameServerSide game) { for (MessageForClient mfc : leftToDoQueue) { LOGGER.finest("|LTDQ: " + mfc.getShortenedMessage()); } Phase phase = game.getPhase(); if (phase.equals(Phase.FIGHT)) { LOGGER.warning("Scratchconnect redoQueue, redo for fightphase might not work yet."); LOGGER.warning("RedoQueue contains " + resendQueue.size() + " items."); } enqeueExtra(Constants.redoOngoing + sep + true); for (MessageForClient mfc : leftToDoQueue) { enqueueForResend(mfc); reEnqueueHistory(mfc); } enqeueExtra(Constants.redoOngoing + sep + false); } private void redoForSpectator(ArrayList<MessageForClient> leftToDoQueue) { for (MessageForClient mfc : leftToDoQueue) { enqueueForResend(mfc); } } */ /** * Re-send all data after the message from which we know client got it * * @param lastReceivedMessageNr Last message which client did still receive * @param syncRequestNumber Every request has own unique id, so we don't mix them */ public void syncAfterReconnect(int lastReceivedMessageNr, int syncRequestNumber) { int size = resendQueue.size(); for (int i = 0; i < size; i++) { MessageForClient mfc = resendQueue.get(i); int queueMsgNr = mfc.getMessageNr(); if (queueMsgNr > lastReceivedMessageNr) { String message = mfc.getMessage(); sendViaChannelRaw(message); messageCounter = queueMsgNr; } } commitPoint(); if (canHandleAdvancedSync()) { tellSyncCompleted(syncRequestNumber); } if (!isSpectator()) { server.othersTellReconnectCompleted(this); } } ByteBuffer bb; String encodedMsg; // used only for logging int should; int writtenTotal; int previousRetries = 0; private long temporarilyInTrouble = -1; public boolean isTemporarilyInTrouble() { return (temporarilyInTrouble != -1); } public long howLongAlreadyInTrouble() { if (temporarilyInTrouble == -1) { return 0; } long now = new Date().getTime(); return now - temporarilyInTrouble; } private void setTemporarilyInTrouble() { long now = new Date().getTime(); temporarilyInTrouble = now; if (!isSpectator()) { server.othersTellOneHasNetworkTrouble(this); } selectorKey.interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE); } public void clearTemporarilyInTrouble() { temporarilyInTrouble = -1; if (!isSpectator()) { server.othersTellOnesTroubleIsOver(this); } } String lastEncodedMsg = ""; private void handleEncoding(String msg) { try { lastEncodedMsg = msg; encodedMsg = msg; String dataToSend = msg + "\n"; CharBuffer cb = CharBuffer.allocate(dataToSend.length()); cb.put(dataToSend); cb.flip(); bb = encoder.encode(cb); should = bb.limit(); writtenTotal = 0; } catch (CharacterCodingException e) { LOGGER.log(Level.WARNING, "EncondingException '" + e.getMessage() + "'" + " was thrown while encoding String '" + msg + "'" + " for writing it to" + " channel for player " + playerName + "; details follow", e); } } private void debug_output(String msg) { List<String> li = Split.split(sep, msg); String method = li.get(0); if (Constants.shouldSkipForDebugPrn(method)) { // skip } else { if (msg.startsWith("gameInitInfo")) { prn("\n--- New connection ---\n"); } String logMessage = "SENDING " + getTruncatedPlayerName() + " -> " + truncateMessage(msg); prn(logMessage); LOGGER.finer(logMessage); } } /** The queue in which messages are stored, until they were really written. * Usually empty; stuff piles up only when writing to socket fails, * e.g. network or client too slow. */ LinkedList<String> queue = new LinkedList<String>(); private void sendViaChannelRaw(String msg) { if (_DEBUG_OUTPUT()) { debug_output(msg); } // Something left undone last time. Postpone this here for a moment. // Except if it is null, then caller called us dedicatedly to give // us a chance to finish earlier stuff. if (msg != null) { queue.add(msg); } if (isTemporarilyInTrouble()) { return; } if (previousRetries > 0) { // Try writing old stuff away. attemptWritingToChannel(); } // If there was no problem and/or now it went well, proceed with new // stuff... if there was. while (previousRetries == 0 && queue.size() > 0) { String queueMsg = queue.poll(); handleEncoding(queueMsg); attemptWritingToChannel(); } if (previousRetries > 0) { setTemporarilyInTrouble(); } else { if (isTemporarilyInTrouble()) { LOGGER.warning("temporaryInTrouble still true for player " + getPlayerName() + "? This should never happen."); } temporarilyInTrouble = -1; } } private String truncateMessage(String message) { String printLine; int _MAXLEN = 80; int len = message.length(); if (len > _MAXLEN) { printLine = message.substring(0, _MAXLEN) + "..."; } else { printLine = message; } return printLine; } private void attemptWritingToChannel() { if (isGone()) { LOGGER.warning("isGone already true when attempting " + "to do WriteToChannel " + lastEncodedMsg + " for player" + getClientName()); LOGGER.warning("Reason: " + this.isGoneReason); Thread.dumpStack(); return; } // Attempt to write away what is in buffer try { int written = socketChannel.write(bb); if (written > 0) { writtenTotal += written; } if (writtenTotal < should) { // Not all written previousRetries += 1; if (spectator) { LOGGER.info("trouble writing, temporarily giving up " + "writing to client " + getPlayerName()); } else { LOGGER.warning("trouble writing, temporarily giving up " + "writing to client " + getPlayerName()); } } else { // OK, now all was written // TODO nowadays we do only one try, can this be a boolean instead? if (previousRetries > 0) { LOGGER.info("Now succeeded, attempt = " + previousRetries); } previousRetries = 0; } } catch (IOException ioe) { LOGGER.log(Level.WARNING, "IOException '" + ioe.getMessage() + "'" + " was thrown while writing String '" + encodedMsg + "'" + " to channel for player " + playerName + "; details follow:", ioe); if (this.supportsReconnect()) { setTemporarilyDisconnected(); } else { setIsGone("IOException and reconnect not supported"); withdrawnAlready = true; server.withdrawFromGame(spectator ? null : playerName); server.queueClientHandlerForChannelChanges(this); server.clientWontConfirmCatchup(this, "IO Exception while writing to client " + playerName); } } Thread.yield(); } /** * Make sure player is withdrawn from game. * Explicit if via Withdraw message from client, implicit because * of disconnect message or connection problems. * This is just a wrapper for the both situations where for * !withdrawnAlready should be checked * * @param explicit Whether client has requested withdraw explicitly */ private void withdrawIfNeeded(boolean explicit) { if (!withdrawnAlready) { if (!explicit) { LOGGER.log(Level.FINE, "Client disconnected without explicit withdraw - " + "doing automatic withdraw (if needed) for player " + playerName); } withdrawnAlready = true; server.withdrawFromGame(); } } // same player re-connected with new ClientHandler // => this one here should "be gone" / inactive public void declareObsolete() { obsolete = true; setIsGone("Declared obsolete because new client " + "with same name connected"); } public String dumpLastProcessedLines() { StringBuffer sb = new StringBuffer("## Last " + MAX_KEEP_LINES + " processed lines were:"); int i = 0; for (String rLine : recentlyProcessedLines) { i++; sb.append("\n #" + i + ": " + rLine); } return sb.toString(); } private void doCallMethodInTryBlock(String line, String method, List<String> li) { try { while (recentlyProcessedLines.size() >= MAX_KEEP_LINES) { recentlyProcessedLines.remove(0); } recentlyProcessedLines.add(line); callMethod(method, li); } catch (Exception e) { String message = "Woooah! An exception was caught while " + "processing from client " + getPlayerName() + " the input line:\n === " + line + " ===\n" + "\nStack trace:\n" + ErrorUtils.makeStackTraceString(e) + "\n\nGame might be unstable or hang from now on..."; LOGGER.severe(message); LOGGER.info(dumpLastProcessedLines()); ErrorUtils.showExceptionDialog(null, message, "Exception caught!", true); } } /** * This is the longish if-elseif-else block which deserialized all * client-to-server calls back from String to actual methodCalls. * @param method The method to execute * @param args A list of argument Strings */ private void callMethod(String method, List<String> args) { if (method.equals(Constants.signOn)) { String signonTryName = args.remove(0); boolean remote = Boolean.valueOf(args.remove(0)).booleanValue(); this.spectator = false; int connectionId = -1; String buildInfo; if (args.size() < 2) { LOGGER.info("Connecting client with signonName " + signonTryName + " did not send version/build info - " + "treating that as version -1, build info NONE."); clientVersion = -1; buildInfo = "NONE"; } else { clientVersion = Integer.parseInt(args.remove(0)); buildInfo = args.remove(0); if (args.size() > 0) { this.spectator = Boolean.valueOf(args.remove(0)) .booleanValue(); } if (args.size() > 0) { connectionId = Integer.parseInt(args.remove(0)); } else { // old client which does not send Id connectionId = -2; } } String reasonFail; if (server.getAllInitialConnectsDone() && (connectionId == -1 || connectionId == -2) && !spectator) { // we exclude spectator in the if, because they are handled // in the legacy case (code works there, didn't want to // change now) LOGGER.info("Scratch reconnect (id -1) for client " + signonTryName); reasonFail = server .handleScratchReconnect(this, signonTryName, remote, clientVersion, buildInfo, spectator); } else { LOGGER.info("Legacy case, connection for client " + signonTryName + ", gives connectionId " + connectionId); reasonFail = server.handleNewConnection(this, signonTryName, remote, clientVersion, buildInfo, spectator, connectionId); } if (reasonFail == null) { sendToClient("Ack: signOn"); this.signonName = signonTryName; } else { LOGGER.info("Rejecting client " + signonTryName); nak("SignOn", reasonFail); } InstanceTracker.setId(this, signonTryName); } else if (method.equals(Constants.joinGame)) { String playerName = args.remove(0); if (!playerName.equals(signonName)) { LOGGER.severe("Joining game with different name '" + playerName + "' than signonName + '" + signonName + "' is currently not supported!"); return; } LOGGER.info("Received joinGame from client " + signonName); setPlayerNameNoSend(signonName); if (server.getAllInitialConnectsDone()) { LOGGER.fine("All initial connects were already done, " + "so for this connection now doing a rejoinGame"); server.rejoinGame(); } else { server.joinGame(signonName); } } else if (method.equals(Constants.watchGame)) { server.watchGame(); } else if (method.equals(Constants.systemInfo)) { this.osInfo = (args.remove(0)); this.javaVersion = (args.remove(0)); String msg = "Connecting client with signonName " + getClientName() + " reports: java version=" + javaVersion + ", OS info=" + osInfo; LOGGER.info(msg); } else if (method.equals(Constants.requestGameInfo)) { server.replyToRequestGameInfo(); } /* else if (method.equals(Constants.fixName)) { String newName = args.remove(0); // Prevent an infinite loop oscillating between two names. // @TODO: is this still needed? if (!newName.equals(playerName) && !newName.startsWith(Constants.byColor)) { setPlayerName(newName); } } */ else if (method.equals(Constants.leaveCarryMode)) { server.leaveCarryMode(); } else if (method.equals(Constants.doneWithBattleMoves)) { server.doneWithBattleMoves(); } else if (method.equals(Constants.doneWithStrikes)) { server.doneWithStrikes(); } else if (method.equals(Constants.acquireAngel)) { String markerId = args.remove(0); String angelType = args.remove(0); server.acquireAngel(resolveLegion(markerId), resolveCreatureTypeNullOk(angelType)); } else if (method.equals(Constants.doSummon)) { Legion legion = resolveLegion(args.remove(0)); if (legion == null) { server.doSummon(null); return; } Legion donor = resolveLegion(args.remove(0)); CreatureType creatureType = resolveCreatureType(args.remove(0)); server.doSummon(new Summoning(legion, donor, creatureType)); } else if (method.equals(Constants.doRecruit)) { Legion legion = resolveLegion(args.remove(0)); // Refusing a reinforcement sends as "recruited" null. CreatureType recruited = resolveCreatureTypeNullOk(args.remove(0)); CreatureType recruiter = resolveCreatureTypeNullOk(args.remove(0)); server.doRecruit(new Recruitment(legion, recruited, recruiter)); } else if (method.equals(Constants.engage)) { String hexLabel = args.remove(0); server.engage(resolveMasterHex(hexLabel)); } else if (method.equals(Constants.concede)) { String markerId = args.remove(0); server.concede(resolveLegion(markerId)); } else if (method.equals(Constants.doNotConcede)) { String markerId = args.remove(0); server.doNotConcede(resolveLegion(markerId)); } else if (method.equals(Constants.flee)) { String markerId = args.remove(0); server.flee(resolveLegion(markerId)); } else if (method.equals(Constants.doNotFlee)) { String markerId = args.remove(0); server.doNotFlee(resolveLegion(markerId)); } else if (method.equals(Constants.makeProposal)) { String proposalString = args.remove(0); server.makeProposal(proposalString); } else if (method.equals(Constants.fight)) { String hexLabel = args.remove(0); server.fight(resolveMasterHex(hexLabel)); } else if (method.equals(Constants.doBattleMove)) { int tag = Integer.parseInt(args.remove(0)); String hexLabel = args.remove(0); BattleHex hex = resolveBattleHex(hexLabel); // silently ignore delayed messages if (!(hex == null && hasBattleRecentlyFinished())) { server.doBattleMove(tag, hex); } } else if (method.equals(Constants.strike)) { int tag = Integer.parseInt(args.remove(0)); String hexLabel = args.remove(0); BattleHex hex = resolveBattleHex(hexLabel); // silently ignore delayed messages if (!(hex == null && hasBattleRecentlyFinished())) { server.strike(tag, hex); } } else if (method.equals(Constants.applyCarries)) { String hexLabel = args.remove(0); BattleHex hex = resolveBattleHex(hexLabel); // silently ignore delayed messages if (!(hex == null && hasBattleRecentlyFinished())) { server.applyCarries(hex); } } else if (method.equals(Constants.undoBattleMove)) { String hexLabel = args.remove(0); BattleHex hex = resolveBattleHex(hexLabel); // silently ignore delayed messages if (!(hex == null && hasBattleRecentlyFinished())) { server.undoBattleMove(hex); } } else if (method.equals(Constants.assignStrikePenalty)) { String prompt = args.remove(0); server.assignStrikePenalty(prompt); } else if (method.equals(Constants.mulligan)) { server.mulligan(); } else if (method.equals(Constants.requestExtraRoll)) { server.requestExtraRoll(); } else if (method.equals(Constants.extraRollResponse)) { boolean approved = Boolean.valueOf(args.remove(0)).booleanValue(); int requestId = Integer.parseInt(args.remove(0)); server.extraRollResponse(approved, requestId); } else if (method.equals(Constants.undoSplit)) { String splitoffId = args.remove(0); server.undoSplit(resolveLegion(splitoffId)); } else if (method.equals(Constants.undoMove)) { String markerId = args.remove(0); server.undoMove(resolveLegion(markerId)); } else if (method.equals(Constants.undoRecruit)) { String markerId = args.remove(0); server.undoRecruit(resolveLegion(markerId)); } else if (method.equals(Constants.doneWithSplits)) { server.doneWithSplits(); } else if (method.equals(Constants.doneWithMoves)) { server.doneWithMoves(); } else if (method.equals(Constants.doneWithEngagements)) { server.doneWithEngagements(); } else if (method.equals(Constants.doneWithRecruits)) { server.doneWithRecruits(); } else if (method.equals(Constants.withdrawFromGame)) { LOGGER.info("Received explicit 'withdrawFromGame' request from " + "Client " + getClientName() + " - calling 'withdrawIfNeeded'."); withdrawIfNeeded(true); } else if (method.equals(Constants.disconnect)) { didExplicitDisconnect = true; setIsGone("received explit 'disconnect' request from client"); LOGGER.info("Received explicit 'disconnect' request from Client " + getClientName() + " - calling 'withdrawIfNeeded'."); withdrawIfNeeded(false); server.sendDisconnect(); } else if (method.equals(Constants.stopGame)) { setIsGone("received explicit 'stopGame' request from " + "client" + getPlayerName()); server.sendDisconnect(); server.stopGame(); } else if (method.equals(Constants.doSplit)) { String parentId = args.remove(0); String childId = args.remove(0); String results = args.remove(0); List<CreatureType> creatures = new ArrayList<CreatureType>(); for (String name : results.split(",")) { creatures.add(resolveCreatureType(name)); } server.doSplit(resolveLegion(parentId), childId, creatures); } else if (method.equals(Constants.doMove)) { String markerId = args.remove(0); String hexLabel = args.remove(0); EntrySide entrySide = EntrySide.fromLabel(args.remove(0)); boolean teleport = Boolean.valueOf(args.remove(0)).booleanValue(); CreatureType teleportingLord = resolveCreatureTypeNullOk(args .remove(0)); server.doMove(resolveLegion(markerId), resolveMasterHex(hexLabel), entrySide, teleport, teleportingLord); } else if (method.equals(Constants.assignColor)) { String color = args.remove(0); server.assignColor(PlayerColor.getByName(color)); } else if (method.equals(Constants.assignFirstMarker)) { String markerId = args.remove(0); server.assignFirstMarker(markerId); } else if (method.equals(Constants.newGame)) { server.newGame(); } else if (method.equals(Constants.loadGame)) { String filename = args.remove(0); server.loadGame(filename); } else if (method.equals(Constants.saveGame)) { String filename = args.remove(0); server.saveGame(filename); } else if (method.equals(Constants.suspendGame)) { boolean save = true; if (args.size() > 0) { save = Boolean.valueOf(args.remove(0)).booleanValue(); } server.requestToSuspendGame(save); } else if (method.equals(Constants.suspendResponse)) { boolean approved = Boolean.valueOf(args.remove(0)).booleanValue(); server.suspendResponse(approved); } else if (method.equals(Constants.checkConnection)) { server.checkServerConnection(); } else if (method.equals(Constants.peerRequestReceived)) { String respondingClientName = args.remove(0); int queueLen = Integer.parseInt(args.remove(0)); server.peerRequestReceived(respondingClientName, queueLen); } else if (method.equals(Constants.peerRequestProcessed)) { String respondingClientName = args.remove(0); server.peerRequestProcessed(respondingClientName); } else if (method.equals(Constants.checkAllConnections)) { String requestingClientName = args.remove(0); server.checkAllConnections(requestingClientName); } else if (method.equals(Constants.requestSyncDelta)) { int lastReceivedMsgNr = Integer.parseInt(args.remove(0)); int syncRequestNr = -1; // clients version 3 don't send this, only from 4 on if (args.size() > 0) { syncRequestNr = Integer.parseInt(args.remove(0)); } server.requestSyncDelta(lastReceivedMsgNr, syncRequestNr); } else if (method.equals(Constants.catchupConfirmation)) { server.clientConfirmedCatchup(); } else if (method.equals(Constants.replyToPing)) { lastPingReplyReceived = new Date().getTime(); long replyReceived = lastPingReplyReceived; if (args.size() >= 3) { int requestNr = Integer.parseInt(args.remove(0)); long requestSent = Long.parseLong(args.remove(0)); long replySent = Long.parseLong(args.remove(0)); server.replyToPing(playerName, requestNr, requestSent, replySent, replyReceived); } else { long requestNr = getLastUsedPingRequestCounter(); if (requestNr > MAX_FAKE_MSG_COUNT) { fakeMsgLogLevel = Level.FINE; } LOGGER.log(fakeMsgLogLevel, "Ping reply from " // + getClientName() + ": does not provide requestNr, " // + "faking it with lastSentNr (" + requestNr + ")"); server.replyToPing(playerName, 0, 0L, 0L, replyReceived); } } else if (method.equals(Constants.confirmCommitPoint)) { int cpNr = Integer.parseInt(args.remove(0)); confirmCommitPoint(cpNr); } else if (method.equals(Constants.logMsgToServer)) { String severity = args.remove(0); String message = args.remove(0); server.logMsgToServer(severity, message); } else if (method.equals(Constants.cheatModeDestroyLegion)) { Legion legion = resolveLegion(args.remove(0)); server.cheatModeDestroyLegion(legion); } else { LOGGER.log(Level.SEVERE, "Bogus packet (Server, method: '" + method + "', args: " + args + ")"); } } private BattleHex resolveBattleHex(String hexLabel) { BattleHex hex = null; try { BattleServerSide battle = server.getGame().getBattleSS(); if (battle != null) { hex = server.getGame().getBattleSS().getLocation() .getTerrain().getHexByLabel(hexLabel); } else if (hasBattleRecentlyFinished()) { LOGGER .info("No battle any more while trying to resolve battleHex " + hexLabel + ", but that's probably ok."); } else { LOGGER.warning("No battle while trying to resolve battleHex " + hexLabel + "?"); } } catch (Exception e) { LOGGER.warning("Exception " + e.getClass().getName() + " while trying to resolve battleHex " + hexLabel + "; ignoring it, returning null."); } return hex; } // TODO resolveX methods are on both sides of the network, they should // be extracted into some resolver object (or a base class) private CreatureType resolveCreatureType(String name) { return server.getGame().getVariant().getCreatureByName(name); } /** * There are cases where "null" comes over network and is not meant to * be resolved to a CreatureType, namely: * teleportingLord if no teleport; null recruiter; decline Acquire. * TODO What to do with the "Anything"? * @param name Name of the creatureType to find, might be "null" * @return CreatureType for that name, or null if name is "null" */ private CreatureType resolveCreatureTypeNullOk(String name) { return name.equals("null") ? null : resolveCreatureType(name); } private MasterHex resolveMasterHex(String hexLabel) { return server.getGame().getVariant().getMasterBoard() .getHexByLabel(hexLabel); } private Legion resolveLegion(String markerId) { // TODO: currently doSummon still allows a null legion (and thus legion marker // on the network) to indicate that a summon was skipped. To disallow // having the null values in here we would need to introduce a new // network message such as "doneSummoning". // Comment (Clemens): one day I would like to get Summon as part of // the move phase, not as "do it or decline it request" from server. // When we get there, there is no need for null nor for explicit // doneSummoning. if (markerId.equals("null")) { return null; } return server.getGame().getLegionByMarkerId(markerId); } // Wrapper for all the send-over-socket methods: @Override protected void sendToClient(String message) { enqueueToRedoQueue(messageCounter, message); // For development purposes... remove when done: /* List<String> li = Split.split(sep, message); String method = li.get(0); if (playerName != null && playerName.equals("remote")) { if (isSpectator()) { System.out.println("-->" + method); } else if (getClientName().equals("remote")) { System.out.println("==>" + method); } } */ /* if (isGone()) { String tmpString = message + " "; String msgStart = tmpString.substring(0, 20); LOGGER.info("No point to send '" + msgStart + "' connection already gone: " + playerName); return; } */ if (isGone) { LOGGER.finest("Skipping sendToClient to player " + playerName + " because isGone is already set."); } else if (obsolete || socketChannel == null) { // do not send any more if (cantSendMessageRepeated < 3) { int flags = (obsolete ? 1 : 0) | (socketChannel == null ? 2 : 0); LOGGER.info("Attempt to send to player " + playerName + " when client connection already gone (reason: " + flags + ")- message: " + message); cantSendMessageRepeated++; } } else { if (Constants.USE_RECORDER) { server.getRecorder().recordMessageToClient(this, message); } if (server.getGame().isGameOver()) { LOGGER.info("GameOver: Sending to " + playerName + ": " + message); } // String logMessage = "SENDING " + getTruncatedPlayerName() + " -> " // + message; //prn(logMessage); // LOGGER.finer(logMessage); sendViaChannel(message); // TODO: are the null checks needed? Can that ever happen? // They were here as explicit if-cases, producing SEVERE log // messages if null. if (server != null && server.getGame() != null && server.getGame().isLoadingGame()) { // Give clients some opportunity to process it // (especially during replay during loading game) Thread.yield(); } } } // ======================================================================= // The IClient methods (which serialize the calls into sendToClient // executions) are all in ClientHandlerStub. // ======================================================================= /** * Debug stuff, only for testing/development purposes */ public boolean fakeDisconnect = false; public void fakeDisconnectClient() { this.fakeDisconnect = true; } public void clearDisconnectClient() { this.fakeDisconnect = false; } public boolean wasFakeDisconnectFlagSet() { return fakeDisconnect; } }