Hedgewars lobby to IRC proxy
authorsheepluva
Wed, 26 May 2021 16:32:43 -0400
changeset 15784 823cf18be1fc
parent 15782 6409d756e9da
child 15785 0a2a7a6023c0
Hedgewars lobby to IRC proxy
tools/hw2irc/123.45.67.89.auth
tools/hw2irc/net/ercatec/hw/INetClient.java
tools/hw2irc/net/ercatec/hw/ProtocolConnection.java
tools/hw2irc/net/ercatec/hw/ProtocolMessage.java
tools/hw2irc/net/ercatec/hw2ircsvr/Connection.java
tools/hw2irc/restart
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tools/hw2irc/123.45.67.89.auth	Wed May 26 16:32:43 2021 -0400
@@ -0,0 +1,1 @@
+sample_user=5177a58dc65c8a14dc90c69db3bf3dd2
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tools/hw2irc/net/ercatec/hw/INetClient.java	Wed May 26 16:32:43 2021 -0400
@@ -0,0 +1,71 @@
+/*
+ * Java net client for Hedgewars, a free turn based strategy game
+ * Copyright (c) 2011 Richard Karolyi <sheepluva@ercatec.net>
+ *
+ * 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; version 2 of the License
+ *
+ * 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, write to the Free Software
+ * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA
+ */
+
+package net.ercatec.hw;
+
+import java.util.List;
+
+public interface INetClient
+{
+    public static enum UserFlagType { UNKNOWN, ADMIN, INROOM, REGISTERED };
+    public static enum BanType { BYNICK, BYIP };
+
+    public void onConnectionLoss();
+    public void onDisconnect(String reason);
+
+    public void onMalformedMessage(String contents);
+
+    public String onPasswordHashNeededForAuth();
+
+    public void onChat(String user, String message);
+    public void onWelcomeMessage(String message);
+
+    public void onNotice(int number);
+    public String onNickCollision(String nick);
+    public void onNickSet(String nick);
+
+    public void onLobbyJoin(String[] users);
+    public void onLobbyLeave(String user, String reason);
+
+    // TODO flags => enum array?
+    public void onRoomInfo(String name, String flags, String newName,
+                           int nUsers, int nTeams, String owner, String map,
+                           String style, String scheme, String weapons);
+    public void onRoomDel(String name);
+
+    public void onRoomJoin(String[] users);
+    public void onRoomLeave(String[] users);
+
+    public void onPing();
+    public void onPong();
+
+    public void onUserFlagChange(String user, UserFlagType flag, boolean newValue);
+
+    public void onUserInfo(String user, String ip, String version, String room);
+
+    public void onBanListEntry(BanType type, String target, String duration, String reason);
+    public void onBanListEnd();
+
+    public void logDebug(String message);
+    public void logError(String message);
+
+    public void sanitizeInputs(final String[] inputs);
+/*
+    public void onEngineMessage(String message);
+*/
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tools/hw2irc/net/ercatec/hw/ProtocolConnection.java	Wed May 26 16:32:43 2021 -0400
@@ -0,0 +1,649 @@
+/*
+ * Java net client for Hedgewars, a free turn based strategy game
+ * Copyright (c) 2011 Richard Karolyi <sheepluva@ercatec.net>
+ *
+ * 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; version 2 of the License
+ *
+ * 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, write to the Free Software
+ * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA
+ */
+
+package net.ercatec.hw;
+
+import java.lang.*;
+import java.lang.IllegalArgumentException;
+import java.lang.Runnable;
+import java.lang.Thread;
+import java.io.*;
+import java.net.*;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Scanner;
+import java.util.Vector;
+// for auth
+import java.math.BigInteger;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.security.NoSuchAlgorithmException;
+
+public final class ProtocolConnection implements Runnable
+{
+    private static final String DEFAULT_HOST = "netserver.hedgewars.org";
+    private static final int DEFAULT_PORT = 46631;
+    private static final String PROTOCOL_VERSION = "53";
+
+    private final Socket socket;
+    private BufferedReader fromSvr;
+    private PrintWriter toSvr;
+    private final INetClient netClient;
+    private boolean quit;
+    private boolean debug;
+
+    private final String host;
+    private final int port;
+
+    private String nick;
+
+    public ProtocolConnection(INetClient netClient) throws Exception {
+        this(netClient, DEFAULT_HOST);
+    }
+
+    public ProtocolConnection(INetClient netClient, String host) throws Exception {
+        this(netClient, host, DEFAULT_PORT);
+    }
+
+    public ProtocolConnection(INetClient netClient, String host, int port) throws Exception {
+        this.netClient = netClient;
+        this.host = host;
+        this.port = port;
+        this.nick = nick = "";
+        this.quit = false;
+
+        fromSvr = null;
+        toSvr = null;
+
+        try {
+            socket = new Socket(host, port);
+            fromSvr = new BufferedReader(new InputStreamReader(socket.getInputStream()));
+            toSvr = new PrintWriter(socket.getOutputStream(), true);
+        }
+        catch(Exception ex) {
+            throw ex;
+        }
+
+        ProtocolMessage firstMsg = processNextMessage();
+        if (firstMsg.getType() != ProtocolMessage.Type.CONNECTED) {
+            closeConnection();
+            throw new Exception("First Message wasn't CONNECTED.");
+        }
+
+    }
+
+    public void run() {
+
+        try {
+            while (!quit) {
+                processNextMessage();
+            }
+        }
+        catch(Exception ex) {
+            netClient.logError("FATAL: Run loop died unexpectedly!");
+            ex.printStackTrace();
+            handleConnectionLoss();
+        }
+
+        // only gets here when connection was closed
+    }
+
+    public void processMessages() {
+        processMessages(false);
+    }
+
+    public Thread processMessages(boolean inNewThread)
+    {
+        if (inNewThread)
+            return new Thread(this);
+
+        run();
+        return null;
+    }
+
+    public void processNextClientFlagsMessages()
+    {
+        while (!quit) {
+            if (!processNextMessage(true).isValid)
+                break;
+        }
+    }
+
+    private ProtocolMessage processNextMessage() {
+        return processNextMessage(false);
+    }
+
+    private void handleConnectionLoss() {
+        closeConnection();
+        netClient.onConnectionLoss();
+    }
+
+    public void close() {
+        this.closeConnection();
+    }
+
+    private synchronized void closeConnection() {
+        if (quit)
+            return;
+
+        quit = true;
+        try {
+            if (fromSvr != null)
+                fromSvr.close();
+        } catch(Exception ex) {};
+        try {
+            if (toSvr != null)
+                toSvr.close();
+        } catch(Exception ex) {};
+        try {
+            socket.close();
+        } catch(Exception ex) {};
+    }
+
+    private String resumeLine = "";
+
+    private ProtocolMessage processNextMessage(boolean onlyIfClientFlags)
+    {
+        String line;
+        final List<String> parts = new ArrayList<String>(32);
+
+        while (!quit) {
+
+            if (!resumeLine.isEmpty()) {
+                    line = resumeLine;
+                    resumeLine = "";
+                }
+            else {
+                try {
+                    line = fromSvr.readLine();
+                    
+                    if (onlyIfClientFlags && (parts.size() == 0)
+                        && !line.equals("CLIENT_FLAGS")) {
+                            resumeLine = line;
+                            // return invalid message
+                            return new ProtocolMessage();
+                        }
+                }
+                catch(Exception whoops) {
+                    handleConnectionLoss();
+                    break;
+                }
+            }
+
+            if (line == null) {
+                handleConnectionLoss();
+                // return invalid message
+                return new ProtocolMessage();
+            }
+
+            if (!quit && line.isEmpty()) {
+
+                if (parts.size() > 0) {
+
+                    ProtocolMessage msg = new ProtocolMessage(parts);
+
+                    netClient.logDebug("Server: " + msg.toString());
+
+                    if (!msg.isValid()) {
+                        netClient.onMalformedMessage(msg.toString());
+                        if (msg.getType() != ProtocolMessage.Type.BYE)
+                            continue;
+                    }
+
+                    final String[] args = msg.getArguments();
+                    netClient.sanitizeInputs(args);
+
+
+                    final int argc = args.length;
+
+                    try {
+                        switch (msg.getType()) {
+
+                            case PING:
+                                netClient.onPing();
+                                break;
+
+                            case LOBBY__JOINED:
+                                try {
+                                    assertAuthNotIncomplete();
+                                }
+                                catch (Exception ex) {
+                                    disconnect();
+                                    netClient.onDisconnect(ex.getMessage());
+                                }
+                                netClient.onLobbyJoin(args);
+                                break;
+
+                            case LOBBY__LEFT:
+                                netClient.onLobbyLeave(args[0], args[1]);
+                                break;
+
+                            case CLIENT_FLAGS:
+                                String user;
+                                final String flags = args[0];
+                                if (flags.length() < 2) {
+                                    //netClient.onMalformedMessage(msg.toString());
+                                    break;
+                                }
+                                final char mode = flags.charAt(0);
+                                if ((mode != '-') && (mode != '+')) {
+                                    //netClient.onMalformedMessage(msg.toString());
+                                    break;
+                                }
+
+                                final int l = flags.length();
+
+                                for (int i = 1; i < l; i++) {
+                                    // set flag type
+                                    final INetClient.UserFlagType flag;
+                                    // TODO support more flags
+                                    switch (flags.charAt(i)) {
+                                        case 'a':
+                                            flag = INetClient.UserFlagType.ADMIN;
+                                            break;
+                                        case 'i':
+                                            flag = INetClient.UserFlagType.INROOM;
+                                            break;
+                                        case 'u':
+                                            flag = INetClient.UserFlagType.REGISTERED;
+                                            break;
+                                        default:
+                                            flag = INetClient.UserFlagType.UNKNOWN;
+                                            break;
+                                        }
+
+                                    for (int j = 1; j < args.length; j++) {
+                                        netClient.onUserFlagChange(args[j], flag, mode=='+');
+                                    }
+                                }
+                                break;
+
+                            case CHAT:
+                                netClient.onChat(args[0], args[1]);
+                                break;
+
+                            case INFO:
+                                netClient.onUserInfo(args[0], args[1], args[2], args[3]);
+                                break;
+
+                            case PONG:
+                                netClient.onPong();
+                                break;
+
+                            case NICK:
+                                final String newNick = args[0];
+                                if (!newNick.equals(this.nick)) {
+                                    this.nick = newNick;
+                                }
+                                    netClient.onNickSet(this.nick);
+                                sendMessage(new String[] { "PROTO", PROTOCOL_VERSION });
+                                break;
+
+                            case NOTICE:
+                                // nickname collision
+                                if (args[0].equals("0"))
+                                    setNick(netClient.onNickCollision(this.nick));
+                                break;
+
+                            case ASKPASSWORD:
+                                try {
+                                    final String pwHash = netClient.onPasswordHashNeededForAuth();
+                                    doAuthPart1(pwHash, args[0]);
+                                }
+                                catch (Exception ex) {
+                                    disconnect();
+                                    netClient.onDisconnect(ex.getMessage());
+                                }
+                                break;
+
+                            case ROOMS:
+                                final int nf = ProtocolMessage.ROOM_FIELD_COUNT;
+                                for (int a = 0; a < argc; a += nf) {
+                                    handleRoomInfo(args[a+1], Arrays.copyOfRange(args, a, a + nf));
+                                }
+
+                            case ROOM_ADD:
+                                handleRoomInfo(args[1], args);
+                                break;
+
+                            case ROOM_DEL:
+                                netClient.onRoomDel(args[0]);
+                                break;
+
+                            case ROOM_UPD:
+                                handleRoomInfo(args[0], Arrays.copyOfRange(args, 1, args.length));
+                                break;
+
+                            case BYE:
+                                closeConnection();
+                                if (argc > 0)
+                                    netClient.onDisconnect(args[0]);
+                                else
+                                    netClient.onDisconnect("");
+                                break;
+
+                            case SERVER_AUTH:
+                                try {
+                                    doAuthPart2(args[0]);
+                                }
+                                catch (Exception ex) {
+                                    disconnect();
+                                    netClient.onDisconnect(ex.getMessage());
+                                }
+                                break;
+                        }
+                        // end of message
+                        return msg;
+                    }
+                    catch(IllegalArgumentException ex) {
+
+                        netClient.logError("Illegal arguments! "
+                            + ProtocolMessage.partsToString(parts)
+                            + "caused: " + ex.getMessage());
+
+                        return new ProtocolMessage();
+                    }
+                }
+            }
+            else
+            {
+                parts.add(line);
+            }
+        }
+
+        netClient.logError("WARNING: Message wasn't parsed correctly: "
+                            + ProtocolMessage.partsToString(parts));
+        // return invalid message
+        return new ProtocolMessage(); // never to be reached
+    }
+
+    private void handleRoomInfo(final String name, final String[] info) throws IllegalArgumentException
+    {
+        // TODO room flags enum array
+
+        final int nUsers;
+        final int nTeams;
+        
+        try {
+            nUsers = Integer.parseInt(info[2]);
+        }
+        catch(IllegalArgumentException ex) {
+            throw new IllegalArgumentException(
+                "Player count is not an valid integer!",
+                ex);
+        }
+
+        try {
+            nTeams = Integer.parseInt(info[3]);
+        }
+        catch(IllegalArgumentException ex) {
+            throw new IllegalArgumentException(
+                "Team count is not an valid integer!",
+                ex);
+        }
+
+        netClient.onRoomInfo(name, info[0], info[1], nUsers, nTeams,
+                             info[4], info[5], info[6], info[7], info[8]);
+    }
+
+    private static final String AUTH_SALT = PROTOCOL_VERSION + "!hedgewars";
+    private static final int PASSWORD_HASH_LENGTH = 32;
+    public static final int SERVER_SALT_MIN_LENGTH = 16;
+    private static final String AUTH_ALG = "SHA-1";
+    private String serverAuthHash = "";
+
+    private void assertAuthNotIncomplete() throws Exception {
+        if (!serverAuthHash.isEmpty()) {
+            netClient.logError("AUTH-ERROR: assertAuthNotIncomplete() found that authentication was not completed!");
+            throw new Exception("Authentication was not finished properly!");
+        }
+        serverAuthHash = "";
+    }
+
+    private void doAuthPart2(final String serverAuthHash) throws Exception {
+        if (!this.serverAuthHash.equals(serverAuthHash)) {
+            netClient.logError("AUTH-ERROR: Server's authentication hash is incorrect!");
+            throw new Exception("Server failed mutual authentication! (wrong hash provided by server)");
+        }
+        netClient.logDebug("Auth: Mutual authentication successful.");
+        this.serverAuthHash = "";
+    }
+
+    private void doAuthPart1(final String pwHash, final String serverSalt) throws Exception {
+        if ((pwHash == null) || pwHash.isEmpty()) {
+            netClient.logDebug("AUTH: Password required, but no password hash was provided.");
+            throw new Exception("Auth: Password needed, but none specified.");
+        }
+        if (pwHash.length() != PASSWORD_HASH_LENGTH) {
+            netClient.logError("AUTH-ERROR: Your password hash has an unexpected length! Should be "
+                               + PASSWORD_HASH_LENGTH + " but is " + pwHash.length()
+                              );
+            throw new Exception("Auth: Your password hash length seems wrong.");
+        }
+        if (serverSalt.length() < SERVER_SALT_MIN_LENGTH) {
+            netClient.logError("AUTH-ERROR: Salt provided by server is too short! Should be at least "
+                               + SERVER_SALT_MIN_LENGTH + " but is " + serverSalt.length()
+                              );
+            throw new Exception("Auth: Server violated authentication protocol! (auth salt too short)");
+        }
+
+        final MessageDigest sha1Digest;
+
+        try {
+             sha1Digest = MessageDigest.getInstance(AUTH_ALG);
+        }
+        catch(NoSuchAlgorithmException ex) {
+            netClient.logError("AUTH-ERROR: Algorithm required for authentication ("
+                                      + AUTH_ALG + ") not available!"
+                                     );
+            return;
+        } 
+        
+
+        // generate 130 bit base32 encoded value
+        // base32 = 5bits/char => 26 chars, which is more than min req
+        final String clientSalt =
+            new BigInteger(130, new SecureRandom()).toString(32);
+
+        final String saltedPwHash =
+            clientSalt + serverSalt + pwHash + AUTH_SALT;
+
+        final String saltedPwHash2 =
+            serverSalt + clientSalt + pwHash + AUTH_SALT;
+
+        final String clientAuthHash =
+            new BigInteger(1, sha1Digest.digest(saltedPwHash.getBytes("UTF-8"))).toString(16);
+
+        serverAuthHash =
+            new BigInteger(1, sha1Digest.digest(saltedPwHash2.getBytes("UTF-8"))).toString(16);
+
+        sendMessage(new String[] { "PASSWORD", clientAuthHash, clientSalt });
+
+/* When we got password hash, and server asked us for a password, perform mutual authentication:
+ * at this point we have salt chosen by server
+ * client sends client salt and hash of secret (password hash) salted with client salt, server salt,
+ * and static salt (predefined string + protocol number)
+ * server should respond with hash of the same set in different order.
+
+    if(m_passwordHash.isEmpty() || m_serverSalt.isEmpty())
+        return;
+
+    QString hash = QCryptographicHash::hash(
+                m_clientSalt.toAscii()
+                .append(m_serverSalt.toAscii())
+                .append(m_passwordHash)
+                .append(cProtoVer->toAscii())
+                .append("!hedgewars")
+                , QCryptographicHash::Sha1).toHex();
+
+    m_serverHash = QCryptographicHash::hash(
+                m_serverSalt.toAscii()
+                .append(m_clientSalt.toAscii())
+                .append(m_passwordHash)
+                .append(cProtoVer->toAscii())
+                .append("!hedgewars")
+                , QCryptographicHash::Sha1).toHex();
+
+    RawSendNet(QString("PASSWORD%1%2%1%3").arg(delimiter).arg(hash).arg(m_clientSalt));
+
+Server:  ("ASKPASSWORD", "5S4q9Dd0Qrn1PNsxymtRhupN") 
+Client:  ("PASSWORD", "297a2b2f8ef83bcead4056b4df9313c27bb948af", "{cc82f4ca-f73c-469d-9ab7-9661bffeabd1}") 
+Server:  ("SERVER_AUTH", "06ecc1cc23b2c9ebd177a110b149b945523752ae") 
+
+ */
+    }
+
+    public void sendCommand(final String command)
+    {
+        String cmd = command;
+
+        // don't execute empty commands
+        if (cmd.length() < 1)
+            return;
+
+        // replace all newlines since they violate protocol
+        cmd = cmd.replace('\n', ' ');
+
+        // parameters are separated by one or more spaces.
+        final String[] parts = cmd.split(" +");
+
+        // command is always CAPS
+        parts[0] = parts[0].toUpperCase();
+
+        sendMessage(parts);
+    }
+
+    public void sendPing()
+    {
+        sendMessage("PING");
+    }
+
+    public void sendPong()
+    {
+        sendMessage("PONG");
+    }
+
+    private void sendMessage(final String msg)
+    {
+        sendMessage(new String[] { msg });
+    }
+
+    private void sendMessage(final String[] parts)
+    {
+        if (quit)
+            return;
+
+        netClient.logDebug("Client: " + messagePartsToString(parts));
+
+        boolean malformed = false;
+        String msg = "";
+
+        for (final String part : parts)
+        {
+            msg += part + '\n';
+            if (part.isEmpty() || (part.indexOf('\n') >= 0)) {
+                malformed = true;
+                break;
+            }
+        }
+
+        if (malformed) {
+            netClient.onMalformedMessage(messagePartsToString(parts));
+            return;
+        }
+
+        try {
+            toSvr.print(msg + '\n'); // don't use println, since we always want '\n'
+            toSvr.flush();
+        }
+        catch(Exception ex) {
+            netClient.logError("FATAL: Couldn't send message! " + ex.getMessage());
+            ex.printStackTrace();
+            handleConnectionLoss();
+        }
+    }
+
+    private String messagePartsToString(String[] parts) {
+
+        if (parts.length == 0)
+            return "([empty message])";
+
+        String result = "(\"" + parts[0] + '"';
+        for (int i=1; i < parts.length; i++)
+        {
+            result += ", \"" + parts[i] + '"';
+        }
+        result += ')';
+
+        return result;
+    }
+
+    public void disconnect() {
+        sendMessage(new String[] { "QUIT", "Client quit" });
+        closeConnection();
+    }
+
+    public void disconnect(final String reason) {
+        sendMessage(new String[] { "QUIT", reason.isEmpty()?"-":reason });
+        closeConnection();
+    }
+
+    public void sendChat(String message) {
+
+        String[] lines = message.split("\n");
+
+        for (String line : lines)
+        {
+            if (!message.trim().isEmpty())
+                sendMessage(new String[] { "CHAT", line });
+        }
+    }
+
+    public void joinRoom(final String roomName) {
+
+        sendMessage(new String[] { "JOIN_ROOM", roomName });
+    }
+
+    public void leaveRoom(final String roomName) {
+
+        sendMessage("PART");
+    }
+
+    public void requestInfo(final String user) {
+
+        sendMessage(new String[] { "INFO", user });
+    }
+
+    public void setNick(final String nick) {
+
+        this.nick = nick;
+        sendMessage(new String[] { "NICK", nick });
+    }
+
+    public void kick(final String nick) {
+
+        sendMessage(new String[] { "KICK", nick });
+    }
+
+    public void requestRoomsList() {
+
+        sendMessage("LIST");
+    }
+}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tools/hw2irc/net/ercatec/hw/ProtocolMessage.java	Wed May 26 16:32:43 2021 -0400
@@ -0,0 +1,280 @@
+/*
+ * Java net client for Hedgewars, a free turn based strategy game
+ * Copyright (c) 2011 Richard Karolyi <sheepluva@ercatec.net>
+ *
+ * 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; version 2 of the License
+ *
+ * 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, write to the Free Software
+ * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA
+ */
+
+package net.ercatec.hw;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Iterator;
+
+public final class ProtocolMessage
+{
+    public static final int ROOM_FIELD_COUNT = 9;
+
+    private static int minServerVersion = 49;
+
+    public static enum Type {
+        // for unknown message types
+        _UNKNOWN_MESSAGETYPE_,
+        // server messages
+        ERROR,
+        PING,
+        PONG,
+        NICK,
+        PROTO,
+        ASKPASSWORD,
+        SERVER_AUTH,
+        CONNECTED,
+        SERVER_MESSAGE,
+        BYE,
+        INFO,
+        NOTICE,
+        CHAT,
+        LOBBY__JOINED,
+        LOBBY__LEFT,
+        ROOMS,
+        ROOM,
+        ROOM_ADD,
+        ROOM_DEL,
+        ROOM_UPD,
+        ROOM__JOINED,
+        ROOM__LEFT,
+        CFG,
+        TOGGLE_RESTRICT_TEAMS,
+        CLIENT_FLAGS,
+        CF, // this just an alias and will be mapped to CLIENT_FLAGS
+        EM // engine messages
+    }
+
+    public final boolean isValid;
+    private Type type;
+    private String[] args;
+
+/*
+    public ProtocolMessage(String messageType)
+    {
+        args = new String[0];
+
+        try
+        {
+            type = Type.valueOf(messageType);
+            isValid = messageSyntaxIsValid();
+        }
+        catch (IllegalArgumentException whoops)
+        {
+            type = Type._UNKNOWN_MESSAGETYPE_;
+            args = new String[] { messageType };
+            isValid = false;
+        }
+    }
+*/
+
+    private final String[] emptyArgs = new String[0];
+
+    private String[] withoutFirst(final String[] array, final int amount) {
+        return Arrays.copyOfRange(array, amount, array.length);
+    }
+
+    private final List<String> parts;
+
+    // invalid Message
+    public ProtocolMessage() {
+        this.parts =  Arrays.asList(emptyArgs);
+        this.args = emptyArgs;
+        this.isValid = false;
+    }
+
+    public ProtocolMessage(final List<String> parts)
+    {
+        this.parts = parts;
+        this.args = emptyArgs;
+
+        final int partc = parts.size();
+
+        if (partc < 1) {
+            isValid = false;
+            return;
+        }
+
+        try {
+            type = Type.valueOf(parts.get(0).replaceAll(":", "__"));
+        }
+        catch (IllegalArgumentException whoops) {
+            type = Type._UNKNOWN_MESSAGETYPE_;
+        }
+
+        if (type == Type._UNKNOWN_MESSAGETYPE_) {
+            args = parts.toArray(args);
+            isValid = false;
+        }
+        else {
+            // all parts after command become arguments
+            if (partc > 1)
+                args = withoutFirst(parts.toArray(args), 1);
+            isValid = checkMessage();
+        }
+    }
+
+    private boolean checkMessage()
+    {
+        int argc = args.length;
+
+        switch (type)
+        {
+            // no arguments allowed
+            case PING:
+            case PONG:
+            case TOGGLE_RESTRICT_TEAMS:
+                if (argc != 0)
+                    return false;
+                break;
+
+            // one argument or more
+            case EM: // engine messages
+            case LOBBY__JOINED: // list of joined players
+            case ROOM__JOINED: // list of joined players
+                if (argc < 1)
+                    return false;
+                break;
+
+            // one argument
+            case SERVER_MESSAGE:
+            case BYE: // disconnect reason
+            case ERROR: // error message
+            case NICK: // nickname
+            case PROTO: // protocol version
+            case SERVER_AUTH: // last stage of mutual of authentication
+            case ASKPASSWORD: // request for auth with salt
+                if (argc != 1)
+                    return false;
+                break;
+
+            case NOTICE: // argument should be a number
+                if (argc != 1)
+                    return false;
+                try {
+                    Integer.parseInt(args[0]);
+                } 
+                catch (NumberFormatException e) {
+                    return false;
+                }
+                break;
+
+            // two arguments
+            case CONNECTED: // server description and version
+            case CHAT: // player nick and chat message
+            case LOBBY__LEFT: // player nick and leave reason
+            case ROOM__LEFT: // player nick and leave reason
+                if (argc != 2)
+                    return false;
+                break;
+                
+            case ROOM: // "ADD" (or "UPD" + room name ) + room attrs or "DEL" and room name
+                if(argc < 2)
+                    return false;
+
+                final String subC = args[0];
+
+                if (subC.equals("ADD")) {
+                    if(argc != ROOM_FIELD_COUNT + 1)
+                        return false;
+                    this.type = Type.ROOM_ADD;
+                    this.args = withoutFirst(args, 1);
+                }
+                else if (subC.equals("UPD")) {
+                    if(argc != ROOM_FIELD_COUNT + 2)
+                        return false;
+                    this.type = Type.ROOM_UPD;
+                    this.args = withoutFirst(args, 1);
+                }
+                else if (subC.equals("DEL") && (argc == 2)) {
+                    this.type = Type.ROOM_DEL;
+                    this.args = withoutFirst(args, 1);
+                }
+                else
+                    return false;
+                break;
+
+            // two arguments or more
+            case CFG: // setting name and list of setting parameters
+                if (argc < 2)
+                    return false;
+                break;
+            case CLIENT_FLAGS: // string of changed flags and player name(s)
+            case CF: // alias of CLIENT_FLAGS
+                if (argc < 2)
+                    return false;
+                if (this.type == Type.CF)
+                    this.type = Type.CLIENT_FLAGS;
+                break;
+
+            // four arguments
+            case INFO: // info about a player, name, ip/id, version, room
+                if (argc != 4)
+                    return false;
+                break;
+
+            // multiple of ROOM_FIELD_COUNT arguments (incl. 0)
+            case ROOMS:
+                if (argc % ROOM_FIELD_COUNT != 0)
+                    return false;
+                break;
+        }
+
+        return true;
+    }
+
+    private void maybeSendPassword() {
+        
+    }
+
+    public Type getType()
+    {
+        return type;
+    }
+
+    public String[] getArguments()
+    {
+        return args;
+    }
+
+    public boolean isValid()
+    {
+        return isValid;
+    }
+
+    public static String partsToString(final List<String> parts)
+    {
+        final Iterator<String> iter = parts.iterator();
+
+        if (!iter.hasNext())
+            return "( -EMPTY- )";
+
+        String result = "(\"" + iter.next();
+
+        while (iter.hasNext()) {
+            result += "\", \"" + iter.next();
+        }
+
+        return result + "\")";
+    }
+
+    public String toString() {
+        return partsToString(this.parts);
+    }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tools/hw2irc/net/ercatec/hw2ircsvr/Connection.java	Wed May 26 16:32:43 2021 -0400
@@ -0,0 +1,1814 @@
+package net.ercatec.hw2ircsvr;
+
+import net.ercatec.hw.INetClient;
+import net.ercatec.hw.ProtocolConnection;
+
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Hashtable;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.Collections;
+import java.util.Vector;
+
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+
+import java.lang.IllegalArgumentException;
+
+// for auth files
+import java.util.Properties;
+import java.io.FileInputStream;
+import java.io.IOException;
+
+/* TODO
+ * disconnect clients that are not irc clients
+ * disconnect excess flooders
+ * recognizes stuff after : as single arg
+ * collect pre-irc-join messages and show on join
+ * allow negating regexps
+ * ban
+ * banlist
+ * commandquery // wth did I mean by that?
+ * more room info
+ * filter rooms
+ * warnings
+ * global notice
+ */
+
+/**
+ * @author sheepluva
+ * 
+ * based on jircs by Alexander Boyd
+ */
+public class Connection implements INetClient, Runnable
+{
+    private static final String DESCRIPTION_SHORT
+        = "connect to hedgewars via irc!";
+
+    private static final String VERSION = "0.6.7-Alpha_2015-11-07";
+
+
+    private static final String MAGIC_BYTES       = "[\1\2\3]";
+    private static final char   MAGIC_BYTE_ACTION = ((char)1); // ^A
+    private static final char   MAGIC_BYTE_BOLD   = ((char)2); // ^B
+    private static final char   MAGIC_BYTE_COLOR  = ((char)3); // ^C
+
+    private static final String[] DEFAULT_MOTD = {
+        "                                         ",
+        " "+MAGIC_BYTE_COLOR+"06"+MAGIC_BYTE_BOLD+"                            SUCH FLUFFY!",
+        "                                         ",
+        " "+MAGIC_BYTE_COLOR+"04 MUCH BAH     "+MAGIC_BYTE_COLOR+"00__  _                     ",
+        " "+MAGIC_BYTE_COLOR+"00          .-.'  `; `-."+MAGIC_BYTE_COLOR+"00_  __  _          ",
+        " "+MAGIC_BYTE_COLOR+"00         (_,         .-:'  `; `"+MAGIC_BYTE_COLOR+"00-._      ",
+        " "+MAGIC_BYTE_COLOR+"14       ,'"+MAGIC_BYTE_COLOR+"02o "+MAGIC_BYTE_COLOR+"00(        (_,           )     ",
+        " "+MAGIC_BYTE_COLOR+"14      (__"+MAGIC_BYTE_COLOR+"00,-'      "+MAGIC_BYTE_COLOR+"15,'"+MAGIC_BYTE_COLOR+"12o "+MAGIC_BYTE_COLOR+"00(            )>   ",
+        " "+MAGIC_BYTE_COLOR+"00         (       "+MAGIC_BYTE_COLOR+"15(__"+MAGIC_BYTE_COLOR+"00,-'            )    ",
+        " "+MAGIC_BYTE_COLOR+"00          `-'._.--._(             )     ",
+        " "+MAGIC_BYTE_COLOR+"14             |||  |||"+MAGIC_BYTE_COLOR+"00`-'._.--._.-'      ",
+        " "+MAGIC_BYTE_COLOR+"15                        |||  |||        ",
+        " "+MAGIC_BYTE_COLOR+"07"+MAGIC_BYTE_BOLD+"  WOW!                                  ",
+        " "+MAGIC_BYTE_COLOR+"09                   VERY SHEEP           ",
+        "                                         ",
+        "                                         ",
+        "                                         ",
+        " "+MAGIC_BYTE_COLOR+"4 Latest hw2irc crimes/changes:",
+        "     ping: ping of hwserver will only get reply if irc client pingable",
+        "     ping: pings of irc clients will only get reply if hwserver pingable",
+        "     rooms: id-rotation, make channel names at least 2 digits wide",
+        "     auth: support passhash being loaded local auth file and irc pass (sent as cleartext - DO NOT USE!)",
+        "                                         ",
+        "                                         ",
+    };
+
+
+    private static final String DEFAULT_QUIT_REASON = "User quit";
+    // NOT final
+    private static char CHAT_COMMAND_CHAR = '\\';
+
+    private final class Room {
+        public final int id;
+        public final String chan;
+        public String name;
+        private String owner = "";
+        public int nPlayers = 0;
+        public int nTeams   = 0;
+
+        public Room(final int id, final String name, final String owner) {
+            this.id = id;
+            this.chan = (id<10?"#0":"#") + id;
+            this.name = name;
+            this.setOwner(owner);
+        }
+
+        public String getOwner() { return this.owner; }
+
+        public void setOwner(final String owner) {
+            // don't to this for first owner
+            if (!this.owner.isEmpty()) {
+
+                // owner didn't change
+                if (this.owner.equals(owner))
+                    return;
+
+                // update old room owner
+                final Player oldOwner = allPlayers.get(this.owner);
+
+                if (oldOwner != null)
+                    oldOwner.isRoomAdm = false;
+
+            }
+
+            // update new room owner
+            final Player newOwner = allPlayers.get(owner);
+
+            if (newOwner != null)
+                newOwner.isRoomAdm = true;
+
+            this.owner = owner;
+
+        }
+    }
+
+    private final class Player {
+        public final String nick;
+        public final String ircNick;
+        private boolean isAdm;
+        private boolean isCont;
+        private boolean isReg;
+        public boolean inRoom;
+        public boolean isRoomAdm;
+        private String ircId;
+        private String ircHostname;
+        private boolean announced;
+
+        // server info
+        private String version = "";
+        private String ip = "";
+        private String room = "";
+
+        public Player(final String nick) {
+            this.nick = nick;
+            this.ircNick = hwToIrcNick(nick);
+            this.announced = false;
+            updateIrcHostname();
+        }
+
+        public String getIrcHostname() { return ircHostname; }
+        public String getIrcId()       { return ircId; }
+
+        public String getRoom()        {
+            if (room.isEmpty())
+                return room;
+
+            return "[" + ((isAdm?"@":"") + (isRoomAdm?"+":"") + this.room);
+        }
+
+        public boolean needsAnnounce() {
+            return !announced;
+        }
+
+        public void setAnnounced() {
+            announced = true;
+        }
+
+        public void setInfo(final String ip, final String version, final String room) {
+            if (this.version.isEmpty()) {
+                this.version = version;
+                this.ip = ip.replaceAll("^\\[|]$", "");
+                updateIrcHostname();
+            }
+
+            if (room.isEmpty())
+                this.room = room;
+            else
+                this.room = room.replaceAll("^\\[[@+]*", "");
+        }
+
+        public boolean isServerAdmin()  { return isAdm; }
+        //public boolean isContributor()  { return isCont; }
+        public boolean isRegistered()   { return isReg; }
+
+        public void setServerAdmin(boolean isAdm) {
+            this.isAdm = isAdm; updateIrcHostname(); }
+        public void setContributor(boolean isCont) {
+            this.isCont = isCont; updateIrcHostname(); }
+        public void setRegistered(boolean isReg) {
+            this.isReg = isReg; updateIrcHostname(); }
+
+        private void updateIrcHostname() {
+            ircHostname = ip.isEmpty()?"":(ip + '/');
+            ircHostname += "hw/";
+            if (!version.isEmpty())
+                ircHostname += version;
+            if (isAdm)
+                ircHostname += "/admin";
+            else if (isCont)
+                ircHostname += "/contributor";
+            else if (isReg)
+                ircHostname += "/member";
+            else
+                ircHostname += "/player";
+
+            updateIrcId();
+        }
+
+        private void updateIrcId() {
+            ircId = ircNick + "!~" + ircNick + "@" + ircHostname;
+        }
+    }
+
+    public String hw404NickToIrcId(String nick) {
+        nick = hwToIrcNick(nick);
+        return nick + "!~" + nick + "@hw/404";
+    }
+
+    // hash tables are thread-safe
+    private final Map<String,  Player>  allPlayers = new Hashtable<String,  Player>();
+    private final Map<String,  Player> roomPlayers = new Hashtable<String,  Player>();
+    private final Map<Integer, Room>   roomsById   = new Hashtable<Integer, Room>();
+    private final Map<String,  Room>   roomsByName = new Hashtable<String,  Room>();
+    private final List<Room> roomsSorted = new Vector<Room>();
+
+    private final List<String> ircPingQueue = new Vector<String>();
+
+    private static final String DEFAULT_SERVER_HOST = "netserver.hedgewars.org";
+    private static String SERVER_HOST = DEFAULT_SERVER_HOST;
+    private static int IRC_PORT = 46667;
+    
+    private String hostname;
+
+    private static final String LOBBY_CHANNEL_NAME = "#lobby";
+    private static final String  ROOM_CHANNEL_NAME = "#room";
+
+    // hack
+    // TODO: ,
+    private static final char MAGIC_SPACE   = ' ';
+    private static final char MAGIC_ATSIGN  = '៙';
+    private static final char MAGIC_PERCENT = '%';
+    private static final char MAGIC_PLUS    = '+';
+    private static final char MAGIC_EXCLAM  = '❢';
+    private static final char MAGIC_COMMA   = ',';
+    private static final char MAGIC_COLON   = ':';
+
+    private static String hwToIrcNick(final String nick) {
+        return nick
+            .replace(' ', MAGIC_SPACE)
+            .replace('@', MAGIC_ATSIGN)
+            .replace('%', MAGIC_PERCENT)
+            .replace('+', MAGIC_PLUS)
+            .replace('!', MAGIC_EXCLAM)
+            .replace(',', MAGIC_COMMA)
+            .replace(':', MAGIC_COLON)
+            ;
+    }
+    private static String ircToHwNick(final String nick) {
+        return nick
+            .replace(MAGIC_COLON,   ':')
+            .replace(MAGIC_COMMA,   ',')
+            .replace(MAGIC_EXCLAM,  '!')
+            .replace(MAGIC_PLUS,    '+')
+            .replace(MAGIC_PERCENT, '%')
+            .replace(MAGIC_ATSIGN,  '@')
+            .replace(MAGIC_SPACE,   ' ')
+            ;
+    }
+
+    private ProtocolConnection hwcon;
+    private boolean joined = false;
+    private boolean ircJoined = false;
+
+    private void collectFurtherInfo() {
+        hwcon.sendPing();
+        hwcon.processNextClientFlagsMessages();
+    }
+
+    public void onPing() {
+        send("PING :" + globalServerName);
+    }
+
+    public void onPong() {
+        if (!ircPingQueue.isEmpty())
+                send(":" + globalServerName + " PONG " + globalServerName
+                        + " :" + ircPingQueue.remove(0));
+            
+    }
+
+    public void onConnectionLoss() {
+        quit("Connection Loss");
+    }
+
+    public void onDisconnect(final String reason) {
+        quit(reason);
+    }
+
+    public String onPasswordHashNeededForAuth() {
+        return passwordHash;
+    }
+
+    public void onMalformedMessage(String contents)
+    {
+        this.logError("MALFORMED MESSAGE: " + contents);
+    }
+
+    public void onChat(final String user, final String message) {
+        String ircId;
+        Player player = allPlayers.get(user);
+        if (player == null) {
+            // fake user - so probably a notice
+            sendChannelNotice(message, hwToIrcNick(user));
+            //logWarning("onChat(): Couldn't find player with specified nick! nick: " + user);
+            //send(":" + hw404NickToIrcId(user) + " PRIVMSG "
+                     //+ LOBBY_CHANNEL_NAME + " :" + hwActionToIrc(message));
+        }
+        else
+            send(":" + player.getIrcId() + " PRIVMSG "
+                     + LOBBY_CHANNEL_NAME + " :" + hwActionToIrc(message));
+    }
+
+    public void onWelcomeMessage(final String message) {
+    }
+
+    public void onNotice(int number) {
+    }
+
+    public void onBanListEntry(BanType type, String target, String duration, String reason) {
+        // TODO
+    }
+    public void onBanListEnd() {
+        // TODO
+    }
+
+    public String onNickCollision(final String nick) {
+        return nick + "_";
+    }
+
+    public void onNickSet(final String nick) {
+        final String newNick = hwToIrcNick(nick);
+        // tell irc client
+        send(":" + ownIrcNick + "!~" + username + "@"
+                            + hostname + " NICK :" + nick);
+        ownIrcNick = newNick;
+        updateLogPrefix();
+        logInfo("Nickname set to " + nick);
+    }
+
+    private void flagAsInLobby(final Player player) {
+        if (!ircJoined)
+            return;
+        final String ircNick = player.ircNick;
+        if (player.isServerAdmin())
+            send(":room-part!~@~ MODE " + LOBBY_CHANNEL_NAME + " -h+o " + ircNick + " " + ircNick);
+        //else
+            //send(":room-part!~@~ MODE " + LOBBY_CHANNEL_NAME + " +v " + ircNick);
+    }
+
+    private void flagAsInRoom(final Player player) {
+        if (!ircJoined)
+            return;
+        final String ircNick = player.ircNick;
+        if (player.isServerAdmin())
+            send(":room-join!~@~ MODE " + LOBBY_CHANNEL_NAME + " -o+h " + ircNick + " " + ircNick);
+        //else
+            //send(":room-join!~@~ MODE " + LOBBY_CHANNEL_NAME + " -v " + ircNick);
+    }
+
+// TODO somewhere: escape char for magic chars!
+
+// TODO /join with playername => FOLLOW :D
+
+    public void sendPlayerMode(final Player player) {
+        char c;
+        if (player.isServerAdmin())
+            c = player.inRoom?'h':'o';
+        else if (player.isRegistered())
+            c = 'v';
+        else
+            // no mode
+            return;
+
+        send(":server-join!~@~ MODE " + LOBBY_CHANNEL_NAME + " +" + c + " " + player.ircNick);
+    }
+
+    private Player ownPlayer = null;
+
+    public void onLobbyJoin(final String[] users) {
+
+        final List<Player> newPlayers = new ArrayList<Player>(users.length);
+
+        // process joins
+        for (final String user : users) {
+            final Player player = new Player(user);
+            if (ownPlayer == null)
+                ownPlayer = player;
+            newPlayers.add(player);
+            allPlayers.put(user, player);
+        }
+
+        // make sure we get the client flags before we announce anything
+        collectFurtherInfo();
+
+        // get player info
+        // NOTE: if player is in room, then info was already retrieved
+        for (final Player player : newPlayers) {
+            if (!player.inRoom)
+                hwcon.requestInfo(player.nick);
+        }
+
+        /* DISABLED - we'll announce later - when receiving info
+        // announce joins
+        if (ircJoined) {
+            for (final Player player : newPlayers) {
+                final String ircId = player.getIrcId();
+                send(":" + ircId
+                         + " JOIN "+ lobbyChannel.name);
+                sendPlayerMode(player);
+            }
+        }
+        */
+        if (!ircJoined) {
+            // don't announced players that were there before join already
+            for (final Player player : newPlayers) {
+                player.setAnnounced();
+            }
+        }
+
+        if (!joined) {
+            joined = true;
+            // forget password hash, we don't need it anymore.
+            passwordHash = "";
+            logInfo("Hedgewars server/lobby joined.");
+            sendSelfNotice("Hedgewars server was joined successfully");
+            // do this after join so that rooms can be assigned to their owners
+            hwcon.requestRoomsList();
+        }
+    }
+
+    private void makeIrcJoinLobby() {
+            sendGlobal("INVITE " + ownIrcNick + " " + LOBBY_CHANNEL_NAME);
+            try{Thread.sleep(3000);}catch(Exception e){}
+            join(lobbyChannel.name);
+            sendSelfNotice("Joining lobby-channel: " + lobbyChannel.name);
+    }
+
+    private void announcePlayerJoinLobby(final Player player) {
+            player.setAnnounced();
+            send(":" + player.getIrcId()
+                     + " JOIN "+ lobbyChannel.name);
+            sendPlayerMode(player);
+    }
+
+    public void onLobbyLeave(final String user, final String reason) {
+        final Player player = allPlayers.get(user);
+        if (player == null) {
+            logWarning("onLobbyLeave(): Couldn't find player with specified nick! nick: " + user);
+            sendIfJoined(":" + hw404NickToIrcId(user)
+                 + " PART " + lobbyChannel.name + " " + reason);
+        }
+        else {
+            if (ircJoined && player.needsAnnounce())
+                announcePlayerJoinLobby(player);
+            sendIfJoined(":" + player.getIrcId()
+                 + " PART " + lobbyChannel.name + " " + reason);
+            allPlayers.remove(user);
+        }
+    }
+
+    private int lastRoomId = 0;
+
+    public void onRoomInfo(final String name, final String flags,
+                           final String newName, final int nUsers,
+                           final int nTeams, final String owner,
+                           final String map, final String style,
+                           final String scheme, final String weapons) {
+
+        Room room = roomsByName.get(name);
+
+        if (room == null) {
+            // try to reuse old ids
+            if (lastRoomId >= 90)
+                lastRoomId = 9;
+
+            // search for first free
+            while(roomsById.containsKey(++lastRoomId)) { }
+
+            room = new Room(lastRoomId, newName, owner);
+            roomsById.put(lastRoomId, room);
+            roomsByName.put(newName, room);
+            roomsSorted.add(room);
+        }
+        else if (!room.name.equals(newName)) {
+            room.name = newName;
+            roomsByName.put(newName, roomsByName.remove(name));
+        }
+
+        // update data
+        room.setOwner(owner);
+        room.nPlayers = nUsers;
+        room.nTeams = nTeams;
+    }
+
+    public void onRoomDel(final String name) {
+        final Room room = roomsByName.remove(name);
+
+        if (room != null) {
+            roomsById.remove(room.id);
+            roomsSorted.remove(room);
+        }
+    }
+
+    public void onRoomJoin(final String[] users) {
+    }
+
+    public void onRoomLeave(final String[] users) {
+    }
+
+    // TODO vector that remembers who's info was requested for manually
+    List<String> requestedInfos =  new Vector<String>();
+
+    public void onUserInfo(final String user, final String ip, final String version, final String room) {
+        Player player = allPlayers.get(user);
+        if (player != null) {
+            player.setInfo(ip, version, room);
+            if (ircJoined) {
+                if (player.needsAnnounce())
+                    announcePlayerJoinLobby(player);
+            }
+            else {
+                if (player == ownPlayer) {
+                    
+                    makeIrcJoinLobby();
+                }
+            }
+        }
+
+        // if MANUAL send notice
+        if (requestedInfos.remove(user)) {
+            final String nick = hwToIrcNick(user);
+            sendServerNotice(nick + " - " + buildInfoString(ip, version, room));
+        }
+    }
+
+    public void onUserFlagChange(final String user, final UserFlagType flag, final boolean newValue) {
+        final Player player = allPlayers.get(user);
+        if (player == null) {
+            logError("onUserFlagChange(): Couldn't find player with specified nick! nick: " + user);
+            return;
+        }
+        switch (flag) {
+            case ADMIN:
+                player.setServerAdmin(newValue);
+                if (newValue) {
+                    logDebug(user + " is server admin");
+                    //sendIfJoined(":server!~@~ MODE " + LOBBY_CHANNEL_NAME + " -v+o " + player.ircNick + " " + player.ircNick);
+                }
+                break;
+            case INROOM:
+                player.inRoom = newValue;
+                if (newValue) {
+                    flagAsInRoom(player);
+                    logDebug(user + " entered a room");
+                    // get new room info
+                    hwcon.requestInfo(player.nick);
+                }
+                else {
+                    flagAsInLobby(player);
+                    logDebug(user + " returned to lobby");
+                    player.inRoom = false;
+                }
+                break;
+            case REGISTERED:
+                player.setRegistered(newValue);
+                break;
+            default: break;
+        }
+    }
+
+    public class Channel
+    {
+        private String topic;
+        private final String name;
+        private final Map<String, Player> players;
+
+        public Channel(final String name, final String topic, final Map<String, Player> players) {
+            this.name = name;
+            this.topic = topic;
+            this.players = players;
+        }
+    }
+
+    public void logInfo(final String message) {
+        System.out.println(this.logPrefix + ": " + message);
+    }
+
+    public void logDebug(final String message) {
+        System.out.println(this.logPrefix + "| " + message);
+    }
+
+    public void logWarning(final String message) {
+        System.err.println(this.logPrefix + "? " + message);
+    }
+
+    public void logError(final String message) {
+        System.err.println(this.logPrefix + "! " + message);
+    }
+
+
+    //private static final Object mutex = new Object();
+    private boolean joinSent = false;
+    private Socket socket;
+    private String username;
+    private String ownIrcNick;
+    private String description;
+    private static Map<String, Connection> connectionMap = new HashMap<String, Connection>();
+    // TODO those MUST NOT be static!
+    //private Map<String, Channel> channelMap = new HashMap<String, Channel>();
+    private final Channel lobbyChannel;
+    private static String globalServerName;
+    private String logPrefix;
+    private final String clientId;
+    private String passwordHash = "";
+
+    private final Connection thisConnection;
+
+    public Connection(Socket socket, final String clientId) throws Exception
+    {
+        this.ownIrcNick = "NONAME";
+        this.socket = socket;
+        this.hostname = ((InetSocketAddress)socket.getRemoteSocketAddress())
+                 .getAddress().getHostAddress();
+        this.clientId = clientId;
+        updateLogPrefix();
+        thisConnection = this;
+        logInfo("New Connection");
+
+        this.hwcon = null;
+
+        try {
+            this.hwcon = new ProtocolConnection(this, SERVER_HOST);
+            logInfo("Connection to " + SERVER_HOST + " established.");
+        }
+        catch(Exception ex) {
+            final String errmsg = "Could not connect to " + SERVER_HOST + ": "
+                + ex.getMessage();
+            logError(errmsg);
+            sendQuit(errmsg);
+        }
+
+        final String lobbyTopic = " # " + SERVER_HOST + " - HEDGEWARS SERVER LOBBY # ";
+        this.lobbyChannel = new Channel(LOBBY_CHANNEL_NAME, lobbyTopic, allPlayers);
+
+        // start in new thread
+        if (hwcon != null) {
+            (this.hwcon.processMessages(true)).start();
+        }
+    }
+    
+    private void updateLogPrefix() {
+        if (ownIrcNick == null)
+            this.logPrefix = clientId + " ";
+        else
+            this.logPrefix = clientId + " [" + ownIrcNick + "] ";
+    }
+
+    private void setNick(final String nick) {
+        if (passwordHash.isEmpty()) {
+            try {
+              final Properties authProps = new Properties();
+              final String authFile = this.hostname + ".auth";
+              logInfo("Attempting to load auth info from " + authFile);
+              authProps.load(new FileInputStream(authFile));
+              passwordHash = authProps.getProperty(nick, "");
+              if (passwordHash.isEmpty())
+                logInfo("Auth info file didn't contain any password hash for: " + nick);
+            } catch (IOException e) {
+                logInfo("Auth info file couldn't be loaded.");
+            }
+        }
+
+        // append _ just in case
+        if (!passwordHash.isEmpty() || nick.endsWith("_")) {
+            ownIrcNick = nick;
+            hwcon.setNick(ircToHwNick(nick));
+        }
+        else {
+            final String nick_ = nick + "_";
+            ownIrcNick = nick_;
+            hwcon.setNick(ircToHwNick(nick_));
+        }
+    }
+
+    public String getRepresentation()
+    {
+        return ownIrcNick + "!~" + username + "@" + hostname;
+    }
+
+    private static int lastClientId = 0;
+
+    /**
+     * @param args
+     */
+    public static void main(String[] args) throws Throwable
+    {
+        if (args.length > 0)
+        {
+            SERVER_HOST = args[0];
+        }
+        if (args.length > 1)
+        {
+            IRC_PORT = Integer.parseInt(args[1]);
+        }
+
+        globalServerName = "hw2irc";
+
+        if (!SERVER_HOST.equals(DEFAULT_SERVER_HOST))
+            globalServerName += "~" + SERVER_HOST;
+
+        final int port = IRC_PORT;
+        ServerSocket ss = new ServerSocket(port);
+        System.out.println("Listening on port " + port);
+        while (true)
+        {
+            Socket s = ss.accept();
+            final String clientId = "client" + (++lastClientId) + '-'
+                 + ((InetSocketAddress)s.getRemoteSocketAddress())
+                 .getAddress().getHostAddress();
+            try {
+                Connection clientCon = new Connection(s, clientId);
+                //clientCon.run();
+                Thread clientThread = new Thread(clientCon, clientId);
+                clientThread.start();
+            }
+            catch (Exception ex) {
+                System.err.println("FATAL: Server connection thread " + clientId + " crashed on startup! " + ex.getMessage());
+                ex.printStackTrace();
+            }
+
+            System.out.println("Note: Not accepting new clients for the next " + SLEEP_BETWEEN_LOGIN_DURATION + "s, trying to avoid reconnecting too quickly.");
+            Thread.sleep(SLEEP_BETWEEN_LOGIN_DURATION * 1000);
+            System.out.println("Note: Accepting clients again!");
+        }
+    }
+
+    private static final int SLEEP_BETWEEN_LOGIN_DURATION = 122;
+
+    private boolean hasQuit = false;
+
+    public synchronized void quit(final String reason) {
+        if (hasQuit)
+            return;
+
+        hasQuit = true;
+        // disconnect from hedgewars server
+        if (hwcon != null)
+            hwcon.disconnect(reason);
+        // disconnect irc client
+        sendQuit("Quit: " + reason);
+        // wait some time so that last data can be pushed
+        try {
+            Thread.sleep(200);
+        }
+        catch (Exception e) { }
+        // terminate
+        terminateConnection = true;
+    }
+
+
+    private static String hwActionToIrc(final String chatMsg) {
+        if (!chatMsg.startsWith("/me ") || (chatMsg.length() <= 4))
+            return chatMsg;
+
+        return MAGIC_BYTE_ACTION + "ACTION " + chatMsg.substring(4) + MAGIC_BYTE_ACTION;
+    }
+
+    private static String ircActionToHw(final String chatMsg) {
+        if (!chatMsg.startsWith(MAGIC_BYTE_ACTION + "ACTION ") || (chatMsg.length() <= 9))
+            return chatMsg;
+
+        return "/me " + chatMsg.substring(8, chatMsg.length() - 1);
+    }
+
+// TODO: why is still still being called when joining bogus channel name?
+    public void join(String channelName)
+    {
+        if (ownPlayer == null) {
+            sendSelfNotice("Trying to join while ownPlayer == null. Aborting!");
+            quit("Something went horribly wrong.");
+            return;
+        }
+
+
+        final Channel channel = getChannel(channelName);
+
+        // TODO reserve special char for creating a new ROOM
+        // it will be named after the player name by default
+        // can be changed with /topic after
+
+        // not a valid channel
+        if (channel == null) {
+            sendSelfNotice("You cannot manually create channels here.");
+            sendGlobal(ERR_NOSUCHCHANNEL + ownIrcNick + " " + channel.name
+                    + " :No such channel");
+            return;
+        }
+
+        // TODO if inRoom "Can't join rooms while still in room"
+
+        // TODO set this based on room host/admin mode maybe
+
+/* :testuser2131!~r@asdasdasdasd.at JOIN #asdkjasda
+:weber.freenode.net MODE #asdkjasda +ns
+:weber.freenode.net 353 testuser2131 @ #asdkjasda :@testuser2131
+:weber.freenode.net 366 testuser2131 #asdkjasda :End of /NAMES list.
+:weber.freenode.net NOTICE #asdkjasda :[freenode-info] why register and identify? your IRC nick is how people know you. http://freenode.net/faq.shtml#nicksetup
+
+*/ 
+        send(":" + ownPlayer.getIrcId() + " JOIN "
+         + channelName);
+
+        //send(":sheeppidgin!~r@localhost JOIN " + channelName);
+
+        ircJoined = true;
+
+        sendGlobal(":hw2irc MODE #lobby +nt");
+
+        sendTopic(channel);
+
+        sendNames(channel);
+
+    }
+
+    private void sendTopic(final Channel channel) {
+        if (channel.topic != null)
+            sendGlobal(RPL_TOPIC + ownIrcNick + " " + channel.name
+                    + " :" + channel.topic);
+        else
+            sendGlobal(RPL_NOTOPIC + ownIrcNick + " " + channel.name
+                    + " :No topic is set");
+    }
+
+    private void sendNames(final Channel channel) {
+        // There is no error reply for bad channel names.
+
+        if (channel != null) {
+            // send player list
+            for (final Player player : channel.players.values()) {
+
+                final String prefix;
+
+                if (player.isServerAdmin())
+                    prefix = (player.isServerAdmin())?"@":"%";
+                else
+                    prefix = (player.isRegistered())?"+":"";
+
+                sendGlobal(RPL_NAMREPLY + ownIrcNick + " = " + channel.name
+                        + " :" + prefix + player.ircNick);
+            }
+        }
+
+        sendGlobal(RPL_ENDOFNAMES + ownIrcNick + " " + channel.name
+                + " :End of /NAMES list");
+    }
+
+    private void sendList(final String filter) {
+        // id column size
+        //int idl = 1 + String.valueOf(lastRoomId).length();
+
+        //if (idl < 3)
+            //idl = 3;
+
+        // send rooms list
+        sendGlobal(RPL_LISTSTART + ownIrcNick 
+            //+ String.format(" %1$" + idl + "s  #P  #T  Name", "ID"));
+            + String.format(" %1$s #P #T Name", "ID"));
+
+        if (filter.isEmpty() || filter.equals(".")) {
+            // lobby
+            if (filter.isEmpty())
+                sendGlobal(RPL_LIST + ownIrcNick + " " + LOBBY_CHANNEL_NAME
+                    + " " + allPlayers.size() + " :" + lobbyChannel.topic);
+
+            // room list could be changed by server while we reply client
+            synchronized (roomsSorted) {
+                for (final Room room : roomsSorted) {
+                    sendGlobal(RPL_LIST + ownIrcNick
+                        //+ String.format(" %1$" + idl + "s  %2$2d  :%3$2d  %4$s",
+                        + String.format(" %1$s %2$d :%3$d  %4$s",
+                            room.chan, room.nPlayers, room.nTeams, room.name));
+                }
+            }
+        }
+        // TODO filter
+
+        sendGlobal(RPL_LISTEND + ownIrcNick + " " + " :End of /LIST");
+    }
+
+    private List<Player> findPlayers(final String expr) {
+        List<Player> matches = new ArrayList<Player>(allPlayers.size());
+
+        try {
+            final int flags = Pattern.CASE_INSENSITIVE + Pattern.UNICODE_CASE;
+            final Pattern regx = Pattern.compile(expr, flags);
+
+            for (final Player p : allPlayers.values()) {
+                if ((regx.matcher(p.nick).find())
+                    || (regx.matcher(p.ircId).find())
+                    //|| (regx.matcher(p.version).find())
+                    //|| ((p.ip.length() > 2) && regx.matcher(p.ip).find())
+                    || (!p.getRoom().isEmpty() && regx.matcher(p.getRoom()).find())
+                ) matches.add(p);
+            }
+        }
+        catch(PatternSyntaxException ex) {
+            sendSelfNotice("Pattern not understood: " + ex.getMessage());
+        }
+
+        return matches;
+    }
+
+    private String buildInfoString(final String ip, final String version, final String room) {
+        return (ip.equals("[]")?"":ip + " ") + version + (room.isEmpty()?"":" " + room);
+    }
+
+    private void sendWhoForPlayer(final Player player) {
+        sendWhoForPlayer(LOBBY_CHANNEL_NAME, player.ircNick, (player.inRoom?player.getRoom():""), player.getIrcHostname());
+    }
+
+    private void sendWhoForPlayer(final Player player, final String info) {
+        sendWhoForPlayer(LOBBY_CHANNEL_NAME, player.ircNick, info, player.getIrcHostname());
+    }
+
+    private void sendWhoForPlayer(final String nick, final String info) {
+        sendWhoForPlayer(LOBBY_CHANNEL_NAME, nick, info);
+    }
+
+    private void sendWhoForPlayer(final String channel, final String nick, final String info) {
+        final Player player = allPlayers.get(nick);
+
+        if (player == null)
+            sendWhoForPlayer("OFFLINE", hwToIrcNick(nick), info, "hw/offline");
+        else
+            sendWhoForPlayer(channel,   player.ircNick,    info, player.getIrcHostname());
+    }
+
+    private void sendWhoForPlayer(final String channel, final String ircNick, final String info, final String hostname) {
+        sendGlobal(RPL_WHOREPLY + channel + " " + channel
+                            + " ~" + ircNick + " " + hostname
+                            + " " + globalServerName + " " + ircNick
+                            + " H :0 " + info);
+    }
+
+    private void sendWhoEnd(final String ofWho) {
+        send(RPL_ENDOFWHO + ownIrcNick + " " + ofWho
+                        + " :End of /WHO list.");
+    }
+
+    private void sendMotd() {
+        sendGlobal(RPL_MOTDSTART + ownIrcNick + " :- Message of the Day -");
+        final String mline = RPL_MOTD + ownIrcNick + " :";
+        for(final String line : DEFAULT_MOTD) {
+            sendGlobal(mline + line);
+        }
+        sendGlobal(RPL_ENDOFMOTD + ownIrcNick + " :End of /MOTD command.");
+    }
+
+    private Channel getChannel(final String name) {
+        if (name.equals(LOBBY_CHANNEL_NAME)) {
+            return lobbyChannel;
+        }
+
+        return null;
+    }
+
+    private enum Command
+    {
+        PASS(1, 1)
+        {
+            @Override
+            public void run(final Connection con, final String prefix, final String[] args)
+                    throws Exception
+            {
+                con.passwordHash = args[0];
+            }
+        },
+        NICK(1, 1)
+        {
+            @Override
+            public void run(final Connection con, final String prefix, final String[] args)
+                    throws Exception
+            {
+                con.setNick(args[0]);
+            }
+        },
+        USER(1, 4)
+        {
+            @Override
+            public void run(final Connection con, final String prefix, final String[] args)
+                    throws Exception
+            {
+                if (con.username != null)
+                {
+                    con.send("NOTICE AUTH :You can't change your user "
+                            + "information after you've logged in right now.");
+                    return;
+                }
+                con.username = args[0];
+                String forDescription = args.length > 3 ? args[3]
+                        : "(no description)";
+                con.description = forDescription;
+                /*
+                 * Now we'll send the user their initial information.
+                 */
+                con.sendGlobal(RPL_WELCOME + con.ownIrcNick + " :Welcome to "
+                        + globalServerName + " - " + DESCRIPTION_SHORT);
+                con.sendGlobal("004 " + con.ownIrcNick + " " + globalServerName
+                        + " " + VERSION);
+
+                con.sendMotd();
+
+            }
+        },
+        MOTD(0, 0)
+        {
+            @Override
+            public void run(final Connection con, final String prefix, final String[] args)
+                    throws Exception
+            {
+                con.sendMotd();
+            }
+        },
+        PING(1, 1)
+        {
+            @Override
+            public void run(final Connection con, final String prefix, final String[] args)
+                    throws Exception
+            {
+                con.ircPingQueue.add(args[0]);
+                con.hwcon.sendPing();
+            }
+        },
+        PONG(1, 2)
+        {
+            @Override
+            public void run(final Connection con, final String prefix, final String[] args)
+                    throws Exception
+            {
+                con.hwcon.sendPong();
+            }
+        },
+        NAMES(1, 1)
+        {
+            @Override
+            public void run(final Connection con, final String prefix, final String[] args)
+                    throws Exception
+            {
+                final Channel channel = con.getChannel(args[0]);
+                con.sendNames(channel);
+            }
+        },
+        LIST(0, 2)
+        {
+            @Override
+            public void run(final Connection con, final String prefix, final String[] args)
+                    throws Exception
+            {
+                // TODO filter by args[1] (comma sep list of chans), make # optional
+                // ignore args[1] (server), TODO: maybe check and send RPL_NOSUCHSERVER if wrong
+                con.sendList(args.length > 0?args[0]:"");
+            }
+        },
+        JOIN(1, 2)
+        {
+            @Override
+            public void run(final Connection con, final String prefix, final String[] args)
+                    throws Exception
+            {
+                if (args.length < 1)  {
+                    con.sendSelfNotice("You didn't specify what you want to join!");
+                    return;
+                }
+
+                if (con.ownPlayer == null) {
+                    con.sendSelfNotice("Lobby is not ready to be joined yet - hold on a second!");
+                    return;
+                }
+
+                if (args[0].equals(LOBBY_CHANNEL_NAME)) {
+                    //con.sendSelfNotice("Lobby can't be joined manually!");
+                    con.join(LOBBY_CHANNEL_NAME);
+                    return;
+                }
+                con.sendSelfNotice("Joining rooms is not supported yet, sorry!");
+            }
+        },
+        WHO(0, 2)
+        {
+            @Override
+            public void run(final Connection con, final String prefix, final String[] args)
+                    throws Exception
+            {
+                if (args.length < 1)
+                    return;
+
+                final String target = args[0];
+
+                Map<String, Player> players = null;
+
+                if (target.equals(LOBBY_CHANNEL_NAME)) {
+                    players = con.allPlayers;
+                }
+                // on channel join WHO is called on channel
+                else if (target.equals(ROOM_CHANNEL_NAME)) {
+                    players = con.roomPlayers;
+                }
+
+                if (players != null) {
+                    for (final Player player : players.values()) {
+                        con.sendWhoForPlayer(player);
+                    }
+                }
+                // not a known channel. assume arg is player name
+                // TODO support search expressions!
+                else {
+                    final String nick = ircToHwNick(target);
+                    final Player player = con.allPlayers.get(nick);
+                    if (player != null)
+                        con.sendWhoForPlayer(player);
+                    else {
+                        con.sendSelfNotice("WHO: No player named " + nick + ", interpreting term as pattern.");
+                        List<Player> matches = con.findPlayers(target);
+                        if (matches.isEmpty())
+                            con.sendSelfNotice("No Match.");
+                        else {
+                            for (final Player match : matches) {
+                                con.sendWhoForPlayer(match);
+                            }
+                        }
+                    }
+                }
+
+                con.sendWhoEnd(target);
+            }
+        },
+        WHOIS(1, 2)
+        {
+            @Override
+            public void run(final Connection con, final String prefix, final String[] args)
+                    throws Exception
+            {
+                // there's an optional param in the beginning that we don't care about
+                final String targets = args[args.length-1];
+                for (final String target : targets.split(",")) {
+                    if (target.isEmpty())
+                        continue;
+                    final String nick = ircToHwNick(target);
+                    // flag this nick as manually requested, so that response is shown
+                    if (con.ircJoined) {
+                        con.requestedInfos.add(nick);
+                        con.hwcon.requestInfo(nick);
+                    }
+
+                    final Player player = con.allPlayers.get(nick);
+                    if (player != null) {
+                        con.send(RPL_WHOISUSER + con.ownIrcNick + " " + target + " ~"
+                                + target + " " + player.getIrcHostname() + " * : "
+                                + player.ircNick);
+                        // TODO send e.g. channels: @#lobby   or   @#123
+                        con.send(RPL_ENDOFWHOIS + con.ownIrcNick + " " + target 
+                                + " :End of /WHOIS list.");
+                    }
+                }
+            }
+        },
+        USERHOST(1, 5)
+        {
+            @Override
+            public void run(final Connection con, final String prefix, final String[] args)
+                    throws Exception
+            {
+                /*
+                // TODO set server host
+                con.hostname = "hw/" + SERVER_HOST;
+                
+                ArrayList<String> replies = new ArrayList<String>();
+                for (String s : arguments)
+                {
+                    Connection user = connectionMap.get(s);
+                    if (user != null)
+                        replies.add(user.nick + "=+" + con.ownIrc + "@"
+                                + con.hostname);
+                }
+                con.sendGlobal(RPL_USERHOST + con.ownIrcNick + " :"
+                        + delimited(replies.toArray(new String[0]), " "));
+                */
+            }
+        },
+        MODE(0, 2)
+        {
+            @Override
+            public void run(final Connection con, final String prefix, final String[] args)
+                    throws Exception
+            {
+                final boolean forChan = args[0].startsWith("#");
+                
+                if (args.length == 1)
+                {
+                    if (forChan) {
+                        //con.sendGlobal(ERR_NOCHANMODES + args[0]
+                        //                + " :Channel doesn't support modes");
+                        con.sendGlobal(RPL_CHANNELMODEIS + con.ownIrcNick + " " + args[0]
+                                + " +nt");
+                    }
+                    else
+                    {
+                        // TODO
+                        con.sendSelfNotice("User mode querying not supported yet.");
+                    }
+                }
+                else if (args.length == 2) {
+
+                    if (forChan) {
+
+                        final int l = args[1].length();
+
+                        for (int i = 0; i < l; i++) {
+
+                            final char c = args[1].charAt(i);  
+
+                            switch (c) {
+                                case '+':
+                                    // skip
+                                    break;
+                                case '-':
+                                    // skip
+                                    break;
+                                case 'b':
+                                    con.sendGlobal(RPL_ENDOFBANLIST
+                                        + con.ownIrcNick + " " + args[0]
+                                        + " :End of channel ban list");
+                                    break;
+                                case 'e':
+                                    con.sendGlobal(RPL_ENDOFEXCEPTLIST
+                                        + con.ownIrcNick + " " + args[0]
+                                        + " :End of channel exception list");
+                                    break;
+                                default:
+                                    con.sendGlobal(ERR_UNKNOWNMODE + c
+                                        + " :Unknown MODE flag " + c);
+                                    break;
+                                    
+                            }
+                        }
+                    }
+                    // user mode
+                    else {
+                        con.sendGlobal(ERR_UMODEUNKNOWNFLAG + args[0]
+                                        + " :Unknown MODE flag");
+                    }
+                }
+                else
+                {
+                    con.sendSelfNotice("Specific modes not supported yet.");
+                }
+            }
+        },
+        PART(1, 2)
+        {
+            @Override
+            public void run(final Connection con, final String prefix, final String[] args)
+                    throws Exception
+            {
+                String[] channels = args[0].split(",");
+                boolean doQuit = false;
+
+                for (String channelName : channels)
+                {
+                    if (channelName.equals(LOBBY_CHANNEL_NAME)) {
+                        doQuit = true;
+                    }
+                    // TODO: part from room
+                    /*
+                    synchronized (mutex)
+                    {
+                        Channel channel = channelMap.get(channelName);
+                        if (channelName == null)
+                            con
+                                    .sendSelfNotice("You're not a member of the channel "
+                                            + channelName
+                                            + ", so you can't part it.");
+                        else
+                        {
+                            channel.send(":" + con.getRepresentation()
+                                    + " PART " + channelName);
+                            channel.channelMembers.remove(con);
+                            if (channel.channelMembers.size() == 0)
+                                channelMap.remove(channelName);
+                        }
+                    }
+                    */
+                }
+
+                final String reason;
+
+                if (args.length > 1)
+                    reason = args[1];
+                else
+                    reason = DEFAULT_QUIT_REASON;
+
+                // quit after parting
+                if (doQuit)
+                    con.quit(reason);
+            }
+        },
+        QUIT(0, 1)
+        {
+            @Override
+            public void run(final Connection con, final String prefix, final String[] args)
+                    throws Exception
+            {
+                final String reason;
+
+                if (args.length == 0)
+                    reason = DEFAULT_QUIT_REASON;
+                else
+                    reason = args[0];
+
+                con.quit(reason);
+            }
+        },
+        PRIVMSG(2, 2)
+        {
+            @Override
+            public void run(final Connection con, final String prefix, final String[] args)
+                    throws Exception
+            {
+                String message = ircActionToHw(args[1]);
+                if (message.charAt(0) == CHAT_COMMAND_CHAR) {
+                    if (message.length() < 1 )
+                        return;
+                    message = message.substring(1);
+                    // TODO maybe \rebind CUSTOMCMDCHAR command
+                    con.hwcon.sendCommand(con.ircToHwNick(message));
+                }
+                else
+                    con.hwcon.sendChat(con.ircToHwNick(message));
+            }
+        },
+        TOPIC(1, 2)
+        {
+            @Override
+            public void run(final Connection con, final String prefix, final String[] args)
+                    throws Exception
+            {
+                final String chan = args[0];
+
+                final Channel channel = con.getChannel(chan);
+
+                if (channel == null) {
+                    con.sendSelfNotice("No such channel for topic viewing: "
+                            + chan);
+                    return;
+                }
+
+                // The user wants to see the channel topic.
+                if (args.length == 1)
+                    con.sendTopic(channel);
+                // The user wants to set the channel topic.
+                else
+                    channel.topic = args[1];
+            }
+        },
+        KICK(3, 3)
+        {
+            @Override
+            public void run(final Connection con, final String prefix, final String[] args)
+                    throws Exception
+            {
+                final String victim = args[1];
+                con.logInfo("Issuing kick for " + victim);
+                // "KICK #channel nick :kick reason (not relevant)"
+                con.hwcon.kick(ircToHwNick(victim));
+            }
+        }
+        ;
+        public final int minArgumentCount;
+        public final int maxArgumentCount;
+        
+        private Command(int min, int max)
+        {
+            minArgumentCount = min;
+            maxArgumentCount = max;
+        }
+
+        public abstract void run(Connection con, String prefix,
+                String[] arguments) throws Exception;
+    }
+    
+    public static String delimited(String[] items, String delimiter)
+    {
+        StringBuffer response = new StringBuffer();
+        boolean first = true;
+        for (String s : items)
+        {
+            if (first)
+                first = false;
+            else
+                response.append(delimiter);
+            response.append(s);
+        }
+        return response.toString();
+    }
+    
+    protected void sendQuit(String quitMessage)
+    {
+        send(":" + getRepresentation() + " QUIT :" + quitMessage);
+    }
+    
+    @Override
+    public void run()
+    {
+        try
+        {
+            doServer();
+        }
+        catch (Exception e) {
+            e.printStackTrace();
+        }
+        finally
+        {
+            // TODO sense?
+            if (ownIrcNick != null && connectionMap.get(ownIrcNick) == this) {
+                quit("Client disconnected.");
+            }
+
+            try {
+                socket.close();
+            }
+            catch (Exception e) { }
+
+            quit("Connection terminated.");
+        }
+    }
+    
+    protected void sendGlobal(String string)
+    {
+        send(":" + globalServerName + " " + string);
+    }
+    
+    private LinkedBlockingQueue<String> outQueue = new LinkedBlockingQueue<String>(
+            1000);
+    
+    private Thread outThread = new Thread()
+    {
+        public void run()
+        {
+            try
+            {
+                OutputStream out = socket.getOutputStream();
+                while (!terminateConnection)
+                {
+                    String s = outQueue.take();
+                    s = s.replace("\n", "").replace("\r", "");
+                    s = s + "\r\n";
+                    out.write(s.getBytes());
+                    out.flush();
+                }
+            }
+            catch (Exception ex)
+            {
+                thisConnection.logError("Outqueue died");
+                //ex.printStackTrace();
+            }
+            finally {
+                outQueue.clear();
+                outQueue = null;
+                try
+                {
+                    socket.close();
+                }
+                catch (Exception e2)
+                {
+                    e2.printStackTrace();
+                }
+            }
+        }
+    };
+
+    private boolean terminateConnection = false;
+
+    private void doServer() throws Exception
+    {
+        outThread.start();
+        InputStream socketIn = socket.getInputStream();
+        BufferedReader clientReader = new BufferedReader(new InputStreamReader(
+                socketIn));
+        String line;
+        while (!terminateConnection && ((line = clientReader.readLine()) != null))
+        {
+            processLine(line);
+        }
+    }
+
+    public void sanitizeInputs(final String[] inputs) {
+
+        // no for-each loop, because we need write access to the elements
+
+        final int l = inputs.length;
+
+        for (int i = 0; i < l; i++) {
+            inputs[i] = inputs[i].replaceAll(MAGIC_BYTES, " ");
+        }
+    }
+
+    private void processLine(final String line) throws Exception
+    {
+        String l = line;
+
+        // log things
+        if (l.startsWith("PASS") || l.startsWith("pass"))
+            this.logInfo("IRC-Client provided PASS");
+        else
+            this.logDebug("IRC-Client: " + l);
+
+        String prefix = "";
+        if (l.startsWith(":"))
+        {
+            String[] tokens = l.split(" ", 2);
+            prefix = tokens[0];
+            l = (tokens.length > 1 ? tokens[1] : "");
+        }
+        String[] tokens1 = l.split(" ", 2);
+        String command = tokens1[0];
+        l = tokens1.length > 1 ? tokens1[1] : "";
+        String[] tokens2 = l.split("(^| )\\:", 2);
+        String trailing = null;
+        l = tokens2[0];
+        if (tokens2.length > 1)
+            trailing = tokens2[1];
+        ArrayList<String> argumentList = new ArrayList<String>();
+        if (!l.equals(""))
+            argumentList.addAll(Arrays.asList(l.split(" ")));
+        if (trailing != null)
+            argumentList.add(trailing);
+        final String[] args = argumentList.toArray(new String[0]);
+
+        // process command
+
+        // numeric commands
+        if (command.matches("[0-9][0-9][0-9]"))
+            command = "N" + command;
+
+        final Command commandObject;
+
+        try {
+            commandObject = Command.valueOf(command.toUpperCase());
+        }
+        catch (Exception ex) {
+            // forward raw unknown command to hw server
+            hwcon.sendCommand(ircToHwNick(line));
+            return;
+        }
+
+        if (args.length < commandObject.minArgumentCount
+                || args.length > commandObject.maxArgumentCount)
+        {
+            sendSelfNotice("Invalid number of arguments for this"
+                    + " command, expected not more than "
+                    + commandObject.maxArgumentCount + " and not less than "
+                    + commandObject.minArgumentCount + " but got " + args.length
+                    + " arguments");
+            return;
+        }
+        commandObject.run(this, prefix, args);
+    }
+
+    /**
+     * Sends a notice from the server to the user represented by this
+     * connection.
+     * 
+     * @param string
+     *            The text to send as a notice
+     */
+
+    private void sendSelfNotice(final String string)
+    {
+        send(":" + globalServerName + " NOTICE " + ownIrcNick + " :" + string);
+    }
+
+    private void sendChannelNotice(final String string) {
+        sendChannelNotice(string, globalServerName);
+    }
+
+    private void sendChannelNotice(final String string, final String from) {
+        // TODO send to room if user is in room
+        send(":" + from + " NOTICE " + LOBBY_CHANNEL_NAME + " :" + string);
+    }
+
+    private void sendServerNotice(final String string)
+    {
+        if (ircJoined)
+            sendChannelNotice(string, "[INFO]");
+
+        sendSelfNotice(string);
+    }
+
+    private String[] padSplit(final String line, final String regex, int max)
+    {
+        String[] split = line.split(regex);
+        String[] output = new String[max];
+        for (int i = 0; i < output.length; i++)
+        {
+            output[i] = "";
+        }
+        for (int i = 0; i < split.length; i++)
+        {
+            output[i] = split[i];
+        }
+        return output;
+    }
+
+    public void sendIfJoined(final String s) {
+        if (joined)
+            send(s);
+    }
+
+    public void send(final String s)
+    {
+        final Queue<String> testQueue = outQueue;
+        if (testQueue != null)
+        {
+            this.logDebug("IRC-Server: " + s);
+            testQueue.add(s);
+        }
+    }
+
+final static String RPL_WELCOME = "001 ";
+final static String RPL_YOURHOST = "002 ";
+final static String RPL_CREATED = "003 ";
+final static String RPL_MYINFO = "004 ";
+final static String RPL_BOUNCE = "005 ";
+final static String RPL_TRACELINK = "200 ";
+final static String RPL_TRACECONNECTING = "201 ";
+final static String RPL_TRACEHANDSHAKE = "202 ";
+final static String RPL_TRACEUNKNOWN = "203 ";
+final static String RPL_TRACEOPERATOR = "204 ";
+final static String RPL_TRACEUSER = "205 ";
+final static String RPL_TRACESERVER = "206 ";
+final static String RPL_TRACESERVICE = "207 ";
+final static String RPL_TRACENEWTYPE = "208 ";
+final static String RPL_TRACECLASS = "209 ";
+final static String RPL_TRACERECONNECT = "210 ";
+final static String RPL_STATSLINKINFO = "211 ";
+final static String RPL_STATSCOMMANDS = "212 ";
+final static String RPL_STATSCLINE = "213 ";
+final static String RPL_STATSNLINE = "214 ";
+final static String RPL_STATSILINE = "215 ";
+final static String RPL_STATSKLINE = "216 ";
+final static String RPL_STATSQLINE = "217 ";
+final static String RPL_STATSYLINE = "218 ";
+final static String RPL_ENDOFSTATS = "219 ";
+final static String RPL_UMODEIS = "221 ";
+final static String RPL_SERVICEINFO = "231 ";
+final static String RPL_ENDOFSERVICES = "232 ";
+final static String RPL_SERVICE = "233 ";
+final static String RPL_SERVLIST = "234 ";
+final static String RPL_SERVLISTEND = "235 ";
+final static String RPL_STATSVLINE = "240 ";
+final static String RPL_STATSLLINE = "241 ";
+final static String RPL_STATSUPTIME = "242 ";
+final static String RPL_STATSOLINE = "243 ";
+final static String RPL_STATSHLINE = "244 ";
+final static String RPL_STATSPING = "246 ";
+final static String RPL_STATSBLINE = "247 ";
+final static String RPL_STATSDLINE = "250 ";
+final static String RPL_LUSERCLIENT = "251 ";
+final static String RPL_LUSEROP = "252 ";
+final static String RPL_LUSERUNKNOWN = "253 ";
+final static String RPL_LUSERCHANNELS = "254 ";
+final static String RPL_LUSERME = "255 ";
+final static String RPL_ADMINME = "256 ";
+final static String RPL_ADMINEMAIL = "259 ";
+final static String RPL_TRACELOG = "261 ";
+final static String RPL_TRACEEND = "262 ";
+final static String RPL_TRYAGAIN = "263 ";
+final static String RPL_NONE = "300 ";
+final static String RPL_AWAY = "301 ";
+final static String RPL_USERHOST = "302 ";
+final static String RPL_ISON = "303 ";
+final static String RPL_UNAWAY = "305 ";
+final static String RPL_NOWAWAY = "306 ";
+final static String RPL_WHOISUSER = "311 ";
+final static String RPL_WHOISSERVER = "312 ";
+final static String RPL_WHOISOPERATOR = "313 ";
+final static String RPL_WHOWASUSER = "314 ";
+final static String RPL_ENDOFWHO = "315 ";
+final static String RPL_WHOISCHANOP = "316 ";
+final static String RPL_WHOISIDLE = "317 ";
+final static String RPL_ENDOFWHOIS = "318 ";
+final static String RPL_WHOISCHANNELS = "319 ";
+final static String RPL_LISTSTART = "321 ";
+final static String RPL_LIST = "322 ";
+final static String RPL_LISTEND = "323 ";
+final static String RPL_CHANNELMODEIS = "324 ";
+final static String RPL_UNIQOPIS = "325 ";
+final static String RPL_NOTOPIC = "331 ";
+final static String RPL_TOPIC = "332 ";
+final static String RPL_INVITING = "341 ";
+final static String RPL_SUMMONING = "342 ";
+final static String RPL_INVITELIST = "346 ";
+final static String RPL_ENDOFINVITELIST = "347 ";
+final static String RPL_EXCEPTLIST = "348 ";
+final static String RPL_ENDOFEXCEPTLIST = "349 ";
+final static String RPL_VERSION = "351 ";
+final static String RPL_WHOREPLY = "352 ";
+final static String RPL_NAMREPLY = "353 ";
+final static String RPL_KILLDONE = "361 ";
+final static String RPL_CLOSING = "362 ";
+final static String RPL_CLOSEEND = "363 ";
+final static String RPL_LINKS = "364 ";
+final static String RPL_ENDOFLINKS = "365 ";
+final static String RPL_ENDOFNAMES = "366 ";
+final static String RPL_BANLIST = "367 ";
+final static String RPL_ENDOFBANLIST = "368 ";
+final static String RPL_ENDOFWHOWAS = "369 ";
+final static String RPL_INFO = "371 ";
+final static String RPL_MOTD = "372 ";
+final static String RPL_INFOSTART = "373 ";
+final static String RPL_ENDOFINFO = "374 ";
+final static String RPL_MOTDSTART = "375 ";
+final static String RPL_ENDOFMOTD = "376 ";
+final static String RPL_YOUREOPER = "381 ";
+final static String RPL_REHASHING = "382 ";
+final static String RPL_YOURESERVICE = "383 ";
+final static String RPL_MYPORTIS = "384 ";
+final static String RPL_TIME = "391 ";
+final static String RPL_USERSSTART = "392 ";
+final static String RPL_USERS = "393 ";
+final static String RPL_ENDOFUSERS = "394 ";
+final static String RPL_NOUSERS = "395 ";
+final static String ERR_NOSUCHNICK = "401 ";
+final static String ERR_NOSUCHSERVER = "402 ";
+final static String ERR_NOSUCHCHANNEL = "403 ";
+final static String ERR_CANNOTSENDTOCHAN = "404 ";
+final static String ERR_TOOMANYCHANNELS = "405 ";
+final static String ERR_WASNOSUCHNICK = "406 ";
+final static String ERR_TOOMANYTARGETS = "407 ";
+final static String ERR_NOSUCHSERVICE = "408 ";
+final static String ERR_NOORIGIN = "409 ";
+final static String ERR_NORECIPIENT = "411 ";
+final static String ERR_NOTEXTTOSEND = "412 ";
+final static String ERR_NOTOPLEVEL = "413 ";
+final static String ERR_WILDTOPLEVEL = "414 ";
+final static String ERR_BADMASK = "415 ";
+final static String ERR_UNKNOWNCOMMAND = "421 ";
+final static String ERR_NOMOTD = "422 ";
+final static String ERR_NOADMININFO = "423 ";
+final static String ERR_FILEERROR = "424 ";
+final static String ERR_NONICKNAMEGIVEN = "431 ";
+final static String ERR_ERRONEUSNICKNAME = "432 ";
+final static String ERR_NICKNAMEINUSE = "433 ";
+final static String ERR_NICKCOLLISION = "436 ";
+final static String ERR_UNAVAILRESOURCE = "437 ";
+final static String ERR_USERNOTINCHANNEL = "441 ";
+final static String ERR_NOTONCHANNEL = "442 ";
+final static String ERR_USERONCHANNEL = "443 ";
+final static String ERR_NOLOGIN = "444 ";
+final static String ERR_SUMMONDISABLED = "445 ";
+final static String ERR_USERSDISABLED = "446 ";
+final static String ERR_NOTREGISTERED = "451 ";
+final static String ERR_NEEDMOREPARAMS = "461 ";
+final static String ERR_ALREADYREGISTRED = "462 ";
+final static String ERR_NOPERMFORHOST = "463 ";
+final static String ERR_PASSWDMISMATCH = "464 ";
+final static String ERR_YOUREBANNEDCREEP = "465 ";
+final static String ERR_YOUWILLBEBANNED = "466 ";
+final static String ERR_KEYSET = "467 ";
+final static String ERR_CHANNELISFULL = "471 ";
+final static String ERR_UNKNOWNMODE = "472 ";
+final static String ERR_INVITEONLYCHAN = "473 ";
+final static String ERR_BANNEDFROMCHAN = "474 ";
+final static String ERR_BADCHANNELKEY = "475 ";
+final static String ERR_BADCHANMASK = "476 ";
+final static String ERR_NOCHANMODES = "477 ";
+final static String ERR_BANLISTFULL = "478 ";
+final static String ERR_NOPRIVILEGES = "481 ";
+final static String ERR_CHANOPRIVSNEEDED = "482 ";
+final static String ERR_CANTKILLSERVER = "483 ";
+final static String ERR_RESTRICTED = "484 ";
+final static String ERR_UNIQOPPRIVSNEEDED = "485 ";
+final static String ERR_NOOPERHOST = "491 ";
+final static String ERR_NOSERVICEHOST = "492 ";
+final static String ERR_UMODEUNKNOWNFLAG = "501 ";
+final static String ERR_USERSDONTMATCH = "502 ";
+
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tools/hw2irc/restart	Wed May 26 16:32:43 2021 -0400
@@ -0,0 +1,8 @@
+#!/bin/bash
+sudo -u nobody killall java; sleep 3; sudo -u nobody killall -9 java;
+sleep 30;
+#sudo -u nobody java net.ercatec.hw2ircsvr.Connection netserver.hedgewars.org 1337 >>out.sheepy 2>>err.sheepy & disown
+sudo -u nobody bash -c 'java net.ercatec.hw2ircsvr.Connection netserver.hedgewars.org 1337 >>out.sheepy 2>&1 & disown'
+sleep 122
+#sudo -u nobody java net.ercatec.hw2ircsvr.Connection >>out 2>>err & disown
+sudo -u nobody bash -c 'java net.ercatec.hw2ircsvr.Connection >>out 2>&1 & disown'