Acceptance: finish combat rejection set and add NPC authority handoff (#22)
Extend the end-to-end harness (issue #15), harness-only — no server change: Combat validation - replayed/out-of-order combat sequence rejected and not routed - combat hit at a disconnected target rejected - out-of-interest combat hit rejected and not delivered NPC authority handoff - disconnect deterministically hands off to a new owner with a newer epoch - cell transition hands off scope authority and the previous owner can no longer submit npcState for the reassigned scope 21/21 pass on real sockets, deterministic across repeated runs.
This commit is contained in:
@@ -41,10 +41,15 @@ internal static class Program
|
|||||||
await Run("tcp: disconnect leaves no stale session; reconnect works", DisconnectNoStale);
|
await Run("tcp: disconnect leaves no stale session; reconnect works", DisconnectNoStale);
|
||||||
await Run("movement: impossible movement is rejected, corrected, and not relayed", ImpossibleMovementRejected);
|
await Run("movement: impossible movement is rejected, corrected, and not relayed", ImpossibleMovementRejected);
|
||||||
await Run("combat: self-targeted combat hit is rejected", SelfTargetedCombatRejected);
|
await Run("combat: self-targeted combat hit is rejected", SelfTargetedCombatRejected);
|
||||||
|
await Run("combat: replayed/out-of-order combat sequence is rejected", ReplayedCombatSequenceRejected);
|
||||||
|
await Run("combat: combat hit at a disconnected target is rejected", DisconnectedTargetCombatRejected);
|
||||||
|
await Run("combat: out-of-interest combat hit is rejected", OutOfInterestCombatRejected);
|
||||||
await Run("interest: same-cell relays, distant is filtered", InterestSameCellVsDistant);
|
await Run("interest: same-cell relays, distant is filtered", InterestSameCellVsDistant);
|
||||||
await Run("interest: 16 clients across cells, no cross-cell transform spam", SixteenClientsNoCrossCellSpam);
|
await Run("interest: 16 clients across cells, no cross-cell transform spam", SixteenClientsNoCrossCellSpam);
|
||||||
await Run("authority: independent populated scopes get independent authorities", IndependentNpcAuthorities);
|
await Run("authority: independent populated scopes get independent authorities", IndependentNpcAuthorities);
|
||||||
await Run("authority: a stale npc authority epoch is rejected over transport", StaleNpcAuthorityEpochRejected);
|
await Run("authority: a stale npc authority epoch is rejected over transport", StaleNpcAuthorityEpochRejected);
|
||||||
|
await Run("authority: disconnect deterministically hands off to a new owner", AuthorityHandoffOnDisconnect);
|
||||||
|
await Run("authority: cell transition hands off and blocks the previous owner", AuthorityHandoffOnCellTransition);
|
||||||
await Run("baseline: handshake timeout closes an unactivated session", HandshakeTimeoutCloses);
|
await Run("baseline: handshake timeout closes an unactivated session", HandshakeTimeoutCloses);
|
||||||
await Run("baseline: idle timeout closes a stale active session", IdleTimeoutCloses);
|
await Run("baseline: idle timeout closes a stale active session", IdleTimeoutCloses);
|
||||||
|
|
||||||
@@ -308,6 +313,67 @@ internal static class Program
|
|||||||
Equal(routedBefore, Stat(s.Server, "combatHitsRouted"), "self-targeted combat hit was not routed");
|
Equal(routedBefore, Stat(s.Server, "combatHitsRouted"), "self-targeted combat hit was not routed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task ReplayedCombatSequenceRejected()
|
||||||
|
{
|
||||||
|
await using var s = new TestServer();
|
||||||
|
await using var a = await Connect(s.Port);
|
||||||
|
await using var b = await Connect(s.Port);
|
||||||
|
await a.SendAsync(TransformPacket("0000CB01", "0000CB01", 0, 0, "spawn"));
|
||||||
|
await b.SendAsync(TransformPacket("0000CB01", "0000CB01", 0, 0, "spawn"));
|
||||||
|
await Drain(a, 300); await Drain(b, 300);
|
||||||
|
|
||||||
|
// first hit at sequence 5 routes to the in-interest target
|
||||||
|
await a.SendAsync(CombatHit(b.PlayerId, 5));
|
||||||
|
NotNull(await ReceiveUntil(b, p => Type(p) == "combatHit", 3000), "in-interest combat hit routed to the target");
|
||||||
|
var routedBefore = Stat(s.Server, "combatHitsRouted");
|
||||||
|
var rejectedBefore = Stat(s.Server, "packetsRejected");
|
||||||
|
|
||||||
|
// an older sequence must be rejected and not routed
|
||||||
|
await a.SendAsync(CombatHit(b.PlayerId, 3));
|
||||||
|
await SpinUntil(() => Stat(s.Server, "packetsRejected") > rejectedBefore, 2000);
|
||||||
|
True(Stat(s.Server, "packetsRejected") > rejectedBefore, "replayed combat sequence was rejected");
|
||||||
|
Equal(routedBefore, Stat(s.Server, "combatHitsRouted"), "replayed combat sequence was not routed");
|
||||||
|
True(await ExpectNone(b, p => Type(p) == "combatHit", 500), "target did not receive the replayed hit");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task DisconnectedTargetCombatRejected()
|
||||||
|
{
|
||||||
|
await using var s = new TestServer();
|
||||||
|
await using var a = await Connect(s.Port);
|
||||||
|
await a.SendAsync(TransformPacket("0000CB02", "0000CB02", 0, 0, "spawn"));
|
||||||
|
await Drain(a, 300);
|
||||||
|
var rejectedBefore = Stat(s.Server, "packetsRejected");
|
||||||
|
var routedBefore = Stat(s.Server, "combatHitsRouted");
|
||||||
|
|
||||||
|
await a.SendAsync(CombatHit(999999, 1));
|
||||||
|
await SpinUntil(() => Stat(s.Server, "packetsRejected") > rejectedBefore, 2000);
|
||||||
|
True(Stat(s.Server, "packetsRejected") > rejectedBefore, "combat hit at a disconnected target was rejected");
|
||||||
|
Equal(routedBefore, Stat(s.Server, "combatHitsRouted"), "disconnected-target combat was not routed");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task OutOfInterestCombatRejected()
|
||||||
|
{
|
||||||
|
await using var s = new TestServer();
|
||||||
|
await using var a = await Connect(s.Port);
|
||||||
|
await using var b = await Connect(s.Port);
|
||||||
|
await a.SendAsync(TransformPacket("0000CB03", "0000CB03", 0, 0, "spawn"));
|
||||||
|
await b.SendAsync(TransformPacket("0000CB04", "0000CB04", 1000, 0, "spawn")); // distinct scope
|
||||||
|
await Drain(a, 300); await Drain(b, 300);
|
||||||
|
var rejectedBefore = Stat(s.Server, "packetsRejected");
|
||||||
|
var routedBefore = Stat(s.Server, "combatHitsRouted");
|
||||||
|
|
||||||
|
await a.SendAsync(CombatHit(b.PlayerId, 1));
|
||||||
|
await SpinUntil(() => Stat(s.Server, "packetsRejected") > rejectedBefore, 2000);
|
||||||
|
True(Stat(s.Server, "packetsRejected") > rejectedBefore, "out-of-interest combat hit was rejected");
|
||||||
|
Equal(routedBefore, Stat(s.Server, "combatHitsRouted"), "out-of-interest combat was not routed");
|
||||||
|
True(await ExpectNone(b, p => Type(p) == "combatHit", 500), "distant target did not receive the hit");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject CombatHit(uint targetPlayerId, uint sequence) => new()
|
||||||
|
{
|
||||||
|
["type"] = "combatHit", ["targetPlayerId"] = targetPlayerId, ["sequence"] = sequence, ["damage"] = 10.0
|
||||||
|
};
|
||||||
|
|
||||||
// ---- npc authority ------------------------------------------------------
|
// ---- npc authority ------------------------------------------------------
|
||||||
|
|
||||||
private static async Task IndependentNpcAuthorities()
|
private static async Task IndependentNpcAuthorities()
|
||||||
@@ -353,6 +419,58 @@ internal static class Program
|
|||||||
True(Stat(s.Server, "npcAuthorityRejects") > rejectsBefore, "npcState with a stale/wrong epoch was rejected");
|
True(Stat(s.Server, "npcAuthorityRejects") > rejectsBefore, "npcState with a stale/wrong epoch was rejected");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task AuthorityHandoffOnDisconnect()
|
||||||
|
{
|
||||||
|
await using var s = new TestServer();
|
||||||
|
var a = await Connect(s.Port); // connects first -> lower id -> initial authority
|
||||||
|
await using var b = await Connect(s.Port);
|
||||||
|
await a.SendAsync(TransformPacket("0000A0FF", "0000A0FF", 0, 0, "spawn"));
|
||||||
|
var grantA = await ReceiveUntil(a, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == a.PlayerId, 3000);
|
||||||
|
NotNull(grantA, "a is the initial authority for the shared scope");
|
||||||
|
var epochA = UInt(grantA!["authorityEpoch"]);
|
||||||
|
await b.SendAsync(TransformPacket("0000A0FF", "0000A0FF", 0, 0, "spawn")); // b joins the same scope
|
||||||
|
await Drain(a, 300); await Drain(b, 300);
|
||||||
|
|
||||||
|
// a leaves; authority must deterministically hand off to b with a newer epoch
|
||||||
|
await a.DisposeAsync();
|
||||||
|
var grantB = await ReceiveUntil(b, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == b.PlayerId, 4000);
|
||||||
|
NotNull(grantB, "authority handed off to b after a disconnected");
|
||||||
|
True(UInt(grantB!["authorityEpoch"]) > epochA, "handoff carries a newer epoch");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AuthorityHandoffOnCellTransition()
|
||||||
|
{
|
||||||
|
await using var s = new TestServer();
|
||||||
|
await using var a = await Connect(s.Port);
|
||||||
|
await using var b = await Connect(s.Port);
|
||||||
|
await a.SendAsync(TransformPacket("0000CE10", "0000CE10", 0, 0, "spawn")); // a authority for scope X
|
||||||
|
var grantA = await ReceiveUntil(a, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == a.PlayerId, 3000);
|
||||||
|
NotNull(grantA, "a is authority for scope X");
|
||||||
|
var epochX1 = UInt(grantA!["authorityEpoch"]);
|
||||||
|
await b.SendAsync(TransformPacket("0000CE10", "0000CE10", 0, 0, "spawn")); // b joins scope X
|
||||||
|
await Drain(a, 300); await Drain(b, 300);
|
||||||
|
|
||||||
|
// a transitions out to a different cell; scope X must hand off to b
|
||||||
|
await a.SendAsync(TransformPacket("0000CE20", "0000CE20", 0, 0, "cell_change"));
|
||||||
|
var grantB = await ReceiveUntil(b, p => Type(p) == "npcAuthority" && UInt(p["authorityPlayerId"]) == b.PlayerId, 4000);
|
||||||
|
NotNull(grantB, "scope X handed off to b after a transitioned away");
|
||||||
|
True(UInt(grantB!["authorityEpoch"]) > epochX1, "handoff carries a newer epoch");
|
||||||
|
|
||||||
|
// the previous owner can no longer submit npcState for the reassigned scope
|
||||||
|
await Drain(a, 200);
|
||||||
|
var rejectsBefore = Stat(s.Server, "npcAuthorityRejects");
|
||||||
|
await a.SendAsync(new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "npcState",
|
||||||
|
["authorityEpoch"] = epochX1,
|
||||||
|
["authorityCellId"] = "0000CE10",
|
||||||
|
["authorityWorldspaceId"] = "0000CE10",
|
||||||
|
["npcs"] = new JsonArray()
|
||||||
|
});
|
||||||
|
await SpinUntil(() => Stat(s.Server, "npcAuthorityRejects") > rejectsBefore, 2000);
|
||||||
|
True(Stat(s.Server, "npcAuthorityRejects") > rejectsBefore, "previous owner cannot submit for the reassigned scope");
|
||||||
|
}
|
||||||
|
|
||||||
// ---- session teardown ---------------------------------------------------
|
// ---- session teardown ---------------------------------------------------
|
||||||
|
|
||||||
private static async Task HandshakeTimeoutCloses()
|
private static async Task HandshakeTimeoutCloses()
|
||||||
|
|||||||
Reference in New Issue
Block a user