diff --git a/server/acceptance/Program.cs b/server/acceptance/Program.cs index 331374f..b390812 100644 --- a/server/acceptance/Program.cs +++ b/server/acceptance/Program.cs @@ -41,10 +41,15 @@ internal static class Program 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("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: 16 clients across cells, no cross-cell transform spam", SixteenClientsNoCrossCellSpam); 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: 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: 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"); } + 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 ------------------------------------------------------ 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"); } + 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 --------------------------------------------------- private static async Task HandshakeTimeoutCloses()