|

Φτιάχνοντας έναν Game Server σε C++ #4: Περί πρωτοκόλλου

Series Navigation<< Φτιάχνοντας έναν Game Server σε C++ #3: Πέτρα Ψαλίδι ΧαρτίΦτιάχνοντας έναν Game Server σε C++ #5: Συμμετοχή σε Παιχνίδια >>

Όπως υποσχέθηκα στο προηγούμενο άρθρο, θα ορίσουμε το πρωτόκολλο εφαρμογής (πάνω στο TCP) που θα χρησιμοποιήσουμε για τη διαχείριση των συνεδριών. Όπως προανέφερα, θα είναι text-based και κάθε γραμμή κειμένου θα αντιστοιχεί με μία εντολή. Έχω ήδη καλύψει αυτή τη λειτουργικότητα στη sessionThread() η οποία συναρμολογεί τις εντολές και τις στέλνει στην clientCommand().

Κάθε εντολή ή απάντηση θα πρέπει να αποτελείται από ένα ή περισσότερα tokens χωρισμένα με κενά ή ερωτηματικά (;). Με άλλα λόγια, τα ερωτηματικά θα αντιμετωπίζονται ως διαχωριστές των tokens. Αν τα tokens περιέχουν κενά, θα πρέπει να αντικαθίστανται με χαρακτήρες υπογράμμισης (_) για αποφυγή ασάφειας. Ας φτιάξουμε έναν μικρό parser για αυτή τη δουλειά:

class Parser {

	private:
	
		istringstream source;

	public:
	
		Parser(string);
		string getToken();

};

Parser::Parser(string s) { 
	replace(s.begin(),s.end(),';',' ');
	source=istringstream(s);
}

string Parser::getToken() {
	string s;
	source >> s;
	replace(s.begin(),s.end(),'_',' ');
	return s;
}

Ο constructor της κλάσης αντικαθιστά τα ερωτηματικά με κενά πριν αρχικοποιήσει το πεδίο source με το τροποποιημένο κείμενο. Από την άλλη, η getToken() επιστρέφει το επόμενο token από το source, χρησιμοποιώντας τον τελεστή >> που χειρίζεται τα κενά για μας, και επαναφέρει τους χαρακτήρες υπογράμμισης σε κενά. Εφόσον ισχύουν οι ίδιοι κανόνες και για τις αποκρίσεις, θα χρειαστούμε και μια συνάρτηση που θα κωδικοποιεί tokens, δηλαδή θα αντικαθιστά τα κενά με χαρακτήρες υπογράμμισης:

string encodeToken(string s) { 
	replace(s.begin(),s.end(),' ','_');
	return s;
}

Για κάθε εντολή, το πρώτο token θα υποδεικνύει τι θα εκτελεστεί, ενώ όλα τα υπόλοιπα θα είναι παράμετροι. Ανάλογα με την εντολή, θα χρησιμοποιείται ένα συγκεκριμένο πλήθος ορισμάτων. Ορίσματα που λείπουν θα προκαλούν σφάλμα, ενώ τυχόν περίσσια θα αγνοούνται σιωπηρά. Επίσης θα έχει σημασία αν τα γράμματα θα είναι κεφαλαία ή μικρά. Για να δούμε μια πιο ευρεία εικόνα για το τι πρέπει να γίνει, θέλω να υλοποιήσω μερικές εντολές που σκέφτομαι:

  • quit: Αποσύνδεσε το χρήστη. Δεν αναμένεται κανένα όρισμα.
  • auth: Διαπίστευση χρήστη. Αναμένει δύο ορίσματα, username και password.
  • games: Λίστα υποστηριζόμενων παιχνιδιών. Δεν αναμένεται κανένα όρισμα.
  • worlds: Λίστα ενεργών στιγμιότυπων παιχνιδιών. Δεν αναμένεται κανένα όρισμα.
  • run: Δημιουργία ενός νέου στιγμιότυπου. Το ένα όρισμά της θα είναι το αναγνωριστικό παιχνιδιού.
  • kill: Διαγραφή στιγμιότυπου. Θα δέχεται ένα όρισμα, το αναγνωριστικό του στιγμιότυπου.

Προφανώς δεν είναι εξαντλητική αυτή η λίστα. Μια πιο πλήρης έκδοση του server πιθανότατα θα παρέχει πολύ περισσότερες, αλλά ας δουλέψουμε με αυτές για την ώρα. Πρωτίστως χρειαζόμαστε έναν τρόπο για να εκτελούμε κάποιον κώδικα ανάλογα με το κείμενο της εντολής. Μια switch() μοιάζει καλή επιλογή, αλλά δυστυχώς δεν δέχεται strings. Ευτυχώς μπορούμε να αντιστοιχίσουμε τα strings μας με τιμές τις οποίες μπορεί να καταλάβει η switch():

enum commandType { cmdQuit=1,cmdAuth,cmdGames,cmdRun,cmdWorlds,cmdKill,cmdJoin, };

map<string,commandType> commandStrType={
	{"quit",cmdQuit},
	{"auth",cmdAuth},
	{"games",cmdGames},
	{"run",cmdRun},
	{"worlds",cmdWorlds},
	{"kill",cmdKill},
	{"join",cmdJoin},
}; 

Παρατηρήστε ότι οι τιμές της commandType ξεκινούν από 1. Αυτό είναι σημαντικό, διότι σε περίπτωση άγνωστης εντολής, το commandStrType[] θα είναι 0. Αν δεν είχαμε ορίσει το cmdQuit να είναι 1, θα ήταν επίσης 0. Με άλλα λόγια, κάθε άγνωστη εντολή θα ερμηνευόταν ως προσπάθεια αποσύνδεσης. Έχοντας πει αυτά, ας δούμε πως διαμορφώνεται η νέα clientCommand() μας:

string Server::clientCommand(int id,string command) {

	Parser p(command);

	switch(commandStrType[p.getToken()]) {
	
		case cmdQuit: 
		
		case cmdAuth: 
		
		case cmdGames: 

		case cmdWorlds: 

		case cmdRun: 

		case cmdKill: 

		default: return {false,"err "+encodeToken("Unknown command\n")};
		
	}

}

Πλέον το μόνο που έχουμε να κάνουμε είναι να γεμίσουμε τα κενά για καθεμία περίπτωση. Ξεκινώντας από την cmdQuit, απλά επιστρέφουμε “ok” μαζί με ένα σήμα στο thread να αποσυνδέσει τον client:

		case cmdQuit: { return {true,"ok\n"}; }

Συνεχίζοντας με την cmdAuth, θα πρέπει να ελέγξουμε αν παρέχεται το username και το password, οπότε το password θα πρέπει να αντιστοιχεί με αυτό που υπάρχει στη βάση δεδομένων για το συγκεκριμένο username:

		case cmdAuth: {
		
			if(""!=getSessionUsername(id)) return {false,"err "+encodeToken("Already authenticated\n")};
		
			string username=p.getToken();
			if(!username.length()) return {false,"err "+encodeToken("Missing username and password\n")};
			
			string password=p.getToken();
			if(!password.length()) return {false,"err "+encodeToken("Missing password\n")};
			
			if(!storage->matchPassword(username,password)) return {false,"err "+encodeToken("Authentication failed\n")};
			
			lock_guard<recursive_mutex> lock(sessionMutex);
			sessions[id]->username=username;
			return {false,"ok\n"};
			
		}

Πρώτα ελέγχουμε αν ο χρήστης είναι ήδη διαπιστευμένος. Πέρα από αυτό, δεν υπάρχει matchPassword() στην κλάση Storage. Η αλήθεια είναι ότι έχω αναβάλει υπερβολικά το όλο θέμα με τη βάση δεδομένων, και ακόμα δεν θέλω να ασχοληθώ. Ας αναδομήσουμε προσωρινά την κλαση ώστε να περιέχει μερικούς hardcoded λογαριασμούς:

class Storage {

	public:
		Storage(string,string,string,string);
		bool matchPassword(string,string);
		
};

Storage::Storage(string h,string d,string u,string p) { }

bool Storage::matchPassword(string username,string password) {

	map<string,string> accounts={
		{"mario","Its-aMe"},
		{"luigi","OkeyDokey"},
		{"michelangelo","cowabunga"},
	};
	
	return accounts[username]==password;
	
}

Αυτό θα μας καλύψει για την ώρα. Προχωρώντας στην cmdGames, η ιδέα είναι να διατρέξουμε μια δομή που περιέχει τα υποστηριζόμενα παιχνίδια, και να επιστρέψουμε μια λίστα με ένα παιχνίδι ανα γραμμή. Κάθε γραμμή θα ξεκινά με τη λέξη “game” ακολουθούμενη από ένα κενό κι έπειτα μια λίστα από χαρακτηριστικά (id, name και description) χωρισμένα με ερωηματικά. Η δομή που προανέφερα θα είναι στατική προς το παρόν, αλλά θέλω μελλοντικά να είναι δυναμική και thread-safe, οπότε ας την προστατέψουμε προληπτικά με ένα mutex:

		case cmdGames: {
		
			lock_guard<recursive_mutex> lock(gamesMutex);

			if(!games.size()) return {false,"err "+encodeToken("No games defined\n")};
		
			string r;
			for(auto &[gid,entry]:games) r+="game "+gid+";"+encodeToken(entry->name)+";"+encodeToken(entry->description)+"\n";
			
			return {false,r};
		
		}

Τώρα, τι μορφή πρέπει να έχει η δομή games; Ας χρησιμοποιήσουμε πάλι ένα map, όπου οι δείκτες θα είναι strings:

struct gameEntry {
	string name,description;
	function<Game*(GameInterface*)> run;
};

class Server: public SessionInterface, public GameInterface {

	private:
  	
  		/* ... */
	
		map<string,gameEntry*> games;
		recursive_mutex gamesMutex;
		
  		/* ... */
	
};

Εκτός από το όνομα και την περιγραφή, η δομή gameEntry περιέχει και ένα πεδίο run, το οποίο είναι μια συνάρτηση. Αυτή η συνάρτηση θα πρέπει να λαμβάνει έναν pointer στον server ως όρισμα, και να επιστρέφει ένα νέο στιγμιότυπο παιχνιδιού. Όπως θα έχετε μαντέψει ως τώρα, θα χρησιμοποιείται από την cmdRun για να δημιουργεί νέα στιγμιότυπα.

Ας γεμίσουμε τώρα τη νέα δομή στον constructor με το μόνο παιχνίδι που έχουμε ως τώρα. Το να δημιουργήσουμε ένα νέο RPSGame είναι εύκολο, οπότε μια συνάρτηση lambda είναι ιδανική:

Server::Server(serverParams p) {

	// backend initialization
	/* ... */

	games["rps"]=new gameEntry{
		"Rock Paper Scissors",
		"A game of Rock Paper Scissors against the machine",
		[](GameInterface *gi) -> Game* { return new RPSGame(gi); }
	};
	
	// socket initialization
	/* ... */

}

Οκ, τώρα οι χρήστες μας μπορούν να βλέπουν τα διαθέσιμα παιχνίδια. Η υλοποίηση της cmdWorlds είναι πάνω-κάτω η ίδια, μόνο που αυτή τη φορά θα χρησιμοποιήσουμε ακέραιους για κλειδιά του map:

struct worldEntry {
	string gameId;
	Game *game;
};

class Server: public SessionInterface, public GameInterface {

	private:
  	
  		/* ... */
	
		int nextWorld=1;
		map<int,worldEntry*> worlds;
		recursive_mutex worldsMutex;
		
  		/* ... */
	
};

Η δομή worldEntry αποτελείται από έναν pointer στο στιγμιότυπο του παιχνιδιού, μαζί με ένα αναγνωριστικό string που αναφέρεται στο κλειδί του games, ενώ το nextWorld είναι το επόμενο αναγνωριστικό που θα δώσει η cmdRun. Τώρα που έχουμε όλα τα απαραίτητα εργαλεία, ας δούμε τη μορφή της cmdWorlds:

		case cmdWorlds: {
		
			lock_guard<recursive_mutex> lock(worldsMutex);
			
			if(!worlds.size()) return "err "+encodeToken("No worlds running\n");
		
			string r;
			for(auto &[wid,entry]:worlds) r+="world "+to_string(wid)+";"+entry->gameId+"\n";
			
			return {false,r};
			
		}

Οι δύο τελευταίες εντολές που θα καλύψουμε, η cmdRun και η cmdKill, θα χρησιμοποιούνται για τη διαχείριση του worlds, δημιουργώντας και διαγράφοντας εγγραφές αντίστοιχα. Φυσικά, σε πραγματικές συνθήκες δεν θα αφήναμε τον κάθε χρήστη να δημουργεί και να διαγράφει στιγμιότυπα παιχνιδιών — αυτό θα οδηγούσε σε χαοτικές καταστάσεις. Αλλά για να κάνουμε τα πράγματα πιο απλά, για την ώρα θα αρκεστούμε στους διαπιστευμένους χρήστες:

class Server: public SessionInterface, public GameInterface {

	private:
  	
  		/* ... */
	
		bool canRunGame(int,string);
		
  		/* ... */
	
};

bool Server::canRunGame(int id,string gid) { return ""!=getSessionEntry(id)->username; }

Ο κανόνας είναι: αν μπορείς να δημιουργήσεις στιγμιότυπα ενός παιχνιδιού, μπορείς και να διαγράψεις στιγμιότυπα του ίδιου παιχνιδιού. Με αυτά υπ’ όψιν, οι cmdRun και cmdKill θα είναι:

		case cmdRun: {
			
			string gid=p.getToken();
			if(!gid.length()) return {false,"err "+encodeToken("A game id is required\n")};
			
			lock_guard<recursive_mutex> gLock(gameMutex);
			gameEntry *g=games[gid];
			if(!g) return {false,"err "+encodeToken("Unknown game id "+gid+"\n")};

			if(!canRunGame(id,gid)) return {false,"err "+encodeToken("Permission denied\n")};
			
			Game *game=g->run(this);
			lock_guard<recursive_mutex> wLock(worldMutex);
			int wid=nextWorld++;
			worlds[wid]=new worldEntry{gid,game};
			
			return {false,"world "+to_string(wid)+"\n"};
			
		}

		case cmdKill: {
			
			string wid_s=p.getToken();
			if(!wid_s.length()) return {false,"err "+encodeToken("A world id is required\n")};
			
			int wid=atoi(wid_s.c_str());
			if(wid_s!=to_string(wid)) return {false,"err "+encodeToken("Invalid world id "+wid_s+"\n")};
			
			lock_guard<recursive_mutex> lock(worldMutex);
			if(!worlds[wid]) return {false,"err "+encodeToken("Unknown world id "+wid_s+"\n")};

			if(!canRunGame(id,worlds[wid]->gameId)) return {false,"err "+encodeToken("Permission denied\n")};
			
			delete worlds[wid]->game;
			delete worlds[wid];
			worlds.erase(wid);
			
			return {false,"ok\n"};
			
		}

Αφού καλύψαμε τη γενική μορφή του πρωτοκόλλου από την πλευρά του client, και έξι βασικές εντολές, ας δούμε λίγο τη μορφή των αποκρίσεων. Στην τελευταία περίπτωση, όπως και στις περιπτώσεις των cmdQuit και cmdAuth, με την προϋπόθεση επιτυχούς αποτελέσματος, επιστρέφουμε απλά “ok“, που σημαίνει ότι η εντολή πέτυχε και δεν υπάρχουν άλλες πληροφορίες. Εξ άλλου, σε όλες τις περιπτώσεις που κάτι πάει στραβά, απαντάμε με “err“, ένα κενό και ένα μήνυμα λάθους. Όταν δημιουργείται ένα νέο στιγμιότυπο, στέλνουμε “world“, ένα κενό και το νέο αναγνωριστικό. Τέλος, όταν το αποτέλεσμα απαιτεί λίστα, επιστρέφονται πολλαπλές γραμμές. Κάθε γραμμή ξεκινά με μία λέξη που περιγράφει το αντικείμενο που επιστρέφεται, ακολουθούμενη από ένα κενό και μια λίστα πεδίων χωρισμένων με ερωτηματικά. Ξεχωρίζει ένα μοτίβο εδώ, και είναι το ακόλουθο:

  • Κάθε απόκριση μορεί να αποτελείται από μία ή περισσότερες γραμμές (που τελειώνουν με \n)
  • Κάθε γραμμή ξεκινά με μία λέξη-κλειδί
  • Αν δεν υπάρχει άλλη πληροφορία να στείλουμε, η λέξη κλειδί είναι “ok“, αλλιώς ακολουθεί ένα κενό και η εν λόγω πληροφορία

Πριν τελειώσουμε, ας το δοκιμάσουμε, αυτή τη φορά με telnet:

$ telnet localhost 9999
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
id 1
fubar
err Unknown_command
games 
game rps;Rock_Paper_Scissors;A_game_of_Rock_Paper_Scissors_against_the_machine
run rps
err Permission_denied
auth
err Missing_username_and_password
auth alice caterpillar
err Authentication_failed
auth mario Its-aMe
ok
run tps
err Unknown_game_id_tps
run rps
world 1
worlds
world 1;rps
kill 1
ok
worlds

Έχουμε πολύ δρόμο ακόμα για ένα αξιοπρεπές σύστημα διαχείρισης παιχνιδιών. Πρώτα απ’ όλα, συνεχίζουμε να ξεκινάμε ένα RPSGame με το handshake. Επιπλέον, όταν τερματίζουμε ένα στιγμιότυπο παιχνιδιού, ούτε διαγράφουμε τους χρήστες από τη λίστα παιχτών, αλλά ούτε δίνουμε την ευκαιρία στα παιχνίδια να το κάνουν τα ίδια. Θα ασχοληθούμε με αυτά και με άλλα θέματα στο επόμενο άρθρο, όπου θα υλοποιήσουμε μια εντολή για να μπορούν οι χρήστες να συμμετέχουν σε παιχνίδια.

Αφήστε μια απάντηση

Η ηλ. διεύθυνση σας δεν δημοσιεύεται. Τα υποχρεωτικά πεδία σημειώνονται με *