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 }