diff --git a/src/App.ts b/src/App.ts index b35e239..bef91a6 100644 --- a/src/App.ts +++ b/src/App.ts @@ -11,6 +11,7 @@ import { QUAKE_RENDER_SUPERSAMPLE } from "./prepare/scene"; import type { QuakeEntity, QuakeEntityManifestPoint, + QuakePreparedRenderBundle, QuakeScene, QuakeVertex, } from "./types/quake"; @@ -161,6 +162,7 @@ import { QUAKE_MULTIPLAYER_ROOM_TOKEN_PATTERN, QUAKE_MULTIPLAYER_MAX_PLAYERS_CAP, quakeMultiplayerDeathmatchSpawnOrder, + quakeMultiplayerDeathmatchWeaponDamage, quakeMultiplayerGameplayDefinitionsFromScene, quakeMultiplayerPlayerFacesTrigger, quakeMultiplayerWorldDefinitionsFromScene, @@ -168,6 +170,7 @@ import { type QuakeMultiplayerAuthoritativePlayerState, type QuakeMultiplayerInventoryState, type QuakeMultiplayerLocalInputIntent, + type QuakeMultiplayerPickupDefinition, type QuakeMultiplayerPlayerPresenceStatus, type QuakeMultiplayerRemoteInterpolationState, type QuakeMultiplayerRemoteVisualHandle, @@ -234,6 +237,7 @@ import { quakePickupModelRenderBundleFrameSet, quakePickupModelRenderBundle, type QuakePickupModel, + type QuakePickupModelAnimationFrame, type QuakePickupModelLibrary, type QuakeProgramMetadata, } from "./runtime/pickups"; @@ -1251,12 +1255,14 @@ const quakeRemotePlayers = createQuakeMultiplayerRemotePlayerPresenter({ createVisual: createQuakeRemotePlayerVisual, shouldRenderPlayer: (playerState) => playerState.playerId !== quakeMultiplayerSpectatorFollowedPlayerId, onPlayerDamaged: (event, playerState) => { - if (!quakeMultiplayerRemoteDamageFromLocalPlayer(event)) return; - quakeImpactParticleFlow.spawnBlood({ - damage: event.damage, - directionHint: quakeMultiplayerRemoteDamageDirectionHint(event, playerState), - origin: quakeMultiplayerRemoteDamageOrigin(playerState), - }); + spawnQuakeMultiplayerRemotePlayerBlood(event, playerState, event.damage); + }, + onPlayerKilled: (event, playerState) => { + spawnQuakeMultiplayerRemotePlayerBlood( + event, + playerState, + quakeMultiplayerDeathmatchWeaponDamage(event.damageSource ?? ""), + ); }, now: () => Date.now(), }); @@ -1956,6 +1962,7 @@ const weapons = createQuakeWeaponsController({ }, onHit: () => quakeWeaponPresentation.flashCrosshairHit(), onWallImpact: (event) => { + if (quakeMultiplayerShouldSuppressLocalWallImpact(event)) return; quakeImpactParticleFlow.spawnWallImpact({ count: quakeWallImpactParticleCount(event.effect), origin: event.origin, @@ -2086,6 +2093,8 @@ let currentPickupModelLibrary: QuakePickupModelLibrary | null = null; let currentProgramMetadata: QuakeProgramMetadata | null = null; let currentCollisionWorld: QuakeCollisionWorld | null = null; let currentResult: QuakeScene | null = null; +let quakeMultiplayerPickupDefinitionsScene: QuakeScene | null = null; +let quakeMultiplayerPickupDefinitions: readonly QuakeMultiplayerPickupDefinition[] = []; let quakeMultiplayerWorldIntentDefinitionsScene: QuakeScene | null = null; let quakeMultiplayerWorldIntentDefinitions: readonly QuakeMultiplayerWorldDefinition[] = []; let quakeGameplayStarted = false; @@ -2854,14 +2863,10 @@ function createQuakeRemotePlayerVisual( ): QuakeMultiplayerRemoteVisualHandle | null { const remote = addQuakeRemotePlayerMesh(); if (!remote) return null; - remote.handle.element.classList.add("remote-player", "remote-player-prototype"); - remote.handle.element.dataset.playerId = playerState.playerId; - remote.handle.element.dataset.clientId = playerState.clientId; - if (playerState.color) { - remote.handle.element.dataset.playerColor = playerState.color; - remote.handle.element.style.setProperty("--quake-multiplayer-player-color", playerState.color); - } - stripPolyMeshMetadata(remote.handle.element); + remote.playerId = playerState.playerId; + remote.clientId = playerState.clientId; + remote.color = playerState.color; + syncQuakeRemotePlayerElementMetadata(remote); return { element: remote.handle.element, setState: (state) => syncQuakeRemotePlayerVisual(remote, state), @@ -2870,9 +2875,17 @@ function createQuakeRemotePlayerVisual( } interface QuakeRemotePlayerMeshMount { + activeFrameSet: "run" | undefined; + animationFrames: readonly QuakePickupModelAnimationFrame[]; + color: string | undefined; + clientId: string; + currentFrameIndex: number; + fullFrameSet: QuakeRenderBundleFrameSet | undefined; handle: PolyMeshHandle; deathFrameIndexes: readonly number[]; painFrameIndexes: readonly number[]; + playerId: string; + runFrameSet: QuakeRenderBundleFrameSet | undefined; runFrameIndexes: readonly number[]; scale: number; standFrameIndex: number; @@ -2881,21 +2894,53 @@ interface QuakeRemotePlayerMeshMount { function addQuakeRemotePlayerMesh(): QuakeRemotePlayerMeshMount | null { const model = quakeRemotePlayerModel(); - const frameSet = model ? quakePickupModelRenderBundleFrameSet(model) : undefined; + const frameSet = model + ? quakeRemotePlayerMountableFrameSet(quakePickupModelRenderBundleFrameSet(model)) + : undefined; + const animationFrames = model?.animationFrames ?? []; + const standFrameIndex = frameSet + ? quakeRemotePlayerDefaultFrameIndex(frameSet) + : quakeRemotePlayerDefaultAnimationFrameIndex(animationFrames); + const runFrameIndexes = frameSet + ? quakeRemotePlayerFrameIndexes(frameSet, QUAKE_MULTIPLAYER_REMOTE_RUN_FRAME_PREFIX) + : quakeRemotePlayerAnimationFrameIndexes(animationFrames, QUAKE_MULTIPLAYER_REMOTE_RUN_FRAME_PREFIX); + const painFrameIndexes = frameSet + ? quakeRemotePlayerFrameIndexes(frameSet, QUAKE_MULTIPLAYER_REMOTE_PAIN_FRAME_PREFIX) + : quakeRemotePlayerAnimationFrameIndexes(animationFrames, QUAKE_MULTIPLAYER_REMOTE_PAIN_FRAME_PREFIX); + const deathFrameIndexes = frameSet + ? quakeRemotePlayerFrameIndexes(frameSet, QUAKE_MULTIPLAYER_REMOTE_DEATH_FRAME_PREFIX) + : quakeRemotePlayerAnimationFrameIndexes(animationFrames, QUAKE_MULTIPLAYER_REMOTE_DEATH_FRAME_PREFIX); + const runFrameSet = !frameSet && model + ? quakeRemotePlayerAnimationFrameSetForIndexes(animationFrames, runFrameIndexes) + : undefined; const handle = frameSet - ? mountQuakeRenderBundleFrameSetMesh(sceneElement, frameSet, quakeRemotePlayerDefaultFrameIndex(frameSet)) + ? mountQuakeRenderBundleFrameSetMesh(sceneElement, frameSet, standFrameIndex) + : model && animationFrames.length + ? mountQuakeRenderBundleMesh( + sceneElement, + quakeRemotePlayerMountableRenderBundle(quakePickupModelRenderBundle(model, standFrameIndex)), + ) : model - ? mountQuakeRenderBundleMesh(sceneElement, quakePickupModelRenderBundle(model, 0)) + ? mountQuakeRenderBundleMesh( + sceneElement, + quakeRemotePlayerMountableRenderBundle(quakePickupModelRenderBundle(model, 0)), + ) : addQuakeProceduralRemotePlayerMesh(); if (!handle) return null; - const standFrameIndex = frameSet ? quakeRemotePlayerDefaultFrameIndex(frameSet) : 0; world.pixelate(handle); void world.schedulePresentationResync(handle); return { + activeFrameSet: undefined, + animationFrames, + clientId: "", + currentFrameIndex: standFrameIndex, + fullFrameSet: frameSet, handle, - deathFrameIndexes: frameSet ? quakeRemotePlayerFrameIndexes(frameSet, QUAKE_MULTIPLAYER_REMOTE_DEATH_FRAME_PREFIX) : [], - painFrameIndexes: frameSet ? quakeRemotePlayerFrameIndexes(frameSet, QUAKE_MULTIPLAYER_REMOTE_PAIN_FRAME_PREFIX) : [], - runFrameIndexes: frameSet ? quakeRemotePlayerFrameIndexes(frameSet, QUAKE_MULTIPLAYER_REMOTE_RUN_FRAME_PREFIX) : [], + deathFrameIndexes, + painFrameIndexes, + playerId: "", + runFrameSet, + runFrameIndexes, scale: model?.renderScale ? 1 / model.renderScale : 1, standFrameIndex, zOffset: model ? quakeRemotePlayerModelZOffset(model) : -QUAKE_MULTIPLAYER_REMOTE_PLAYER_EYE_HEIGHT, @@ -2916,22 +2961,112 @@ function syncQuakeRemotePlayerVisual( remote: QuakeRemotePlayerMeshMount, state: QuakeMultiplayerRemoteInterpolationState, ): void { - const { handle } = remote; - handle.element.hidden = false; - setQuakeRenderBundleFrameSetHandleFrame(handle, quakeRemotePlayerVisualFrameIndex(remote, state)); + const frameIndex = quakeRemotePlayerVisualFrameIndex(remote, state); + syncQuakeRemotePlayerMeshFrame(remote, frameIndex); + remote.handle.element.hidden = false; const rotY = quakeRemotePlayerVisualRotY(state); - handle.setTransform({ + remote.handle.setTransform({ position: quakeRemotePlayerVisualOrigin(state.renderOrigin, remote.zOffset), - rotation: [0, 0, rotY + quakeRemotePlayerVisualRotYOffset(handle.element)], + rotation: [0, 0, rotY + quakeRemotePlayerVisualRotYOffset(remote.handle.element)], scale: remote.scale, }); } +function syncQuakeRemotePlayerElementMetadata(remote: QuakeRemotePlayerMeshMount): void { + remote.handle.element.classList.add("remote-player", "remote-player-prototype"); + remote.handle.element.dataset.playerId = remote.playerId; + remote.handle.element.dataset.clientId = remote.clientId; + if (remote.color) { + remote.handle.element.dataset.playerColor = remote.color; + remote.handle.element.style.setProperty("--quake-multiplayer-player-color", remote.color); + } + stripPolyMeshMetadata(remote.handle.element); +} + +function syncQuakeRemotePlayerMeshFrame(remote: QuakeRemotePlayerMeshMount, frameIndex: number): void { + if (remote.fullFrameSet) { + setQuakeRenderBundleFrameSetHandleFrame(remote.handle, frameIndex); + remote.currentFrameIndex = frameIndex; + syncQuakeRemotePlayerFrameMetadata(remote, frameIndex, remote.fullFrameSet.frames[frameIndex]?.name); + return; + } + const runFrameIndex = remote.runFrameIndexes.indexOf(frameIndex); + if (runFrameIndex >= 0 && remote.runFrameSet) { + if (remote.activeFrameSet !== "run") { + replaceQuakeRemotePlayerHandle( + remote, + mountQuakeRenderBundleFrameSetMesh(sceneElement, remote.runFrameSet, runFrameIndex), + "run", + ); + } else { + setQuakeRenderBundleFrameSetHandleFrame(remote.handle, runFrameIndex); + } + remote.currentFrameIndex = frameIndex; + syncQuakeRemotePlayerFrameMetadata(remote, frameIndex, remote.runFrameSet.frames[runFrameIndex]?.name); + return; + } + if (remote.activeFrameSet === undefined && remote.currentFrameIndex === frameIndex) { + syncQuakeRemotePlayerFrameMetadata(remote, frameIndex, quakeRemotePlayerAnimationFrameName(remote, frameIndex)); + return; + } + const frame = remote.animationFrames[frameIndex]; + if (!frame) return; + replaceQuakeRemotePlayerHandle( + remote, + mountQuakeRenderBundleMesh(sceneElement, quakeRemotePlayerMountableRenderBundle(frame.renderBundle)), + undefined, + ); + remote.currentFrameIndex = frameIndex; + syncQuakeRemotePlayerFrameMetadata(remote, frameIndex, frame.name); +} + +function replaceQuakeRemotePlayerHandle( + remote: QuakeRemotePlayerMeshMount, + nextHandle: PolyMeshHandle, + activeFrameSet: "run" | undefined, +): void { + const previousHandle = remote.handle; + nextHandle.element.hidden = previousHandle.element.hidden; + remote.handle = nextHandle; + remote.activeFrameSet = activeFrameSet; + world.pixelate(nextHandle); + syncQuakeRemotePlayerElementMetadata(remote); + void world.schedulePresentationResync(nextHandle); + previousHandle.remove(); +} + +function syncQuakeRemotePlayerFrameMetadata( + remote: QuakeRemotePlayerMeshMount, + frameIndex: number, + frameName: string | undefined, +): void { + remote.handle.element.dataset.remoteFrameIndex = String(frameIndex); + if (frameName) { + remote.handle.element.dataset.remoteFrameName = frameName; + } else { + delete remote.handle.element.dataset.remoteFrameName; + } +} + +function quakeRemotePlayerAnimationFrameName( + remote: QuakeRemotePlayerMeshMount, + frameIndex: number, +): string | undefined { + return remote.animationFrames[frameIndex]?.name ?? remote.fullFrameSet?.frames[frameIndex]?.name; +} + function quakeRemotePlayerDefaultFrameIndex(frameSet: QuakeRenderBundleFrameSet): number { const frameIndex = frameSet.frames.findIndex((frame) => frame.name === QUAKE_MULTIPLAYER_REMOTE_DEFAULT_FRAME); return frameIndex >= 0 ? frameIndex : 0; } +function quakeRemotePlayerDefaultAnimationFrameIndex( + frames: readonly QuakePickupModelAnimationFrame[], +): number { + const frameIndex = frames.findIndex((frame) => frame.name === QUAKE_MULTIPLAYER_REMOTE_DEFAULT_FRAME); + return frameIndex >= 0 ? frameIndex : 0; +} + function quakeRemotePlayerFrameIndexes( frameSet: QuakeRenderBundleFrameSet, prefix: string, @@ -2942,6 +3077,76 @@ function quakeRemotePlayerFrameIndexes( .map(({ index }) => index); } +function quakeRemotePlayerAnimationFrameIndexes( + frames: readonly QuakePickupModelAnimationFrame[], + prefix: string, +): readonly number[] { + return frames + .map((frame, index) => ({ frame, index })) + .filter(({ frame }) => frame.name.startsWith(prefix)) + .map(({ index }) => index); +} + +function quakeRemotePlayerAnimationFrameSetForIndexes( + frames: readonly QuakePickupModelAnimationFrame[], + frameIndexes: readonly number[], +): QuakeRenderBundleFrameSet | undefined { + if (!frameIndexes.length) return undefined; + const frameSetFrames = frameIndexes + .map((index) => frames[index]) + .filter((frame): frame is QuakePickupModelAnimationFrame => Boolean(frame)) + .map((frame) => ({ + name: frame.name, + renderBundle: quakeRemotePlayerMountableRenderBundle(frame.renderBundle), + })); + const leafCount = frameSetFrames[0] + ? quakeRemotePlayerRenderBundleMountLeafCount(frameSetFrames[0].renderBundle) + : 0; + if (!leafCount || frameSetFrames.length !== frameIndexes.length) return undefined; + if (!frameSetFrames.every((frame) => + quakeRemotePlayerRenderBundleMountLeafCount(frame.renderBundle) === leafCount + )) { + return undefined; + } + return { + leafCount, + renderBundle: frameSetFrames[0].renderBundle, + frames: frameSetFrames, + }; +} + +function quakeRemotePlayerMountableFrameSet( + frameSet: QuakeRenderBundleFrameSet | undefined, +): QuakeRenderBundleFrameSet | undefined { + if (!frameSet) return undefined; + const frames = frameSet.frames.map((frame) => ({ + name: frame.name, + renderBundle: quakeRemotePlayerMountableRenderBundle(frame.renderBundle), + })); + const leafCount = frames[0] + ? quakeRemotePlayerRenderBundleMountLeafCount(frames[0].renderBundle) + : frameSet.leafCount; + return { + leafCount, + renderBundle: quakeRemotePlayerMountableRenderBundle(frameSet.renderBundle), + frames, + }; +} + +function quakeRemotePlayerMountableRenderBundle( + renderBundle: QuakePreparedRenderBundle, +): QuakePreparedRenderBundle { + const leafCount = quakeRemotePlayerRenderBundleMountLeafCount(renderBundle); + return leafCount === renderBundle.leafCount ? renderBundle : { + ...renderBundle, + leafCount, + }; +} + +function quakeRemotePlayerRenderBundleMountLeafCount(renderBundle: QuakePreparedRenderBundle): number { + return renderBundle.leafMetadata.length || renderBundle.leafCount; +} + function quakeRemotePlayerVisualFrameIndex( remote: QuakeRemotePlayerMeshMount, state: QuakeMultiplayerRemoteInterpolationState, @@ -3743,6 +3948,19 @@ function handleQuakeMultiplayerPlayerDamaged( if (event.health <= 0 && !quakePlayerDead) showQuakePlayerDeath(); } +function spawnQuakeMultiplayerRemotePlayerBlood( + event: Extract, + playerState: QuakeMultiplayerAuthoritativePlayerState, + damage?: number, +): void { + if (!quakeMultiplayerRemoteDamageFromLocalPlayer(event)) return; + quakeImpactParticleFlow.spawnBlood({ + damage: damage && damage > 0 ? damage : undefined, + directionHint: quakeMultiplayerRemoteDamageDirectionHint(event, playerState), + origin: quakeMultiplayerRemoteDamageOrigin(playerState), + }); +} + function quakeMultiplayerRemoteDamageOrigin(playerState: QuakeMultiplayerAuthoritativePlayerState): Vec3 { return [ playerState.origin[0], @@ -3752,7 +3970,7 @@ function quakeMultiplayerRemoteDamageOrigin(playerState: QuakeMultiplayerAuthori } function quakeMultiplayerRemoteDamageDirectionHint( - event: Extract, + event: Extract, playerState: QuakeMultiplayerAuthoritativePlayerState, ): Vec3 | undefined { if (!quakeMultiplayerRemoteDamageFromLocalPlayer(event)) return undefined; @@ -3765,11 +3983,17 @@ function quakeMultiplayerRemoteDamageDirectionHint( } function quakeMultiplayerRemoteDamageFromLocalPlayer( - event: Extract, + event: Extract, ): boolean { return event.attackerPlayerId === quakeMultiplayerPlayerIdForClient(QUAKE_MULTIPLAYER_LOCAL_CLIENT_ID); } +function quakeMultiplayerShouldSuppressLocalWallImpact(event: QuakeWeaponWallImpactEvent): boolean { + return QUAKE_MULTIPLAYER_ENABLED && + quakeMultiplayerSession.status().state === "connected" && + (event.fireKind === "hitscan" || event.fireKind === "beam"); +} + function handleQuakeMultiplayerPlayerKilled( event: Extract, ): void { @@ -3923,6 +4147,7 @@ function currentQuakeMultiplayerWorldIntentRoomKey(): QuakeMultiplayerRoomCompat if ( quakeMultiplayerApplyingWorldEvent || quakeMultiplayerSpectating || + quakePlayerDead || !QUAKE_MULTIPLAYER_ENABLED || quakeMultiplayerSession.status().state !== "connected" ) { @@ -4130,6 +4355,7 @@ function sendQuakeMultiplayerHello(roomKey: QuakeMultiplayerRoomCompatibilityKey function requestQuakeMultiplayerPickup(entityIndex: number): boolean { if (quakeMultiplayerSpectating) return false; if (!Number.isInteger(entityIndex) || entityIndex < 0) return false; + if (!quakeMultiplayerPickupDefinitionForEntity(entityIndex)) return true; const now = Date.now(); const lastRequestedAt = quakeMultiplayerPickupRequestAt.get(entityIndex) ?? -Infinity; if (now - lastRequestedAt < 250) return true; @@ -4148,12 +4374,33 @@ function requestQuakeMultiplayerPickup(entityIndex: number): boolean { pickupSequence: ++quakeMultiplayerPickupSequence, requestedAt: now, entityIndex, + origin: getPlayer().currentOrigin(), }, }, })); return true; } +function quakeMultiplayerPickupDefinitionForEntity( + entityIndex: number, +): QuakeMultiplayerPickupDefinition | null { + return currentQuakeMultiplayerPickupDefinitions() + .find((definition) => definition.entityIndex === entityIndex) ?? null; +} + +function currentQuakeMultiplayerPickupDefinitions(): readonly QuakeMultiplayerPickupDefinition[] { + if (!currentResult) return []; + if (quakeMultiplayerPickupDefinitionsScene !== currentResult) { + quakeMultiplayerPickupDefinitionsScene = currentResult; + quakeMultiplayerPickupDefinitions = quakeMultiplayerGameplayDefinitionsFromScene(currentResult, { + pointToRoom: quakeCameraView.pointToPoly, + playerEyeHeight: getPlayer().eyeHeight(), + playerMinsZ: QUAKE_PLAYER_MINS_Z, + }).pickupDefinitions; + } + return quakeMultiplayerPickupDefinitions; +} + function sendQuakeMultiplayerHazardDamageIntent(hazard: QuakeHazardDamage): boolean { if (hazard.kind === "trigger" && hazard.entityIndex !== undefined) { return requestQuakeMultiplayerTouchIntent(hazard.entityIndex, "touch"); diff --git a/src/runtime/multiplayer/items.ts b/src/runtime/multiplayer/items.ts index c31d84b..55855c9 100644 --- a/src/runtime/multiplayer/items.ts +++ b/src/runtime/multiplayer/items.ts @@ -4,6 +4,7 @@ import type { QuakeMultiplayerInventoryState, QuakeMultiplayerPickupEffect, QuakeMultiplayerPickupDefinition, + QuakeMultiplayerVec3, } from "./protocol"; const QUAKE_MULTIPLAYER_MAX_AMMO = { @@ -20,6 +21,8 @@ const QUAKE_MULTIPLAYER_DEFAULT_WEAPON_FLAGS = { const QUAKE_MULTIPLAYER_ARMOR_FLAGS = 8192 | 16384 | 32768; const QUAKE_MULTIPLAYER_PICKUP_REACH_DISTANCE = 3.5; +const QUAKE_MULTIPLAYER_PICKUP_ORIGIN_HINT_MAX_HORIZONTAL_DRIFT = 3; +const QUAKE_MULTIPLAYER_PICKUP_ORIGIN_HINT_MAX_VERTICAL_DRIFT = 8; export interface QuakeMultiplayerDamageOptions { applyHealth?: boolean; @@ -75,14 +78,41 @@ export function quakeMultiplayerPlayerCanReachPickup( player: QuakeMultiplayerAuthoritativePlayerState, pickup: QuakeMultiplayerPickupDefinition, maxDistance = QUAKE_MULTIPLAYER_PICKUP_REACH_DISTANCE, + originHint?: QuakeMultiplayerVec3, ): boolean { if (!player.alive) return false; - const dx = player.origin[0] - pickup.origin[0]; - const dy = player.origin[1] - pickup.origin[1]; - const dz = player.origin[2] - pickup.origin[2]; + if (quakeMultiplayerOriginCanReachPickup(player.origin, pickup, maxDistance)) return true; + return Boolean( + originHint && + quakeMultiplayerPickupOriginHintWithinDrift(player.origin, originHint) && + quakeMultiplayerOriginCanReachPickup(originHint, pickup, maxDistance) + ); +} + +function quakeMultiplayerOriginCanReachPickup( + origin: QuakeMultiplayerVec3, + pickup: QuakeMultiplayerPickupDefinition, + maxDistance: number, +): boolean { + const dx = origin[0] - pickup.origin[0]; + const dy = origin[1] - pickup.origin[1]; + const dz = origin[2] - pickup.origin[2]; return Math.hypot(dx, dy, dz) <= maxDistance; } +function quakeMultiplayerPickupOriginHintWithinDrift( + authoritativeOrigin: QuakeMultiplayerVec3, + hintOrigin: QuakeMultiplayerVec3, +): boolean { + const horizontalDrift = Math.hypot( + authoritativeOrigin[0] - hintOrigin[0], + authoritativeOrigin[1] - hintOrigin[1], + ); + const verticalDrift = Math.abs(authoritativeOrigin[2] - hintOrigin[2]); + return horizontalDrift <= QUAKE_MULTIPLAYER_PICKUP_ORIGIN_HINT_MAX_HORIZONTAL_DRIFT && + verticalDrift <= QUAKE_MULTIPLAYER_PICKUP_ORIGIN_HINT_MAX_VERTICAL_DRIFT; +} + export function quakeMultiplayerPickupStateWithoutOwner( state: QuakeMultiplayerAuthoritativePickupState, playerId: string, diff --git a/src/runtime/multiplayer/loopback.ts b/src/runtime/multiplayer/loopback.ts index 4b44dfc..c8bb1f6 100644 --- a/src/runtime/multiplayer/loopback.ts +++ b/src/runtime/multiplayer/loopback.ts @@ -87,6 +87,7 @@ import { quakeMultiplayerShootableWorldHit, quakeMultiplayerTriggerCounterMessage, quakeMultiplayerTriggerUsesMultiTrigger, + quakeMultiplayerWorldIntentRejectionIsIgnorableTouchMiss, rejectQuakeMultiplayerClientWorldEvent, resolveQuakeMultiplayerWorldIntent, } from "./world"; @@ -1169,7 +1170,6 @@ export function createQuakeLoopbackMultiplayerSession( const definition = pickupDefinitions.get(message.payload.pickup.entityIndex); const state = pickupStates.get(message.payload.pickup.entityIndex); if (!definition || !state) { - emitPickupRejected(message, undefined, "unknown-pickup"); return; } const ownerPlayerIds = new Set(state.ownerPlayerIds ?? []); @@ -1177,7 +1177,7 @@ export function createQuakeLoopbackMultiplayerSession( emitPickupRejected(message, definition, "unavailable"); return; } - if (!quakeMultiplayerPlayerCanReachPickup(playerState, definition)) { + if (!quakeMultiplayerPlayerCanReachPickup(playerState, definition, undefined, message.payload.pickup.origin)) { emitPickupRejected(message, definition, "too-far"); return; } @@ -1293,12 +1293,15 @@ export function createQuakeLoopbackMultiplayerSession( now(), ); if (!resolution.ok) { + if (quakeMultiplayerWorldIntentRejectionIsIgnorableTouchMiss(message.payload.intent, resolution.reason)) { + return; + } emitReject({ code: "unsupported", message: resolution.message, recoverable: true, rejectedMessageId: message.messageId, - details: { reason: resolution.reason }, + details: { reason: resolution.reason, ...(resolution.details ?? {}) }, }); return; } diff --git a/src/runtime/multiplayer/partyRoom.ts b/src/runtime/multiplayer/partyRoom.ts index 76be31b..84c74ad 100644 --- a/src/runtime/multiplayer/partyRoom.ts +++ b/src/runtime/multiplayer/partyRoom.ts @@ -98,6 +98,7 @@ import { sameQuakeMultiplayerMoverOffset, quakeMultiplayerTriggerUsesMultiTrigger, quakeMultiplayerWorldDefinitionsFromScene, + quakeMultiplayerWorldIntentRejectionIsIgnorableTouchMiss, rejectQuakeMultiplayerClientWorldEvent, resolveQuakeMultiplayerWorldIntent, } from "./world"; @@ -1086,7 +1087,6 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { const definition = this.pickupDefinitions.get(message.payload.pickup.entityIndex); const state = this.pickupStates.get(message.payload.pickup.entityIndex); if (!definition || !state) { - this.broadcastPickupRejected(player.playerId, message, undefined, "unknown-pickup"); return; } const ownerPlayerIds = new Set(state.ownerPlayerIds ?? []); @@ -1094,7 +1094,7 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { this.broadcastPickupRejected(player.playerId, message, definition, "unavailable"); return; } - if (!quakeMultiplayerPlayerCanReachPickup(player, definition)) { + if (!quakeMultiplayerPlayerCanReachPickup(player, definition, undefined, message.payload.pickup.origin)) { this.broadcastPickupRejected(player.playerId, message, definition, "too-far"); return; } @@ -1183,12 +1183,15 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { Date.now(), ); if (!resolution.ok) { + if (quakeMultiplayerWorldIntentRejectionIsIgnorableTouchMiss(message.payload.intent, resolution.reason)) { + return; + } this.reject(sender, { code: "unsupported", message: resolution.message, recoverable: true, rejectedMessageId: message.messageId, - details: { reason: resolution.reason }, + details: { reason: resolution.reason, ...(resolution.details ?? {}) }, }); return; } diff --git a/src/runtime/multiplayer/presentation.ts b/src/runtime/multiplayer/presentation.ts index c578678..b884720 100644 --- a/src/runtime/multiplayer/presentation.ts +++ b/src/runtime/multiplayer/presentation.ts @@ -17,6 +17,10 @@ type QuakeMultiplayerPlayerDamagedEvent = Extract< QuakeMultiplayerSharedWorldEvent, { eventType: "player.damaged" } >; +type QuakeMultiplayerPlayerKilledEvent = Extract< + QuakeMultiplayerSharedWorldEvent, + { eventType: "player.killed" } +>; export interface QuakeMultiplayerRemoteVisualHandle { element?: HTMLElement; @@ -32,6 +36,10 @@ export interface QuakeMultiplayerRemotePlayerPresenterOptions { event: QuakeMultiplayerPlayerDamagedEvent, player: QuakeMultiplayerAuthoritativePlayerState, ) => void; + onPlayerKilled?: ( + event: QuakeMultiplayerPlayerKilledEvent, + player: QuakeMultiplayerAuthoritativePlayerState, + ) => void; now?: () => number; requestFrame?: (callback: FrameRequestCallback) => number; cancelFrame?: (handle: number) => void; @@ -148,7 +156,7 @@ export function createQuakeMultiplayerRemotePlayerPresenter( } else if (event.eventType === "player.damaged") { markRemotePlayerPain(event); } else if (event.eventType === "player.killed") { - markRemotePlayerDeath(event.victimPlayerId); + markRemotePlayerDeath(event); } else if (event.eventType === "player.respawned") { if (event.player.clientId === options.localClientId) return; syncRemotePlayer(event.player); @@ -163,11 +171,12 @@ export function createQuakeMultiplayerRemotePlayerPresenter( scheduleFrame(); } - function markRemotePlayerDeath(playerId: string): void { - const entry = players.get(playerId); + function markRemotePlayerDeath(event: QuakeMultiplayerPlayerKilledEvent): void { + const entry = players.get(event.victimPlayerId); if (!entry || entry.player.clientId === options.localClientId) return; entry.lastPainAt = undefined; entry.deathAt = now(); + options.onPlayerKilled?.(event, entry.player); entry.player = { ...entry.player, alive: false, diff --git a/src/runtime/multiplayer/protocol.ts b/src/runtime/multiplayer/protocol.ts index 2e20648..d53385b 100644 --- a/src/runtime/multiplayer/protocol.ts +++ b/src/runtime/multiplayer/protocol.ts @@ -292,6 +292,7 @@ export interface QuakeMultiplayerPickupIntent { pickupSequence: number; requestedAt: number; entityIndex: number; + origin?: QuakeMultiplayerVec3; } export interface QuakeMultiplayerMatchIntent { diff --git a/src/runtime/multiplayer/validation.ts b/src/runtime/multiplayer/validation.ts index 7e05084..1037404 100644 --- a/src/runtime/multiplayer/validation.ts +++ b/src/runtime/multiplayer/validation.ts @@ -412,7 +412,8 @@ function isPickupIntent(value: unknown): value is QuakeMultiplayerPickupIntent { if (!isRecord(value)) return false; return isNonNegativeInteger(value.pickupSequence) && Number.isFinite(value.requestedAt) && - isNonNegativeInteger(value.entityIndex); + isNonNegativeInteger(value.entityIndex) && + (value.origin === undefined || isVec3(value.origin)); } function isMatchIntent(value: unknown): value is QuakeMultiplayerMatchIntent { diff --git a/src/runtime/multiplayer/world.ts b/src/runtime/multiplayer/world.ts index 603be71..dbd74d5 100644 --- a/src/runtime/multiplayer/world.ts +++ b/src/runtime/multiplayer/world.ts @@ -31,6 +31,8 @@ import { } from "./sceneFacts"; const QUAKE_MULTIPLAYER_WORLD_TOUCH_TOLERANCE = 1; +const QUAKE_MULTIPLAYER_WORLD_TOUCH_ORIGIN_HINT_MAX_HORIZONTAL_DRIFT = 3; +const QUAKE_MULTIPLAYER_WORLD_TOUCH_ORIGIN_HINT_MAX_VERTICAL_DRIFT = 8; export const QUAKE_MULTIPLAYER_TELEPORT_EXIT_SPEED = 300 * QUAKE_COLLISION_UNIT_SCALE; export const QUAKE_MULTIPLAYER_TELEPORT_TARGET_ACTIVATION_WINDOW_MS = 200; export const QUAKE_MULTIPLAYER_TELEFRAG_DAMAGE = 50000; @@ -110,6 +112,7 @@ export type QuakeMultiplayerWorldIntentResolution = ok: false; reason: string; message: string; + details?: Record; }; export function quakeMultiplayerWorldDefinitionsFromScene( @@ -287,11 +290,12 @@ export function resolveQuakeMultiplayerWorldIntent( message: "Multiplayer world intent targets an unknown shared world entity.", }; } - if (!quakeMultiplayerPlayerTouchesWorldDefinition(player, definition)) { + if (!quakeMultiplayerWorldIntentTouchesDefinition(player, intent, definition)) { return { ok: false, reason: "too-far", message: "Multiplayer world intent is too far from the authoritative player position.", + details: quakeMultiplayerWorldIntentTouchRejectDetails(player, intent, definition), }; } if (intent.intentType === "teleport" && definition.kind !== "teleport") { @@ -402,6 +406,13 @@ export function resolveQuakeMultiplayerWorldIntent( }; } +export function quakeMultiplayerWorldIntentRejectionIsIgnorableTouchMiss( + intent: QuakeMultiplayerWorldIntent, + reason: string, +): boolean { + return intent.intentType === "touch" && (reason === "too-far" || reason === "not-alive"); +} + export function rejectQuakeMultiplayerClientWorldEvent( message: QuakeMultiplayerClientWorldEnvelope, ): QuakeMultiplayerRoomRejectPayload { @@ -615,8 +626,58 @@ function quakeMultiplayerWorldBoundsFromLogic( }; } -function quakeMultiplayerPlayerTouchesWorldDefinition( +function quakeMultiplayerWorldIntentTouchesDefinition( + player: QuakeMultiplayerAuthoritativePlayerState, + intent: QuakeMultiplayerWorldIntent, + definition: QuakeMultiplayerWorldDefinition, +): boolean { + if (quakeMultiplayerPlayerTouchesWorldDefinition(player, definition)) return true; + const origin = "origin" in intent ? intent.origin : undefined; + if (!origin) return false; + if (!quakeMultiplayerTouchOriginHintWithinDrift(player.origin, origin)) { + return false; + } + return quakeMultiplayerPlayerTouchesWorldDefinition({ origin }, definition); +} + +function quakeMultiplayerWorldIntentTouchRejectDetails( player: QuakeMultiplayerAuthoritativePlayerState, + intent: QuakeMultiplayerWorldIntent, + definition: QuakeMultiplayerWorldDefinition, +): Record { + const origin = "origin" in intent ? intent.origin : undefined; + const horizontalDrift = origin + ? Math.hypot(player.origin[0] - origin[0], player.origin[1] - origin[1]) + : null; + const verticalDrift = origin ? Math.abs(player.origin[2] - origin[2]) : null; + return { + authoritativeOrigin: player.origin, + authoritativeTouches: quakeMultiplayerPlayerTouchesWorldDefinition(player, definition), + definitionKind: definition.kind, + entityIndex: definition.entityIndex, + hintOrigin: origin ?? null, + hintTouches: origin ? quakeMultiplayerPlayerTouchesWorldDefinition({ origin }, definition) : false, + horizontalDrift, + hintWithinDrift: origin ? quakeMultiplayerTouchOriginHintWithinDrift(player.origin, origin) : false, + verticalDrift, + }; +} + +function quakeMultiplayerTouchOriginHintWithinDrift( + authoritativeOrigin: QuakeMultiplayerVec3, + hintOrigin: QuakeMultiplayerVec3, +): boolean { + const horizontalDrift = Math.hypot( + authoritativeOrigin[0] - hintOrigin[0], + authoritativeOrigin[1] - hintOrigin[1], + ); + const verticalDrift = Math.abs(authoritativeOrigin[2] - hintOrigin[2]); + return horizontalDrift <= QUAKE_MULTIPLAYER_WORLD_TOUCH_ORIGIN_HINT_MAX_HORIZONTAL_DRIFT && + verticalDrift <= QUAKE_MULTIPLAYER_WORLD_TOUCH_ORIGIN_HINT_MAX_VERTICAL_DRIFT; +} + +function quakeMultiplayerPlayerTouchesWorldDefinition( + player: Pick, definition: QuakeMultiplayerWorldDefinition, ): boolean { if (!definition.bounds) return true; diff --git a/test/browser/runMultiplayerBrowserSmoke.mjs b/test/browser/runMultiplayerBrowserSmoke.mjs index c34420c..9af5b58 100644 --- a/test/browser/runMultiplayerBrowserSmoke.mjs +++ b/test/browser/runMultiplayerBrowserSmoke.mjs @@ -20,6 +20,8 @@ const DEFAULT_TIMEOUT_MS = 60_000; const DEFAULT_VIEWPORT = "960x540"; const DEFAULT_DURATION_MS = 6_000; const DEFAULT_JSON_OUT = "bench/results/quake/multiplayer-browser-smoke.json"; +const DEFAULT_MAX_REMOTE_HIDDEN_GAP_MS = 500; +const REMOTE_FRAME_TRACE_LIMIT = 6_000; const ROOM_TOKEN_ALPHABET = "bcdfghjkmnpqrstvwxyz23456789"; const args = process.argv.slice(2); @@ -37,41 +39,53 @@ const common = parseCommonBrowserArgs(args, { const clientsCount = Math.max(2, Math.min(4, Math.round(numberOption(args, "clients", 2)))); const durationMs = Math.max(1_000, Math.round(numberOption(args, "duration-ms", DEFAULT_DURATION_MS))); const fireEnabled = hasFlag(args, "fire"); +const maxRemoteHiddenGapMs = Math.max(0, Math.round(numberOption(args, "max-remote-hidden-gap-ms", DEFAULT_MAX_REMOTE_HIDDEN_GAP_MS))); const mapName = optionValue(args, "map", "e1m1").trim().toLowerCase(); const preferredPartyPort = Math.max(1, Math.round(numberOption(args, "party-port", DEFAULT_PARTY_PORT))); +const externalAppUrl = normalizeAppUrl(common.explicitUrl); +const requestedPartyHost = optionValue(args, "party-host", ""); +const externalPartyHost = normalizePartyHost(requestedPartyHost || (externalAppUrl ? process.env.VITE_CSSQUAKE_PARTY_HOST ?? "" : "")); console.log("Multiplayer browser smoke gate"); -console.log("validates: PartyKit room join, compact invite route, isolated browser clients, remote player movement, zero rejects"); -console.log(`requires prepared assets: yes, map ${mapName}`); +console.log("validates: PartyKit room join, compact invite route, isolated browser clients, remote player movement, remote player continuity, zero rejects"); console.log("classification: multiplayer acceptance"); -assertAssetState({ requiredMaps: [mapName], requireRenderBundle: true, requireGameLogic: true }); +if (externalAppUrl) { + if (!externalPartyHost) throw new Error("--party-host is required when --url is used."); + console.log(`requires prepared assets: deployed app manifest, map ${mapName}`); +} else { + if (externalPartyHost) throw new Error("--party-host is only supported with --url."); + console.log(`requires prepared assets: yes, map ${mapName}`); + assertAssetState({ requiredMaps: [mapName], requireRenderBundle: true, requireGameLogic: true }); +} -const manifest = readAssetManifest(); +const manifest = externalAppUrl ? await readRemoteAssetManifest(externalAppUrl, common.timeoutMs) : readAssetManifest(); const invite = createCompactInvite(manifest, mapName); -const vitePort = await findFreePort(common.port); -const partyPort = await findFreePort(preferredPartyPort, new Set([vitePort])); -const appUrl = `http://127.0.0.1:${vitePort}/`; -const partyHost = `127.0.0.1:${partyPort}`; +const vitePort = externalAppUrl ? null : await findFreePort(common.port); +const partyPort = externalAppUrl ? null : await findFreePort(preferredPartyPort, new Set([vitePort])); +const appUrl = externalAppUrl || `http://127.0.0.1:${vitePort}/`; +const partyHost = externalPartyHost || `127.0.0.1:${partyPort}`; const servers = []; let browser = null; try { - servers.push(await startManagedServer({ - name: "vite", - command: "pnpm", - args: ["exec", "vite", "--host", "127.0.0.1", "--port", String(vitePort), "--strictPort"], - ready: /Local:\s+http:\/\/127\.0\.0\.1:|ready in/i, - timeoutMs: common.timeoutMs, - })); - servers.push(await startManagedServer({ - name: "partykit", - command: "pnpm", - args: ["exec", "partykit", "dev", "--port", String(partyPort), "--serve", "build/generated/public"], - ready: /Ready on|Updated and ready/i, - timeoutMs: common.timeoutMs, - })); + if (!externalAppUrl) { + servers.push(await startManagedServer({ + name: "vite", + command: "pnpm", + args: ["exec", "vite", "--host", "127.0.0.1", "--port", String(vitePort), "--strictPort"], + ready: /Local:\s+http:\/\/127\.0\.0\.1:|ready in/i, + timeoutMs: common.timeoutMs, + })); + servers.push(await startManagedServer({ + name: "partykit", + command: "pnpm", + args: ["exec", "partykit", "dev", "--port", String(partyPort), "--serve", "build/generated/public"], + ready: /Ready on|Updated and ready/i, + timeoutMs: common.timeoutMs, + })); + } await assertHttpReady(appUrl, common.timeoutMs); - await assertHttpReady(`http://${partyHost}/parties/main/${encodeURIComponent(invite.internalRoom)}`, common.timeoutMs); + await assertHttpReady(partyRoomUrl(partyHost, invite.internalRoom), common.timeoutMs); const chromium = await loadChromium(); browser = await chromium.launch({ headless: !common.headed }); @@ -103,6 +117,7 @@ try { fireEnabled, invite, mapName, + maxRemoteHiddenGapMs, partyHost, }); await writeJsonArtifact(common.jsonOut, report); @@ -122,6 +137,10 @@ Options: --clients Active browser players, 2-4. Default: 2 --duration-ms Input-driving duration. Default: ${DEFAULT_DURATION_MS} --fire Also trigger debug weapon fire while moving. + --max-remote-hidden-gap-ms + Fail if an expected remote player is hidden/missing for longer than this. Default: ${DEFAULT_MAX_REMOTE_HIDDEN_GAP_MS} + --url Use an already deployed app instead of starting local Vite. + --party-host PartyKit host for --url, without protocol. --port Preferred Vite port. Default: ${DEFAULT_PORT} --party-port Preferred PartyKit port. Default: ${DEFAULT_PARTY_PORT} --headed Run Chromium headed. @@ -142,6 +161,7 @@ async function openClient(browser, options) { }); const requestFailures = []; page.on("requestfailed", (request) => { + if (ignoreRequestFailure(request.url())) return; const failure = request.failure(); requestFailures.push({ url: request.url(), @@ -215,6 +235,7 @@ async function driveClients(clients, { durationMs, fireEnabled }) { await client.page.keyboard.down(key); await client.page.waitForTimeout(120); if (fireEnabled && tick % 4 === 0) await client.page.evaluate(() => window.__cssQuakeDebug?.fire?.()); + await sampleRemotePlayerAnimation(client); await client.page.keyboard.up(key); })); tick += 1; @@ -225,15 +246,61 @@ async function driveClients(clients, { durationMs, fireEnabled }) { })); } +async function sampleRemotePlayerAnimation(client) { + await client.page.evaluate((remoteFrameTraceLimit) => { + const trace = window.__cssQuakeMpTrace; + if (!trace?.remoteFrames) return; + const stats = window.__cssQuakeDebug?.stats?.(); + const localClientId = stats?.multiplayer?.clientId ?? null; + const expectedRemotes = new Map(); + for (const player of trace.lastSnapshotPlayers ?? []) { + if (!player?.clientId || player.clientId === localClientId) continue; + expectedRemotes.set(player.clientId, player); + } + const elementsByClient = new Map(); + for (const element of document.querySelectorAll("[data-player-id][data-client-id]")) { + const clientId = element.dataset.clientId ?? null; + if (!clientId || clientId === localClientId) continue; + elementsByClient.set(clientId, element); + if (!expectedRemotes.has(clientId)) { + expectedRemotes.set(clientId, { + clientId, + playerId: element.dataset.playerId ?? null, + }); + } + } + for (const [clientId, expected] of expectedRemotes) { + const element = elementsByClient.get(clientId) ?? null; + trace.remoteFrames.push({ + sampledAt: performance.now(), + playerId: element?.dataset.playerId ?? expected.playerId ?? null, + clientId, + missing: !element, + hidden: element instanceof HTMLElement ? element.hidden : true, + frameIndex: element?.dataset.remoteFrameIndex ?? null, + frameName: element?.dataset.remoteFrameName ?? null, + transform: element instanceof HTMLElement ? element.style.transform : "", + computedTransform: element instanceof HTMLElement ? getComputedStyle(element).transform : "", + }); + } + if (trace.remoteFrames.length > remoteFrameTraceLimit) { + trace.remoteFrames.splice(0, trace.remoteFrames.length - remoteFrameTraceLimit); + } + }, REMOTE_FRAME_TRACE_LIMIT); +} + async function readClientSnapshot(client) { return await client.page.evaluate(() => { const stats = window.__cssQuakeDebug?.stats?.() ?? null; const trace = window.__cssQuakeMpTrace ?? - { connections: [], sent: [], received: [], events: [], rejects: [], snapshots: 0 }; + { connections: [], sent: [], sentDetails: [], received: [], events: [], lastSnapshotPlayers: [], rejects: [], snapshots: 0, remoteFrames: [] }; const remotePlayers = Array.from(document.querySelectorAll("[data-player-id][data-client-id]")) .map((element) => ({ playerId: element.dataset.playerId ?? null, clientId: element.dataset.clientId ?? null, + color: element.dataset.playerColor ?? null, + frameIndex: element.dataset.remoteFrameIndex ?? null, + frameName: element.dataset.remoteFrameName ?? null, hidden: element instanceof HTMLElement ? element.hidden : false, transform: element instanceof HTMLElement ? element.style.transform : "", computedTransform: element instanceof HTMLElement ? getComputedStyle(element).transform : "", @@ -251,10 +318,13 @@ async function readClientSnapshot(client) { mpTrace: { connections: trace.connections, sent: trace.sent, + sentDetails: trace.sentDetails, received: trace.received, events: trace.events, + lastSnapshotPlayers: trace.lastSnapshotPlayers, rejects: trace.rejects, snapshots: trace.snapshots, + remoteFrames: trace.remoteFrames, }, }; }); @@ -266,7 +336,10 @@ function installWebSocketTrace() { events: [], received: [], rejects: [], + remoteFrames: [], sent: [], + sentDetails: [], + lastSnapshotPlayers: [], snapshots: 0, }; Object.defineProperty(window, "__cssQuakeMpTrace", { @@ -283,11 +356,19 @@ function installWebSocketTrace() { if (bucket === "sent") { trace.sent.push(message.type); if (trace.sent.length > 500) trace.sent.shift(); + const detail = compactSentMessage(message); + if (detail) { + trace.sentDetails.push(detail); + if (trace.sentDetails.length > 100) trace.sentDetails.shift(); + } return; } trace.received.push(message.type); if (trace.received.length > 500) trace.received.shift(); - if (message.type === "room.snapshot") trace.snapshots += 1; + if (message.type === "room.snapshot") { + trace.snapshots += 1; + trace.lastSnapshotPlayers = compactSnapshotPlayers(message.payload?.players); + } if (message.type === "room.event" && message.payload?.event?.eventType) { trace.events.push(message.payload.event.eventType); if (trace.events.length > 500) trace.events.shift(); @@ -299,6 +380,7 @@ function installWebSocketTrace() { recoverable: Boolean(message.payload?.recoverable), rejectedMessageId: message.payload?.rejectedMessageId ?? null, retryAfterMs: message.payload?.retryAfterMs ?? null, + details: message.payload?.details ?? null, }); if (trace.rejects.length > 50) trace.rejects.shift(); } @@ -322,9 +404,71 @@ function installWebSocketTrace() { WrappedWebSocket.prototype = NativeWebSocket.prototype; Object.setPrototypeOf(WrappedWebSocket, NativeWebSocket); window.WebSocket = WrappedWebSocket; + + function compactSentMessage(message) { + if (!message || typeof message !== "object") return null; + if (message.type !== "client.world" && message.type !== "client.fire" && message.type !== "client.pickup") { + return null; + } + const base = { + messageId: message.messageId ?? null, + sentAt: message.sentAt ?? null, + type: message.type, + }; + if (message.type === "client.fire") { + const fire = message.payload?.fire ?? {}; + return { + ...base, + fire: { + fireKind: fire.fireKind ?? null, + weapon: fire.weapon ?? null, + origin: fire.origin ?? null, + direction: fire.direction ?? null, + range: fire.range ?? null, + }, + }; + } + if (message.type === "client.pickup") { + const pickup = message.payload?.pickup ?? {}; + return { + ...base, + pickup: { + pickupSequence: pickup.pickupSequence ?? null, + requestedAt: pickup.requestedAt ?? null, + entityIndex: pickup.entityIndex ?? null, + origin: pickup.origin ?? null, + }, + }; + } + const intent = message.payload?.intent ?? {}; + return { + ...base, + intent: { + intentType: intent.intentType ?? null, + worldSequence: intent.worldSequence ?? null, + requestedAt: intent.requestedAt ?? null, + entityIndex: intent.entityIndex ?? null, + origin: intent.origin ?? null, + }, + }; + } + + function compactSnapshotPlayers(players) { + if (!Array.isArray(players)) return []; + return players + .filter((player) => player && typeof player === "object") + .map((player) => ({ + playerId: player.playerId ?? null, + clientId: player.clientId ?? null, + name: player.name ?? null, + alive: player.alive ?? null, + health: player.health ?? null, + })) + .filter((player) => player.clientId || player.playerId); + } } -function buildReport({ after, appUrl, before, clients, clientsCount, durationMs, fireEnabled, invite, mapName, partyHost }) { +function buildReport({ after, appUrl, before, clients, clientsCount, durationMs, fireEnabled, invite, mapName, maxRemoteHiddenGapMs, partyHost }) { const clientReports = clients.map((client, index) => ({ index, url: client.url, @@ -333,6 +477,10 @@ function buildReport({ after, appUrl, before, clients, clientsCount, durationMs, pageErrors: client.pageErrors, requestFailures: client.requestFailures, })); + const remoteVisibilityByClient = clientReports.map((client) => ({ + index: client.index, + ...remoteVisibilitySummary(client.after.mpTrace.remoteFrames), + })); const aggregate = { clients: clientsCount, loaded: clientReports.filter((client) => client.after.stats?.loading === false).length, @@ -344,6 +492,24 @@ function buildReport({ after, appUrl, before, clients, clientsCount, durationMs, remotePlayersMoved: clientReports.filter((client) => remotePlayersMoved(client.before.remotePlayers, client.after.remotePlayers) ).length, + remotePlayersAnimated: clientReports.filter((client) => + remotePlayerAnimationObserved(client.after.mpTrace.remoteFrames) + ).length, + remoteAnimationFrameNames: countAll(clientReports.flatMap((client) => + (client.after.mpTrace.remoteFrames ?? []) + .filter((sample) => !sample.hidden && sample.frameName) + .map((sample) => sample.frameName) + )), + remoteAnimationSamples: clientReports.reduce((total, client) => + total + (client.after.mpTrace.remoteFrames?.length ?? 0), + 0, + ), + remoteVisibility: { + maxHiddenGapMs: Math.max(0, ...remoteVisibilityByClient.map((client) => client.maxHiddenGapMs)), + missingSamples: remoteVisibilityByClient.reduce((total, client) => total + client.missingSamples, 0), + hiddenSamples: remoteVisibilityByClient.reduce((total, client) => total + client.hiddenSamples, 0), + byClient: remoteVisibilityByClient, + }, presentationReady: clientReports.filter((client) => client.after.presentation.menuOpen === false && client.after.presentation.menuUnlocked === false && @@ -379,6 +545,7 @@ function buildReport({ after, appUrl, before, clients, clientsCount, durationMs, clients: clientsCount, durationMs, fireEnabled, + maxRemoteHiddenGapMs, }, aggregate, clients: clientReports, @@ -398,6 +565,13 @@ function validateReport(report) { if (report.aggregate.presentationReady !== clients) failures.push(`Only ${report.aggregate.presentationReady}/${clients} clients entered active play presentation.`); if (report.aggregate.moved !== clients) failures.push(`Only ${report.aggregate.moved}/${clients} clients moved locally.`); if (report.aggregate.remotePlayersMoved !== clients) failures.push(`Only ${report.aggregate.remotePlayersMoved}/${clients} clients saw remote player movement.`); + if (report.aggregate.remotePlayersAnimated !== clients) failures.push(`Only ${report.aggregate.remotePlayersAnimated}/${clients} clients saw remote player frame animation.`); + if (report.aggregate.remoteVisibility.maxHiddenGapMs > report.options.maxRemoteHiddenGapMs) { + failures.push( + `Remote players were hidden/missing for up to ${report.aggregate.remoteVisibility.maxHiddenGapMs}ms ` + + `(limit ${report.options.maxRemoteHiddenGapMs}ms).`, + ); + } if (report.aggregate.websocket.snapshots === 0) failures.push("No room snapshots were observed."); if (report.aggregate.websocket.rejects.length) failures.push(`Room rejected ${report.aggregate.websocket.rejects.length} message(s).`); if (report.aggregate.errors.page) failures.push(`${report.aggregate.errors.page} page error(s) were reported.`); @@ -420,10 +594,12 @@ function validateReport(report) { function printSummary(report, artifact) { console.log(`target: app=${report.target.appUrl}, party=${report.target.partyHost}, room=${report.target.room}, invite=${report.target.invite}`); console.log(`clients: loaded ${report.aggregate.loaded}/${report.options.clients}, scoreboard ${report.aggregate.scoreboardReady}/${report.options.clients}, remote DOM ${report.aggregate.remotePlayersReady}/${report.options.clients}, visible ${report.aggregate.remotePlayersVisible}/${report.options.clients}`); - console.log(`play: moved ${report.aggregate.moved}/${report.options.clients}, remote movement ${report.aggregate.remotePlayersMoved}/${report.options.clients}, snapshots ${report.aggregate.websocket.snapshots}, rejects ${report.aggregate.websocket.rejects.length}`); + console.log(`play: moved ${report.aggregate.moved}/${report.options.clients}, remote movement ${report.aggregate.remotePlayersMoved}/${report.options.clients}, remote animation ${report.aggregate.remotePlayersAnimated}/${report.options.clients}, snapshots ${report.aggregate.websocket.snapshots}, rejects ${report.aggregate.websocket.rejects.length}`); console.log(`messages sent: ${compactCounts(report.aggregate.websocket.sentByType)}`); console.log(`messages received: ${compactCounts(report.aggregate.websocket.receivedByType)}`); console.log(`events: ${compactCounts(report.aggregate.websocket.events)}`); + console.log(`remote frames: samples ${report.aggregate.remoteAnimationSamples}, names ${compactCounts(report.aggregate.remoteAnimationFrameNames)}`); + console.log(`remote visibility: max hidden/missing gap ${report.aggregate.remoteVisibility.maxHiddenGapMs}ms, missing samples ${report.aggregate.remoteVisibility.missingSamples}, hidden samples ${report.aggregate.remoteVisibility.hiddenSamples}`); console.log(`failures: ${report.failures.length ? report.failures.join(" | ") : "none"}`); if (artifact) console.log(`artifact: ${artifact}`); } @@ -440,6 +616,125 @@ function remotePlayersMoved(before, after) { return false; } +function remotePlayerAnimationObserved(samples) { + const frameKeysByRemote = new Map(); + for (const sample of samples ?? []) { + if (!sample?.clientId || sample.hidden) continue; + const frameKey = `${sample.frameIndex ?? ""}:${sample.frameName ?? ""}`; + if (frameKey === ":") continue; + let frameKeys = frameKeysByRemote.get(sample.clientId); + if (!frameKeys) { + frameKeys = new Set(); + frameKeysByRemote.set(sample.clientId, frameKeys); + } + frameKeys.add(frameKey); + if (frameKeys.size >= 2) return true; + } + return false; +} + +function remoteVisibilitySummary(samples) { + const byRemote = new Map(); + for (const sample of samples ?? []) { + if (!sample?.clientId) continue; + let list = byRemote.get(sample.clientId); + if (!list) { + list = []; + byRemote.set(sample.clientId, list); + } + list.push(sample); + } + + const remotes = []; + let maxHiddenGapMs = 0; + let hiddenSamples = 0; + let missingSamples = 0; + for (const [clientId, list] of byRemote) { + list.sort((a, b) => Number(a.sampledAt ?? 0) - Number(b.sampledAt ?? 0)); + let hiddenSince = null; + let remoteMaxHiddenGapMs = 0; + let remoteHiddenSamples = 0; + let remoteMissingSamples = 0; + for (const sample of list) { + const sampledAt = Number(sample.sampledAt ?? 0); + const unavailable = Boolean(sample.missing || sample.hidden); + if (sample.missing) { + remoteMissingSamples += 1; + missingSamples += 1; + } + if (sample.hidden) { + remoteHiddenSamples += 1; + hiddenSamples += 1; + } + if (unavailable) { + if (hiddenSince === null) hiddenSince = sampledAt; + remoteMaxHiddenGapMs = Math.max(remoteMaxHiddenGapMs, Math.round(sampledAt - hiddenSince)); + continue; + } + if (hiddenSince !== null) { + remoteMaxHiddenGapMs = Math.max(remoteMaxHiddenGapMs, Math.round(sampledAt - hiddenSince)); + hiddenSince = null; + } + } + maxHiddenGapMs = Math.max(maxHiddenGapMs, remoteMaxHiddenGapMs); + remotes.push({ + clientId, + hiddenSamples: remoteHiddenSamples, + maxHiddenGapMs: remoteMaxHiddenGapMs, + missingSamples: remoteMissingSamples, + samples: list.length, + }); + } + remotes.sort((a, b) => String(a.clientId).localeCompare(String(b.clientId))); + return { hiddenSamples, maxHiddenGapMs, missingSamples, remotes }; +} + +async function readRemoteAssetManifest(appUrl, timeoutMs) { + const manifestUrl = new URL("/q/manifest.json", appUrl).toString(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(manifestUrl, { + cache: "no-store", + signal: controller.signal, + }); + if (response.status === 404) { + console.warn(`${manifestUrl} returned 404; using the local generated manifest for invite encoding only.`); + return readAssetManifest(); + } + if (!response.ok) throw new Error(`${manifestUrl} returned HTTP ${response.status}`); + return await response.json(); + } finally { + clearTimeout(timeout); + } +} + +function normalizeAppUrl(value) { + const trimmed = String(value ?? "").trim(); + if (!trimmed) return ""; + const url = new URL(trimmed); + return url.toString(); +} + +function normalizePartyHost(value) { + const trimmed = String(value ?? "").trim(); + if (!trimmed) return ""; + try { + return new URL(trimmed).host; + } catch { + return trimmed.replace(/^wss?:\/\//i, "").replace(/^https?:\/\//i, "").replace(/\/.*$/, ""); + } +} + +function partyRoomUrl(host, room) { + const protocol = isLocalPartyHost(host) ? "http" : "https"; + return `${protocol}://${host}/parties/main/${encodeURIComponent(room)}`; +} + +function isLocalPartyHost(host) { + return /^(127\.0\.0\.1|localhost|\[::1\])(?::\d+)?$/i.test(host); +} + function distance3(a, b) { if (!Array.isArray(a) || !Array.isArray(b)) return 0; return Math.hypot( @@ -476,6 +771,15 @@ function socketUrlMatchesTarget(value, target) { } } +function ignoreRequestFailure(url) { + try { + const parsed = new URL(url); + return parsed.hostname === "www.google-analytics.com" && parsed.pathname === "/g/collect"; + } catch { + return false; + } +} + function createRoomToken(length = 8) { let token = ""; for (let index = 0; index < length; index += 1) { diff --git a/test/browser/runMultiplayerDeepChecks.mjs b/test/browser/runMultiplayerDeepChecks.mjs new file mode 100644 index 0000000..174fda8 --- /dev/null +++ b/test/browser/runMultiplayerDeepChecks.mjs @@ -0,0 +1,1031 @@ +#!/usr/bin/env node +import net from "node:net"; +import { spawn } from "node:child_process"; +import { setTimeout as sleep } from "node:timers/promises"; + +import { assertAssetState, readAssetManifest } from "../assets/checkAssetState.mjs"; +import { + collectPageErrors, + hasFlag, + loadChromium, + numberOption, + optionValue, + parseCommonBrowserArgs, + writeJsonArtifact, +} from "./browserHarnessSupport.mjs"; + +const DEFAULT_PORT = 5191; +const DEFAULT_PARTY_PORT = 2001; +const DEFAULT_TIMEOUT_MS = 60_000; +const DEFAULT_VIEWPORT = "960x540"; +const DEFAULT_JSON_OUT = "bench/results/quake/multiplayer-deep-checks.json"; +const ROOM_TOKEN_ALPHABET = "bcdfghjkmnpqrstvwxyz23456789"; +const CONTROLLED_DAMAGE_CENTER_DROP = 0.85; +const CONTROLLED_WEAPONS = [ + { weapon: "axe", damage: 20, distance: 1.2 }, + { weapon: "shotgun", damage: 24, distance: 3.0 }, +]; + +const args = process.argv.slice(2); +if (hasFlag(args, "help") || hasFlag(args, "h")) { + printHelp(); + process.exit(0); +} + +const common = parseCommonBrowserArgs(args, { + port: DEFAULT_PORT, + timeoutMs: DEFAULT_TIMEOUT_MS, + viewport: DEFAULT_VIEWPORT, + jsonOut: DEFAULT_JSON_OUT, +}); +const mapName = optionValue(args, "map", "e1m7").trim().toLowerCase(); +const preferredPartyPort = Math.max(1, Math.round(numberOption(args, "party-port", DEFAULT_PARTY_PORT))); +const skipControlledDamage = hasFlag(args, "skip-controlled-damage"); +const skipControlledKill = hasFlag(args, "skip-controlled-kill"); +const skipReconnect = hasFlag(args, "skip-reconnect"); +const controlledWeaponNames = new Set(optionList(args, "weapons", CONTROLLED_WEAPONS.map((spec) => spec.weapon))); +const controlledDirections = optionList(args, "directions", ["a-to-b", "b-to-a"]); + +console.log("Multiplayer deep checks"); +console.log("validates: controlled A/B damage, remote animation evidence, reconnect no-duplicate state"); +console.log(`requires prepared assets: yes, map ${mapName}`); +console.log("classification: multiplayer deep acceptance"); +assertAssetState({ requiredMaps: [mapName], requireRenderBundle: true, requireGameLogic: true }); + +const manifest = readAssetManifest(); +const vitePort = await findFreePort(common.port); +const partyPort = await findFreePort(preferredPartyPort, new Set([vitePort])); +const appUrl = `http://127.0.0.1:${vitePort}/`; +const partyHost = `127.0.0.1:${partyPort}`; +const servers = []; +let browser = null; + +try { + servers.push(await startManagedServer({ + name: "vite", + command: "pnpm", + args: ["exec", "vite", "--host", "127.0.0.1", "--port", String(vitePort), "--strictPort"], + ready: /Local:\s+http:\/\/127\.0\.0\.1:|ready in/i, + timeoutMs: common.timeoutMs, + })); + servers.push(await startManagedServer({ + name: "partykit", + command: "pnpm", + args: ["exec", "partykit", "dev", "--port", String(partyPort), "--serve", "build/generated/public"], + ready: /Ready on|Updated and ready/i, + timeoutMs: common.timeoutMs, + })); + await assertHttpReady(appUrl, common.timeoutMs); + + const chromium = await loadChromium(); + browser = await chromium.launch({ headless: !common.headed }); + + const checks = []; + if (!skipControlledDamage) { + for (const spec of CONTROLLED_WEAPONS.filter((candidate) => controlledWeaponNames.has(candidate.weapon))) { + for (const direction of controlledDirections) { + checks.push(await runControlledDamageCase({ + appUrl, + browser, + common, + direction, + mapName, + manifest, + partyHost, + spec, + })); + } + } + } + if (!skipControlledKill) { + checks.push(await runControlledKillCase({ + appUrl, + browser, + common, + mapName, + manifest, + partyHost, + })); + } + if (!skipReconnect) { + checks.push(await runReconnectCase({ + appUrl, + browser, + common, + mapName, + manifest, + partyHost, + })); + } + + const report = buildReport({ + appUrl, + checks, + mapName, + partyHost, + }); + await writeJsonArtifact(common.jsonOut, report); + printSummary(report, common.jsonOut); + if (report.failures.length) throw new Error(report.failures.join("\n")); +} finally { + await browser?.close().catch(() => undefined); + await Promise.all([...servers].reverse().map((server) => stopManagedServer(server))); +} + +function printHelp() { + console.log(`Usage: + node test/browser/runMultiplayerDeepChecks.mjs [options] + +Options: + --map Map route. Default: e1m7 + --port Preferred Vite port. Default: ${DEFAULT_PORT} + --party-port Preferred PartyKit port. Default: ${DEFAULT_PARTY_PORT} + --headed Run Chromium headed. + --viewport Browser viewport. Default: ${DEFAULT_VIEWPORT} + --timeout-ms Server/page readiness timeout. Default: ${DEFAULT_TIMEOUT_MS} + --json-out Report path. Default: ${DEFAULT_JSON_OUT} + --weapons Controlled damage weapons. Default: axe,shotgun + --directions Controlled damage directions. Default: a-to-b,b-to-a + --skip-controlled-damage Skip controlled A/B damage checks. + --skip-controlled-kill Skip controlled browser death/kill animation check. + --skip-reconnect Skip reconnect check.`); +} + +function optionList(args, name, fallback) { + const raw = optionValue(args, name, fallback.join(",")); + return raw.split(",").map((item) => item.trim()).filter(Boolean); +} + +async function runControlledDamageCase(options) { + const room = `cssquake-deep-${options.mapName}-${options.spec.weapon}-${options.direction}-${createRoomToken(6)}`; + console.log(`controlled damage: ${options.direction} ${options.spec.weapon} room=${room}`); + const clients = await Promise.all([ + openClient(options.browser, { + ...options, + clientIndex: 0, + clientsCount: 2, + debugMultiplayer: true, + debugMultiplayerInputPaused: true, + room, + }), + openClient(options.browser, { + ...options, + clientIndex: 1, + clientsCount: 2, + debugMultiplayer: true, + debugMultiplayerInputPaused: true, + room, + }), + ]); + try { + await Promise.all(clients.map((client) => + waitForClientReady(client, 2, options.common.timeoutMs, { allowInputPaused: true }) + )); + await waitForSnapshotPlayers(clients, 2, options.common.timeoutMs); + const pose = await setControlledDuelPose(clients, options.spec, options.direction); + await Promise.all(clients.map((client) => client.page.evaluate(() => window.__cssQuakeDebug?.syncMultiplayerPose?.()))); + await waitForSnapshotPlayers(clients, 2, options.common.timeoutMs); + await Promise.all(clients.map((client) => client.page.evaluate((weapon) => window.__cssQuakeDebug?.setWeapon?.(weapon), options.spec.weapon))); + + const attackerIndex = options.direction === "a-to-b" ? 0 : 1; + const victimIndex = attackerIndex === 0 ? 1 : 0; + const attacker = clients[attackerIndex]; + const victim = clients[victimIndex]; + const attackerPlayer = await localSnapshotPlayer(attacker); + const victimPlayer = await localSnapshotPlayer(victim); + await attacker.page.evaluate(() => window.__cssQuakeDebug?.setMultiplayerInputPaused?.(false)); + await waitForLocalInput(attacker, options.common.timeoutMs); + await waitForSnapshotPlayerWeapon(clients, attackerPlayer.clientId, options.spec.weapon, options.common.timeoutMs); + + const before = await readClientSnapshot(victim); + const beforeAttacker = await readClientSnapshot(attacker); + const fireResult = await attacker.page.evaluate(() => window.__cssQuakeDebug?.fire?.() ?? null); + let event = null; + const failures = []; + if (fireResult !== true) { + failures.push(`debug fire returned ${String(fireResult)}`); + } + try { + event = await waitForPlayerEvent(clients, (candidate) => + candidate.eventType === "player.damaged" && + candidate.attackerPlayerId === attackerPlayer.playerId && + candidate.victimPlayerId === victimPlayer.playerId && + candidate.damageSource === options.spec.weapon, + options.common.timeoutMs, + ); + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)); + } + const impactParticles = event + ? await waitForImpactParticles(attacker, "blood", 1_000) + : null; + if (event) await waitForRemoteFramePrefix(attacker, victimPlayer.clientId, "pain", 1_000); + const after = await readClientSnapshot(victim); + const afterAttacker = await readClientSnapshot(attacker); + if (event) { + if (event.damage !== options.spec.damage) { + failures.push(`expected damage ${options.spec.damage}, got ${String(event.damage)}`); + } + if (event.health !== 100 - options.spec.damage) { + failures.push(`expected victim health ${100 - options.spec.damage}, got ${String(event.health)}`); + } + if (after.stats?.playerHealth !== event.health) { + failures.push(`victim local health ${String(after.stats?.playerHealth)} did not match event health ${String(event.health)}`); + } + } + const animation = remoteAnimationSummary(attacker.afterRemoteFrames ?? []); + if (event && !animation.names.some((name) => name.startsWith("pain"))) { + failures.push("attacker did not sample victim pain animation"); + } + if (event && (impactParticles?.blood ?? 0) <= 0) { + failures.push("attacker did not sample remote victim blood particles"); + } + return { + kind: "controlled-damage", + direction: options.direction, + mapName: options.mapName, + room, + weapon: options.spec.weapon, + expectedDamage: options.spec.damage, + pass: failures.length === 0, + failures, + before: compactSnapshot(before), + beforeAttacker: compactSnapshot(beforeAttacker), + after: compactSnapshot(after), + afterAttacker: compactSnapshot(afterAttacker), + event, + fireResult, + impactParticles, + pose, + attacker: compactClient(attacker), + victim: compactClient(victim), + remoteAnimation: animation, + }; + } finally { + await Promise.all(clients.map((client) => client.context.close().catch(() => undefined))); + } +} + +async function runControlledKillCase(options) { + const spec = { weapon: "shotgun", damage: 24, distance: 3.0 }; + const room = `cssquake-deep-${options.mapName}-shotgun-kill-${createRoomToken(6)}`; + console.log(`controlled kill: shotgun room=${room}`); + const clients = await Promise.all([ + openClient(options.browser, { + ...options, + clientIndex: 0, + clientsCount: 2, + debugMultiplayer: true, + debugMultiplayerInputPaused: true, + room, + }), + openClient(options.browser, { + ...options, + clientIndex: 1, + clientsCount: 2, + debugMultiplayer: true, + debugMultiplayerInputPaused: true, + room, + }), + ]); + try { + await Promise.all(clients.map((client) => + waitForClientReady(client, 2, options.common.timeoutMs, { allowInputPaused: true }) + )); + await waitForSnapshotPlayers(clients, 2, options.common.timeoutMs); + let pose = await setControlledDuelPose(clients, spec, "a-to-b"); + await Promise.all(clients.map((client) => client.page.evaluate(() => window.__cssQuakeDebug?.syncMultiplayerPose?.()))); + await waitForSnapshotPlayers(clients, 2, options.common.timeoutMs); + await Promise.all(clients.map((client) => client.page.evaluate(() => window.__cssQuakeDebug?.setWeapon?.("shotgun")))); + const attacker = clients[0]; + const victim = clients[1]; + await attacker.page.evaluate(() => window.__cssQuakeDebug?.setMultiplayerInputPaused?.(false)); + await waitForLocalInput(attacker, options.common.timeoutMs); + + const attackerPlayer = await localSnapshotPlayer(attacker); + const victimPlayer = await localSnapshotPlayer(victim); + const before = await readClientSnapshot(victim); + const beforeAttacker = await readClientSnapshot(attacker); + const failures = []; + const fireResults = []; + const poseUpdates = [pose]; + let killEvent = null; + for (let index = 0; index < 6; index += 1) { + pose = await setControlledDuelPose(clients, spec, "a-to-b"); + poseUpdates.push(pose); + await Promise.all(clients.map((client) => client.page.evaluate(() => window.__cssQuakeDebug?.syncMultiplayerPose?.()))); + await sleep(150); + const fireResult = await attacker.page.evaluate(() => window.__cssQuakeDebug?.fire?.() ?? null); + fireResults.push(fireResult); + if (fireResult !== true) failures.push(`debug fire ${index + 1} returned ${String(fireResult)}`); + try { + killEvent = await waitForPlayerEvent(clients, (candidate) => + candidate.eventType === "player.killed" && + candidate.attackerPlayerId === attackerPlayer.playerId && + candidate.victimPlayerId === victimPlayer.playerId && + candidate.damageSource === "shotgun", + 650, + ); + } catch { + // Keep firing until cumulative shotgun damage kills the victim. + } + if (killEvent) break; + await sleep(600); + } + if (!killEvent) failures.push("Timed out waiting for authoritative shotgun kill."); + const impactParticles = killEvent + ? await waitForImpactParticles(attacker, "blood", 1_000) + : null; + if (killEvent) await waitForRemoteFramePrefix(attacker, victimPlayer.clientId, "deatha", 1_500); + const after = await readClientSnapshot(victim); + const afterAttacker = await readClientSnapshot(attacker); + const victimSnapshotPlayer = after.trace.lastSnapshot?.players?.find((player) => player.playerId === victimPlayer.playerId); + if (killEvent) { + if (victimSnapshotPlayer?.alive !== false) failures.push("victim snapshot did not mark player dead"); + if (victimSnapshotPlayer?.health !== 0) failures.push(`expected victim health 0 after kill, got ${String(victimSnapshotPlayer?.health)}`); + } + const animation = remoteAnimationSummary(attacker.afterRemoteFrames ?? []); + if (killEvent && !animation.names.some((name) => name.startsWith("deatha"))) { + failures.push("attacker did not sample victim death animation"); + } + if (killEvent && (impactParticles?.blood ?? 0) <= 0) { + failures.push("attacker did not sample victim kill blood particles"); + } + return { + kind: "controlled-kill", + mapName: options.mapName, + room, + weapon: "shotgun", + pass: failures.length === 0, + failures, + before: compactSnapshot(before), + beforeAttacker: compactSnapshot(beforeAttacker), + after: compactSnapshot(after), + afterAttacker: compactSnapshot(afterAttacker), + event: killEvent, + fireResults, + impactParticles, + pose, + poseUpdates, + attacker: compactClient(attacker), + victim: compactClient(victim), + remoteAnimation: animation, + }; + } finally { + await Promise.all(clients.map((client) => client.context.close().catch(() => undefined))); + } +} + +async function runReconnectCase(options) { + const room = `cssquake-deep-reconnect-${options.mapName}-${createRoomToken(8)}`; + const clients = await Promise.all(Array.from({ length: 3 }, (_, index) => + openClient(options.browser, { + ...options, + clientIndex: index, + clientsCount: 3, + debugMultiplayer: true, + room, + }) + )); + const failures = []; + let before = []; + let after = []; + try { + try { + await Promise.all(clients.map((client) => waitForClientReady(client, 3, options.common.timeoutMs))); + await waitForSnapshotPlayers(clients, 3, options.common.timeoutMs); + await waitForRemoteDomCounts(clients, 2, options.common.timeoutMs); + } catch (error) { + failures.push(`initial readiness failed: ${errorMessage(error)}`); + before = await safeReadClientSnapshots(clients); + return { + kind: "reconnect", + mapName: options.mapName, + room, + pass: false, + failures, + before: before.map(compactSnapshot), + after: [], + clients: clients.map(compactClient), + }; + } + before = await safeReadClientSnapshots(clients); + try { + await clients[2].page.reload({ waitUntil: "domcontentloaded", timeout: options.common.timeoutMs }); + await waitForClientReady(clients[2], 3, options.common.timeoutMs); + await waitForSnapshotPlayers(clients, 3, options.common.timeoutMs); + await waitForRemoteDomCounts(clients, 2, options.common.timeoutMs); + } catch (error) { + failures.push(`reload readiness failed: ${errorMessage(error)}`); + } + after = await safeReadClientSnapshots(clients); + for (const [index, snapshot] of after.entries()) { + const players = snapshot.trace.lastSnapshot?.players ?? []; + const clientIds = players.map((player) => player.clientId); + if (new Set(clientIds).size !== clientIds.length) { + failures.push(`client ${index} saw duplicate snapshot client ids: ${clientIds.join(",")}`); + } + if (snapshot.remotePlayers.length < 2) failures.push(`client ${index} saw only ${snapshot.remotePlayers.length} remote DOM players`); + if (snapshot.remotePlayers.filter((player) => !player.hidden).length < 2) { + failures.push(`client ${index} saw hidden/missing remote players after reconnect`); + } + } + return { + kind: "reconnect", + mapName: options.mapName, + room, + pass: failures.length === 0, + failures, + before: before.map(compactSnapshot), + after: after.map(compactSnapshot), + clients: clients.map(compactClient), + }; + } finally { + await Promise.all(clients.map((client) => client.context.close().catch(() => undefined))); + } +} + +async function openClient(browser, options) { + const context = await browser.newContext({ + viewport: options.common.viewport, + deviceScaleFactor: 1, + }); + await context.addInitScript(installMultiplayerTrace); + const page = await context.newPage(); + const pageErrors = collectPageErrors(page, { + ignoreConsoleError: (text) => text.startsWith("[vite]"), + }); + const requestFailures = []; + page.on("requestfailed", (request) => { + const failure = request.failure(); + requestFailures.push({ + url: request.url(), + method: request.method(), + errorText: failure?.errorText ?? "request failed", + }); + }); + const url = clientUrl(options); + await page.goto(url, { + waitUntil: "domcontentloaded", + timeout: options.common.timeoutMs, + }); + return { + context, + index: options.clientIndex, + page, + pageErrors, + requestFailures, + url, + }; +} + +function clientUrl(options) { + const url = new URL(options.appUrl); + url.searchParams.set("debug", "1"); + url.searchParams.set("map", options.mapName); + url.searchParams.set("room", options.room); + url.searchParams.set("partyHost", options.partyHost); + url.searchParams.set("clientId", `deep-${options.clientIndex + 1}`); + url.searchParams.set("player", `Deep ${options.clientIndex + 1}`); + url.searchParams.set("color", colorForClient(options.clientIndex)); + url.searchParams.set("maxPlayers", String(options.clientsCount)); + url.searchParams.set("disableEnemies", "1"); + if (options.debugMultiplayer) url.searchParams.set("debugMultiplayer", "party"); + if (options.debugMultiplayerInputPaused) url.searchParams.set("debugMultiplayerInputPaused", "1"); + return url.toString(); +} + +async function waitForClientReady(client, clientsCount, timeoutMs, options = {}) { + await client.page.waitForFunction(({ minPlayers, allowInputPaused }) => { + const stats = window.__cssQuakeDebug?.stats?.(); + const rows = document.querySelectorAll("#quake-multiplayer-scoreboard tbody tr"); + return Boolean( + stats && + !stats.loading && + stats.multiplayer?.sessionState === "connected" && + stats.multiplayer?.helloAccepted === true && + (allowInputPaused || stats.multiplayer?.inputPaused === false) && + rows.length >= minPlayers + ); + }, { minPlayers: clientsCount, allowInputPaused: Boolean(options.allowInputPaused) }, { timeout: timeoutMs }); +} + +async function waitForLocalInput(client, timeoutMs) { + await client.page.waitForFunction(() => { + const stats = window.__cssQuakeDebug?.stats?.(); + return Boolean(stats?.multiplayer?.inputPaused === false && Number(stats.multiplayer.inputSequence) > 0); + }, undefined, { timeout: timeoutMs }); +} + +async function waitForSnapshotPlayerWeapon(clients, clientId, weapon, timeoutMs) { + const expectedWeapon = String(weapon ?? "").trim().toLowerCase(); + await Promise.all(clients.map((client) => + client.page.waitForFunction(({ clientId, expectedWeapon }) => { + const players = window.__cssQuakeMpDeepTrace?.lastSnapshot?.players ?? []; + const player = players.find((candidate) => candidate.clientId === clientId); + const snapshotWeapon = String(player?.activeWeapon ?? player?.inventory?.activeWeapon ?? player?.weapon ?? "") + .trim() + .toLowerCase(); + return snapshotWeapon === expectedWeapon; + }, { clientId, expectedWeapon }, { timeout: timeoutMs }) + )); +} + +async function waitForSnapshotPlayers(clients, count, timeoutMs) { + await Promise.all(clients.map((client) => + client.page.waitForFunction((expected) => { + const trace = window.__cssQuakeMpDeepTrace; + return (trace?.lastSnapshot?.players?.length ?? 0) >= expected; + }, count, { timeout: timeoutMs }) + )); +} + +async function waitForRemoteDomCounts(clients, expected, timeoutMs) { + await Promise.all(clients.map((client) => + client.page.waitForFunction((minimum) => { + const players = Array.from(document.querySelectorAll("[data-player-id][data-client-id]")); + return players.length >= minimum && + players.filter((element) => element instanceof HTMLElement && !element.hidden).length >= minimum; + }, expected, { timeout: timeoutMs }) + )); +} + +async function setControlledDuelPose(clients, spec, direction) { + const attackerIndex = direction === "a-to-b" ? 0 : 1; + const victimIndex = attackerIndex === 0 ? 1 : 0; + const attacker = clients[attackerIndex]; + const victim = clients[victimIndex]; + const aimRotX = (Math.atan2(spec.distance, CONTROLLED_DAMAGE_CENTER_DROP) * 180) / Math.PI; + const pose = await attacker.page.evaluate((rotX) => { + const debug = window.__cssQuakeDebug; + const stats = debug.stats(); + const origin = stats.origin; + debug.setPose(origin, rotX, 270, { gameplay: true, stableViewmodel: true }); + const next = debug.stats(); + const forward = next.cameraForward; + const horizontalLength = Math.hypot(forward[0], forward[1]) || 1; + return { + origin: next.origin, + forward, + horizontalForward: [forward[0] / horizontalLength, forward[1] / horizontalLength, 0], + rotX: next.cameraRotX, + rotY: next.cameraRotY, + }; + }, aimRotX); + const victimOrigin = [ + pose.origin[0] + pose.horizontalForward[0] * spec.distance, + pose.origin[1] + pose.horizontalForward[1] * spec.distance, + pose.origin[2], + ]; + await victim.page.evaluate(({ origin, rotX, rotY }) => { + window.__cssQuakeDebug?.setPose?.(origin, rotX, (rotY + 180) % 360, { + gameplay: true, + stableViewmodel: true, + }); + }, { origin: victimOrigin, rotX: pose.rotX, rotY: pose.rotY }); + return { + attackerIndex, + victimIndex, + attackerOrigin: pose.origin, + attackerForward: pose.forward, + attackerHorizontalForward: pose.horizontalForward, + attackerRotX: pose.rotX, + attackerRotY: pose.rotY, + damageCenterDrop: CONTROLLED_DAMAGE_CENTER_DROP, + distance: spec.distance, + targetCenter: [victimOrigin[0], victimOrigin[1], victimOrigin[2] - CONTROLLED_DAMAGE_CENTER_DROP], + victimOrigin, + }; +} + +async function waitForPlayerEvent(clients, predicate, timeoutMs) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + for (const client of clients) { + const events = await client.page.evaluate(() => window.__cssQuakeMpDeepTrace?.playerEvents ?? []); + const match = events.findLast(predicate); + if (match) return match; + } + await sleep(50); + } + throw new Error("Timed out waiting for authoritative player event."); +} + +async function waitForRemoteFramePrefix(client, remoteClientId, prefix, timeoutMs) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + const samples = await sampleRemoteFrames(client); + client.afterRemoteFrames = samples; + if (samples.some((sample) => + sample.clientId === remoteClientId && + !sample.hidden && + String(sample.frameName ?? "").startsWith(prefix) + )) { + return true; + } + await sleep(50); + } + return false; +} + +async function sampleRemoteFrames(client) { + return await client.page.evaluate(() => { + const trace = window.__cssQuakeMpDeepTrace; + if (!trace) return []; + for (const element of document.querySelectorAll("[data-player-id][data-client-id]")) { + trace.remoteFrames.push({ + sampledAt: performance.now(), + playerId: element.dataset.playerId ?? null, + clientId: element.dataset.clientId ?? null, + hidden: element instanceof HTMLElement ? element.hidden : false, + frameIndex: element.dataset.remoteFrameIndex ?? null, + frameName: element.dataset.remoteFrameName ?? null, + }); + } + if (trace.remoteFrames.length > 500) trace.remoteFrames.splice(0, trace.remoteFrames.length - 500); + return trace.remoteFrames; + }); +} + +async function waitForImpactParticles(client, expectedKind, timeoutMs) { + const started = Date.now(); + let lastSample = null; + while (Date.now() - started < timeoutMs) { + lastSample = await sampleImpactParticles(client); + if ((lastSample[expectedKind] ?? 0) > 0) return lastSample; + await sleep(25); + } + return lastSample ?? { blood: 0, explosion: 0, total: 0, wall: 0 }; +} + +async function sampleImpactParticles(client) { + return await client.page.evaluate(() => { + const active = (element) => { + if (!(element instanceof HTMLElement)) return false; + const opacity = Number(element.style.opacity || window.getComputedStyle(element).opacity || "0"); + return opacity > 0.01; + }; + const particles = Array.from(document.querySelectorAll(".quake-impact-particle")).filter(active); + const countWithClassPrefix = (prefix) => + particles.filter((element) => Array.from(element.classList).some((className) => className.startsWith(prefix))).length; + return { + blood: countWithClassPrefix("quake-impact-particle-red-"), + explosion: countWithClassPrefix("quake-impact-particle-explosion-"), + total: particles.length, + wall: countWithClassPrefix("quake-impact-particle-dust-"), + }; + }); +} + +async function localSnapshotPlayer(client) { + const value = await client.page.evaluate(() => { + const stats = window.__cssQuakeDebug?.stats?.(); + const clientId = stats?.multiplayer?.clientId; + const players = window.__cssQuakeMpDeepTrace?.lastSnapshot?.players ?? []; + return players.find((player) => player.clientId === clientId) ?? null; + }); + if (!value) throw new Error(`Could not find local snapshot player for client ${client.index}.`); + return value; +} + +async function readClientSnapshot(client) { + return await client.page.evaluate(() => { + const stats = window.__cssQuakeDebug?.stats?.() ?? null; + const trace = window.__cssQuakeMpDeepTrace ?? {}; + const remotePlayers = Array.from(document.querySelectorAll("[data-player-id][data-client-id]")) + .map((element) => ({ + playerId: element.dataset.playerId ?? null, + clientId: element.dataset.clientId ?? null, + frameIndex: element.dataset.remoteFrameIndex ?? null, + frameName: element.dataset.remoteFrameName ?? null, + hidden: element instanceof HTMLElement ? element.hidden : false, + })); + return { + stats, + remotePlayers, + trace: { + events: trace.events ?? [], + lastSnapshot: trace.lastSnapshot ?? null, + playerEvents: trace.playerEvents ?? [], + received: trace.received ?? [], + rejects: trace.rejects ?? [], + remoteFrames: trace.remoteFrames ?? [], + roomEvents: trace.roomEvents ?? [], + sent: trace.sent ?? [], + }, + }; + }); +} + +async function safeReadClientSnapshots(clients) { + const snapshots = []; + for (const client of clients) { + try { + snapshots.push(await readClientSnapshot(client)); + } catch (error) { + snapshots.push({ + stats: null, + remotePlayers: [], + trace: { + events: [], + lastSnapshot: null, + playerEvents: [], + received: [], + rejects: [], + remoteFrames: [], + roomEvents: [], + sent: [], + }, + error: errorMessage(error), + }); + } + } + return snapshots; +} + +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +function installMultiplayerTrace() { + const trace = { + connections: [], + events: [], + lastSnapshot: null, + playerEvents: [], + received: [], + rejects: [], + remoteFrames: [], + roomEvents: [], + sent: [], + snapshots: 0, + }; + Object.defineProperty(window, "__cssQuakeMpDeepTrace", { + value: trace, + configurable: true, + }); + + const NativeWebSocket = window.WebSocket; + function record(bucket, data) { + if (typeof data !== "string") return; + try { + const message = JSON.parse(data); + if (!message || typeof message !== "object") return; + if (bucket === "sent") { + trace.sent.push(compactTraceMessage(message)); + if (trace.sent.length > 500) trace.sent.shift(); + return; + } + trace.received.push(compactTraceMessage(message)); + if (trace.received.length > 500) trace.received.shift(); + if (message.type === "room.snapshot") { + trace.snapshots += 1; + trace.lastSnapshot = message.payload; + } + if (message.type === "room.event" && message.payload?.event) { + const event = message.payload.event; + trace.events.push(event.eventType); + trace.roomEvents.push(event); + if (trace.roomEvents.length > 200) trace.roomEvents.shift(); + if (String(event.eventType ?? "").startsWith("player.")) { + trace.playerEvents.push(event); + if (trace.playerEvents.length > 200) trace.playerEvents.shift(); + } + if (trace.events.length > 500) trace.events.shift(); + } + if (message.type === "room.reject") { + trace.rejects.push(message.payload); + if (trace.rejects.length > 100) trace.rejects.shift(); + } + } catch { + return; + } + } + + function compactTraceMessage(message) { + const payload = message.payload ?? null; + const compact = { + messageId: message.messageId ?? null, + sequence: message.sequence ?? null, + type: message.type ?? null, + }; + if (message.type === "client.fire") { + compact.payload = payload; + } else if (message.type === "client.input") { + compact.payload = { + activeWeapon: payload?.input?.activeWeapon ?? null, + clientId: payload?.clientId ?? null, + inputSequence: payload?.inputSequence ?? null, + }; + } else if (message.type === "room.event") { + compact.event = payload?.event ?? null; + } else if (message.type === "room.reject") { + compact.payload = payload; + } else if (message.type === "room.snapshot") { + compact.players = Array.isArray(payload?.players) + ? payload.players.map((player) => ({ + alive: player.alive, + clientId: player.clientId, + health: player.health, + origin: player.origin, + playerId: player.playerId, + rotX: player.rotX, + rotY: player.rotY, + weapon: player.activeWeapon ?? player.inventory?.activeWeapon ?? null, + })) + : []; + } + return compact; + } + + function WrappedWebSocket(...socketArgs) { + trace.connections.push(String(socketArgs[0] ?? "")); + if (trace.connections.length > 20) trace.connections.shift(); + const socket = new NativeWebSocket(...socketArgs); + const nativeSend = socket.send; + socket.send = function send(data) { + record("sent", data); + return nativeSend.call(this, data); + }; + socket.addEventListener("message", (event) => record("received", event.data)); + return socket; + } + WrappedWebSocket.prototype = NativeWebSocket.prototype; + Object.setPrototypeOf(WrappedWebSocket, NativeWebSocket); + window.WebSocket = WrappedWebSocket; +} + +function buildReport({ appUrl, checks, mapName, partyHost }) { + const failures = checks.flatMap((check) => + check.pass ? [] : check.failures.map((failure) => `${check.kind}:${check.weapon ?? check.direction ?? check.room}: ${failure}`) + ); + const pageErrors = checks.flatMap((check) => check.clients ?? [check.attacker, check.victim].filter(Boolean)) + .flatMap((client) => client?.pageErrors ?? []); + const requestFailures = checks.flatMap((check) => check.clients ?? [check.attacker, check.victim].filter(Boolean)) + .flatMap((client) => client?.requestFailures ?? []); + if (pageErrors.length) failures.push(`${pageErrors.length} page error(s) were reported.`); + if (requestFailures.length) failures.push(`${requestFailures.length} request failure(s) were reported.`); + return { + kind: "cssquake-multiplayer-deep-checks", + generatedAt: new Date().toISOString(), + target: { + appUrl, + partyHost, + mapName, + }, + aggregate: { + checks: checks.length, + passed: checks.filter((check) => check.pass).length, + pageErrors: pageErrors.length, + requestFailures: requestFailures.length, + }, + checks, + failures, + }; +} + +function printSummary(report, artifact) { + console.log(`target: app=${report.target.appUrl}, party=${report.target.partyHost}, map=${report.target.mapName}`); + console.log(`checks: passed ${report.aggregate.passed}/${report.aggregate.checks}, page errors ${report.aggregate.pageErrors}, request failures ${report.aggregate.requestFailures}`); + for (const check of report.checks) { + if (check.kind === "controlled-damage") { + console.log(`damage ${check.direction} ${check.weapon}: ${check.pass ? "pass" : "fail"} damage=${check.event?.damage ?? "n/a"} health=${check.event?.health ?? "n/a"} frames=${compactCounts(countAll(check.remoteAnimation.names))}`); + } else if (check.kind === "controlled-kill") { + console.log(`kill ${check.weapon}: ${check.pass ? "pass" : "fail"} killed=${check.event ? "yes" : "no"} frames=${compactCounts(countAll(check.remoteAnimation.names))}`); + } else { + console.log(`${check.kind}: ${check.pass ? "pass" : "fail"}`); + } + } + console.log(`failures: ${report.failures.length ? report.failures.join(" | ") : "none"}`); + if (artifact) console.log(`artifact: ${artifact}`); +} + +function compactClient(client) { + return { + index: client.index, + url: client.url, + pageErrors: client.pageErrors, + requestFailures: client.requestFailures, + }; +} + +function compactSnapshot(snapshot) { + return { + clientId: snapshot.stats?.multiplayer?.clientId ?? null, + health: snapshot.stats?.playerHealth ?? null, + multiplayer: { + inputPaused: snapshot.stats?.multiplayer?.inputPaused ?? null, + inputSequence: snapshot.stats?.multiplayer?.inputSequence ?? null, + lastReject: snapshot.stats?.multiplayer?.lastReject ?? null, + remoteDomCount: snapshot.stats?.multiplayer?.remoteDomCount ?? null, + remoteVisibleDomCount: snapshot.stats?.multiplayer?.remoteVisibleDomCount ?? null, + scoreboardRows: snapshot.stats?.multiplayer?.scoreboardRows ?? null, + }, + remotePlayers: snapshot.remotePlayers, + playerEvents: snapshot.trace.playerEvents, + received: snapshot.trace.received.slice(-20), + rejects: snapshot.trace.rejects, + roomEvents: snapshot.trace.roomEvents.slice(-20), + sent: snapshot.trace.sent.slice(-20), + error: snapshot.error ?? null, + }; +} + +function remoteAnimationSummary(samples) { + return { + count: samples.length, + names: [...new Set(samples.map((sample) => sample.frameName).filter(Boolean))], + }; +} + +async function findFreePort(preferred, reserved = new Set()) { + let port = preferred; + while (reserved.has(port) || !(await portAvailable(port))) port += 1; + return port; +} + +function portAvailable(port) { + return new Promise((resolve) => { + const server = net.createServer(); + server.once("error", () => resolve(false)); + server.once("listening", () => { + server.close(() => resolve(true)); + }); + server.listen(port, "127.0.0.1"); + }); +} + +async function startManagedServer({ name, command, args, ready, timeoutMs }) { + const logs = []; + const child = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + }); + const appendLog = (chunk) => { + logs.push(chunk.toString()); + while (logs.length > 80) logs.shift(); + }; + child.stdout.on("data", appendLog); + child.stderr.on("data", appendLog); + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + const text = logs.join(""); + if (ready.test(text)) return { name, child, logs }; + if (child.exitCode !== null) { + throw new Error(`${name} exited before ready.\n${text}`); + } + await sleep(100); + } + child.kill("SIGTERM"); + throw new Error(`Timed out waiting for ${name}.\n${logs.join("")}`); +} + +async function stopManagedServer(server) { + if (!server?.child || server.child.exitCode !== null) return; + server.child.kill("SIGTERM"); + const started = Date.now(); + while (server.child.exitCode === null && Date.now() - started < 3_000) await sleep(100); + if (server.child.exitCode === null) server.child.kill("SIGKILL"); +} + +async function assertHttpReady(url, timeoutMs) { + const started = Date.now(); + let lastError = null; + while (Date.now() - started < timeoutMs) { + try { + const response = await fetch(url); + if (response.ok) return; + lastError = new Error(`${url} returned ${response.status}`); + } catch (error) { + lastError = error; + } + await sleep(100); + } + throw lastError ?? new Error(`Timed out waiting for ${url}`); +} + +function createRoomToken(length = 8) { + let token = ""; + for (let index = 0; index < length; index += 1) { + token += ROOM_TOKEN_ALPHABET[Math.floor(Math.random() * ROOM_TOKEN_ALPHABET.length)]; + } + return token; +} + +function colorForClient(index) { + const colors = ["#f2a94b", "#4ba3ff", "#78d66b", "#e66b91"]; + return colors[index % colors.length]; +} + +function countAll(values) { + const counts = {}; + for (const value of values) { + const key = String(value ?? "unknown"); + counts[key] = (counts[key] ?? 0) + 1; + } + return counts; +} + +function compactCounts(counts) { + const entries = Object.entries(counts ?? {}).sort(([a], [b]) => a.localeCompare(b)); + return entries.length ? entries.map(([key, value]) => `${key}=${value}`).join(", ") : "none"; +} diff --git a/test/multiplayer/items.test.mjs b/test/multiplayer/items.test.mjs new file mode 100644 index 0000000..3c8e5b4 --- /dev/null +++ b/test/multiplayer/items.test.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createPlayer } from "./harness.mjs"; +import { importTsModule } from "../importTsModule.mjs"; + +const items = await importTsModule("src/runtime/multiplayer/items.ts"); + +function pickupDefinition(overrides = {}) { + return { + pickupId: "item-shells", + entityIndex: 20, + classname: "item_shells", + origin: [2, 0, 1], + effect: { shells: 20 }, + ...overrides, + }; +} + +test("pickup reach accepts the authoritative player position", () => { + assert.equal( + items.quakeMultiplayerPlayerCanReachPickup( + createPlayer({ origin: [2.2, 0, 1] }), + pickupDefinition(), + ), + true, + ); +}); + +test("pickup reach accepts a bounded local origin hint during vertical server prediction drift", () => { + assert.equal( + items.quakeMultiplayerPlayerCanReachPickup( + createPlayer({ origin: [2.2, 0, 6] }), + pickupDefinition(), + undefined, + [2.2, 0, 1], + ), + true, + ); +}); + +test("pickup reach rejects a forged origin hint far from the authoritative player", () => { + assert.equal( + items.quakeMultiplayerPlayerCanReachPickup( + createPlayer({ origin: [0, 0, 1] }), + pickupDefinition({ origin: [20, 0, 1] }), + undefined, + [20, 0, 1], + ), + false, + ); +}); diff --git a/test/multiplayer/presentation.test.mjs b/test/multiplayer/presentation.test.mjs index 87b1d9b..2da9147 100644 --- a/test/multiplayer/presentation.test.mjs +++ b/test/multiplayer/presentation.test.mjs @@ -10,6 +10,7 @@ function createRemotePresenterHarness() { let now = 1_000; const callbacks = new Map(); const damageEvents = []; + const killEvents = []; const visualStates = []; const removed = []; const presenter = presentation.createQuakeMultiplayerRemotePlayerPresenter({ @@ -19,6 +20,7 @@ function createRemotePresenterHarness() { remove: () => removed.push(player.playerId), }), onPlayerDamaged: (event, player) => damageEvents.push({ event, player }), + onPlayerKilled: (event, player) => killEvents.push({ event, player }), now: () => now, renderDelayMs: 0, requestFrame: (callback) => { @@ -33,6 +35,7 @@ function createRemotePresenterHarness() { return { damageEvents, + killEvents, presenter, removed, visualStates, @@ -118,3 +121,34 @@ test("remote presenter ignores local player damage for remote visuals", () => { assert.equal(harness.damageEvents.length, 0); }); + +test("remote presenter reports remote player kills before death state is applied", () => { + const harness = createRemotePresenterHarness(); + const remotePlayer = createPlayer({ + playerId: "remote-player", + clientId: "remote-client", + updatedAt: 1_000, + origin: [10, 20, 30], + }); + + harness.presenter.handleRoomMessage(roomSnapshot([remotePlayer])); + harness.flushFrame(); + + harness.setNow(1_200); + harness.presenter.handleRoomMessage(roomEvent({ + eventType: "player.killed", + eventId: "kill-1", + roomTime: 1_200, + victimPlayerId: "remote-player", + attackerPlayerId: "local-player", + damageSource: "shotgun", + })); + harness.flushFrame(); + + assert.equal(harness.killEvents.length, 1); + assert.equal(harness.killEvents[0].event.damageSource, "shotgun"); + assert.equal(harness.killEvents[0].player.playerId, "remote-player"); + assert.equal(harness.killEvents[0].player.alive, true); + assert.equal(harness.visualStates.at(-1).state.alive, false); + assert.equal(harness.visualStates.at(-1).state.deathAt, 1_200); +}); diff --git a/test/multiplayer/protocol.test.mjs b/test/multiplayer/protocol.test.mjs index e10ee09..c63698c 100644 --- a/test/multiplayer/protocol.test.mjs +++ b/test/multiplayer/protocol.test.mjs @@ -19,6 +19,9 @@ import { validation, worldEnvelope, } from "./harness.mjs"; +import { importTsModule } from "../importTsModule.mjs"; + +const facts = await importTsModule("src/runtime/multiplayer/facts.ts"); class FakePartyConnection { constructor(id) { @@ -72,6 +75,127 @@ function latestConnectionMessage(connection, type) { return message; } +function roomEvents(connection, eventType) { + return connection.messages + .filter((message) => message.type === "room.event" && message.payload.event.eventType === eventType) + .map((message) => message.payload.event); +} + +function latestSnapshotPlayerForClient(connection, clientId) { + const snapshot = latestConnectionMessage(connection, "room.snapshot"); + const player = snapshot.payload.players.find((candidate) => candidate.clientId === clientId); + assert.ok(player, `expected snapshot player for ${clientId}`); + return player; +} + +const weaponPickupFlags = { + axe: 4096, + supershotgun: 2, + nailgun: 4, + supernailgun: 8, + grenadelauncher: 16, + rocketlauncher: 32, + lightning: 64, +}; + +const weaponPickupAmmo = { + axe: { shells: 0 }, + supershotgun: { shells: 10 }, + nailgun: { nails: 25 }, + supernailgun: { nails: 25 }, + grenadelauncher: { rockets: 5 }, + rocketlauncher: { rockets: 5 }, + lightning: { cells: 25 }, +}; + +function weaponPickupDefinition(weapon) { + return { + pickupId: `weapon-${weapon}`, + entityIndex: 1000 + Object.keys(weaponPickupFlags).indexOf(weapon), + classname: `weapon_${weapon}`, + origin: [0, 0, 0], + effect: { + ...(weaponPickupAmmo[weapon] ?? {}), + weapon: { + id: weapon, + itemFlag: weaponPickupFlags[weapon] ?? 0, + select: true, + }, + }, + }; +} + +function connectDuelRoom({ id, pickupDefinitions = [], spawnDistance = 4 }) { + const deathmatchSpawns = [ + { + spawnId: "spawn-a", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }, + { + spawnId: "spawn-b", + classname: "info_player_deathmatch", + origin: [spawnDistance, 0, 0], + rotX: -78, + rotY: 180, + }, + ]; + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns, + pickupDefinitions, + }); + const { room, createConnection } = createFakePartyRoom(id); + const RoomClass = partyRoomModule.default; + const partyRoom = new RoomClass(room, { trustedGameplayDefinitions: gameplayDefinitions }); + const alice = createConnection("alice"); + const bob = createConnection("bob"); + partyRoom.onConnect(alice); + partyRoom.onConnect(bob); + partyRoom.onMessage(JSON.stringify(helloEnvelope({ + clientId: "client-a", + displayName: "Alice", + messageId: `hello-a-${id}`, + sequence: 1, + sentAt: Date.now(), + matchSettings: { fragLimit: 1 }, + })), alice); + partyRoom.onMessage(JSON.stringify(helloEnvelope({ + clientId: "client-b", + displayName: "Bob", + messageId: `hello-b-${id}`, + sequence: 1, + sentAt: Date.now(), + matchSettings: { fragLimit: 1 }, + })), bob); + return { alice, bob, partyRoom }; +} + +function cleanupDuelRoom(partyRoom, alice, bob) { + partyRoom.onClose(alice); + partyRoom.onClose(bob); +} + +function pickupWeapon(partyRoom, connection, { clientId, sequence, weapon }) { + const definition = weaponPickupDefinition(weapon); + partyRoom.onMessage(JSON.stringify(pickupEnvelope({ + clientId, + messageId: `pickup-${weapon}-${clientId}`, + sequence, + pickupSequence: 1, + sentAt: Date.now(), + pickup: { + entityIndex: definition.entityIndex, + origin: [0, 0, 0], + }, + })), connection); + const event = roomEvents(connection, "pickup.taken") + .find((candidate) => candidate.entityIndex === definition.entityIndex); + assert.ok(event, `expected ${clientId} to pick up ${weapon}`); + return event; +} + test("multiplayer room compatibility keys normalize map names and compare full asset identity", () => { const normalized = protocol.createQuakeMultiplayerRoomCompatibilityKey(ROOM_KEY); assert.deepEqual(normalized, NORMALIZED_ROOM_KEY); @@ -221,6 +345,154 @@ test("party room closes a connection after repeated recoverable rejects", () => assert.deepEqual(connection.closed.at(-1), { code: 1008, reason: "too-many-rejects" }); }); +test("party room applies authoritative fire damage in both player directions", () => { + const deathmatchSpawns = [ + { + spawnId: "spawn-a", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }, + { + spawnId: "spawn-b", + classname: "info_player_deathmatch", + origin: [4, 0, 0], + rotX: -78, + rotY: 180, + }, + ]; + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns, + pickupDefinitions: [], + }); + const { room, createConnection } = createFakePartyRoom("fire-damage-room"); + const RoomClass = partyRoomModule.default; + const partyRoom = new RoomClass(room, { trustedGameplayDefinitions: gameplayDefinitions }); + const alice = createConnection("alice"); + const bob = createConnection("bob"); + partyRoom.onConnect(alice); + partyRoom.onConnect(bob); + + partyRoom.onMessage(JSON.stringify(helloEnvelope({ + clientId: "client-a", + displayName: "Alice", + messageId: "hello-a", + sequence: 1, + sentAt: Date.now(), + })), alice); + partyRoom.onMessage(JSON.stringify(helloEnvelope({ + clientId: "client-b", + displayName: "Bob", + messageId: "hello-b", + sequence: 1, + sentAt: Date.now(), + })), bob); + + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-a", + sequence: 2, + fireSequence: 1, + sentAt: Date.now(), + })), alice); + const damageAtoB = roomEvents(alice, "player.damaged") + .find((event) => event.attackerPlayerId === "party:client-a" && event.victimPlayerId === "party:client-b"); + assert.ok(damageAtoB, "expected client-a to damage client-b"); + assert.equal(damageAtoB.damage, 24); + assert.equal(damageAtoB.health, 76); + assert.equal(damageAtoB.damageSource, "shotgun"); + + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-b", + messageId: "fire-b", + sequence: 2, + fireSequence: 1, + sentAt: Date.now(), + })), bob); + const damageBtoA = roomEvents(alice, "player.damaged") + .find((event) => event.attackerPlayerId === "party:client-b" && event.victimPlayerId === "party:client-a"); + assert.ok(damageBtoA, "expected client-b to damage client-a"); + assert.equal(damageBtoA.damage, 24); + assert.equal(damageBtoA.health, 76); + assert.equal(damageBtoA.damageSource, "shotgun"); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); +}); + +test("party room applies authoritative weapon damage after weapon pickups", () => { + const cases = [ + { weapon: "axe", damage: 20, pickup: true, spawnDistance: 1.2, eventType: "player.damaged", health: 80 }, + { weapon: "shotgun", damage: 24, pickup: false, spawnDistance: 4, eventType: "player.damaged", health: 76 }, + { weapon: "supershotgun", damage: 56, pickup: true, spawnDistance: 4, eventType: "player.damaged", health: 44 }, + { weapon: "nailgun", damage: 9, pickup: true, spawnDistance: 4, eventType: "player.damaged", health: 91 }, + { weapon: "supernailgun", damage: 18, pickup: true, spawnDistance: 4, eventType: "player.damaged", health: 82 }, + { weapon: "lightning", damage: 30, pickup: true, spawnDistance: 4, eventType: "player.damaged", health: 70 }, + { weapon: "grenadelauncher", pickup: true, spawnDistance: 4, eventType: "player.killed" }, + { weapon: "rocketlauncher", pickup: true, spawnDistance: 4, eventType: "player.killed" }, + ]; + + for (const spec of cases) { + const pickupDefinitions = spec.pickup ? [weaponPickupDefinition(spec.weapon)] : []; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: `weapon-${spec.weapon}`, + pickupDefinitions, + spawnDistance: spec.spawnDistance, + }); + try { + if (spec.pickup) { + pickupWeapon(partyRoom, alice, { + clientId: "client-a", + sequence: 2, + weapon: spec.weapon, + }); + const player = latestSnapshotPlayerForClient(alice, "client-a"); + assert.equal(player.inventory.activeWeapon, spec.weapon, `${spec.weapon} should become active after pickup`); + assert.ok(player.inventory.weapons.includes(spec.weapon), `${spec.weapon} should be in authoritative inventory`); + } else { + partyRoom.onMessage(JSON.stringify(inputEnvelope({ + clientId: "client-a", + messageId: `select-${spec.weapon}`, + sequence: 2, + inputSequence: 1, + sentAt: Date.now(), + input: { activeWeapon: spec.weapon }, + })), alice); + } + + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: `fire-${spec.weapon}`, + sequence: 3, + fireSequence: 1, + sentAt: Date.now(), + fire: { weapon: spec.weapon }, + })), alice); + + const event = roomEvents(alice, spec.eventType) + .find((candidate) => + candidate.attackerPlayerId === "party:client-a" && + candidate.victimPlayerId === "party:client-b" && + candidate.damageSource === spec.weapon + ); + assert.ok(event, `expected ${spec.eventType} for ${spec.weapon}`); + if (spec.eventType === "player.damaged") { + assert.equal(event.damage, spec.damage, `${spec.weapon} damage`); + assert.equal(event.health, spec.health, `${spec.weapon} victim health`); + } + if (spec.eventType === "player.killed") { + const victim = latestSnapshotPlayerForClient(alice, "client-b"); + assert.equal(victim.alive, false, `${spec.weapon} should kill the victim`); + assert.equal(victim.health, 0, `${spec.weapon} should leave victim at zero health`); + } + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0, `${spec.weapon} alice rejects`); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0, `${spec.weapon} bob rejects`); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } + } +}); + test("client authority rejects non-hello first messages and client id swaps", () => { const input = inputEnvelope({ sequence: 1, inputSequence: 1, sentAt: 100 }); const firstResult = authority.validateQuakeMultiplayerClientAuthority(input, null, { now: 100 }); @@ -488,3 +760,164 @@ test("loopback session rejects paused mutation intents", async () => { harness.disconnect(); } }); + +test("loopback pickup intent accepts bounded local origin hints during vertical drift", async () => { + const pickupDefinition = { + pickupId: "item-shells", + entityIndex: 20, + classname: "item_shells", + origin: [2, 0, 1], + effect: { shells: 20 }, + }; + const deathmatchSpawns = [{ + spawnId: "spawn-high", + classname: "info_player_deathmatch", + origin: [2.2, 0, 6], + rotX: 0, + rotY: 0, + }]; + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns, + pickupDefinitions: [pickupDefinition], + }); + const harness = await createLoopbackHarness({ + now: 3000, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ + messageId: "hello-pickup-drift", + sequence: 1, + sentAt: harness.now(), + })); + + harness.advanceNow(120); + session.send(pickupEnvelope({ + messageId: "pickup-drift", + sequence: 2, + pickupSequence: 1, + sentAt: harness.now(), + pickup: { + entityIndex: pickupDefinition.entityIndex, + origin: [2.2, 0, 1], + }, + })); + + const event = latestMessage(messages, "room.event").payload.event; + assert.equal(event.eventType, "pickup.taken"); + assert.equal(event.entityIndex, pickupDefinition.entityIndex); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } +}); + +test("loopback ignores unknown pickup intents without broadcast noise", async () => { + const harness = await createLoopbackHarness({ now: 3500 }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ + messageId: "hello-unknown-pickup", + sequence: 1, + sentAt: harness.now(), + })); + + harness.advanceNow(120); + const beforeCount = messages.length; + session.send(pickupEnvelope({ + messageId: "pickup-unknown", + sequence: 2, + pickupSequence: 1, + sentAt: harness.now(), + pickup: { + entityIndex: 999, + origin: [0, 0, 1], + }, + })); + + assert.equal(messages.length, beforeCount); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + assert.equal( + messages.some((message) => + message.type === "room.event" && message.payload.event.eventType === "pickup.rejected" + ), + false, + ); + } finally { + harness.disconnect(); + } +}); + +test("loopback ignores touch prediction misses without room rejects", async () => { + const moverDefinition = { + kind: "mover", + entityIndex: 88, + classname: "func_button", + bounds: { + mins: [9.8, -0.5, 0], + maxs: [10.2, 0.5, 1.2], + }, + touchActivates: true, + useActivates: false, + shootActivates: false, + speed: 40, + moveMs: 150, + delayMs: 0, + fromOrigin: [0, 0, 0], + toOrigin: [0, 0, -0.12], + targetEntityIndexes: [], + }; + const deathmatchSpawns = [{ + spawnId: "spawn-far", + classname: "info_player_deathmatch", + origin: [0, 0, 1], + rotX: 0, + rotY: 0, + }]; + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns, + pickupDefinitions: [], + }); + const harness = await createLoopbackHarness({ + now: 4000, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + trustedWorldDefinitions: [moverDefinition], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ + messageId: "hello-world-touch-miss", + sequence: 1, + sentAt: harness.now(), + })); + + harness.advanceNow(120); + const beforeCount = messages.length; + session.send(worldEnvelope({ + messageId: "world-touch-miss", + sequence: 2, + worldSequence: 1, + sentAt: harness.now(), + intent: { + entityIndex: moverDefinition.entityIndex, + origin: [10, 0, 1], + }, + })); + + assert.equal(messages.length, beforeCount); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + assert.equal( + messages.some((message) => + message.type === "room.event" && message.payload.event.eventType === "world.mover" + ), + false, + ); + } finally { + harness.disconnect(); + } +}); diff --git a/test/multiplayer/world.test.mjs b/test/multiplayer/world.test.mjs new file mode 100644 index 0000000..b7f5b45 --- /dev/null +++ b/test/multiplayer/world.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createPlayer } from "./harness.mjs"; +import { importTsModule } from "../importTsModule.mjs"; + +const world = await importTsModule("src/runtime/multiplayer/world.ts"); + +function triggerDefinition(overrides = {}) { + return { + kind: "trigger", + entityIndex: 42, + classname: "trigger_once", + bounds: { + mins: [2, -1, 0], + maxs: [3, 1, 1], + }, + touchActivates: true, + useActivates: true, + oneShot: true, + delayMs: 0, + waitMs: -1, + targetEntityIndexes: [], + ...overrides, + }; +} + +function touchIntent(overrides = {}) { + return { + intentType: "touch", + worldSequence: 1, + requestedAt: 100, + entityIndex: 42, + origin: [0, 0, 1], + ...overrides, + }; +} + +test("world touch accepts a bounded local origin hint when the authoritative pose is one tick behind", () => { + const resolution = world.resolveQuakeMultiplayerWorldIntent( + createPlayer({ origin: [0, 0, 1] }), + touchIntent({ origin: [1.2, 0, 1] }), + [triggerDefinition()], + 100, + ); + + assert.equal(resolution.ok, true); + assert.equal(resolution.kind, "trigger"); +}); + +test("world touch accepts a local origin hint during vertical server prediction drift", () => { + const resolution = world.resolveQuakeMultiplayerWorldIntent( + createPlayer({ origin: [1.2, 0, 6] }), + touchIntent({ origin: [1.2, 0, 1] }), + [triggerDefinition()], + 100, + ); + + assert.equal(resolution.ok, true); + assert.equal(resolution.kind, "trigger"); +}); + +test("world touch rejects a forged origin hint far from the authoritative player", () => { + const resolution = world.resolveQuakeMultiplayerWorldIntent( + createPlayer({ origin: [0, 0, 1] }), + touchIntent({ origin: [20, 0, 1] }), + [triggerDefinition({ + bounds: { + mins: [20, -1, 0], + maxs: [21, 1, 1], + }, + })], + 100, + ); + + assert.equal(resolution.ok, false); + assert.equal(resolution.reason, "too-far"); +});