Add UDP socket client/server multiplayer protocol
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.
This commit is contained in:
@@ -3,4 +3,9 @@
|
||||
# This software is released under the MIT License.
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
include(dusktest)
|
||||
|
||||
dusktest(test_network.c)
|
||||
|
||||
add_subdirectory(http)
|
||||
add_subdirectory(socket)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2026 Dominic Masters
|
||||
#
|
||||
# This software is released under the MIT License.
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
include(dusktest)
|
||||
|
||||
dusktest(test_networksocket.c)
|
||||
dusktest(test_serveroffline.c)
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 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/localclient.h"
|
||||
#include "network/socket/server/serveroffline.h"
|
||||
#include "network/socket/payload/packetplayerstate.h"
|
||||
#include "util/memory.h"
|
||||
#include "time/time.h"
|
||||
|
||||
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 pumpOffline(localclient_t *client, const int32_t iterations) {
|
||||
for(int32_t i = 0; i < iterations; i++) {
|
||||
errorCatch(serverOfflineUpdate());
|
||||
errorCatch(localClientUpdate(client));
|
||||
}
|
||||
}
|
||||
|
||||
static int offline_setup(void **state) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int offline_teardown(void **state) {
|
||||
if(SERVER_OFFLINE.running) errorCatch(serverOfflineStop());
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void test_offline_client_connects_without_networking(void **state) {
|
||||
assert_true(errorIsOk(serverOfflineHost()));
|
||||
|
||||
localclient_t client;
|
||||
clientevents_t events;
|
||||
memoryZero(&events, sizeof(events));
|
||||
|
||||
assert_true(errorIsOk(localClientConnectOffline(
|
||||
&client, "player", onConnected, onRejected, onDisconnected, &events
|
||||
)));
|
||||
|
||||
// Handshake -> accept takes exactly two drain cycles, same as online:
|
||||
// one for the server to see the HANDSHAKE, one for the client to see
|
||||
// the HANDSHAKE_ACCEPT.
|
||||
pumpOffline(&client, 4);
|
||||
|
||||
assert_true(events.connected);
|
||||
assert_false(events.rejected);
|
||||
assert_int_equal(client.state, LOCAL_CLIENT_STATE_CONNECTED);
|
||||
|
||||
assert_int_equal(SERVER_OFFLINE.client.type, SERVER_CLIENT_TYPE_LOCAL);
|
||||
assert_true(uuidEquals(&SERVER_OFFLINE.client.local.id, &client.id));
|
||||
|
||||
packetplayerstate_t playerState;
|
||||
memoryZero(&playerState, sizeof(playerState));
|
||||
playerState.position[0] = 4.0f;
|
||||
playerState.position[1] = 5.0f;
|
||||
playerState.position[2] = 6.0f;
|
||||
|
||||
assert_true(errorIsOk(localClientSend(
|
||||
&client, PACKET_TYPE_PLAYER_STATE, &playerState, false
|
||||
)));
|
||||
pumpOffline(&client, 2);
|
||||
|
||||
assert_true(SERVER_OFFLINE.client.local.position[0] == 4.0f);
|
||||
assert_true(SERVER_OFFLINE.client.local.position[1] == 5.0f);
|
||||
assert_true(SERVER_OFFLINE.client.local.position[2] == 6.0f);
|
||||
|
||||
// Simulate well past LOCAL_CLIENT_TIMEOUT_SECONDS of elapsed game time
|
||||
// -- the offline server's own idle-ping keepalive must prevent the
|
||||
// client from falsely timing itself out, same as the online fix.
|
||||
for(int32_t i = 0; i < 6; i++) {
|
||||
TIME.time += 3.0f;
|
||||
pumpOffline(&client, 2);
|
||||
}
|
||||
|
||||
assert_false(events.disconnected);
|
||||
assert_int_equal(client.state, LOCAL_CLIENT_STATE_CONNECTED);
|
||||
|
||||
localClientDisconnect(&client);
|
||||
pumpOffline(&client, 2);
|
||||
|
||||
assert_int_equal(SERVER_OFFLINE.client.type, SERVER_CLIENT_TYPE_NULL);
|
||||
|
||||
errorCatch(serverOfflineStop());
|
||||
|
||||
assert_int_equal(memoryGetAllocatedCount(), 0);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
const struct CMUnitTest tests[] = {
|
||||
cmocka_unit_test_setup_teardown(
|
||||
test_offline_client_connects_without_networking,
|
||||
offline_setup, offline_teardown
|
||||
),
|
||||
};
|
||||
return cmocka_run_group_tests(tests, NULL, NULL);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
#include "dusktest.h"
|
||||
#include "network/network.h"
|
||||
#include "network/networkinfo.h"
|
||||
#include "util/memory.h"
|
||||
|
||||
typedef struct {
|
||||
bool_t connected;
|
||||
bool_t failed;
|
||||
} networkevents_t;
|
||||
|
||||
static void onConnected(void *user) {
|
||||
((networkevents_t *)user)->connected = true;
|
||||
}
|
||||
|
||||
static void onFailed(errorret_t error, void *user) {
|
||||
errorCatch(error);
|
||||
((networkevents_t *)user)->failed = true;
|
||||
}
|
||||
|
||||
static void onDisconnect(errorret_t error, void *user) {
|
||||
errorCatch(error);
|
||||
}
|
||||
|
||||
// Regression test: networkIsConnected() reports physical connectivity and
|
||||
// is NOT the same thing as NETWORK.state -- networkGetInfo() asserts on
|
||||
// the latter. Code must gate networkGetInfo() on NETWORK.state, reached
|
||||
// only via networkRequestConnection(), not on networkIsConnected() alone.
|
||||
static void test_request_connection_enables_get_info(void **state) {
|
||||
assert_true(errorIsOk(networkInit()));
|
||||
|
||||
networkevents_t events;
|
||||
memoryZero(&events, sizeof(events));
|
||||
|
||||
// Resolves synchronously on Linux (no networkPlatformRequestConnection).
|
||||
networkRequestConnection(onConnected, onFailed, onDisconnect, &events);
|
||||
assert_true(events.connected || events.failed);
|
||||
|
||||
if(events.connected) {
|
||||
assert_int_equal(NETWORK.state, NETWORK_STATE_CONNECTED);
|
||||
const networkinfo_t info = networkGetInfo(); // must not assert/crash
|
||||
(void)info;
|
||||
} else {
|
||||
assert_int_equal(NETWORK.state, NETWORK_STATE_DISCONNECTED);
|
||||
}
|
||||
|
||||
assert_true(errorIsOk(networkDispose()));
|
||||
assert_int_equal(memoryGetAllocatedCount(), 0);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
const struct CMUnitTest tests[] = {
|
||||
cmocka_unit_test(test_request_connection_enables_get_info),
|
||||
};
|
||||
return cmocka_run_group_tests(tests, NULL, NULL);
|
||||
}
|
||||
Reference in New Issue
Block a user