mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
ec37f1c790
The client cancels its own room load when the server says it is nowhere. OnPresenceHeartbeatResponse XORs (cachedRoomInstance != null) against (serverRoomInstance != null), so a client holding the instance it is joining while we answer null reads as the room vanishing mid-join: it logs "presence heartbeat response indicates local presence is out-of-sync", then "Attempting to go to Invalid RoomInstance", and cancels the very join the server was waiting on. It then retried until a heartbeat happened to miss the window, so entering a dorm took several attempts and looked like a load failure. Deferring the presence commit is about what everyone ELSE sees — presence stays uncommitted until the join is confirmed so a failed join leaves no ghost behind. It was never meant to change what the joining player is told about themselves. Echoing the pending target satisfies the XOR and matches what the client already has, while presence itself stays uncommitted. The null-room branch a few lines below already documents this exact XOR for the boot race and synthesises a dorm rather than answering null; the deferred branch contradicted it. The test drives matchmake/dorm and heartbeats inside the window before the join is reported, which is the state the client is actually in. Verified to fail against the previous behaviour rather than passing vacuously.
102 lines
4.6 KiB
C#
102 lines
4.6 KiB
C#
using System.Net.Http.Headers;
|
|
using System.Text.Json;
|
|
using DorkNet.Server.Data;
|
|
using DorkNet.Server.Data.Entities;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
namespace DorkNet.Server.Tests;
|
|
|
|
/// <summary>
|
|
/// While a join is pending, the heartbeat must tell the joining player which
|
|
/// instance they are joining — never null.
|
|
///
|
|
/// Deferring the presence commit is about what everyone ELSE sees: presence
|
|
/// stays uncommitted until the join is confirmed so a failed join leaves no
|
|
/// ghost behind. It must not change what the joining player is told about
|
|
/// themselves, because the client XORs (cachedRoomInstance != null) against
|
|
/// (serverRoomInstance != null): holding the target while the server says null
|
|
/// reads as the room vanishing mid-join. The client then logs "presence
|
|
/// heartbeat response indicates local presence is out-of-sync", "Attempting to
|
|
/// go to Invalid RoomInstance", and cancels the very join the server was
|
|
/// waiting on — so it retries until a heartbeat happens to miss the window, and
|
|
/// entering a dorm takes several attempts while looking like a load failure.
|
|
/// </summary>
|
|
public sealed class PendingJoinPresenceTests : IClassFixture<DorkNetServerFactory>
|
|
{
|
|
private readonly DorkNetServerFactory _factory;
|
|
|
|
public PendingJoinPresenceTests(DorkNetServerFactory factory) => _factory = factory;
|
|
|
|
[Fact]
|
|
public async Task Heartbeat_during_a_pending_dorm_join_reports_the_instance_being_joined()
|
|
{
|
|
using var client = Client("match");
|
|
var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain);
|
|
client.DefaultRequestHeaders.Authorization =
|
|
new AuthenticationHeaderValue("Bearer", session.AccessToken);
|
|
|
|
// matchmake/dorm is what arms the deferred-commit pending join.
|
|
using var dormForm = new FormUrlEncodedContent(
|
|
[new KeyValuePair<string, string>("MaxPersistenceVersion", "45")]);
|
|
using var dorm = await client.PostAsync("/matchmake/dorm", dormForm);
|
|
var dormBody = await dorm.Content.ReadAsStringAsync();
|
|
Assert.True(dorm.IsSuccessStatusCode,
|
|
$"POST /matchmake/dorm -> {(int)dorm.StatusCode}: {dormBody}");
|
|
|
|
var expectedInstance = JsonDocument.Parse(dormBody).RootElement;
|
|
var instanceId = FindInstanceId(expectedInstance);
|
|
Assert.True(instanceId is not null, $"no roomInstanceId in the dorm reply: {dormBody}");
|
|
|
|
// The join has NOT been reported yet, so the commit is still deferred —
|
|
// this is exactly the window the client heartbeats in.
|
|
using var beatForm = new FormUrlEncodedContent(
|
|
[new KeyValuePair<string, string>("LoginLock", Guid.NewGuid().ToString())]);
|
|
using var beat = await client.PostAsync("/player/heartbeat", beatForm);
|
|
var beatBody = await beat.Content.ReadAsStringAsync();
|
|
Assert.True(beat.IsSuccessStatusCode,
|
|
$"POST /player/heartbeat -> {(int)beat.StatusCode}: {beatBody}");
|
|
|
|
var presence = JsonDocument.Parse(beatBody).RootElement;
|
|
Assert.True(presence.TryGetProperty("roomInstance", out var reported)
|
|
|| presence.TryGetProperty("RoomInstance", out reported),
|
|
$"no roomInstance key in the heartbeat: {beatBody}");
|
|
|
|
Assert.True(reported.ValueKind != JsonValueKind.Null,
|
|
$"""
|
|
The heartbeat returned a null roomInstance while the player's join was
|
|
still pending. The client reads that as the room vanishing mid-join and
|
|
cancels the load it is waiting on:
|
|
|
|
{beatBody}
|
|
""");
|
|
|
|
Assert.Equal(instanceId, FindInstanceId(reported));
|
|
}
|
|
|
|
/// <summary>Pull roomInstanceId out of either the matchmake reply (which
|
|
/// wraps it) or the heartbeat's roomInstance object, whatever the casing.</summary>
|
|
private static string? FindInstanceId(JsonElement element)
|
|
{
|
|
if (element.ValueKind != JsonValueKind.Object) return null;
|
|
|
|
foreach (var property in element.EnumerateObject())
|
|
{
|
|
if (property.Name.Equals("roomInstanceId", StringComparison.OrdinalIgnoreCase))
|
|
return property.Value.ToString();
|
|
|
|
if (property.Value.ValueKind == JsonValueKind.Object
|
|
&& FindInstanceId(property.Value) is { } nested)
|
|
return nested;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private HttpClient Client(string host)
|
|
{
|
|
var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
|
|
client.BaseAddress = new Uri($"http://{host}.{_factory.ApexDomain}");
|
|
return client;
|
|
}
|
|
}
|