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:
Nomads_Reach
2026-08-16 19:56:38 -04:00
committed by GitHub
parent f5d8261130
commit 51d35c935b
+118
View File
@@ -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()