Solvus est un jeu de quiz en ligne dont la progression est partagée par tous les participants.
Jouable seul contre tous comme en équipe, le jeu propose des énigmes faisant appel à la rapidité.
Afin que personne ne soit désavantagé par la qualité de sa connexion ou la distance le séparant du master server, l'affichage des énigmes est synchronisé entre les clients et les gagnants de chaque manche sont désignés en prenant en compte leur ping.
L'interface est composée de quatre panneaux : celui du haut est un historique des derniers événements, le central affiche l'énigme sous forme de texte, de son ou d'image, celui de droite est un récapitulatif des scores des joueurs (ou des équipes). Enfin, celui du bas permet au joueur d'écrire et d'envoyer sa réponse.
Solvus a été développé en tenant compte du fait que de nombreuses connexions seront actives simultanément et que le serveur leur transmettra régulièrement des données de poids élevé et variable (textes, sons et images) mais que le poids et la fréquence des données envoyées par les clients au serveur seront toujours faibles.
Puisqu'il correspond parfaitement à ces différentes particularitées, le protocole TCP a été choisi pour la couche réseau.
Le serveur est développé sous Linux pour être déployé sur un serveur dédié, tandis que le client est uniquement destiné à Windows.
Lorsque le serveur transmet le fichier de l'énigme aux joueurs, chaque envoi est morcelé et géré indépendamment de façon à ne pas être ralenti par les clients dotés d'une connexion lente.
Aussi, le ping des clients est calculé par le serveur lors d'évenements clés pour s'assurer qu'aucun type de connexion ne soit avantagé ou désavantagé, que ce soit lors de l'affichage de l'énigme par le client ou de la réception d'une réponse validée par le serveur.
Voici comment le serveur gère la connection d'un nouveau client.
/** Function called every tick to accept one new connection at the most
*
* @param
* @return
* @throw
*/
void cNetwork::Accept() {
int nfds = ::epoll_wait(m_epfdAccept, &m_evAccept, 1, 0);
if (nfds < 0)
ERRNO("epoll_wait(m_epfdAccept)")
else
for (int i = 0; i < nfds; ++i)
if (m_evAccept.events & EPOLLIN) {
sockaddr_in addrFrom = { 0 };
socklen_t iAddrLen = sizeof(addrFrom);
SOCKET sockTmp = ::accept(m_sckAccept, (SOCKADDR*)(&addrFrom), &iAddrLen);
if (sockTmp != INVALID_SOCKET) {
// Store client infos
m_mapClients[sockTmp] = std::make_unique<sClient>();
m_mapClients[sockTmp]->m_address = addrFrom;
// Create new I/O event handler
m_mapClients[sockTmp]->m_evClient.events = EPOLLIN | EPOLLOUT | EPOLLRDHUP | EPOLLPRI | EPOLLERR | EPOLLHUP;
m_mapClients[sockTmp]->m_evClient.data.fd = sockTmp;
// Register new I/O event handler
if (::epoll_ctl(m_epfdClients, EPOLL_CTL_ADD, sockTmp, &m_mapClients[sockTmp]->m_evClient) == -1)
ERRNO("epoll_ctl(m_epfdClients, EPOLL_CTL_ADD)")
}
}
// ...
Pour le serveur, afin de palier à la restriction de 1024 descripteurs de fichiers maximum de Select(), la gestion des évènements d'entrée et de sortie des sockets utilise ePoll().
/** Function called each tick to handle clients I/O events about data received/send and disconnection
*
* @param
* @return
* @throw
*/
void cNetwork::Receive() {
// Handle I/O events notification
int nfds = ::epoll_wait(m_epfdClients, m_evClients, EPOLL_QUEUE_LEN, 0);
if (nfds < 0)
ERRNO("epoll_wait(m_epfdClients)")
else
// For every client having an I/O notification
for (int i = 0; i < nfds; ++i) {
int fd = m_evClients[i].data.fd;
// If file descriptor throw any error
if (m_evClients[i].events & EPOLLRDHUP
|| m_evClients[i].events & EPOLLPRI
|| m_evClients[i].events & EPOLLERR
|| m_evClients[i].events & EPOLLHUP)
m_mapClients[fd]->m_bConnected = false;
else {
// If file descriptor is available to read
if (m_evClients[i].events & EPOLLIN)
m_mapClients[fd]->Receive();
// If file descriptor is available to write
if (m_mapClients[fd]->m_vecSendBuff.size() > 0
&& m_evClients[i].events & EPOLLOUT)
m_mapClients[fd]->Send();
}
}
// Erase disconnected clients
for (mapClients_t::iterator itClients = m_mapClients.begin(); itClients != m_mapClients.cend();)
if (itClients->second->m_bConnected == false)
itClients = m_mapClients.erase(itClients);
else
++itClients;
}
...
Sur le serveur, chaque client connecté dispose de son propre buffer d'envoi et de réception des données.
/** Function managing the buffer of data to be sent to a client
*
* @param
* @return
* @throw
*/
void sClient::Send() {
if (m_vecSendBuff.size() > 0) {
size_t iSent = 0, // The size of the data sent during this tick
iSizeSend = m_vecSendBuff[0]->m_vecBuff.size() - m_iSent; // The size of the remaining data to be sent
// Limiting the size of messages sent to the TCP MTU
if (iSizeSend > 1400)
iSizeSend = 1400;
if ((iSent = ::send(m_evClient.data.fd, &m_vecSendBuff[0]->m_vecBuff[m_iSent], iSizeSend, MSG_NOSIGNAL)) < 0)
m_bConnected = false;
else
m_iSent += iSent;
// Erase the current buffer if all the data is sent
if (m_iSent >= m_vecSendBuff[0]->m_vecBuff.size()) {
m_vecSendBuff.erase(m_vecSendBuff.begin());
m_iSent = 0;
}
}
}
...
Chaque transmission comporte deux partie :
- La première est un header de taille fixe renseignant le type de donnée envoyé, la taille du fichier de l'énigme, ainsi que deux checksums SHA1 distincts du header et du fichier ;
- La seconde contient le fichier de l'énigme (texte encodé en utf-8, image encodée en png ou son encodé en mp3).
A la réception d'un header, chaque appel à cette fonction stockera le stream de données entrant jusqu'à ce que la taille du fichier attendu soit atteinte.
/** Function managing the reception of data from a client
*
* @param
* @return
* @throw
*/
void sClient::Receive() {
// If no data is expected, receive the header
if (m_uiRecvSizeRemain == 0) {
sHeader hdrRecv;
if (::recv(m_evClient.data.fd, &hdrRecv, sizeof(hdrRecv), MSG_NOSIGNAL) <= 0)
m_bConnected = false;
else if (hdrRecv.ChksmVerifyHeader()) {
m_sHeaderRecv = hdrRecv;
m_uiRecvSizeRemain = hdrRecv.GetSize();
}
}
// If data is expected
else {
size_t iSizeBuff = (m_uiRecvSizeRemain == 0 ? 4096 : (m_uiRecvSizeRemain > 4096 ? 4096 : m_uiRecvSizeRemain)); // Receive until the end of the current message
ssize_t iSizeRecv = 0;
uint8_t uiArrBuffer[4096] = { 0 };
if ((iSizeRecv = ::recv(m_evClient.data.fd, uiArrBuffer, iSizeBuff, MSG_NOSIGNAL)) <= 0)
m_bConnected = false;
else {
// Insert the received data into the client's receive buffer
m_vecRecvBuff.insert(m_vecRecvBuff.end(), &uiArrBuffer[0], &uiArrBuffer[iSizeRecv]);
// Deduct the size of the data received from the remainder to be received
m_uiRecvSizeRemain -= static_cast<uint32_t>(iSizeRecv);
// If this message has been fully received
if (m_uiRecvSizeRemain <= 0) {
if (m_sHeaderRecv.ChksmVerifyData(m_vecRecvBuff))
Dispatch();
m_vecRecvBuff.clear();
}
}
}
}
...
Afin d'optimiser la mémoire utilisée lorsqu'une nouvelle énigme (texte, son ou image) est envoyée aux différents clients simultanément, le serveur utilise un pointer partagé qu'il renseigne dans leur piles d'envoi respective.
Cela permet aussi à la zone mémoire allouée d'être libérée automatiquement lorsque son pointeur est supprimé de la dernière pile le contenant.
/** Function called punctually to send the current selected enigma and its specifications (which will be displayed as a helper by the client).
*
* @param
* @return
* @throw
*/
void cCore::Coroutine_Process() {
switch (m_sHeaderRecv.GetAction()) {
// Broadcast the enigma and its specifications
case static_cast<int>(sFuncPonc::eFuncPonc::Broadcast_Def) : {
// Get the list of online and registered players
vecClients_t vecClients = GetPlayersRegistered();
// Create the enigma specifications in Json and its shared buffer
json jHelper = Helper_Create();
std::shared_ptr<sMessage> ptrMessHelper = std::make_shared<sMessage>(sHeader::eAction::Helper_Nfo, jHelper.dump().data(), jHelper.dump().size());
// Create the shared buffer of the current enigma
std::shared_ptr<sMessage> ptrMessEnigma = std::make_shared<sMessage>(m_vecDefs[0]->m_eType, m_vecDefs[0]->m_vecUi8Buff.data(), m_vecDefs[0]->m_iBuffSize);
// Send the new enigma and its specifications to every client with the status TriggerHideSent
for (vecClients_t::iterator itClients = vecClients.begin(); itClients != vecClients.end(); ++itClients;)
if ((*itClients)->second->m_eStatus == sClient::eStatus::TriggerHideSent) {
(*itClients)->second->m_vecSendBuff.emplace_back(ptrMessHelper);
(*itClients)->second->m_vecSendBuff.emplace_back(ptrMessEnigma);
(*itClients)->second->m_eStatus = sClient::eStatus::DefinitionSent;
}
// Start waiting to receive each client acknowledgement before sending them all a synchronized "show enigma" trigger
Coroutine_Add(sFuncPonc::eFuncPonc::Broadcast_AwaitAck, 1000);
}
break;
...
}
}
...
Pour gérer le serveur en C++ de Solvus, un portail web administrateur a été développé. Il s'agit d'une page HTML dont le contenu est mis à jour localement par du code Javascript procédant à des requêtes vers un script Python.
Ce dernier se connecte directement au serveur C++ grâce aux sockets Python et fournit une clef administrateur afin de bénéficier d'un accès privilégié.
En utilisant du Json de bout en bout, il est aisé de recevoir des données de la partie en cours pour l'afficher sur une page internet ou d'envoyer des directives provenants de cette même page au serveur.
Ci-dessous, le code de la gestion du socket du script Python :
...
class sHeader_recv:
def __init__(_self, _data):
_self.m_uiAction, _self.m_uiLength, _self.m_uiChkHed, _self.m_uiChkDat = (_data[0:4], _data[4:8], _data[8:28], _data[28:48])
_self.m_uiAction = int.from_bytes(_self.m_uiAction, byteorder='little', signed=False)
_self.m_uiLength = int.from_bytes(_self.m_uiLength, byteorder='little', signed=False)
class sHeader_send:
def __init__(_self, _action, _data):
_self.m_bytes = bytearray(48)
_self.m_bytes[0:4] = struct.pack('I', _action)
_self.m_bytes[4:8] = struct.pack('I', len(_data))
hash_head = hashlib.sha1()
hash_head.update(_self.m_bytes[0:4])
hash_head.update(_self.m_bytes[4:8])
_self.m_bytes[8:28] = hash_head.digest()
hash_data = hashlib.sha1()
hash_data.update(_data)
_self.m_bytes[28:48] = hash_data.digest()
def scop_connect(_hote, _port):
sckt = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sckt.settimeout(10)
sckt.connect((_hote, _port))
return sckt
def scop_send(_sckt, _action, _data):
data = json.dumps(_data).encode()
header = sHeader_send(_action, data)
_sckt.send(header.m_bytes)
_sckt.send(data)
return
def scop_receive(_sckt):
BUFFERSIZERECV = 4096
recvHeader = _sckt.recv(48)
header = sHeader_recv(recvHeader)
recvData = bytearray()
while len(recvData) < header.m_uiLength:
sizeRecv = header.m_uiLength - len(recvData)
if (sizeRecv > BUFFERSIZERECV):
sizeRecv = BUFFERSIZERECV
packet = _sckt.recv(sizeRecv)
recvData.extend(packet)
return json.loads(recvData.decode("utf-8"))
def scop_disconnect(_sckt):
_sckt.close()
return
...