diff --git a/server/package.json b/server/package.json index 62336f37..a702f5ad 100644 --- a/server/package.json +++ b/server/package.json @@ -32,6 +32,7 @@ "export-frontend-graphics": "tsx src/scripts/exportFrontendOptimizedGraphics.ts", "export-frontend-maps": "tsx src/scripts/exportFrontendOptimizedMaps.ts", "export-client-objs": "tsx src/scripts/exportClientObjs.ts", + "test:client-activity": "tsx src/scripts/testClientActivityPolicy.ts", "start": "node dist/server.js", "lint": "eslint './src/**/*.ts'", "world-map": "node scripts/build-world-map.cjs", diff --git a/server/src/scripts/testClientActivityPolicy.ts b/server/src/scripts/testClientActivityPolicy.ts new file mode 100644 index 00000000..a8c65b10 --- /dev/null +++ b/server/src/scripts/testClientActivityPolicy.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const source = readFileSync(resolve(process.cwd(), "src/server.ts"), "utf8"); +const start = source.indexOf("function trackClientActivity"); +const end = source.indexOf("\n}\n\n(async () => {", start); + +assert.ok(start >= 0 && end > start, "trackClientActivity must remain present"); + +const fn = source.slice(start, end + 2); +const pingMatch = fn.match(/if \(isPingPacket\) \{([\s\S]*?)\n \}/); +assert.ok(pingMatch, "ping branch must remain explicit"); + +const pingBranch = pingMatch[1]; +assert.match(fn, /ws\.packetCount = Number\(ws\.packetCount \?\? 0\) \+ 1;/, "all packets increment packetCount"); +assert.match(pingBranch, /ws\.lastPingAt = now;/, "ping must refresh transport liveness"); +assert.ok(!pingBranch.includes("lastActivityAt"), "ping must not reset real player activity"); +assert.match(pingBranch, /return;/, "ping must return before non-ping accounting"); + +for (const forbidden of [ + "lastPacketAt", + "packetCountNonPing", + "recentPacketTimestamps", + "recentPacketIntervalsMs", + "packetTypeCounts", +]) { + assert.ok(!pingBranch.includes(forbidden), `ping must not mutate non-ping metric: ${forbidden}`); +} + +const pingBlockEnd = fn.indexOf("}", fn.indexOf("if (isPingPacket)")); +const nonPingTail = fn.slice(pingBlockEnd + 1); +assert.match(nonPingTail, /ws\.lastPacketAt = now;/, "non-ping packets still refresh lastPacketAt"); +assert.match(nonPingTail, /ws\.lastActivityAt = now;/, "non-ping packets still refresh real activity"); + +const helperStart = source.indexOf("function getClientLivenessReferenceAt"); +const helperEnd = source.indexOf("\n}\n\nfunction getScoutIdleReferenceAt", helperStart); +assert.ok(helperStart >= 0 && helperEnd > helperStart, "liveness helper must remain present"); +const helperFn = source.slice(helperStart, helperEnd + 2); +assert.match(helperFn, /client\.lastActivityAt/, "real activity contributes to liveness"); +assert.match(helperFn, /client\.lastPingAt/, "keepalive ping contributes to liveness"); +assert.match(helperFn, /Math\.max\(lastActivityAt, lastPingAt, connectedAt\)/, "freshest liveness signal wins"); + +const idleStart = source.indexOf("function processIdleCharactersTick"); +const idleEnd = source.indexOf("\n}\n\nfunction getClientLivenessReferenceAt", idleStart); +assert.ok(idleStart >= 0 && idleEnd > idleStart, "idle sweep must remain present"); +const idleFn = source.slice(idleStart, idleEnd + 2); +assert.match(idleFn, /getClientLivenessReferenceAt\(client, now\)/, "normal sessions use transport liveness"); +assert.match(idleFn, /getScoutIdleReferenceAt\(client, user\)/, "duplicate scouts keep real-activity policy"); + +console.log("client activity policy tests passed"); diff --git a/server/src/server.ts b/server/src/server.ts index c3800bcb..4bce2386 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -430,6 +430,9 @@ function trackClientActivity(ws: RuntimeClient, packageID: number) { ws.packetCount = Number(ws.packetCount ?? 0) + 1; if (isPingPacket) { + // Keep transport liveness separate from real player activity so + // keepalive traffic cannot contaminate AFK/gameplay metrics. + ws.lastPingAt = now; return; } @@ -785,7 +788,7 @@ function processIdleCharactersTick(now: number) { continue; } - if (typeof client.lastActivityAt !== "number") { + if (typeof client.lastActivityAt !== "number" && typeof client.lastPingAt !== "number") { client.lastActivityAt = now; continue; } @@ -794,7 +797,7 @@ function processIdleCharactersTick(now: number) { const effectiveIdleTimeoutMs = isDuplicateIpScout ? DUPLICATE_IP_IDLE_TIMEOUT_MS : idleCharacterTimeoutMs; const idleReferenceAt = isDuplicateIpScout ? getScoutIdleReferenceAt(client, user) - : Number(client.lastActivityAt ?? now); + : getClientLivenessReferenceAt(client, now); if (now - idleReferenceAt < effectiveIdleTimeoutMs) { continue; @@ -808,6 +811,14 @@ function processIdleCharactersTick(now: number) { } } +function getClientLivenessReferenceAt(client: RuntimeClient, now: number): number { + const lastActivityAt = Number(client.lastActivityAt ?? 0); + const lastPingAt = Number(client.lastPingAt ?? 0); + const connectedAt = Number(client.connectedAt ?? now); + + return Math.max(lastActivityAt, lastPingAt, connectedAt); +} + function getScoutIdleReferenceAt(client: RuntimeClient, user: ServerCharacter): number { const lastMovedAt = Number(user.lastMovementActivityAt ?? 0); const lastCombatActivityAt = Number(user.lastCombatActivityAt ?? 0); diff --git a/server/src/types/runtime.ts b/server/src/types/runtime.ts index caef0124..0c2ee0b5 100644 --- a/server/src/types/runtime.ts +++ b/server/src/types/runtime.ts @@ -485,6 +485,7 @@ export type RuntimeClient = { lastPacketIntervalMs?: number; minPacketIntervalMs?: number; lastActivityAt?: number; + lastPingAt?: number; packetTypeCounts?: Record; nextMapClickAt?: number; nextDoorToggleAt?: number;