f8607d114c
Implements the multiplayer transport layer from ROADMAP.md: UUID-based client identity, a reliable-packet channel over UDP, handshake/ack/ ping/disconnect/player-joined/player-left/player-state packet types, and split online (networked) vs offline (in-process singleplayer) server modes wired into the engine's update/dispose loop.
259 lines
7.6 KiB
C
259 lines
7.6 KiB
C
/**
|
|
* Copyright (c) 2026 Dominic Masters
|
|
*
|
|
* This software is released under the MIT License.
|
|
* https://opensource.org/licenses/MIT
|
|
*/
|
|
|
|
#include "dusktest.h"
|
|
#include "network/socket/client/client.h"
|
|
#include "network/socket/client/clientroster.h"
|
|
#include "network/socket/server/serveronline.h"
|
|
#include "network/socket/payload/packetplayerstate.h"
|
|
#include "util/memory.h"
|
|
#include "util/string.h"
|
|
#include "time/time.h"
|
|
#include <unistd.h>
|
|
|
|
#define TEST_PORT 58211
|
|
|
|
typedef struct {
|
|
bool_t connected;
|
|
bool_t rejected;
|
|
bool_t disconnected;
|
|
} clientevents_t;
|
|
|
|
static void onConnected(void *user) {
|
|
((clientevents_t *)user)->connected = true;
|
|
}
|
|
|
|
static void onRejected(
|
|
const packethandshakerejectreason_t reason,
|
|
void *user
|
|
) {
|
|
(void)reason;
|
|
((clientevents_t *)user)->rejected = true;
|
|
}
|
|
|
|
static void onDisconnected(errorret_t error, void *user) {
|
|
errorCatch(error);
|
|
((clientevents_t *)user)->disconnected = true;
|
|
}
|
|
|
|
static void pumpServerAndClients(
|
|
localclient_t *clients[],
|
|
const uint32_t clientCount,
|
|
const int32_t ms
|
|
) {
|
|
for(int32_t i = 0; i < ms; i++) {
|
|
for(uint32_t c = 0; c < clientCount; c++) {
|
|
errorCatch(localClientUpdate(clients[c]));
|
|
}
|
|
errorCatch(serverOnlineUpdate());
|
|
usleep(1000);
|
|
}
|
|
}
|
|
|
|
static uint32_t countServerRemotes(void) {
|
|
uint32_t count = 0;
|
|
for(uint32_t i = 0; i < SERVER_CLIENT_COUNT_MAX; i++) {
|
|
if(SERVER_ONLINE.clients[i].type == SERVER_CLIENT_TYPE_REMOTE) count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
static int socket_setup(void **state) {
|
|
clientRosterInit();
|
|
return 0;
|
|
}
|
|
|
|
static int socket_teardown(void **state) {
|
|
if(SERVER_ONLINE.running) errorCatch(serverOnlineStop());
|
|
clientRosterInit();
|
|
return 0;
|
|
}
|
|
|
|
static void test_host_connects_local_client_to_itself(void **state) {
|
|
assert_true(errorIsOk(serverOnlineHost(TEST_PORT)));
|
|
|
|
client_t *local = clientRosterClaim(CLIENT_TYPE_LOCAL);
|
|
assert_non_null(local);
|
|
|
|
clientevents_t events;
|
|
memoryZero(&events, sizeof(events));
|
|
|
|
const errorret_t ret = localClientConnect(
|
|
&local->local, "127.0.0.1", TEST_PORT, "host",
|
|
onConnected, onRejected, onDisconnected, &events
|
|
);
|
|
assert_true(errorIsOk(ret));
|
|
|
|
localclient_t *clients[] = { &local->local };
|
|
pumpServerAndClients(clients, 1, 2000);
|
|
|
|
assert_true(events.connected);
|
|
assert_false(events.rejected);
|
|
assert_int_equal(local->local.state, LOCAL_CLIENT_STATE_CONNECTED);
|
|
|
|
assert_int_equal(countServerRemotes(), 1);
|
|
|
|
serverclient_t *remote = NULL;
|
|
for(uint32_t i = 0; i < SERVER_CLIENT_COUNT_MAX; i++) {
|
|
if(SERVER_ONLINE.clients[i].type == SERVER_CLIENT_TYPE_REMOTE) {
|
|
remote = &SERVER_ONLINE.clients[i];
|
|
}
|
|
}
|
|
assert_non_null(remote);
|
|
assert_true(uuidEquals(&remote->remote.id, &local->local.id));
|
|
assert_string_equal(remote->remote.username, "host");
|
|
|
|
localClientDisconnect(&local->local);
|
|
clientRosterRemove(local);
|
|
|
|
pumpServerAndClients(NULL, 0, 200);
|
|
assert_int_equal(countServerRemotes(), 0);
|
|
|
|
errorCatch(serverOnlineStop());
|
|
|
|
assert_int_equal(memoryGetAllocatedCount(), 0);
|
|
}
|
|
|
|
// Regression test: a quiet connection (no state/chat traffic) must not
|
|
// falsely time itself out. The server has to keep sending the client
|
|
// something (a PING) on its own idle timer -- otherwise the client only
|
|
// ever hears from the server once, at handshake, and its own 10s
|
|
// no-packets-received timeout eventually fires even though the
|
|
// connection is healthy.
|
|
static void test_server_keepalive_prevents_client_timeout(void **state) {
|
|
assert_true(errorIsOk(serverOnlineHost(TEST_PORT)));
|
|
|
|
client_t *local = clientRosterClaim(CLIENT_TYPE_LOCAL);
|
|
assert_non_null(local);
|
|
|
|
clientevents_t events;
|
|
memoryZero(&events, sizeof(events));
|
|
|
|
assert_true(errorIsOk(localClientConnect(
|
|
&local->local, "127.0.0.1", TEST_PORT, "host",
|
|
onConnected, onRejected, onDisconnected, &events
|
|
)));
|
|
|
|
localclient_t *clients[] = { &local->local };
|
|
pumpServerAndClients(clients, 1, 500);
|
|
assert_true(events.connected);
|
|
|
|
// Simulate several ping intervals of elapsed game time -- well past
|
|
// SERVER_CLIENT_TIMEOUT_SECONDS in total -- jumping the logical clock
|
|
// directly (no real 10s wait) but still pumping for real in between
|
|
// each jump so any keepalive packets actually go out over the real
|
|
// loopback socket and get processed.
|
|
for(int32_t i = 0; i < 6; i++) {
|
|
TIME.time += 3.0f;
|
|
pumpServerAndClients(clients, 1, 100);
|
|
}
|
|
|
|
assert_false(events.disconnected);
|
|
assert_int_equal(local->local.state, LOCAL_CLIENT_STATE_CONNECTED);
|
|
assert_int_equal(countServerRemotes(), 1);
|
|
|
|
localClientDisconnect(&local->local);
|
|
clientRosterRemove(local);
|
|
|
|
pumpServerAndClients(NULL, 0, 200);
|
|
errorCatch(serverOnlineStop());
|
|
|
|
assert_int_equal(memoryGetAllocatedCount(), 0);
|
|
}
|
|
|
|
static void test_second_client_joins_and_state_relays(void **state) {
|
|
assert_true(errorIsOk(serverOnlineHost(TEST_PORT)));
|
|
|
|
client_t *alice = clientRosterClaim(CLIENT_TYPE_LOCAL);
|
|
client_t *bob = clientRosterClaim(CLIENT_TYPE_LOCAL);
|
|
assert_non_null(alice);
|
|
assert_non_null(bob);
|
|
|
|
clientevents_t aliceEvents, bobEvents;
|
|
memoryZero(&aliceEvents, sizeof(aliceEvents));
|
|
memoryZero(&bobEvents, sizeof(bobEvents));
|
|
|
|
assert_true(errorIsOk(localClientConnect(
|
|
&alice->local, "127.0.0.1", TEST_PORT, "alice",
|
|
onConnected, onRejected, onDisconnected, &aliceEvents
|
|
)));
|
|
|
|
localclient_t *clients[] = { &alice->local, &bob->local };
|
|
pumpServerAndClients(clients, 1, 500);
|
|
assert_true(aliceEvents.connected);
|
|
|
|
assert_true(errorIsOk(localClientConnect(
|
|
&bob->local, "127.0.0.1", TEST_PORT, "bob",
|
|
onConnected, onRejected, onDisconnected, &bobEvents
|
|
)));
|
|
|
|
pumpServerAndClients(clients, 2, 500);
|
|
assert_true(bobEvents.connected);
|
|
assert_int_equal(countServerRemotes(), 2);
|
|
|
|
// Alice reports a position; the server should track it on her slot and
|
|
// relay it to bob (who doesn't know alice's id yet, so his handler
|
|
// claims a fresh roster slot for her -- exactly what a real second
|
|
// process would do).
|
|
packetplayerstate_t playerState;
|
|
memoryZero(&playerState, sizeof(playerState));
|
|
playerState.position[0] = 1.0f;
|
|
playerState.position[1] = 2.0f;
|
|
playerState.position[2] = 3.0f;
|
|
|
|
assert_true(errorIsOk(localClientSend(
|
|
&alice->local, PACKET_TYPE_PLAYER_STATE, &playerState, false
|
|
)));
|
|
|
|
pumpServerAndClients(clients, 2, 500);
|
|
|
|
serverclient_t *aliceRemote = NULL;
|
|
for(uint32_t i = 0; i < SERVER_CLIENT_COUNT_MAX; i++) {
|
|
if(
|
|
SERVER_ONLINE.clients[i].type == SERVER_CLIENT_TYPE_REMOTE &&
|
|
stringEquals(SERVER_ONLINE.clients[i].remote.username, "alice")
|
|
) {
|
|
aliceRemote = &SERVER_ONLINE.clients[i];
|
|
}
|
|
}
|
|
assert_non_null(aliceRemote);
|
|
assert_true(aliceRemote->remote.position[0] == 1.0f);
|
|
assert_true(aliceRemote->remote.position[1] == 2.0f);
|
|
assert_true(aliceRemote->remote.position[2] == 3.0f);
|
|
|
|
client_t *bobsViewOfAlice = clientRosterFindById(&alice->local.id);
|
|
assert_non_null(bobsViewOfAlice);
|
|
assert_true(bobsViewOfAlice->remote.hasState);
|
|
assert_true(bobsViewOfAlice->remote.lastPosition[0] == 1.0f);
|
|
|
|
localClientDisconnect(&alice->local);
|
|
localClientDisconnect(&bob->local);
|
|
clientRosterRemove(alice);
|
|
clientRosterRemove(bob);
|
|
|
|
pumpServerAndClients(NULL, 0, 200);
|
|
errorCatch(serverOnlineStop());
|
|
|
|
assert_int_equal(memoryGetAllocatedCount(), 0);
|
|
}
|
|
|
|
int main(void) {
|
|
const struct CMUnitTest tests[] = {
|
|
cmocka_unit_test_setup_teardown(
|
|
test_host_connects_local_client_to_itself, socket_setup, socket_teardown
|
|
),
|
|
cmocka_unit_test_setup_teardown(
|
|
test_second_client_joins_and_state_relays, socket_setup, socket_teardown
|
|
),
|
|
cmocka_unit_test_setup_teardown(
|
|
test_server_keepalive_prevents_client_timeout,
|
|
socket_setup, socket_teardown
|
|
),
|
|
};
|
|
return cmocka_run_group_tests(tests, NULL, NULL);
|
|
}
|