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:
2026-08-02 21:06:23 -05:00
parent 56230dd340
commit f8607d114c
90 changed files with 4247 additions and 2 deletions
+5
View File
@@ -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)
+9
View File
@@ -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)
+258
View File
@@ -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);
}
+121
View File
@@ -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);
}
+62
View File
@@ -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);
}