1 module netorcai.client; 2 3 import std.socket; 4 import std.stdio; 5 import std.utf; 6 import std.bitmanip; 7 import std.json; 8 import std.format; 9 import std.exception; 10 11 import netorcai.message; 12 import netorcai.json_util; 13 import netorcai.metaprotocol_version; 14 15 /// Netorcai metaprotocol client class (D version) 16 class Client 17 { 18 /// Constructor. Initializes a TCP socket (AF_INET, STREAM) 19 this() 20 { 21 sock = new Socket(AddressFamily.INET, SocketType.STREAM); 22 sock.setOption(SocketOptionLevel.TCP, SocketOption.TCP_NODELAY, 1); 23 } 24 25 /// Destructor. Closes the socket if needed. 26 ~this() 27 { 28 close(); 29 } 30 31 /// Connect to a remote endpoint. Throw Exception on error. 32 void connect(in string hostname = "localhost", in ushort port = 4242) 33 { 34 sock.connect(new InternetAddress(hostname, port)); 35 } 36 37 /// Close the socket. 38 void close() 39 { 40 sock.shutdown(SocketShutdown.BOTH); 41 sock.close(); 42 } 43 44 /// Reads a string message on the client socket. Throw Exception on error. 45 string recvString() 46 { 47 // Read content size 48 ubyte[4] contentSizeBuf; 49 auto received = sock.receive(contentSizeBuf); 50 checkSocketOperation(received, "Cannot read content size."); 51 52 immutable uint contentSize = littleEndianToNative!uint(contentSizeBuf); 53 54 // Read content 55 int receivedBytes = 0; 56 ubyte[] contentBuf, temporaryBuf; 57 58 while (receivedBytes < contentSize) 59 { 60 temporaryBuf.length = contentSize - receivedBytes; 61 received = sock.receive(temporaryBuf); 62 checkSocketOperation(received, "Cannot read content."); 63 64 contentBuf ~= temporaryBuf[0..received]; 65 receivedBytes += received; 66 } 67 68 return cast(string) contentBuf; 69 } 70 71 /// Reads a JSON message on the client socket. Throw Exception on error. 72 JSONValue recvJson() 73 { 74 return recvString.parseJSON; 75 } 76 77 /// Reads a LOGIN_ACK message on the client socket. Throw Exception on error. 78 LoginAckMessage readLoginAck() 79 { 80 auto msg = recvJson(); 81 switch (msg["message_type"].str) 82 { 83 case "LOGIN_ACK": 84 return parseLoginAckMessage(msg); 85 case "KICK": 86 throw new Exception(format!"Kicked from netorai. Reason: %s"(msg["kick_reason"].str)); 87 default: 88 throw new Exception(format!"Unexpected message received: %s"(msg["message_type"].str)); 89 } 90 } 91 92 /// Reads a GAME_STARTS message on the client socket. Throw Exception on error. 93 GameStartsMessage readGameStarts() 94 { 95 auto msg = recvJson; 96 switch (msg["message_type"].str) 97 { 98 case "GAME_STARTS": 99 return parseGameStartsMessage(msg); 100 case "KICK": 101 throw new Exception(format!"Kicked from netorai. Reason: %s"(msg["kick_reason"])); 102 default: 103 throw new Exception(format!"Unexpected message received: %s"(msg["message_type"])); 104 } 105 } 106 107 /// Reads a TURN message on the client socket. Throw Exception on error. 108 TurnMessage readTurn() 109 { 110 auto msg = recvJson; 111 switch (msg["message_type"].str) 112 { 113 case "TURN": 114 return parseTurnMessage(msg); 115 case "GAME_ENDS": 116 throw new Exception("Game over!"); 117 case "KICK": 118 throw new Exception(format!"Kicked from netorai. Reason: %s"(msg["kick_reason"])); 119 default: 120 throw new Exception(format!"Unexpected message received: %s"(msg["message_type"])); 121 } 122 } 123 124 /// Reads a GAME_ENDS message on the client socket. Throw Exception on error. 125 GameEndsMessage readGameEnds() 126 { 127 auto msg = recvJson; 128 switch (msg["message_type"].str) 129 { 130 case "GAME_ENDS": 131 return parseGameEndsMessage(msg); 132 case "KICK": 133 throw new Exception(format!"Kicked from netorai. Reason: %s"(msg["kick_reason"])); 134 default: 135 throw new Exception(format!"Unexpected message received: %s"(msg["message_type"])); 136 } 137 } 138 139 /// Reads a DO_INIT message on the client socket. Throw Exception on error. 140 DoInitMessage readDoInit() 141 { 142 auto msg = recvJson; 143 switch (msg["message_type"].str) 144 { 145 case "DO_INIT": 146 return parseDoInitMessage(msg); 147 case "KICK": 148 throw new Exception(format!"Kicked from netorai. Reason: %s"(msg["kick_reason"])); 149 default: 150 throw new Exception(format!"Unexpected message received: %s"(msg["message_type"])); 151 } 152 } 153 154 /// Reads a DO_TURN message on the client socket. Throw Exception on error. 155 DoTurnMessage readDoTurn() 156 { 157 auto msg = recvJson; 158 switch (msg["message_type"].str) 159 { 160 case "DO_TURN": 161 return parseDoTurnMessage(msg); 162 case "KICK": 163 throw new Exception(format!"Kicked from netorai. Reason: %s"(msg["kick_reason"])); 164 default: 165 throw new Exception(format!"Unexpected message received: %s"(msg["message_type"])); 166 } 167 } 168 169 /// Send a string message on the client socket. Throw Exception on error. 170 void sendString(in string message) 171 { 172 string content = toUTF8(message ~ "\n"); 173 if (content.length >= 16777215) 174 throw new Exception(format!"Content size (%d) does not fit in 24 bits"(content.length)); 175 176 uint contentSize = cast(uint) content.length; 177 ubyte[4] contentSizeBuf = nativeToLittleEndian(contentSize); 178 179 auto sent = sock.send(contentSizeBuf); 180 checkSocketOperation(sent, "Cannot send content size."); 181 182 // Send content. 183 int sentBytes = 0; 184 185 while (sentBytes < contentSize) 186 { 187 sent = sock.send(content[sentBytes..$]); 188 checkSocketOperation(sent, "Cannot send content."); 189 190 sentBytes += sent; 191 } 192 } 193 unittest // Message is too big 194 { 195 import std.algorithm : fill; 196 import std.conv : to; 197 import std.range : repeat; 198 import std.exception : assertThrown; 199 200 dchar[66000] chars; 201 fill(chars[], 'a'); 202 203 Client c = new Client; 204 assertThrown(c.sendString(to!string(chars))); 205 } 206 207 /// Send a JSON message on the client socket. Throw Exception on error. 208 void sendJson(in JSONValue message) 209 { 210 sendString(message.toString); 211 } 212 213 /// Send a LOGIN message on the client socket. Throw Exception on error. 214 void sendLogin(in string nickname, in string role) 215 { 216 JSONValue msg = ["message_type" : "LOGIN", "nickname" : nickname, "role" : role, "metaprotocol_version": metaprotocolVersion]; 217 218 sendJson(msg); 219 } 220 221 /// Send a TURN_ACK message on the client socket. Throw Exception on error. 222 void sendTurnAck(in int turnNumber, in JSONValue actions) 223 { 224 JSONValue msg = ["message_type" : "TURN_ACK"]; 225 msg.object["turn_number"] = turnNumber; 226 msg.object["actions"] = actions; 227 228 sendJson(msg); 229 } 230 231 /// Send a DO_INIT_ACK message on the client socket. Throw Exception on error. 232 void sendDoInitAck(in JSONValue initialGameState) 233 { 234 JSONValue msg = ["message_type" : "DO_INIT_ACK"]; 235 msg.object["initial_game_state"] = initialGameState; 236 237 sendJson(msg); 238 } 239 240 /// Send a DO_TURN_ACK message on the client socket. Throw Exception on error. 241 void sendDoTurnAck(in JSONValue gameState, in int winnerPlayerID) 242 { 243 JSONValue msg = ["message_type" : "DO_TURN_ACK"]; 244 msg.object["winner_player_id"] = winnerPlayerID; 245 msg.object["game_state"] = gameState; 246 247 sendJson(msg); 248 } 249 250 private void checkSocketOperation(in ptrdiff_t result, in string description) 251 { 252 if (result == Socket.ERROR) 253 throw new Exception(description ~ "Socket error."); 254 else if (result == 0) 255 throw new Exception(description ~ "Socket closed by remote?"); 256 } 257 258 private Socket sock; 259 } 260 261 unittest // Client/GL: Everything goes well. 262 { 263 import std.process : wait; 264 import netorcai.test : launchNetorcaiWaitListening; 265 266 // Run netorcai 267 auto n = launchNetorcaiWaitListening(); 268 269 // Run game logic 270 auto gameLogic = new Client; 271 scope(exit) destroy(gameLogic); 272 gameLogic.connect(); 273 gameLogic.sendLogin("gl", "game logic"); 274 gameLogic.readLoginAck(); 275 276 // Run player 277 auto player = new Client; 278 scope(exit) destroy(player); 279 player.connect(); 280 player.sendLogin("player", "player"); 281 player.readLoginAck(); 282 283 // Run game 284 n.stdin.writeln("start"); 285 n.stdin.flush(); 286 287 const auto doInit = gameLogic.readDoInit(); 288 gameLogic.sendDoInitAck(`{"all_clients": {"gl": "D"}}`.parseJSON); 289 player.readGameStarts(); 290 291 foreach (i; 1..doInit.nbTurnsMax) 292 { 293 gameLogic.readDoTurn(); 294 gameLogic.sendDoTurnAck(`{"all_clients": {"gl": "D"}}`.parseJSON, -1); 295 296 auto turn = player.readTurn; 297 player.sendTurnAck(turn.turnNumber, `[{"player": "D"}]`.parseJSON); 298 } 299 300 gameLogic.readDoTurn(); 301 gameLogic.sendDoTurnAck(`{"all_clients": {"gl": "D"}}`.parseJSON, -1); 302 303 player.readGameEnds(); 304 wait(n.pid); 305 } 306 307 unittest // Kicked instead of expected message 308 { 309 import std.process : kill, wait; 310 import netorcai.test : launchNetorcaiWaitListening; 311 import core.sys.posix.signal : SIGTERM; 312 import std.exception : assertThrown; 313 314 auto n = launchNetorcaiWaitListening; 315 scope(exit) { 316 kill(n.pid, SIGTERM); 317 wait(n.pid); 318 } 319 320 auto kickedClient() 321 { 322 auto c = new Client; 323 c.connect(); 324 c.sendString(`¿qué?`); 325 return c; 326 } 327 328 assertThrown(kickedClient.readLoginAck()); 329 assertThrown(kickedClient.readGameStarts()); 330 assertThrown(kickedClient.readTurn()); 331 assertThrown(kickedClient.readGameEnds()); 332 assertThrown(kickedClient.readDoInit()); 333 assertThrown(kickedClient.readDoTurn()); 334 } 335 336 unittest // Unexpected message received (and not KICK) 337 { 338 import std.process : kill, wait; 339 import netorcai.test : launchNetorcaiWaitListening; 340 import core.sys.posix.signal : SIGTERM; 341 import std.exception : assertThrown; 342 343 auto n = launchNetorcaiWaitListening; 344 scope(exit) { 345 kill(n.pid, SIGTERM); 346 wait(n.pid); 347 } 348 349 auto loggedClient() 350 { 351 auto c = new Client; 352 c.connect(); 353 c.sendLogin("I", "player"); 354 return c; 355 } 356 357 // LOGIN_ACK instead of something else 358 assertThrown(loggedClient.readGameStarts()); 359 assertThrown(loggedClient.readTurn()); 360 assertThrown(loggedClient.readGameEnds()); 361 assertThrown(loggedClient.readDoInit()); 362 assertThrown(loggedClient.readDoTurn()); 363 364 // Start a game. 365 auto player1 = loggedClient; // Unexpected msg while reading LOGIN_ACK 366 auto player2 = loggedClient; // GAME_ENDS while reading TURN 367 auto gl = new Client; 368 gl.connect(); 369 gl.sendLogin("gl", "game logic"); 370 371 gl.readLoginAck; 372 player1.readLoginAck; 373 player2.readLoginAck; 374 375 // Run game 376 n.stdin.writeln("start"); 377 n.stdin.flush(); 378 379 const auto doInit = gl.readDoInit(); 380 gl.sendDoInitAck(`{"all_clients": {"gl": "D"}}`.parseJSON); 381 assertThrown(player1.readLoginAck); 382 player2.readGameStarts(); 383 384 foreach (i; 1..doInit.nbTurnsMax) 385 { 386 gl.readDoTurn(); 387 gl.sendDoTurnAck(`{"all_clients": {"gl": "D"}}`.parseJSON, -1); 388 389 auto turn = player2.readTurn; 390 player2.sendTurnAck(turn.turnNumber, `[{"player": "D"}]`.parseJSON); 391 } 392 393 gl.readDoTurn(); 394 gl.sendDoTurnAck(`{"all_clients": {"gl": "D"}}`.parseJSON, -1); 395 396 assertThrown(player2.readTurn); 397 } 398 399 unittest // Socket errors 400 { 401 import std.datetime : dur; 402 import std.exception : assertThrown; 403 import std.process : kill, wait; 404 import netorcai.test : launchNetorcaiWaitListening; 405 import core.sys.posix.signal : SIGTERM; 406 import core.thread : Thread; 407 408 auto n = launchNetorcaiWaitListening; 409 scope(exit) { 410 kill(n.pid, SIGTERM); 411 wait(n.pid); 412 } 413 414 // Never connected 415 auto c = new Client; 416 assertThrown(c.sendString(`Hello!`)); 417 418 // Disconnected 419 c.connect(); 420 c.sendLogin("I", "superplayer"); 421 assertThrown(c.readLoginAck); 422 Thread.sleep(dur!"seconds"(2)); 423 assertThrown(c.readLoginAck); 424 } 425 426 unittest // Non-critical metaprotocol version mismatch 427 { 428 import std.process : kill, wait; 429 import netorcai.test : launchNetorcaiWaitListening; 430 import core.sys.posix.signal : SIGTERM; 431 import std.exception : assertNotThrown; 432 433 auto n = launchNetorcaiWaitListening; 434 scope(exit) { 435 kill(n.pid, SIGTERM); 436 wait(n.pid); 437 } 438 439 minorVersion += 1; 440 scope(exit) { 441 minorVersion -= 1; 442 } 443 444 auto c = new Client; 445 c.connect(); 446 c.sendLogin("d-test", "player"); 447 assertNotThrown(c.readLoginAck); 448 }