From bb2c5e215909da460fd262408ec85b53f46f0782 Mon Sep 17 00:00:00 2001 From: agustin-lowpoly Date: Tue, 23 Jun 2026 15:30:48 -0300 Subject: [PATCH 1/5] Improve cssQuake SEO metadata --- index.html | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/index.html b/index.html index 1130c51..908ec44 100644 --- a/index.html +++ b/index.html @@ -35,20 +35,20 @@ document.head.appendChild(tag); })(); - cssQuake - Powered by PolyCSS + cssQuake - Quake in HTML/CSS with PolyCSS - + @@ -56,20 +56,22 @@ - + - cssQuake - Quake in HTML/CSS with PolyCSS + cssQuake - Powered by PolyCSS - + - + Date: Tue, 23 Jun 2026 15:42:11 -0300 Subject: [PATCH 3/5] Harden multiplayer damage authority --- src/App.ts | 416 +- src/runtime/app/assetWarmupFlow.ts | 14 +- src/runtime/app/context.ts | 4 + src/runtime/app/debugApi.ts | 15 +- src/runtime/constants.ts | 1 + src/runtime/debug/quakeDebug.ts | 62 +- src/runtime/entities.ts | 26 +- src/runtime/multiplayer/authority.ts | 9 +- src/runtime/multiplayer/deathmatch.ts | 485 ++- src/runtime/multiplayer/history.ts | 218 + src/runtime/multiplayer/items.ts | 186 +- src/runtime/multiplayer/loopback.ts | 630 ++- src/runtime/multiplayer/partyRoom.ts | 547 ++- src/runtime/multiplayer/presentation.ts | 56 +- .../multiplayer/projectileAuthority.ts | 383 ++ src/runtime/multiplayer/protocol.ts | 102 +- src/runtime/multiplayer/simulation.ts | 93 + src/runtime/multiplayer/validation.ts | 98 +- src/runtime/multiplayer/world.ts | 56 +- src/runtime/pickups.ts | 9 + src/runtime/weapons.ts | 34 +- test/browser/runMultiplayerDeepChecks.mjs | 2992 ++++++++++++- test/gameplay/weaponImpactParticles.test.mjs | 38 +- test/multiplayer/deathmatch.test.mjs | 663 +++ test/multiplayer/harness.mjs | 27 +- test/multiplayer/history.test.mjs | 188 + test/multiplayer/items.test.mjs | 258 ++ test/multiplayer/movement.test.mjs | 24 + test/multiplayer/presentation.test.mjs | 125 +- test/multiplayer/protocol.test.mjs | 3707 ++++++++++++++++- test/multiplayer/world.test.mjs | 129 + test/runtime/entities.test.mjs | 32 + 32 files changed, 10973 insertions(+), 654 deletions(-) create mode 100644 src/runtime/multiplayer/history.ts create mode 100644 src/runtime/multiplayer/projectileAuthority.ts create mode 100644 test/multiplayer/deathmatch.test.mjs create mode 100644 test/multiplayer/history.test.mjs create mode 100644 test/runtime/entities.test.mjs diff --git a/src/App.ts b/src/App.ts index bef91a6..218ed8a 100644 --- a/src/App.ts +++ b/src/App.ts @@ -39,7 +39,7 @@ import { import { isQuakeDebugHooksEnabled } from "./runtime/debug/quakeDebug"; import { createQuakeDebugRecorder } from "./runtime/debug/recording"; import { markQuakeTrace } from "./runtime/debug/traceMarks"; -import { shouldSpawnQuakeEntityForCurrentGame } from "./runtime/entities"; +import { shouldSpawnQuakeEntityForCurrentGame, shouldSpawnQuakeEntityForGameMode } from "./runtime/entities"; import { applyQuakeInventoryDelta, createQuakeHudElements, @@ -157,6 +157,7 @@ import { QUAKE_MULTIPLAYER_COMPACT_MAP_CODE_LENGTH, QUAKE_MULTIPLAYER_DEFAULT_CLIENT_MESSAGE_INTERVAL_MS, QUAKE_MULTIPLAYER_DEFAULT_REGION, + QUAKE_MULTIPLAYER_MAX_INPUT_BATCH_SIZE, QUAKE_MULTIPLAYER_ROOM_TOKEN_ALPHABET, QUAKE_MULTIPLAYER_ROOM_TOKEN_LENGTH, QUAKE_MULTIPLAYER_ROOM_TOKEN_PATTERN, @@ -172,6 +173,7 @@ import { type QuakeMultiplayerLocalInputIntent, type QuakeMultiplayerPickupDefinition, type QuakeMultiplayerPlayerPresenceStatus, + type QuakeMultiplayerProjectileState, type QuakeMultiplayerRemoteInterpolationState, type QuakeMultiplayerRemoteVisualHandle, type QuakeMultiplayerRoomCompatibilityKey, @@ -217,9 +219,12 @@ import { } from "./runtime/viewmodel"; import { createQuakeWeaponsController, + quakeProjectileRenderYaw, + quakeWeaponProjectileModelPath, type QuakeWeaponFireEvent, type QuakeWeaponFireSoundId, type QuakeWeaponWallImpactEffect, + type QuakeWeaponProjectileVisualHandle, } from "./runtime/weapons"; import { createQuakeWorldController, @@ -236,6 +241,7 @@ import { quakePickupMessageForEntity, quakePickupModelRenderBundleFrameSet, quakePickupModelRenderBundle, + type QuakePickupEffect, type QuakePickupModel, type QuakePickupModelAnimationFrame, type QuakePickupModelLibrary, @@ -763,11 +769,12 @@ const quakeMultiplayerCompactInvite = parseQuakeMultiplayerCompactInvite( const quakeDebugMultiplayerMode = quakeStartupUrlParams.get("debugMultiplayer"); const quakeMultiplayerMode = quakeStartupUrlParams.get("multiplayer") ?? (quakeMultiplayerCompactInvite ? "party" : null); const QUAKE_MULTIPLAYER_DEBUG_REQUESTED = import.meta.env.DEV && quakeDebugMultiplayerMode !== null; +const QUAKE_MULTIPLAYER_DEBUG_HOOKS_ENABLED = isQuakeDebugHooksEnabled(); const QUAKE_MULTIPLAYER_ENABLED = quakeMultiplayerMode !== null || QUAKE_MULTIPLAYER_DEBUG_REQUESTED; const QUAKE_MULTIPLAYER_MENU_ENABLED = true; -const QUAKE_MULTIPLAYER_DEBUG_POSE_ONLY = QUAKE_MULTIPLAYER_DEBUG_REQUESTED && +const QUAKE_MULTIPLAYER_DEBUG_POSE_ONLY = QUAKE_MULTIPLAYER_DEBUG_HOOKS_ENABLED && quakeUrlBoolean("debugMultiplayerPoseOnly"); -const QUAKE_MULTIPLAYER_DEBUG_INPUT_PAUSED = QUAKE_MULTIPLAYER_DEBUG_REQUESTED && +const QUAKE_MULTIPLAYER_DEBUG_INPUT_PAUSED = QUAKE_MULTIPLAYER_DEBUG_HOOKS_ENABLED && quakeUrlBoolean("debugMultiplayerInputPaused"); const QUAKE_MULTIPLAYER_DEFAULT_FRAG_LIMIT = 20; const QUAKE_MULTIPLAYER_DEFAULT_MAX_PLAYERS = QUAKE_MULTIPLAYER_MAX_PLAYERS_CAP; @@ -822,12 +829,23 @@ const QUAKE_MULTIPLAYER_REMOTE_RUN_FRAME_PREFIX = "rockrun"; const QUAKE_MULTIPLAYER_REMOTE_PAIN_FRAME_PREFIX = "pain"; const QUAKE_MULTIPLAYER_REMOTE_DEATH_FRAME_PREFIX = "deatha"; const QUAKE_MULTIPLAYER_REMOTE_RUN_FPS = 10; +const QUAKE_MULTIPLAYER_REMOTE_ATTACK_FPS = 10; const QUAKE_MULTIPLAYER_REMOTE_PAIN_FPS = 10; const QUAKE_MULTIPLAYER_REMOTE_DEATH_FPS = 10; const QUAKE_MULTIPLAYER_REMOTE_RUN_SPEED_THRESHOLD = QUAKE_PMOVE_FORWARD_SPEED * 0.1; const QUAKE_MULTIPLAYER_REMOTE_PLAYER_EYE_HEIGHT = QUAKE_PLAYER_VIEW_Z - QUAKE_PLAYER_MINS_Z; const QUAKE_MULTIPLAYER_REMOTE_MODEL_ROT_Y_OFFSET = 0; const QUAKE_MULTIPLAYER_REMOTE_FALLBACK_ROT_Y_OFFSET = 45; +const QUAKE_MULTIPLAYER_REMOTE_ATTACK_FRAME_NAMES_BY_WEAPON: Record = { + axe: ["axatt1", "axatt2", "axatt3", "axatt4"], + shotgun: ["shotatt1", "shotatt2", "shotatt3", "shotatt4", "shotatt5", "shotatt6"], + supershotgun: ["shotatt1", "shotatt2", "shotatt3", "shotatt4", "shotatt5", "shotatt6"], + nailgun: ["nailatt1", "nailatt2"], + supernailgun: ["nailatt1", "nailatt2"], + grenadelauncher: ["rockatt1", "rockatt2", "rockatt3", "rockatt4", "rockatt5", "rockatt6"], + rocketlauncher: ["rockatt1", "rockatt2", "rockatt3", "rockatt4", "rockatt5", "rockatt6"], + lightning: ["light1", "light2"], +}; const quakeMultiplayerScoreboard = QUAKE_MULTIPLAYER_ENABLED && quakeHud ? mountQuakeMultiplayerScoreboard(quakeHud) : null; @@ -895,6 +913,11 @@ function shouldSpawnQuakeShootableForCurrentMode(entity: QuakeEntity): boolean { return shouldSpawnQuakeEntityForCurrentGame(entity); } +function shouldSpawnQuakePickupForCurrentMode(entity: QuakeEntity): boolean { + if (QUAKE_MULTIPLAYER_ENABLED) return shouldSpawnQuakeEntityForGameMode(entity, { deathmatch: true }); + return shouldSpawnQuakeEntityForCurrentGame(entity); +} + function currentQuakeViewUrl(): string { return quakeRoute.currentViewUrl(); } @@ -1266,6 +1289,13 @@ const quakeRemotePlayers = createQuakeMultiplayerRemotePlayerPresenter({ }, now: () => Date.now(), }); +interface QuakeRemoteMultiplayerProjectileVisual { + handle: QuakeWeaponProjectileVisualHandle; + ownerPlayerId: string; + weapon: QuakeWeaponId; +} + +const quakeRemoteMultiplayerProjectiles = new Map(); const quakeLoopbackTrustedSceneMovement = { collisionWorld: { contentsAt: (point: Vec3) => currentCollisionWorld?.contentsAt?.(point) ?? null, @@ -1911,7 +1941,7 @@ pickups = createQuakePickupController({ gameLogic: () => currentResult?.gameLogic ?? null, isGameplayPaused: isQuakeGamePaused, programMetadata: () => currentProgramMetadata, - shouldSpawn: shouldSpawnQuakeEntityForCurrentGame, + shouldSpawn: shouldSpawnQuakePickupForCurrentMode, startMegahealthRot: (entity, delaySeconds) => quakePowerups.startMegahealthRot(entity, delaySeconds), startPowerup: (entity, powerup) => quakePowerups.startPowerup(entity, powerup), useTargets: targetSystem.useTargets, @@ -1941,9 +1971,18 @@ const weapons = createQuakeWeaponsController({ audio.playEvent(quakeWeaponFireSoundEvent(weapon), { volume: 0.74 }); }, playFireAnimation: (animation) => quakeWeaponPresentation.playFireAnimation(animation), - damageShootable: shootables.damage, - damageBrushEntity: (entityIndex, amount) => quakeDamageableBrushes.damage(entityIndex, amount), - damagePlayer: (amount, context) => getPlayer().damage(amount, context), + damageShootable: (entityIndex, amount) => + quakeMultiplayerRoomOwnsLocalDamage() + ? false + : shootables.damage(entityIndex, amount), + damageBrushEntity: (entityIndex, amount) => + quakeMultiplayerRoomOwnsLocalDamage() + ? false + : quakeDamageableBrushes.damage(entityIndex, amount), + damagePlayer: (amount, context) => + quakeMultiplayerRoomOwnsLocalDamage() + ? false + : getPlayer().damage(amount, context), canDamageTargetOrigin: (start, targetOrigin) => shootables.canDamageTargetOrigin(start, targetOrigin), damageMultiplier: () => quakePowerups.damageMultiplier(), onFire: sendQuakeMultiplayerFireIntent, @@ -2095,6 +2134,7 @@ let currentCollisionWorld: QuakeCollisionWorld | null = null; let currentResult: QuakeScene | null = null; let quakeMultiplayerPickupDefinitionsScene: QuakeScene | null = null; let quakeMultiplayerPickupDefinitions: readonly QuakeMultiplayerPickupDefinition[] = []; +let quakeMultiplayerDynamicPickupDefinitions = new Map(); let quakeMultiplayerWorldIntentDefinitionsScene: QuakeScene | null = null; let quakeMultiplayerWorldIntentDefinitions: readonly QuakeMultiplayerWorldDefinition[] = []; let quakeGameplayStarted = false; @@ -2112,6 +2152,7 @@ let quakeMultiplayerPoseSequence = 0; let quakeMultiplayerPoseFrame = 0; let quakeMultiplayerLastInputAt = 0; let quakeMultiplayerLastInputSentAt = 0; +let quakeMultiplayerPendingInputs: QuakeMultiplayerLocalInputIntent[] = []; let quakeMultiplayerLastPoseAt = 0; let quakeMultiplayerHelloAccepted = false; let quakeMultiplayerLocalSpawnId: string | null = null; @@ -2184,6 +2225,8 @@ const quakeAssetWarmup = createQuakeAssetWarmupFlow({ onProgramMetadata: (metadata) => { currentProgramMetadata = metadata; }, + shouldSpawnPickup: shouldSpawnQuakePickupForCurrentMode, + shouldSpawnShootable: shouldSpawnQuakeShootableForCurrentMode, }); const quakeLoading = createQuakeLoadingFlow({ clearAttackInput: quakePointerGameplay.clearAttackInput, @@ -2818,6 +2861,31 @@ function resumeQuakeGameplayAfterMapLoad(): void { quakePlayerLifecycle.resumeGameplayAfterMapLoad(); } +function resumeQuakeDebugGameplayInput(): void { + quakePlayerLifecycle.resumeGameplayAfterMapLoad(); + setQuakeClickToPlayPauseState(false); +} + +function runQuakeWithDebugGameplayInput(callback: () => T): T { + const previousDebugGameplaySyncActive = quakeDebugGameplaySyncActive; + quakeDebugGameplaySyncActive = true; + let result: T; + try { + result = callback(); + } catch (error) { + quakeDebugGameplaySyncActive = previousDebugGameplaySyncActive; + throw error; + } + if (result && typeof (result as PromiseLike).then === "function") { + return Promise.resolve(result) + .finally(() => { + quakeDebugGameplaySyncActive = previousDebugGameplaySyncActive; + }) as T; + } + quakeDebugGameplaySyncActive = previousDebugGameplaySyncActive; + return result; +} + function isQuakeLevelTransitionActive(): boolean { return quakePlayerLifecycle.isLevelTransitionActive(); } @@ -2877,6 +2945,7 @@ function createQuakeRemotePlayerVisual( interface QuakeRemotePlayerMeshMount { activeFrameSet: "run" | undefined; animationFrames: readonly QuakePickupModelAnimationFrame[]; + attackFrameIndexesByWeapon: Record; color: string | undefined; clientId: string; currentFrameIndex: number; @@ -2910,6 +2979,7 @@ function addQuakeRemotePlayerMesh(): QuakeRemotePlayerMeshMount | null { const deathFrameIndexes = frameSet ? quakeRemotePlayerFrameIndexes(frameSet, QUAKE_MULTIPLAYER_REMOTE_DEATH_FRAME_PREFIX) : quakeRemotePlayerAnimationFrameIndexes(animationFrames, QUAKE_MULTIPLAYER_REMOTE_DEATH_FRAME_PREFIX); + const attackFrameIndexesByWeapon = quakeRemotePlayerAttackFrameIndexesByWeapon(frameSet, animationFrames); const runFrameSet = !frameSet && model ? quakeRemotePlayerAnimationFrameSetForIndexes(animationFrames, runFrameIndexes) : undefined; @@ -2932,6 +3002,7 @@ function addQuakeRemotePlayerMesh(): QuakeRemotePlayerMeshMount | null { return { activeFrameSet: undefined, animationFrames, + attackFrameIndexesByWeapon, clientId: "", currentFrameIndex: standFrameIndex, fullFrameSet: frameSet, @@ -2965,11 +3036,14 @@ function syncQuakeRemotePlayerVisual( syncQuakeRemotePlayerMeshFrame(remote, frameIndex); remote.handle.element.hidden = false; const rotY = quakeRemotePlayerVisualRotY(state); + const appliedRotY = rotY + quakeRemotePlayerVisualRotYOffset(remote.handle.element); + const origin = quakeRemotePlayerVisualOrigin(state.renderOrigin, remote.zOffset); remote.handle.setTransform({ - position: quakeRemotePlayerVisualOrigin(state.renderOrigin, remote.zOffset), - rotation: [0, 0, rotY + quakeRemotePlayerVisualRotYOffset(remote.handle.element)], + position: origin, + rotation: [0, 0, appliedRotY], scale: remote.scale, }); + syncQuakeRemotePlayerPoseMetadata(remote, state, origin, rotY, appliedRotY); } function syncQuakeRemotePlayerElementMetadata(remote: QuakeRemotePlayerMeshMount): void { @@ -3048,6 +3122,40 @@ function syncQuakeRemotePlayerFrameMetadata( } } +function syncQuakeRemotePlayerPoseMetadata( + remote: QuakeRemotePlayerMeshMount, + state: QuakeMultiplayerRemoteInterpolationState, + origin: Vec3, + visualRotY: number, + appliedRotY: number, +): void { + remote.handle.element.dataset.remoteAlive = state.alive ? "true" : "false"; + remote.handle.element.dataset.remoteAppliedRotY = quakeRemotePlayerMetadataNumber(appliedRotY); + syncQuakeRemotePlayerOptionalMetadata(remote.handle.element, "remoteLastAttackAt", state.lastAttackAt); + syncQuakeRemotePlayerOptionalMetadata(remote.handle.element, "remoteLastPainAt", state.lastPainAt); + remote.handle.element.dataset.remoteOrigin = origin.map(quakeRemotePlayerMetadataNumber).join(","); + remote.handle.element.dataset.remoteRenderAt = quakeRemotePlayerMetadataNumber(state.renderAt); + remote.handle.element.dataset.remoteRenderRotY = quakeRemotePlayerMetadataNumber(state.renderRotY); + remote.handle.element.dataset.remoteStale = state.stale ? "true" : "false"; + remote.handle.element.dataset.remoteVisualRotY = quakeRemotePlayerMetadataNumber(visualRotY); +} + +function syncQuakeRemotePlayerOptionalMetadata( + element: HTMLElement, + key: string, + value: number | undefined, +): void { + if (value === undefined) { + delete element.dataset[key]; + return; + } + element.dataset[key] = quakeRemotePlayerMetadataNumber(value); +} + +function quakeRemotePlayerMetadataNumber(value: number): string { + return Number.isFinite(value) ? value.toFixed(4) : "0.0000"; +} + function quakeRemotePlayerAnimationFrameName( remote: QuakeRemotePlayerMeshMount, frameIndex: number, @@ -3077,6 +3185,17 @@ function quakeRemotePlayerFrameIndexes( .map(({ index }) => index); } +function quakeRemotePlayerFrameIndexesByName( + frameSet: QuakeRenderBundleFrameSet, + frameNames: readonly string[], +): readonly number[] { + const desired = new Set(frameNames); + return frameSet.frames + .map((frame, index) => ({ frame, index })) + .filter(({ frame }) => desired.has(frame.name)) + .map(({ index }) => index); +} + function quakeRemotePlayerAnimationFrameIndexes( frames: readonly QuakePickupModelAnimationFrame[], prefix: string, @@ -3087,6 +3206,30 @@ function quakeRemotePlayerAnimationFrameIndexes( .map(({ index }) => index); } +function quakeRemotePlayerAnimationFrameIndexesByName( + frames: readonly QuakePickupModelAnimationFrame[], + frameNames: readonly string[], +): readonly number[] { + const desired = new Set(frameNames); + return frames + .map((frame, index) => ({ frame, index })) + .filter(({ frame }) => desired.has(frame.name)) + .map(({ index }) => index); +} + +function quakeRemotePlayerAttackFrameIndexesByWeapon( + frameSet: QuakeRenderBundleFrameSet | undefined, + frames: readonly QuakePickupModelAnimationFrame[], +): Record { + const indexes: Record = {}; + for (const [weapon, frameNames] of Object.entries(QUAKE_MULTIPLAYER_REMOTE_ATTACK_FRAME_NAMES_BY_WEAPON)) { + indexes[weapon] = frameSet + ? quakeRemotePlayerFrameIndexesByName(frameSet, frameNames) + : quakeRemotePlayerAnimationFrameIndexesByName(frames, frameNames); + } + return indexes; +} + function quakeRemotePlayerAnimationFrameSetForIndexes( frames: readonly QuakePickupModelAnimationFrame[], frameIndexes: readonly number[], @@ -3170,6 +3313,15 @@ function quakeRemotePlayerVisualFrameIndex( ); if (painFrameIndex !== null) return painFrameIndex; } + if (state.lastAttackAt !== undefined) { + const attackFrameIndex = quakeRemotePlayerVisualTransientFrameIndex( + quakeRemotePlayerAttackFrameIndexes(remote, state.lastAttackWeapon), + state.lastAttackAt, + state.renderAt, + QUAKE_MULTIPLAYER_REMOTE_ATTACK_FPS, + ); + if (attackFrameIndex !== null) return attackFrameIndex; + } const speed = quakeRemotePlayerHorizontalSpeed(state); if (speed < QUAKE_MULTIPLAYER_REMOTE_RUN_SPEED_THRESHOLD || !remote.runFrameIndexes.length) { return remote.standFrameIndex; @@ -3178,6 +3330,14 @@ function quakeRemotePlayerVisualFrameIndex( return remote.runFrameIndexes[runFrame % remote.runFrameIndexes.length] ?? remote.standFrameIndex; } +function quakeRemotePlayerAttackFrameIndexes( + remote: QuakeRemotePlayerMeshMount, + weapon: string | undefined, +): readonly number[] { + if (!weapon) return []; + return remote.attackFrameIndexesByWeapon[weapon] ?? []; +} + function quakeRemotePlayerVisualTransientFrameIndex( frameIndexes: readonly number[], startedAt: number, @@ -3418,9 +3578,13 @@ function quakeMultiplayerDebugSnapshot(): Record { lastReject: quakeMultiplayerLastReject, lastError: quakeMultiplayerLastError, remotePresenterCount: quakeRemotePlayers.count(), + remoteProjectileCount: quakeRemoteMultiplayerProjectiles.size, remoteDomCount: document.querySelectorAll(".remote-player").length, remoteVisibleDomCount: Array.from(document.querySelectorAll(".remote-player")) .filter((element) => !element.hidden).length, + remoteProjectileDomCount: document.querySelectorAll(".remote-projectile").length, + remoteVisibleProjectileDomCount: Array.from(document.querySelectorAll(".remote-projectile")) + .filter((element) => !element.hidden).length, scoreboardRows: quakeMultiplayerScoreboard?.querySelectorAll("tbody tr").length ?? 0, }; } @@ -3463,6 +3627,7 @@ function stopQuakeMultiplayerScene( } quakeMultiplayerLastInputAt = 0; quakeMultiplayerLastInputSentAt = 0; + quakeMultiplayerPendingInputs = []; quakeMultiplayerLastPoseAt = 0; quakeMultiplayerLastPresenceStatusSent = null; quakeMultiplayerHelloAccepted = false; @@ -3472,6 +3637,8 @@ function stopQuakeMultiplayerScene( quakeMultiplayerLocalPingMs = null; quakeMultiplayerLastReconciledInputSequence = 0; quakeMultiplayerLastInventoryFingerprint = null; + quakeMultiplayerDynamicPickupDefinitions.clear(); + pickups?.clearRuntimePickups(); quakeMultiplayerPickupRequestAt.clear(); quakeMultiplayerWorldRequestAt.clear(); quakeMultiplayerLastRoomEvent = null; @@ -3487,6 +3654,7 @@ function stopQuakeMultiplayerScene( quakeMultiplayerLastError = null; quakeMultiplayerSession.disconnect(reason); quakeRemotePlayers.clear(); + clearQuakeMultiplayerRemoteProjectiles(); syncQuakeMultiplayerScoreboard([]); } @@ -3524,7 +3692,9 @@ function handleQuakeMultiplayerRoomMessage(message: QuakeMultiplayerRoomEnvelope quakeMultiplayerLastMatchState = message.payload.match; quakeMultiplayerSpectatorCount = message.payload.spectators?.length ?? 0; syncQuakeMultiplayerScoreboard(message.payload.players, quakeMultiplayerSpectatorCount); + if (message.payload.dynamicPickups) syncQuakeMultiplayerDynamicPickups(message.payload.dynamicPickups); if (message.payload.pickups) syncQuakeMultiplayerPickupStates(message.payload.pickups); + if (message.payload.projectiles) syncQuakeMultiplayerRemoteProjectileStates(message.payload.projectiles); const localPlayer = message.payload.players.find((candidate) => candidate.clientId === QUAKE_MULTIPLAYER_LOCAL_CLIENT_ID ); @@ -3560,12 +3730,21 @@ function handleQuakeMultiplayerRoomMessage(message: QuakeMultiplayerRoomEnvelope feedback: event.feedback, hide: !event.leaveInPlace, }); + if (!event.leaveInPlace) quakeMultiplayerDynamicPickupDefinitions.delete(event.entityIndex); } else if (event.eventType === "pickup.respawned") { getPickups().applyAuthoritativeRespawn(event.pickup.entityIndex); + } else if (event.eventType === "pickup.dropped") { + syncQuakeMultiplayerDynamicPickups([event.definition], { pruneMissing: false }); + } else if (event.eventType === "pickup.expired") { + removeQuakeMultiplayerDynamicPickup(event.entityIndex); } else if (event.eventType === "player.damaged") { handleQuakeMultiplayerPlayerDamaged(event); } else if (event.eventType === "player.killed") { handleQuakeMultiplayerPlayerKilled(event); + } else if (event.eventType === "projectile.spawned") { + handleQuakeMultiplayerProjectileSpawned(event); + } else if (event.eventType === "projectile.impacted") { + handleQuakeMultiplayerProjectileImpacted(event); } else if (event.eventType === "world.changed") { handleQuakeMultiplayerWorldChanged(event); } else if (event.eventType === "world.teleport") { @@ -3674,6 +3853,9 @@ function noteQuakeMultiplayerRoomEvent(event: QuakeMultiplayerSharedWorldEvent): ...("classname" in event && event.classname !== undefined ? { classname: event.classname } : {}), ...("activation" in event && event.activation !== undefined ? { activation: event.activation } : {}), ...("code" in event && event.code !== undefined ? { code: event.code } : {}), + ...("projectileId" in event && event.projectileId !== undefined ? { projectileId: event.projectileId } : {}), + ...("projectile" in event && event.projectile !== undefined ? { projectileId: event.projectile.projectileId } : {}), + ...("weapon" in event && event.weapon !== undefined ? { weapon: event.weapon } : {}), }; quakeMultiplayerLastRoomEvent = snapshot; quakeMultiplayerRecentRoomEvents = [...quakeMultiplayerRecentRoomEvents.slice(-15), snapshot]; @@ -3866,6 +4048,62 @@ function syncQuakeMultiplayerPickupStates(pickups: readonly QuakeMultiplayerAuth } } +function syncQuakeMultiplayerDynamicPickups( + definitions: readonly QuakeMultiplayerPickupDefinition[], + options: { pruneMissing?: boolean } = {}, +): void { + const pruneMissing = options.pruneMissing ?? true; + const nextEntityIndexes = new Set(definitions.map((definition) => definition.entityIndex)); + if (pruneMissing) { + for (const entityIndex of [...quakeMultiplayerDynamicPickupDefinitions.keys()]) { + if (!nextEntityIndexes.has(entityIndex)) removeQuakeMultiplayerDynamicPickup(entityIndex); + } + } + for (const definition of definitions) { + if (!definition.runtime) continue; + const previous = quakeMultiplayerDynamicPickupDefinitions.get(definition.entityIndex); + if (previous && JSON.stringify(previous) === JSON.stringify(definition)) continue; + if (previous) removeQuakeMultiplayerDynamicPickup(definition.entityIndex); + quakeMultiplayerDynamicPickupDefinitions.set(definition.entityIndex, definition); + spawnQuakeMultiplayerDynamicPickup(definition); + } +} + +function spawnQuakeMultiplayerDynamicPickup(definition: QuakeMultiplayerPickupDefinition): void { + const entity: QuakeEntity = { + index: definition.entityIndex, + classname: definition.classname, + origin: { + x: definition.origin[0], + y: definition.origin[1], + z: definition.origin[2], + }, + properties: { + classname: definition.classname, + origin: definition.origin.join(" "), + }, + }; + getPickups().addRuntimePickup({ + effect: definition.effect as QuakePickupEffect, + entity, + ...(definition.feedback ? { feedback: definition.feedback } : {}), + ...(definition.modelPath ? { modelPath: definition.modelPath } : {}), + origin: [definition.origin[0], definition.origin[1], definition.origin[2]], + ...(definition.removeAt !== undefined + ? { removeAfterSeconds: Math.max(0, (definition.removeAt - Date.now()) / 1000) } + : {}), + visibilityOrigin: controls.getOrigin(), + }); +} + +function removeQuakeMultiplayerDynamicPickup(entityIndex: number): void { + quakeMultiplayerDynamicPickupDefinitions.delete(entityIndex); + getPickups().applyAuthoritativePickup(entityIndex, { + applyEffect: false, + hide: true, + }); +} + function applyQuakeMultiplayerInventoryState(state: QuakeMultiplayerInventoryState): void { const inventory = getPlayer().inventory(); inventory.health = state.health; @@ -3989,11 +4227,15 @@ function quakeMultiplayerRemoteDamageFromLocalPlayer( } function quakeMultiplayerShouldSuppressLocalWallImpact(event: QuakeWeaponWallImpactEvent): boolean { - return QUAKE_MULTIPLAYER_ENABLED && - quakeMultiplayerSession.status().state === "connected" && + return quakeMultiplayerRoomOwnsLocalDamage() && (event.fireKind === "hitscan" || event.fireKind === "beam"); } +function quakeMultiplayerRoomOwnsLocalDamage(): boolean { + return QUAKE_MULTIPLAYER_ENABLED && + quakeMultiplayerSession.status().state === "connected"; +} + function handleQuakeMultiplayerPlayerKilled( event: Extract, ): void { @@ -4002,6 +4244,103 @@ function handleQuakeMultiplayerPlayerKilled( if (!quakePlayerDead) showQuakePlayerDeath(); } +function handleQuakeMultiplayerProjectileSpawned( + event: Extract, +): void { + syncQuakeMultiplayerRemoteProjectileState(event.projectile); +} + +function syncQuakeMultiplayerRemoteProjectileStates(projectiles: readonly QuakeMultiplayerProjectileState[]): void { + const seen = new Set(); + for (const projectile of projectiles) { + if (projectile.ownerPlayerId === quakeMultiplayerPlayerIdForClient(QUAKE_MULTIPLAYER_LOCAL_CLIENT_ID)) continue; + seen.add(projectile.projectileId); + syncQuakeMultiplayerRemoteProjectileState(projectile); + } + for (const [projectileId, visual] of quakeRemoteMultiplayerProjectiles) { + if (seen.has(projectileId)) continue; + visual.handle.handle.remove(); + quakeRemoteMultiplayerProjectiles.delete(projectileId); + } +} + +function syncQuakeMultiplayerRemoteProjectileState(projectile: QuakeMultiplayerProjectileState): void { + if (projectile.ownerPlayerId === quakeMultiplayerPlayerIdForClient(QUAKE_MULTIPLAYER_LOCAL_CLIENT_ID)) return; + if (!isQuakeWeaponId(projectile.weapon)) return; + const modelPath = quakeWeaponProjectileModelPath(projectile.weapon); + if (!modelPath) return; + const existing = quakeRemoteMultiplayerProjectiles.get(projectile.projectileId); + let visual = existing; + if (existing && existing.weapon !== projectile.weapon) { + existing.handle.handle.remove(); + quakeRemoteMultiplayerProjectiles.delete(projectile.projectileId); + visual = undefined; + } + if (!visual) { + const handle = quakeWeaponPresentation.addProjectileMesh(modelPath, projectile.weapon); + if (!handle) return; + handle.handle.element.classList.add("remote-projectile", `remote-projectile-${projectile.weapon}`); + handle.handle.element.dataset.projectileId = projectile.projectileId; + handle.handle.element.dataset.ownerPlayerId = projectile.ownerPlayerId; + visual = { + handle, + ownerPlayerId: projectile.ownerPlayerId, + weapon: projectile.weapon, + }; + quakeRemoteMultiplayerProjectiles.set(projectile.projectileId, visual); + } + syncQuakeMultiplayerRemoteProjectileVisual(visual, projectile.origin, projectile.direction, projectile.speed); +} + +function handleQuakeMultiplayerProjectileImpacted( + event: Extract, +): void { + const visual = quakeRemoteMultiplayerProjectiles.get(event.projectileId); + if (visual) { + syncQuakeMultiplayerRemoteProjectileVisual(visual, event.origin); + visual.handle.handle.remove(); + quakeRemoteMultiplayerProjectiles.delete(event.projectileId); + } + if (event.ownerPlayerId === quakeMultiplayerPlayerIdForClient(QUAKE_MULTIPLAYER_LOCAL_CLIENT_ID)) return; + if (event.weapon === "grenadelauncher" || event.weapon === "rocketlauncher") { + quakeEffectSpriteFlow.spawnExplosion({ + origin: event.origin, + radiusUnits: 120, + }); + } else if (event.impactKind === "world" && (event.weapon === "nailgun" || event.weapon === "supernailgun")) { + quakeImpactParticleFlow.spawnWallImpact({ + count: quakeWallImpactParticleCount(event.weapon === "supernailgun" ? "superspike" : "spike"), + origin: event.origin, + }); + } +} + +function syncQuakeMultiplayerRemoteProjectileVisual( + visual: QuakeRemoteMultiplayerProjectileVisual, + origin: Vec3, + direction: Vec3 = [1, 0, 0], + speed = 0, +): void { + const velocity = [ + direction[0] * speed, + direction[1] * speed, + direction[2] * speed, + ] as Vec3; + visual.handle.handle.element.dataset.remoteProjectileOrigin = origin + .map((coordinate) => coordinate.toFixed(4)) + .join(","); + visual.handle.handle.setTransform({ + position: origin, + rotation: [0, 0, quakeProjectileRenderYaw((Math.atan2(velocity[1], velocity[0]) * 180) / Math.PI)], + scale: visual.handle.scale, + }); +} + +function clearQuakeMultiplayerRemoteProjectiles(): void { + for (const visual of quakeRemoteMultiplayerProjectiles.values()) visual.handle.handle.remove(); + quakeRemoteMultiplayerProjectiles.clear(); +} + function handleQuakeMultiplayerWorldTeleport( event: Extract, ): void { @@ -4398,7 +4737,10 @@ function currentQuakeMultiplayerPickupDefinitions(): readonly QuakeMultiplayerPi playerMinsZ: QUAKE_PLAYER_MINS_Z, }).pickupDefinitions; } - return quakeMultiplayerPickupDefinitions; + return [ + ...quakeMultiplayerPickupDefinitions, + ...quakeMultiplayerDynamicPickupDefinitions.values(), + ]; } function sendQuakeMultiplayerHazardDamageIntent(hazard: QuakeHazardDamage): boolean { @@ -4458,17 +4800,39 @@ function sendQuakeMultiplayerInput(roomKey: QuakeMultiplayerRoomCompatibilityKey const input = quakeMultiplayerLocalInputIntent(sampleNow, sentAt); quakeMultiplayerLastInputAt = sampleNow; quakeMultiplayerLastInputSentAt = sentAt; - quakeMultiplayerSession.send(createQuakeMultiplayerEnvelope({ - direction: "client", - type: "client.input", - roomKey, - sequence: ++quakeMultiplayerClientSequence, - sentAt, - payload: { - clientId: QUAKE_MULTIPLAYER_LOCAL_CLIENT_ID, - input, - }, - })); + quakeMultiplayerPendingInputs.push(input); + flushQuakeMultiplayerPendingInputs(roomKey, sentAt); +} + +function flushQuakeMultiplayerPendingInputs(roomKey: QuakeMultiplayerRoomCompatibilityKey, sentAt: number): void { + while (quakeMultiplayerPendingInputs.length > 0) { + const inputs = quakeMultiplayerPendingInputs.splice(0, QUAKE_MULTIPLAYER_MAX_INPUT_BATCH_SIZE); + if (inputs.length === 1) { + quakeMultiplayerSession.send(createQuakeMultiplayerEnvelope({ + direction: "client", + type: "client.input", + roomKey, + sequence: ++quakeMultiplayerClientSequence, + sentAt, + payload: { + clientId: QUAKE_MULTIPLAYER_LOCAL_CLIENT_ID, + input: inputs[0], + }, + })); + continue; + } + quakeMultiplayerSession.send(createQuakeMultiplayerEnvelope({ + direction: "client", + type: "client.inputBatch", + roomKey, + sequence: ++quakeMultiplayerClientSequence, + sentAt, + payload: { + clientId: QUAKE_MULTIPLAYER_LOCAL_CLIENT_ID, + inputs, + }, + })); + } } function quakeMultiplayerLocalInputIntent(sampleNow: number, sampledAt: number): QuakeMultiplayerLocalInputIntent { @@ -4593,7 +4957,7 @@ function sendQuakeMultiplayerDebugPose(): boolean { } function setQuakeMultiplayerDebugInputPaused(paused: boolean): boolean { - if (!QUAKE_MULTIPLAYER_ENABLED || !QUAKE_MULTIPLAYER_DEBUG_REQUESTED) return false; + if (!QUAKE_MULTIPLAYER_ENABLED || !QUAKE_MULTIPLAYER_DEBUG_HOOKS_ENABLED) return false; setQuakeMultiplayerInputPaused(paused); if (!paused) scheduleQuakeMultiplayerPoseFrame(); return true; @@ -4891,6 +5255,7 @@ function installQuakeAppDebugHooks(): void { mapExists: quakeAssetCatalog.mapExists, pointToPoly: quakeCameraView.pointToPoly, renderOrigin: quakeCameraView.currentRenderOrigin, + requestMultiplayerPickup: requestQuakeMultiplayerPickup, setCollisionBypassUntil: (until) => { quakeDebugCollisionBypassUntil = until; }, @@ -5146,6 +5511,7 @@ const quakeAppRuntime = createQuakeAppRuntimeContext({ movers, pickups: getPickups, player: getPlayer, + damageableBrushes: quakeDamageableBrushes, shootables, targets: targetSystem, triggers: triggerSystem, @@ -5164,6 +5530,8 @@ const quakeAppRuntime = createQuakeAppRuntimeContext({ }, gameplay: { isPaused: isQuakeGamePaused, + resumeForDebugInput: resumeQuakeDebugGameplayInput, + runWithDebugInput: runQuakeWithDebugGameplayInput, setPaused: setQuakeGamePaused, isPlayerDead: () => quakePlayerDead, isStarted: () => quakeGameplayStarted, diff --git a/src/runtime/app/assetWarmupFlow.ts b/src/runtime/app/assetWarmupFlow.ts index d181f18..f030ea8 100644 --- a/src/runtime/app/assetWarmupFlow.ts +++ b/src/runtime/app/assetWarmupFlow.ts @@ -20,6 +20,8 @@ export interface QuakeAssetWarmupFlowOptions { isDisposed(): boolean; onPickupModelLibrary(library: QuakePickupModelLibrary): void; onProgramMetadata(metadata: QuakeProgramMetadata): void; + shouldSpawnPickup?(entity: QuakeEntity): boolean; + shouldSpawnShootable?(entity: QuakeEntity): boolean; } export interface QuakeAssetWarmupFlow { @@ -95,13 +97,13 @@ export function createQuakeAssetWarmupFlow(options: QuakeAssetWarmupFlowOptions) const entitiesByIndex = new Map(scene.entities.map((entity) => [entity.index, entity])); const pickupEntities = sceneEntitiesForIndexes(entitiesByIndex, runtime.pickupEntityIndexes); for (const entity of pickupEntities) { - if (!shouldSpawnQuakeEntityForCurrentGame(entity)) continue; + if (!shouldSpawnPickup(entity)) continue; const modelPath = quakePickupModelPath(entity, currentProgramMetadata, scene.gameLogic); if (modelPath) pickupModelPaths.add(modelPath); } const shootableEntities = sceneEntitiesForIndexes(entitiesByIndex, runtime.shootableEntityIndexes); for (const entity of shootableEntities) { - if (!shouldSpawnQuakeEntityForCurrentGame(entity)) continue; + if (!shouldSpawnShootable(entity)) continue; const modelPath = quakeShootableModelPath(entity, currentProgramMetadata); if (modelPath) monsterModelPaths.add(modelPath); } @@ -121,6 +123,14 @@ export function createQuakeAssetWarmupFlow(options: QuakeAssetWarmupFlowOptions) ]); } + function shouldSpawnPickup(entity: QuakeEntity): boolean { + return options.shouldSpawnPickup?.(entity) ?? shouldSpawnQuakeEntityForCurrentGame(entity); + } + + function shouldSpawnShootable(entity: QuakeEntity): boolean { + return options.shouldSpawnShootable?.(entity) ?? shouldSpawnQuakeEntityForCurrentGame(entity); + } + return { loadPickupModels, loadProgramMetadata, diff --git a/src/runtime/app/context.ts b/src/runtime/app/context.ts index 92b4476..5e8ae3f 100644 --- a/src/runtime/app/context.ts +++ b/src/runtime/app/context.ts @@ -3,6 +3,7 @@ import type { createPolyFirstPersonControls, createPolyScene } from "@layoutit/p import type { QuakeEntity, QuakeScene } from "../../types/quake"; import type { QuakeCollisionWorld } from "../collision"; import type { createQuakeSoundController } from "../audio"; +import type { QuakeDamageableBrushFlow } from "./damageableBrushFlow"; import type { createQuakeMenuController } from "../menu"; import type { createQuakeMoversController } from "../movers"; import type { createQuakePickupController } from "../pickups"; @@ -34,6 +35,7 @@ export interface QuakeAppRuntimeContext { readonly sceneElement: HTMLElement; readonly controllers: { readonly audio: QuakeAppAudioController; + readonly damageableBrushes: QuakeDamageableBrushFlow; readonly menu: QuakeAppMenuController; readonly movers: QuakeAppMoversController; readonly pickups: () => QuakeAppPickupController; @@ -56,6 +58,8 @@ export interface QuakeAppRuntimeContext { }; readonly gameplay: { readonly isPaused: () => boolean; + readonly resumeForDebugInput: () => void; + readonly runWithDebugInput: (callback: () => T) => T; readonly setPaused: (paused: boolean) => void; readonly isPlayerDead: () => boolean; readonly isStarted: () => boolean; diff --git a/src/runtime/app/debugApi.ts b/src/runtime/app/debugApi.ts index eb22915..217575b 100644 --- a/src/runtime/app/debugApi.ts +++ b/src/runtime/app/debugApi.ts @@ -29,6 +29,7 @@ export interface QuakeAppDebugApiOptions { mapExists(mapName: string): boolean; pointToPoly(point: { x: number; y: number; z: number }): Vec3; renderOrigin(): Vec3; + requestMultiplayerPickup(entityIndex: number): boolean; setCollisionBypassUntil(until: number): void; setMultiplayerInputPaused(paused: boolean): boolean; syncHud(): void; @@ -56,6 +57,7 @@ function createQuakeAppDebugRuntime({ mapExists, pointToPoly, renderOrigin, + requestMultiplayerPickup, setCollisionBypassUntil, setMultiplayerInputPaused, syncHud, @@ -84,6 +86,7 @@ function createQuakeAppDebugRuntime({ setOrigin: (origin) => runtime.controllers.player().setDebugOrigin(origin), }, currentMapName: runtime.session.currentMapName, + damageableBrushesStats: () => runtime.controllers.damageableBrushes.snapshot(), damagePlayer: (amount, context) => runtime.controllers.player().damage(amount, context), damageWeaponTarget: (entityIndex, amount) => runtime.controllers.shootables.debugDamageWeaponTarget(entityIndex, amount), @@ -114,8 +117,14 @@ function createQuakeAppDebugRuntime({ runtime.controllers.shootables.debugSetEnemyProjectileCaptureEnabled(enabled), enemyProjectileTraceStep: (dtMs) => runtime.controllers.shootables.debugStepEnemyProjectiles(dtMs), entities: runtime.session.entities, - fireWeapon: () => runtime.controllers.weapons.fire(), - fireWeaponDebug: (options) => runtime.controllers.weapons.debugFireProjectile(options), + fireWeapon: () => { + runtime.gameplay.resumeForDebugInput(); + return runtime.gameplay.runWithDebugInput(() => runtime.controllers.weapons.fire()); + }, + fireWeaponDebug: (options) => { + runtime.gameplay.resumeForDebugInput(); + return runtime.gameplay.runWithDebugInput(() => runtime.controllers.weapons.debugFireProjectile(options)); + }, fireballEmittersCount, fireballsCount, floorAt: (x, y, maxZ, minZ) => @@ -146,11 +155,13 @@ function createQuakeAppDebugRuntime({ playerMoveDebug: () => runtime.controllers.player().debugMovement(), pointToPoly, renderOrigin, + requestMultiplayerPickup, projectileImpact: (weapon, entityIndex, origin, directDamage) => runtime.controllers.weapons.debugProjectileImpact(weapon, entityIndex, origin, directDamage), projectileTraceCapture: () => runtime.controllers.weapons.debugProjectileCapture(), projectileTraceClear: () => runtime.controllers.weapons.debugClearProjectileCapture(), projectileTraceEnabled: (enabled) => runtime.controllers.weapons.debugSetProjectileCaptureEnabled(enabled), + runWithDebugInput: runtime.gameplay.runWithDebugInput, setUnmountedAi: (enabled) => runtime.controllers.shootables.setUnmountedAiEnabled(enabled), setCollisionBypassUntil, setShootableOrigin: (entityIndex, origin) => runtime.controllers.shootables.debugSetOrigin(entityIndex, origin), diff --git a/src/runtime/constants.ts b/src/runtime/constants.ts index ce079b9..7bd8f26 100644 --- a/src/runtime/constants.ts +++ b/src/runtime/constants.ts @@ -24,4 +24,5 @@ export const QUAKE_DOOR_TRIGGER_Z = 8 * QUAKE_COLLISION_UNIT_SCALE; export const QUAKE_SPAWNFLAG_NOT_EASY = 256; export const QUAKE_SPAWNFLAG_NOT_MEDIUM = 512; export const QUAKE_SPAWNFLAG_NOT_HARD = 1024; +export const QUAKE_SPAWNFLAG_NOT_DEATHMATCH = 2048; export const QUAKE_SINGLE_PLAYER_SKILL = 0; diff --git a/src/runtime/debug/quakeDebug.ts b/src/runtime/debug/quakeDebug.ts index 7dcf58c..f10966e 100644 --- a/src/runtime/debug/quakeDebug.ts +++ b/src/runtime/debug/quakeDebug.ts @@ -8,6 +8,7 @@ import type { QuakePlayerDamageContext } from "../player"; import type { QuakeMoversDebugStats } from "../movers"; import type { QuakePickupDebugStats } from "../pickups"; import type { QuakeCanDamageResult } from "../shootables/damage"; +import type { CssQuakeDamageableBrushProgressSnapshot } from "../saveLoad"; import type { QuakeEnemyProjectileDebugCapture, QuakeShootableEnemyAcquisitionDebugResult, @@ -40,6 +41,7 @@ export interface QuakeDebugHooks { ): QuakeCanDamageResult | null; contentsAt(x: number, y: number, z: number): number | null; copyViewUrl(): Promise; + damageableBrushesStats(): CssQuakeDamageableBrushProgressSnapshot; damage(amount?: number, inflictorX?: number, inflictorY?: number, inflictorZ?: number): boolean; damageWeaponTarget(entityIndex: number, amount?: number): boolean; debugMountEntity(entityIndex: number): boolean; @@ -79,6 +81,7 @@ export interface QuakeDebugHooks { focusEntity(entityIndex: number, distance?: number, rotX?: number, rotY?: number): boolean; loadMap(mapName: string): Promise; getWeaponTuning(): QuakeResolvedViewmodelTuning; + requestMultiplayerPickup(entityIndex: number): boolean; resetWeaponTuning(): QuakeResolvedViewmodelTuning; setExpandedLogicalCombat(enabled: boolean): boolean; setMountedEnemyAcquisition(enabled: boolean): boolean; @@ -154,6 +157,7 @@ export interface QuakeDebugRuntime { currentMapName(): string; contentsAt(point: { x: number; y: number; z: number }): number | null; activateEntity(entityIndex: number, sourceEntityIndex?: number): boolean; + damageableBrushesStats(): CssQuakeDamageableBrushProgressSnapshot; damagePlayer(amount: number, context?: QuakePlayerDamageContext): boolean; damageWeaponTarget(entityIndex: number, amount: number): boolean; debugMountEntity(entityIndex: number): boolean; @@ -171,7 +175,7 @@ export interface QuakeDebugRuntime { enemyProjectileTraceEnabled(enabled: boolean): void; enemyProjectileTraceStep(dtMs?: number): QuakeEnemyProjectileDebugCapture; entities(): ReadonlyMap; - fireWeapon(): void; + fireWeapon(): boolean; fireWeaponDebug(options?: QuakeWeaponProjectileDebugFireOptions): boolean; fireballEmittersCount(): number; fireballsCount(): number; @@ -200,6 +204,7 @@ export interface QuakeDebugRuntime { playerMoveDebug(): Record; pointToPoly(point: { x: number; y: number; z: number }): Vec3; renderOrigin(): Vec3; + requestMultiplayerPickup(entityIndex: number): boolean; projectileImpact( weapon: QuakeWeaponId, entityIndex: number, @@ -209,6 +214,7 @@ export interface QuakeDebugRuntime { projectileTraceCapture(): QuakeWeaponProjectileDebugCapture; projectileTraceClear(): void; projectileTraceEnabled(enabled: boolean): void; + runWithDebugInput(callback: () => T): T; setCollisionBypassUntil(until: number): void; setShootableOrigin(entityIndex: number, origin: Vec3): boolean; setShootableYaw(entityIndex: number, yaw: number): boolean; @@ -242,6 +248,7 @@ export function installQuakeDebugHooks(enabled: boolean, runtime: QuakeDebugRunt canDamageQuakeDebugTrace(runtime, inflictorX, inflictorY, inflictorZ, targetX, targetY, targetZ), contentsAt: (x, y, z) => contentsAtQuakeDebugPoint(runtime, x, y, z), copyViewUrl: () => runtime.copyViewUrl(), + damageableBrushesStats: () => runtime.damageableBrushesStats(), damage: (amount, inflictorX, inflictorY, inflictorZ) => damageQuakeDebugPlayer(runtime, amount, inflictorX, inflictorY, inflictorZ), damageWeaponTarget: (entityIndex, amount) => @@ -270,6 +277,7 @@ export function installQuakeDebugHooks(enabled: boolean, runtime: QuakeDebugRunt loadMap: (mapName) => loadQuakeDebugMap(runtime, mapName), projectileImpact: (weapon, entityIndex, x, y, z, directDamage) => projectileImpactQuakeDebugWeapon(runtime, weapon, entityIndex, x, y, z, directDamage), + requestMultiplayerPickup: (entityIndex) => requestQuakeDebugMultiplayerPickup(runtime, entityIndex), resetWeaponTuning: () => runtime.resetWeaponTuning(), setExpandedLogicalCombat: (enabled) => setQuakeDebugExpandedLogicalCombat(runtime, enabled), setMountedEnemyAcquisition: (enabled) => setQuakeDebugMountedEnemyAcquisition(runtime, enabled), @@ -314,6 +322,13 @@ function touchQuakeDebugEntity(runtime: QuakeDebugRuntime, entityIndex: number): return runtime.touchEntity(entityIndex); } +function requestQuakeDebugMultiplayerPickup(runtime: QuakeDebugRuntime, entityIndex: number): boolean { + if (runtime.isLoading() || !runtime.hasCurrentScene()) return false; + if (!Number.isInteger(entityIndex)) return false; + runtime.hideMainMenu(); + return runtime.requestMultiplayerPickup(entityIndex); +} + function syncQuakeDebugMultiplayerPose(runtime: QuakeDebugRuntime): boolean { if (runtime.isLoading() || !runtime.hasCurrentScene()) return false; runtime.hideMainMenu(); @@ -525,8 +540,7 @@ async function loadQuakeDebugMap(runtime: QuakeDebugRuntime, mapName: string): P function fireQuakeDebugWeapon(runtime: QuakeDebugRuntime): boolean { if (runtime.isLoading() || !runtime.hasCurrentScene()) return false; runtime.hideMainMenu(); - runtime.fireWeapon(); - return true; + return runtime.runWithDebugInput(() => runtime.fireWeapon()); } function startQuakeDebugGameplayRecording(runtime: QuakeDebugRuntime): boolean { @@ -548,30 +562,32 @@ async function fireProjectileTraceQuakeDebugWeapon( if (directDamage !== undefined && !Number.isFinite(directDamage)) return null; if (!Number.isFinite(timeoutMs)) return null; runtime.hideMainMenu(); - runtime.projectileTraceClear(); - runtime.projectileTraceEnabled(true); - const fired = runtime.fireWeaponDebug({ directDamage }); - if (!fired) { - const capture = runtime.projectileTraceCapture(); - runtime.projectileTraceEnabled(false); - return { capture, fired }; - } - - const deadline = performance.now() + Math.max(1, Math.min(10_000, timeoutMs)); - while (performance.now() < deadline) { - const capture = runtime.projectileTraceCapture(); - const removed = capture.events.some((event) => event.type === "remove"); - const impacted = capture.events.some((event) => event.type === "impact" && event.impactResult === "remove"); - if ((removed && impacted) || (impacted && capture.activeCount === 0)) { + return await runtime.runWithDebugInput(async () => { + runtime.projectileTraceClear(); + runtime.projectileTraceEnabled(true); + const fired = runtime.fireWeaponDebug({ directDamage }); + if (!fired) { + const capture = runtime.projectileTraceCapture(); runtime.projectileTraceEnabled(false); return { capture, fired }; } - await nextAnimationFrame(); - } - const capture = runtime.projectileTraceCapture(); - runtime.projectileTraceEnabled(false); - return { capture, fired }; + const deadline = performance.now() + Math.max(1, Math.min(10_000, timeoutMs)); + while (performance.now() < deadline) { + const capture = runtime.projectileTraceCapture(); + const removed = capture.events.some((event) => event.type === "remove"); + const impacted = capture.events.some((event) => event.type === "impact" && event.impactResult === "remove"); + if ((removed && impacted) || (impacted && capture.activeCount === 0)) { + runtime.projectileTraceEnabled(false); + return { capture, fired }; + } + await nextAnimationFrame(); + } + + const capture = runtime.projectileTraceCapture(); + runtime.projectileTraceEnabled(false); + return { capture, fired }; + }); } function nextAnimationFrame(): Promise { diff --git a/src/runtime/entities.ts b/src/runtime/entities.ts index 217911a..1f3589e 100644 --- a/src/runtime/entities.ts +++ b/src/runtime/entities.ts @@ -1,6 +1,7 @@ import type { QuakeEntity } from "../types/quake"; import { QUAKE_SINGLE_PLAYER_SKILL, + QUAKE_SPAWNFLAG_NOT_DEATHMATCH, QUAKE_SPAWNFLAG_NOT_EASY, QUAKE_SPAWNFLAG_NOT_HARD, QUAKE_SPAWNFLAG_NOT_MEDIUM, @@ -15,11 +16,28 @@ export function quakeEntitySpawnflags(entity: QuakeEntity): number { return Math.trunc(quakeEntityNumber(entity, "spawnflags", 0)); } +export interface QuakeEntitySpawnMode { + deathmatch?: boolean; + skill?: number; +} + export function shouldSpawnQuakeEntityForCurrentGame(entity: QuakeEntity): boolean { + return shouldSpawnQuakeEntityForGameMode(entity, { skill: QUAKE_SINGLE_PLAYER_SKILL }); +} + +export function shouldSpawnQuakeEntityForGameMode( + entity: QuakeEntity, + mode: QuakeEntitySpawnMode = {}, +): boolean { const spawnflags = quakeEntitySpawnflags(entity); - if (QUAKE_SINGLE_PLAYER_SKILL <= 0 && (spawnflags & QUAKE_SPAWNFLAG_NOT_EASY)) return false; - if (QUAKE_SINGLE_PLAYER_SKILL === 1 && (spawnflags & QUAKE_SPAWNFLAG_NOT_MEDIUM)) return false; - if (QUAKE_SINGLE_PLAYER_SKILL >= 2 && (spawnflags & QUAKE_SPAWNFLAG_NOT_HARD)) return false; + if (mode.deathmatch === true) { + return (spawnflags & QUAKE_SPAWNFLAG_NOT_DEATHMATCH) === 0; + } + const skill = typeof mode.skill === "number" && Number.isFinite(mode.skill) + ? mode.skill + : QUAKE_SINGLE_PLAYER_SKILL; + if (skill <= 0 && (spawnflags & QUAKE_SPAWNFLAG_NOT_EASY)) return false; + if (skill === 1 && (spawnflags & QUAKE_SPAWNFLAG_NOT_MEDIUM)) return false; + if (skill >= 2 && (spawnflags & QUAKE_SPAWNFLAG_NOT_HARD)) return false; return true; } - diff --git a/src/runtime/multiplayer/authority.ts b/src/runtime/multiplayer/authority.ts index ad117cf..c88b627 100644 --- a/src/runtime/multiplayer/authority.ts +++ b/src/runtime/multiplayer/authority.ts @@ -32,7 +32,8 @@ export type QuakeMultiplayerClientAuthorityResult = export const QUAKE_MULTIPLAYER_DEFAULT_CLIENT_MESSAGE_INTERVAL_MS = { "client.hello": 250, "client.presence": 0, - "client.input": 10, + "client.input": 0, + "client.inputBatch": 0, "client.fire": 25, "client.damage": 100, "client.pickup": 150, @@ -127,6 +128,7 @@ export function quakeMultiplayerClientIdForEnvelope( case "client.hello": case "client.presence": case "client.input": + case "client.inputBatch": case "client.fire": case "client.damage": case "client.pickup": @@ -146,6 +148,11 @@ function quakeMultiplayerClientIntentSequence( switch (message.type) { case "client.input": return { key: "input", sequence: message.payload.input.inputSequence }; + case "client.inputBatch": + return { + key: "input", + sequence: message.payload.inputs[message.payload.inputs.length - 1]?.inputSequence ?? 0, + }; case "client.pose": return { key: "pose", sequence: message.payload.pose.poseSequence }; case "client.fire": diff --git a/src/runtime/multiplayer/deathmatch.ts b/src/runtime/multiplayer/deathmatch.ts index 277e418..c9c0e43 100644 --- a/src/runtime/multiplayer/deathmatch.ts +++ b/src/runtime/multiplayer/deathmatch.ts @@ -1,6 +1,9 @@ import type { QuakeEntity, QuakeScene } from "../../types/quake"; import type { QuakeCollisionWorld } from "../collision"; import { + COLLISION_EPSILON, + PLAYER_HEIGHT, + PLAYER_RADIUS, QUAKE_COLLISION_UNIT_SCALE, QUAKE_PLAYER_MINS_Z, QUAKE_PLAYER_VIEW_Z, @@ -9,6 +12,7 @@ import { quakePlayerWaterLevel } from "../hazards"; import type { QuakeMultiplayerAuthoritativePlayerState, QuakeMultiplayerClientDamageEnvelope, + QuakeMultiplayerFireDecisionReason, QuakeMultiplayerFireIntent, QuakeMultiplayerRoomRejectPayload, QuakeMultiplayerSpawnPoint, @@ -29,17 +33,26 @@ const QUAKE_MULTIPLAYER_DEATHMATCH_RADIUS_DAMAGE_EXTRA_RANGE = 40; const QUAKE_MULTIPLAYER_DEATHMATCH_SHOTGUN_COOLDOWN_MS = 500; const QUAKE_MULTIPLAYER_DEATHMATCH_SUPER_SHOTGUN_COOLDOWN_MS = 700; const QUAKE_MULTIPLAYER_DEATHMATCH_AXE_COOLDOWN_MS = 500; -const QUAKE_MULTIPLAYER_DEATHMATCH_NAIL_COOLDOWN_MS = 100; -const QUAKE_MULTIPLAYER_DEATHMATCH_EXPLOSIVE_COOLDOWN_MS = 800; -const QUAKE_MULTIPLAYER_DEATHMATCH_LIGHTNING_COOLDOWN_MS = 100; +const QUAKE_MULTIPLAYER_DEATHMATCH_NAIL_COOLDOWN_MS = 200; +const QUAKE_MULTIPLAYER_DEATHMATCH_GRENADE_COOLDOWN_MS = 600; +const QUAKE_MULTIPLAYER_DEATHMATCH_ROCKET_COOLDOWN_MS = 800; +const QUAKE_MULTIPLAYER_DEATHMATCH_LIGHTNING_COOLDOWN_MS = 200; const QUAKE_MULTIPLAYER_DEATHMATCH_HIT_RADIUS = 0.7; const QUAKE_MULTIPLAYER_DEATHMATCH_PROJECTILE_HIT_RADIUS = 0.95; -const QUAKE_MULTIPLAYER_DEATHMATCH_HIT_HEIGHT = 1.7; -const QUAKE_MULTIPLAYER_DEATHMATCH_MAX_HITSCAN_RANGE = 64; +const QUAKE_MULTIPLAYER_DEATHMATCH_HITSCAN_RANGE = 2048 * QUAKE_COLLISION_UNIT_SCALE; +const QUAKE_MULTIPLAYER_DEATHMATCH_NAIL_RANGE = 1000 * 6 * QUAKE_COLLISION_UNIT_SCALE; +const QUAKE_MULTIPLAYER_DEATHMATCH_GRENADE_RANGE = 600 * 2.5 * QUAKE_COLLISION_UNIT_SCALE; +const QUAKE_MULTIPLAYER_DEATHMATCH_ROCKET_RANGE = 1000 * 5 * QUAKE_COLLISION_UNIT_SCALE; +const QUAKE_MULTIPLAYER_DEATHMATCH_LIGHTNING_RANGE = 600 * QUAKE_COLLISION_UNIT_SCALE; const QUAKE_MULTIPLAYER_DEATHMATCH_MELEE_RANGE = 2.1; -const QUAKE_MULTIPLAYER_DEATHMATCH_SPLASH_RADIUS = 4.2; const QUAKE_MULTIPLAYER_DEATHMATCH_MIN_FIRE_DIRECTION_LENGTH = 0.5; const QUAKE_MULTIPLAYER_DEATHMATCH_CAN_DAMAGE_OFFSET = 15 * QUAKE_COLLISION_UNIT_SCALE; +const QUAKE_MULTIPLAYER_DEATHMATCH_TARGET_LOS_MIN_TRACE_FRACTION = 0.95; +const QUAKE_MULTIPLAYER_DEATHMATCH_FIRE_ORIGIN_HINT_MAX_HORIZONTAL_DRIFT = 3; +const QUAKE_MULTIPLAYER_DEATHMATCH_FIRE_ORIGIN_HINT_MAX_VERTICAL_DRIFT = 8; +const QUAKE_MULTIPLAYER_DEATHMATCH_REMOTE_RENDER_REWIND_MS = 100; +const QUAKE_MULTIPLAYER_DEATHMATCH_MAX_REWIND_MS = 250; +const QUAKE_MULTIPLAYER_DEATHMATCH_SPAWN_CLEAR_RADIUS = 84 * QUAKE_COLLISION_UNIT_SCALE; export interface QuakeMultiplayerDeathmatchSpawnOptions { pointToPoly(point: { x: number; y: number; z: number }): QuakeMultiplayerVec3; @@ -47,6 +60,10 @@ export interface QuakeMultiplayerDeathmatchSpawnOptions { playerMinsZ: number; } +export interface QuakeMultiplayerDeathmatchHitOptions { + targetRewindMs?: number; +} + export interface QuakeMultiplayerDeathmatchHit { target: QuakeMultiplayerAuthoritativePlayerState; damage: number; @@ -55,10 +72,22 @@ export interface QuakeMultiplayerDeathmatchHit { lateralMiss: number; } +export interface QuakeMultiplayerDeathmatchVisibleHitDecision { + blockedCandidateCount: number; + candidateCount: number; + hit: QuakeMultiplayerDeathmatchHit | null; + reason: Extract; +} + export interface QuakeMultiplayerDeathmatchSplashHit extends QuakeMultiplayerDeathmatchHit { direct: boolean; } +export interface QuakeMultiplayerDeathmatchProjectileImpact { + distance: number; + origin: QuakeMultiplayerVec3; +} + export interface QuakeMultiplayerDeathmatchLightningDischargeHit { target: QuakeMultiplayerAuthoritativePlayerState; damage: number; @@ -80,6 +109,15 @@ export interface QuakeMultiplayerDeathmatchDamageMomentumOptions { player: QuakeMultiplayerAuthoritativePlayerState; } +export interface QuakeMultiplayerDeathmatchSpawnSelection { + nextCursor: number; + spawn: QuakeMultiplayerSpawnPoint; +} + +export interface QuakeMultiplayerDeathmatchSpawnSelectionOptions { + random?: () => number; +} + export function quakeMultiplayerDeathmatchSpawnsFromScene( scene: QuakeScene, options: QuakeMultiplayerDeathmatchSpawnOptions, @@ -104,6 +142,25 @@ export function quakeMultiplayerDeathmatchSpawnOrder( return [...spawns]; } +export function quakeMultiplayerDeathmatchSelectSpawnPoint( + spawns: readonly QuakeMultiplayerSpawnPoint[], + players: Iterable>, + cursorOrOptions: number | QuakeMultiplayerDeathmatchSpawnSelectionOptions = {}, +): QuakeMultiplayerDeathmatchSpawnSelection | null { + if (!spawns.length) return null; + const playerList = [...players]; + const options = typeof cursorOrOptions === "number" ? {} : cursorOrOptions; + const random = options.random ?? Math.random; + const clearSpawns: QuakeMultiplayerSpawnPoint[] = []; + for (const spawn of spawns) { + if (quakeMultiplayerDeathmatchSpawnIsClear(spawn, playerList)) clearSpawns.unshift(spawn); + } + const candidates = clearSpawns.length ? clearSpawns : [...spawns]; + const spawn = candidates[quakeMultiplayerDeathmatchRandomSpawnIndex(candidates.length, random)] ?? candidates[0]; + const sourceIndex = Math.max(0, spawns.indexOf(spawn)); + return { spawn, nextCursor: sourceIndex + 1 }; +} + function quakeMultiplayerSpawnPointFromEntity( entity: QuakeEntity, options: QuakeMultiplayerDeathmatchSpawnOptions, @@ -132,6 +189,22 @@ function quakeMultiplayerSpawnYaw(entity: QuakeEntity): number { return (180 + angle + 360) % 360; } +function quakeMultiplayerDeathmatchSpawnIsClear( + spawn: QuakeMultiplayerSpawnPoint, + players: readonly Pick[], +): boolean { + return players.every((player) => + distance3(spawn.origin, player.origin) > QUAKE_MULTIPLAYER_DEATHMATCH_SPAWN_CLEAR_RADIUS + ); +} + +function quakeMultiplayerDeathmatchRandomSpawnIndex(count: number, random: () => number): number { + if (count <= 1) return 0; + const value = random(); + const normalized = Number.isFinite(value) ? Math.max(0, Math.min(0.999999, value)) : 0; + return Math.max(0, Math.min(count - 1, Math.round(normalized * (count - 1)))); +} + export function quakeMultiplayerDeathmatchWeaponDamage(weapon: string): number { const normalized = weapon.trim().toLowerCase(); if (normalized === "axe") return QUAKE_MULTIPLAYER_DEATHMATCH_AXE_DAMAGE; @@ -144,13 +217,26 @@ export function quakeMultiplayerDeathmatchWeaponDamage(weapon: string): number { return 0; } +export function quakeMultiplayerDeathmatchLagCompensationMs( + attacker: Pick, +): number { + const pingMs = Number.isFinite(attacker.pingMs) && attacker.pingMs !== undefined + ? Math.max(0, attacker.pingMs) + : 0; + return Math.min( + QUAKE_MULTIPLAYER_DEATHMATCH_MAX_REWIND_MS, + QUAKE_MULTIPLAYER_DEATHMATCH_REMOTE_RENDER_REWIND_MS + pingMs * 0.5, + ); +} + export function quakeMultiplayerDeathmatchWeaponCooldownMs(weapon: string): number { const normalized = weapon.trim().toLowerCase(); if (normalized === "axe") return QUAKE_MULTIPLAYER_DEATHMATCH_AXE_COOLDOWN_MS; if (normalized === "shotgun") return QUAKE_MULTIPLAYER_DEATHMATCH_SHOTGUN_COOLDOWN_MS; if (normalized === "supershotgun") return QUAKE_MULTIPLAYER_DEATHMATCH_SUPER_SHOTGUN_COOLDOWN_MS; if (normalized === "nailgun" || normalized === "supernailgun") return QUAKE_MULTIPLAYER_DEATHMATCH_NAIL_COOLDOWN_MS; - if (normalized === "grenadelauncher" || normalized === "rocketlauncher") return QUAKE_MULTIPLAYER_DEATHMATCH_EXPLOSIVE_COOLDOWN_MS; + if (normalized === "grenadelauncher") return QUAKE_MULTIPLAYER_DEATHMATCH_GRENADE_COOLDOWN_MS; + if (normalized === "rocketlauncher") return QUAKE_MULTIPLAYER_DEATHMATCH_ROCKET_COOLDOWN_MS; if (normalized === "lightning") return QUAKE_MULTIPLAYER_DEATHMATCH_LIGHTNING_COOLDOWN_MS; return Infinity; } @@ -159,7 +245,7 @@ export function quakeMultiplayerDeathmatchFragDeltaForKill(input: { attackerPlayerId?: string; victimPlayerId: string; }): number { - if (!input.attackerPlayerId) return 0; + if (!input.attackerPlayerId) return -1; return input.attackerPlayerId === input.victimPlayerId ? -1 : 1; } @@ -168,13 +254,15 @@ export function quakeMultiplayerDeathmatchFireFromPlayer( fire: QuakeMultiplayerFireIntent, ): QuakeMultiplayerFireIntent { const weapon = player.inventory?.activeWeapon ?? player.activeWeapon; + const direction = normalizedFireDirection(fire.direction) ?? + quakeMultiplayerDeathmatchForwardDirection(player.rotX, player.rotY); return { ...fire, weapon, fireKind: quakeMultiplayerDeathmatchFireKindForWeapon(weapon), range: quakeMultiplayerDeathmatchFireRangeForWeapon(weapon), - origin: player.origin, - direction: quakeMultiplayerDeathmatchForwardDirection(player.rotX, player.rotY), + origin: quakeMultiplayerDeathmatchFireOrigin(player.origin, fire.origin), + direction, }; } @@ -183,15 +271,24 @@ export function quakeMultiplayerDeathmatchFireKindForWeapon( ): QuakeMultiplayerFireIntent["fireKind"] { const normalized = weapon.trim().toLowerCase(); if (normalized === "axe") return "melee"; - if (normalized === "grenadelauncher" || normalized === "rocketlauncher") return "projectile"; + if ( + normalized === "nailgun" || + normalized === "supernailgun" || + normalized === "grenadelauncher" || + normalized === "rocketlauncher" + ) return "projectile"; if (normalized === "lightning") return "beam"; return "hitscan"; } export function quakeMultiplayerDeathmatchFireRangeForWeapon(weapon: string): number { - const kind = quakeMultiplayerDeathmatchFireKindForWeapon(weapon); - if (kind === "melee") return QUAKE_MULTIPLAYER_DEATHMATCH_MELEE_RANGE; - return QUAKE_MULTIPLAYER_DEATHMATCH_MAX_HITSCAN_RANGE; + const normalized = weapon.trim().toLowerCase(); + if (normalized === "axe") return QUAKE_MULTIPLAYER_DEATHMATCH_MELEE_RANGE; + if (normalized === "nailgun" || normalized === "supernailgun") return QUAKE_MULTIPLAYER_DEATHMATCH_NAIL_RANGE; + if (normalized === "grenadelauncher") return QUAKE_MULTIPLAYER_DEATHMATCH_GRENADE_RANGE; + if (normalized === "rocketlauncher") return QUAKE_MULTIPLAYER_DEATHMATCH_ROCKET_RANGE; + if (normalized === "lightning") return QUAKE_MULTIPLAYER_DEATHMATCH_LIGHTNING_RANGE; + return QUAKE_MULTIPLAYER_DEATHMATCH_HITSCAN_RANGE; } export function rejectQuakeMultiplayerClientDamageIntent( @@ -210,28 +307,84 @@ export function quakeMultiplayerDeathmatchHitscanHit( players: Iterable, attackerPlayerId: string, ): QuakeMultiplayerDeathmatchHit | null { + return quakeMultiplayerDeathmatchHitscanHits(fire, players, attackerPlayerId)[0] ?? null; +} + +export function quakeMultiplayerDeathmatchVisibleHit( + fire: QuakeMultiplayerFireIntent, + players: Iterable, + attackerPlayerId: string, + collisionWorld: Pick | null | undefined, + options: QuakeMultiplayerDeathmatchHitOptions = {}, +): QuakeMultiplayerDeathmatchHit | null { + return quakeMultiplayerDeathmatchVisibleHitDecision(fire, players, attackerPlayerId, collisionWorld, options).hit; +} + +export function quakeMultiplayerDeathmatchVisibleHitDecision( + fire: QuakeMultiplayerFireIntent, + players: Iterable, + attackerPlayerId: string, + collisionWorld: Pick | null | undefined, + options: QuakeMultiplayerDeathmatchHitOptions = {}, +): QuakeMultiplayerDeathmatchVisibleHitDecision { + const hits = quakeMultiplayerDeathmatchHitscanHits(fire, players, attackerPlayerId, options); + let blockedCandidateCount = 0; + for (const hit of hits) { + if (quakeMultiplayerDeathmatchHitHasLineOfSight(fire, hit, collisionWorld)) { + return { + blockedCandidateCount, + candidateCount: hits.length, + hit, + reason: "player-direct", + }; + } + blockedCandidateCount += 1; + } + return { + blockedCandidateCount, + candidateCount: hits.length, + hit: null, + reason: hits.length > 0 ? "line-of-sight-blocked" : "no-candidate", + }; +} + +export function quakeMultiplayerDeathmatchHitscanHits( + fire: QuakeMultiplayerFireIntent, + players: Iterable, + attackerPlayerId: string, + options: QuakeMultiplayerDeathmatchHitOptions = {}, +): QuakeMultiplayerDeathmatchHit[] { if ( fire.fireKind !== "hitscan" && fire.fireKind !== "projectile" && fire.fireKind !== "beam" && fire.fireKind !== "melee" - ) return null; + ) return []; const damage = quakeMultiplayerDeathmatchWeaponDamage(fire.weapon); - if (damage <= 0) return null; + if (damage <= 0) return []; const direction = normalizedFireDirection(fire.direction); - if (!direction) return null; + if (!direction) return []; const maxRange = quakeMultiplayerDeathmatchFireRange(fire); const hitRadius = fire.fireKind === "projectile" ? QUAKE_MULTIPLAYER_DEATHMATCH_PROJECTILE_HIT_RADIUS : QUAKE_MULTIPLAYER_DEATHMATCH_HIT_RADIUS; - let best: QuakeMultiplayerDeathmatchHit | null = null; + const hits: QuakeMultiplayerDeathmatchHit[] = []; for (const player of players) { if (player.playerId === attackerPlayerId || !player.alive) continue; - const hit = quakeMultiplayerDeathmatchPlayerHit(fire.origin, direction, maxRange, player, damage, hitRadius); + const hit = quakeMultiplayerDeathmatchPlayerHit( + fire.origin, + direction, + maxRange, + player, + damage, + hitRadius, + options.targetRewindMs, + ); if (!hit) continue; - if (!best || hit.distance < best.distance) best = hit; + hits.push(hit); } - return best; + hits.sort((left, right) => left.distance - right.distance); + return hits; } export function quakeMultiplayerDeathmatchSplashHits( @@ -239,22 +392,94 @@ export function quakeMultiplayerDeathmatchSplashHits( directHit: QuakeMultiplayerDeathmatchHit, players: Iterable, attackerPlayerId: string, + collisionWorld?: Pick | null, + options: QuakeMultiplayerDeathmatchHitOptions = {}, ): QuakeMultiplayerDeathmatchSplashHit[] { if (fire.fireKind !== "projectile") return [{ ...directHit, direct: true }]; if (!quakeMultiplayerDeathmatchWeaponHasSplash(fire.weapon)) return [{ ...directHit, direct: true }]; - const hits: QuakeMultiplayerDeathmatchSplashHit[] = [{ ...directHit, direct: true }]; + const splashOrigin = directHit.impact; + if (fire.weapon.trim().toLowerCase() === "grenadelauncher") { + return quakeMultiplayerDeathmatchProjectileSplashHitsAtImpact( + fire, + splashOrigin, + players, + attackerPlayerId, + collisionWorld, + undefined, + options, + ); + } + return [ + { ...directHit, damage: quakeMultiplayerDeathmatchDirectHitDamage(fire), direct: true }, + ...quakeMultiplayerDeathmatchProjectileSplashHitsAtImpact( + fire, + splashOrigin, + players, + attackerPlayerId, + collisionWorld, + directHit.target.playerId, + options, + ), + ]; +} + +export function quakeMultiplayerDeathmatchProjectileWorldSplashHits( + fire: QuakeMultiplayerFireIntent, + players: Iterable, + attackerPlayerId: string, + collisionWorld?: Pick | null, + options: QuakeMultiplayerDeathmatchHitOptions = {}, +): QuakeMultiplayerDeathmatchSplashHit[] { + const impact = quakeMultiplayerDeathmatchProjectileWorldImpact(fire, collisionWorld); + if (!impact) return []; + return quakeMultiplayerDeathmatchProjectileSplashHitsAtImpact( + fire, + impact.origin, + players, + attackerPlayerId, + collisionWorld, + undefined, + options, + ); +} + +export function quakeMultiplayerDeathmatchProjectileSplashHitsAtImpact( + fire: QuakeMultiplayerFireIntent, + splashOrigin: QuakeMultiplayerVec3, + players: Iterable, + attackerPlayerId: string, + collisionWorld?: Pick | null, + ignoredPlayerId?: string, + options: QuakeMultiplayerDeathmatchHitOptions = {}, +): QuakeMultiplayerDeathmatchSplashHit[] { + if (fire.fireKind !== "projectile") return []; + if (!quakeMultiplayerDeathmatchWeaponHasSplash(fire.weapon)) return []; + const baseDamage = quakeMultiplayerDeathmatchWeaponDamage(fire.weapon); + if (baseDamage <= 0) return []; + const splashRadius = quakeMultiplayerDeathmatchSplashRadius(baseDamage); + const hits: QuakeMultiplayerDeathmatchSplashHit[] = []; for (const player of players) { - if (player.playerId === directHit.target.playerId || !player.alive) continue; - const distance = distance3(player.origin, directHit.target.origin); - if (distance > QUAKE_MULTIPLAYER_DEATHMATCH_SPLASH_RADIUS) continue; - const damageScale = Math.max(0, 1 - distance / QUAKE_MULTIPLAYER_DEATHMATCH_SPLASH_RADIUS); - const damage = Math.round(directHit.damage * damageScale); + if (player.playerId === ignoredPlayerId || !player.alive) continue; + const target = { + ...player, + origin: quakeMultiplayerDeathmatchRewoundPlayerOrigin(player, options.targetRewindMs), + }; + if (!quakeMultiplayerDeathmatchRadiusDamageHasLineOfSight(splashOrigin, target, collisionWorld)) continue; + const targetCenter = quakeMultiplayerDeathmatchPlayerDamageCenter(target); + const distance = distance3(targetCenter, splashOrigin); + if (distance > splashRadius) continue; + const damage = quakeMultiplayerDeathmatchSplashDamage( + baseDamage, + distance, + player.playerId, + attackerPlayerId, + ); if (damage <= 0) continue; hits.push({ target: player, damage, distance, - impact: player.origin, + impact: splashOrigin, lateralMiss: distance, direct: false, }); @@ -262,13 +487,43 @@ export function quakeMultiplayerDeathmatchSplashHits( return hits; } +export function quakeMultiplayerDeathmatchProjectileWorldImpact( + fire: QuakeMultiplayerFireIntent, + collisionWorld?: Pick | null, +): QuakeMultiplayerDeathmatchProjectileImpact | null { + if (fire.fireKind !== "projectile") return null; + if (!quakeMultiplayerDeathmatchWeaponHasSplash(fire.weapon)) return null; + if (!collisionWorld?.traceUse) return null; + const direction = normalizedFireDirection(fire.direction); + if (!direction) return null; + const maxRange = quakeMultiplayerDeathmatchFireRange(fire); + if (maxRange <= 0) return null; + const end: QuakeMultiplayerVec3 = [ + fire.origin[0] + direction[0] * maxRange, + fire.origin[1] + direction[1] * maxRange, + fire.origin[2] + direction[2] * maxRange, + ]; + const trace = collisionWorld.traceUse(fire.origin, end); + if (!trace) return null; + const distance = distance3(fire.origin, trace.end); + if (!Number.isFinite(distance) || distance > maxRange + 1e-6) return null; + return { + distance, + origin: [trace.end[0], trace.end[1], trace.end[2]], + }; +} + export function quakeMultiplayerDeathmatchHitHasLineOfSight( fire: QuakeMultiplayerFireIntent, hit: QuakeMultiplayerDeathmatchHit, collisionWorld: Pick | null | undefined, ): boolean { if (!collisionWorld?.traceUse) return true; - return collisionWorld.traceUse(fire.origin, hit.impact) === null; + const trace = collisionWorld.traceUse(fire.origin, hit.impact); + if (!trace) return true; + const targetSkin = quakeMultiplayerDeathmatchTargetLosSkin(fire); + return trace.fraction >= QUAKE_MULTIPLAYER_DEATHMATCH_TARGET_LOS_MIN_TRACE_FRACTION && + distance3(trace.end, hit.impact) <= targetSkin; } export function quakeMultiplayerDeathmatchLightningDischarge(input: { @@ -345,11 +600,40 @@ export function quakeMultiplayerDeathmatchPlayerWithDamageMomentum( } function quakeMultiplayerDeathmatchFireRange(fire: QuakeMultiplayerFireIntent): number { - if (fire.fireKind === "melee") return Math.min(QUAKE_MULTIPLAYER_DEATHMATCH_MELEE_RANGE, fire.range); + const maxRange = quakeMultiplayerDeathmatchFireRangeForWeapon(fire.weapon); + if (fire.fireKind === "melee") return Math.min(maxRange, fire.range); return Math.min( - QUAKE_MULTIPLAYER_DEATHMATCH_MAX_HITSCAN_RANGE, - Number.isFinite(fire.range) && fire.range > 0 ? fire.range : QUAKE_MULTIPLAYER_DEATHMATCH_MAX_HITSCAN_RANGE, + maxRange, + Number.isFinite(fire.range) && fire.range > 0 ? fire.range : maxRange, + ); +} + +function quakeMultiplayerDeathmatchTargetLosSkin(fire: QuakeMultiplayerFireIntent): number { + return fire.fireKind === "projectile" + ? QUAKE_MULTIPLAYER_DEATHMATCH_PROJECTILE_HIT_RADIUS + : QUAKE_MULTIPLAYER_DEATHMATCH_HIT_RADIUS; +} + +function quakeMultiplayerDeathmatchFireOrigin( + authoritativeOrigin: QuakeMultiplayerVec3, + originHint: QuakeMultiplayerVec3, +): QuakeMultiplayerVec3 { + return quakeMultiplayerDeathmatchFireOriginHintWithinDrift(authoritativeOrigin, originHint) + ? originHint + : authoritativeOrigin; +} + +function quakeMultiplayerDeathmatchFireOriginHintWithinDrift( + authoritativeOrigin: QuakeMultiplayerVec3, + originHint: QuakeMultiplayerVec3, +): boolean { + const horizontalDrift = Math.hypot( + authoritativeOrigin[0] - originHint[0], + authoritativeOrigin[1] - originHint[1], ); + const verticalDrift = Math.abs(authoritativeOrigin[2] - originHint[2]); + return horizontalDrift <= QUAKE_MULTIPLAYER_DEATHMATCH_FIRE_ORIGIN_HINT_MAX_HORIZONTAL_DRIFT && + verticalDrift <= QUAKE_MULTIPLAYER_DEATHMATCH_FIRE_ORIGIN_HINT_MAX_VERTICAL_DRIFT; } function quakeMultiplayerDeathmatchWeaponHasSplash(weapon: string): boolean { @@ -357,6 +641,36 @@ function quakeMultiplayerDeathmatchWeaponHasSplash(weapon: string): boolean { return normalized === "grenadelauncher" || normalized === "rocketlauncher"; } +function quakeMultiplayerDeathmatchSplashDamage( + baseDamage: number, + distance: number, + playerId: string, + attackerPlayerId: string, +): number { + const quakeDistance = Math.max(0, distance / QUAKE_COLLISION_UNIT_SCALE); + const points = Math.max(0, baseDamage - 0.5 * quakeDistance); + const selfDamageScale = playerId === attackerPlayerId ? 0.5 : 1; + return Math.round(points * selfDamageScale); +} + +function quakeMultiplayerDeathmatchDirectHitDamage(fire: QuakeMultiplayerFireIntent): number { + const normalized = fire.weapon.trim().toLowerCase(); + if (normalized !== "rocketlauncher") return quakeMultiplayerDeathmatchWeaponDamage(fire.weapon); + return 100 + quakeMultiplayerDeathmatchFireRandom01(fire) * 20; +} + +function quakeMultiplayerDeathmatchFireRandom01(fire: QuakeMultiplayerFireIntent): number { + let value = Math.max(0, Math.floor(fire.fireSequence ?? 0)) || 1; + value = Math.imul(value ^ 0x9e3779b9, 0x85ebca6b); + value = Math.imul(value ^ (value >>> 13), 0xc2b2ae35); + return ((value ^ (value >>> 16)) >>> 0) / 0x100000000; +} + +function quakeMultiplayerDeathmatchSplashRadius(baseDamage: number): number { + return Math.max(0, baseDamage + QUAKE_MULTIPLAYER_DEATHMATCH_RADIUS_DAMAGE_EXTRA_RANGE) * + QUAKE_COLLISION_UNIT_SCALE; +} + function normalizePlayerEyeHeight(value: number | undefined): number { return Number.isFinite(value) && value !== undefined && value > 0 ? value @@ -370,39 +684,65 @@ function quakeMultiplayerDeathmatchPlayerHit( target: QuakeMultiplayerAuthoritativePlayerState, damage: number, hitRadius: number, + rewindMs = 0, ): QuakeMultiplayerDeathmatchHit | null { - const targetCenter = quakeMultiplayerDeathmatchPlayerDamageCenter(target); - const delta: QuakeMultiplayerVec3 = [ - targetCenter[0] - origin[0], - targetCenter[1] - origin[1], - targetCenter[2] - origin[2], - ]; - const distanceAlongRay = dotVec3(delta, direction); - if (distanceAlongRay < 0 || distanceAlongRay > maxRange) return null; - const closest: QuakeMultiplayerVec3 = [ - origin[0] + direction[0] * distanceAlongRay, - origin[1] + direction[1] * distanceAlongRay, - origin[2] + direction[2] * distanceAlongRay, - ]; - const dx = targetCenter[0] - closest[0]; - const dy = targetCenter[1] - closest[1]; - const dz = targetCenter[2] - closest[2]; - const lateralMiss = Math.hypot(dx, dy, dz); - return lateralMiss <= hitRadius - ? { target, damage, distance: distanceAlongRay, impact: closest, lateralMiss } - : null; + const targetOrigin = quakeMultiplayerDeathmatchRewoundPlayerOrigin(target, rewindMs); + const bounds = quakeMultiplayerDeathmatchPlayerHitBounds(targetOrigin, hitRadius); + const trace = rayAabbIntersection(origin, direction, maxRange, bounds.min, bounds.max); + if (!trace) return null; + const center = quakeMultiplayerDeathmatchPlayerDamageCenter({ ...target, origin: targetOrigin }); + return { + target, + damage, + distance: trace.distance, + impact: trace.impact, + lateralMiss: distance3(trace.impact, center), + }; } function quakeMultiplayerDeathmatchPlayerDamageCenter( player: QuakeMultiplayerAuthoritativePlayerState, ): QuakeMultiplayerVec3 { + const eyeHeight = normalizePlayerEyeHeight(undefined); return [ player.origin[0], player.origin[1], - player.origin[2] - QUAKE_MULTIPLAYER_DEATHMATCH_HIT_HEIGHT * 0.5, + player.origin[2] - eyeHeight + PLAYER_HEIGHT * 0.5, ]; } +function quakeMultiplayerDeathmatchRewoundPlayerOrigin( + player: QuakeMultiplayerAuthoritativePlayerState, + rewindMs: number | undefined, +): QuakeMultiplayerVec3 { + const clampedMs = Number.isFinite(rewindMs) + ? Math.max(0, Math.min(QUAKE_MULTIPLAYER_DEATHMATCH_MAX_REWIND_MS, rewindMs ?? 0)) + : 0; + if (clampedMs <= 0) return player.origin; + const seconds = clampedMs / 1000; + return [ + player.origin[0] - player.velocity[0] * seconds, + player.origin[1] - player.velocity[1] * seconds, + player.origin[2] - player.velocity[2] * seconds, + ]; +} + +function quakeMultiplayerDeathmatchPlayerHitBounds( + origin: QuakeMultiplayerVec3, + hitRadius: number, +): { min: QuakeMultiplayerVec3; max: QuakeMultiplayerVec3 } { + const eyeHeight = normalizePlayerEyeHeight(undefined); + const horizontalSkin = Math.max(0, hitRadius - PLAYER_RADIUS); + const verticalSkin = Math.max(0, hitRadius - PLAYER_RADIUS); + const radius = PLAYER_RADIUS + horizontalSkin; + const minZ = origin[2] - eyeHeight - verticalSkin; + const maxZ = minZ + PLAYER_HEIGHT + verticalSkin * 2; + return { + min: [origin[0] - radius, origin[1] - radius, minZ], + max: [origin[0] + radius, origin[1] + radius, maxZ], + }; +} + function quakeMultiplayerDeathmatchRadiusDamageHasLineOfSight( origin: QuakeMultiplayerVec3, target: QuakeMultiplayerAuthoritativePlayerState, @@ -448,10 +788,41 @@ function quakeMultiplayerDeathmatchForwardDirection(rotX: number, rotY: number): ]; } -function dotVec3(a: QuakeMultiplayerVec3, b: QuakeMultiplayerVec3): number { - return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; -} - function distance3(a: QuakeMultiplayerVec3, b: QuakeMultiplayerVec3): number { return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); } + +function rayAabbIntersection( + origin: QuakeMultiplayerVec3, + direction: QuakeMultiplayerVec3, + maxRange: number, + min: QuakeMultiplayerVec3, + max: QuakeMultiplayerVec3, +): { distance: number; impact: QuakeMultiplayerVec3 } | null { + let enter = 0; + let exit = maxRange; + for (let axis = 0; axis < 3; axis += 1) { + const rayOrigin = origin[axis]; + const rayDirection = direction[axis]; + if (Math.abs(rayDirection) <= COLLISION_EPSILON) { + if (rayOrigin < min[axis] || rayOrigin > max[axis]) return null; + continue; + } + let near = (min[axis] - rayOrigin) / rayDirection; + let far = (max[axis] - rayOrigin) / rayDirection; + if (near > far) [near, far] = [far, near]; + enter = Math.max(enter, near); + exit = Math.min(exit, far); + if (enter > exit) return null; + } + if (exit < 0 || enter > maxRange) return null; + const distance = Math.max(0, enter); + return { + distance, + impact: [ + origin[0] + direction[0] * distance, + origin[1] + direction[1] * distance, + origin[2] + direction[2] * distance, + ], + }; +} diff --git a/src/runtime/multiplayer/history.ts b/src/runtime/multiplayer/history.ts new file mode 100644 index 0000000..82bcce6 --- /dev/null +++ b/src/runtime/multiplayer/history.ts @@ -0,0 +1,218 @@ +import { QUAKE_COLLISION_UNIT_SCALE } from "../constants"; +import type { + QuakeMultiplayerAuthoritativePlayerState, + QuakeMultiplayerVec3, +} from "./protocol"; + +export const QUAKE_MULTIPLAYER_SNAPSHOT_HISTORY_RETENTION_MS = 1_000; +export const QUAKE_MULTIPLAYER_SNAPSHOT_HISTORY_MAX_ENTRIES = 32; +export const QUAKE_MULTIPLAYER_SNAPSHOT_HISTORY_MAX_INTERPOLATION_GAP_MS = 300; +export const QUAKE_MULTIPLAYER_SNAPSHOT_HISTORY_TELEPORT_DISTANCE = + 128 * QUAKE_COLLISION_UNIT_SCALE; + +export interface QuakeMultiplayerPlayerHistorySample { + playerId: string; + sampledAt: number; + origin: QuakeMultiplayerVec3; + velocity: QuakeMultiplayerVec3; + rotX: number; + rotY: number; + alive: boolean; + lastInputSequence: number; + spawnId?: string; + respawnAt?: number; +} + +export interface QuakeMultiplayerSnapshotHistoryEntry { + sampledAt: number; + roomTime: number; + tick: number; + players: readonly QuakeMultiplayerPlayerHistorySample[]; +} + +export type QuakeMultiplayerSnapshotHistory = readonly QuakeMultiplayerSnapshotHistoryEntry[]; + +export interface QuakeMultiplayerSnapshotHistoryRecordOptions { + maxEntries?: number; + retentionMs?: number; +} + +export interface QuakeMultiplayerHistoricalPlayerLookupOptions { + maxDiscontinuityDistance?: number; + maxInterpolationGapMs?: number; +} + +export interface QuakeMultiplayerHistoricalCombatPlayersOptions + extends QuakeMultiplayerHistoricalPlayerLookupOptions { + attackerPlayerId: string; + fallbackToCurrent?: boolean; + targetTime: number; +} + +export function recordQuakeMultiplayerSnapshotHistory( + history: QuakeMultiplayerSnapshotHistory, + input: { + players: Iterable; + roomTime: number; + sampledAt: number; + tick: number; + }, + options: QuakeMultiplayerSnapshotHistoryRecordOptions = {}, +): QuakeMultiplayerSnapshotHistoryEntry[] { + const retentionMs = normalizePositiveNumber( + options.retentionMs, + QUAKE_MULTIPLAYER_SNAPSHOT_HISTORY_RETENTION_MS, + ); + const maxEntries = Math.max( + 1, + Math.floor(normalizePositiveNumber( + options.maxEntries, + QUAKE_MULTIPLAYER_SNAPSHOT_HISTORY_MAX_ENTRIES, + )), + ); + const entry: QuakeMultiplayerSnapshotHistoryEntry = { + sampledAt: input.sampledAt, + roomTime: input.roomTime, + tick: input.tick, + players: [...input.players].map(quakeMultiplayerPlayerHistorySample), + }; + const oldestAllowed = input.sampledAt - retentionMs; + return [...history, entry] + .filter((candidate) => candidate.sampledAt >= oldestAllowed) + .slice(-maxEntries); +} + +export function quakeMultiplayerHistoricalCombatPlayers( + history: QuakeMultiplayerSnapshotHistory, + currentPlayers: Iterable, + options: QuakeMultiplayerHistoricalCombatPlayersOptions, +): QuakeMultiplayerAuthoritativePlayerState[] { + const fallbackToCurrent = options.fallbackToCurrent ?? true; + const players = [...currentPlayers]; + return players.flatMap((player) => { + if (player.playerId === options.attackerPlayerId) return [player]; + const historical = quakeMultiplayerHistoricalPlayerAt( + history, + player, + options.targetTime, + options, + ); + if (historical) return [historical]; + return fallbackToCurrent ? [player] : []; + }); +} + +export function quakeMultiplayerHistoricalPlayerAt( + history: QuakeMultiplayerSnapshotHistory, + currentPlayer: QuakeMultiplayerAuthoritativePlayerState, + targetTime: number, + options: QuakeMultiplayerHistoricalPlayerLookupOptions = {}, +): QuakeMultiplayerAuthoritativePlayerState | null { + if (!Number.isFinite(targetTime)) return null; + const samples = samplesForPlayer(history, currentPlayer.playerId); + if (!samples.length) return null; + let previous: QuakeMultiplayerPlayerHistorySample | null = null; + let next: QuakeMultiplayerPlayerHistorySample | null = null; + for (const sample of samples) { + if (sample.sampledAt <= targetTime) previous = sample; + if (sample.sampledAt >= targetTime) { + next = sample; + break; + } + } + if (!previous || !next) return null; + if (!samplesCanInterpolate(previous, next, options)) return null; + const span = next.sampledAt - previous.sampledAt; + const t = span > 0 ? clamp01((targetTime - previous.sampledAt) / span) : 0; + return { + ...currentPlayer, + origin: lerpVec3(previous.origin, next.origin, t), + velocity: lerpVec3(previous.velocity, next.velocity, t), + rotX: lerp(previous.rotX, next.rotX, t), + rotY: lerp(previous.rotY, next.rotY, t), + alive: previous.alive && next.alive, + lastInputSequence: Math.max(previous.lastInputSequence, next.lastInputSequence), + updatedAt: targetTime, + ...(next.spawnId !== undefined ? { spawnId: next.spawnId } : {}), + ...(next.respawnAt !== undefined ? { respawnAt: next.respawnAt } : {}), + }; +} + +export function quakeMultiplayerPlayerHistorySample( + player: QuakeMultiplayerAuthoritativePlayerState, +): QuakeMultiplayerPlayerHistorySample { + return { + playerId: player.playerId, + sampledAt: player.updatedAt, + origin: cloneVec3(player.origin), + velocity: cloneVec3(player.velocity), + rotX: player.rotX, + rotY: player.rotY, + alive: player.alive, + lastInputSequence: player.lastInputSequence, + ...(player.spawnId !== undefined ? { spawnId: player.spawnId } : {}), + ...(player.respawnAt !== undefined ? { respawnAt: player.respawnAt } : {}), + }; +} + +function samplesForPlayer( + history: QuakeMultiplayerSnapshotHistory, + playerId: string, +): QuakeMultiplayerPlayerHistorySample[] { + const samples: QuakeMultiplayerPlayerHistorySample[] = []; + for (const entry of history) { + const sample = entry.players.find((player) => player.playerId === playerId); + if (sample) samples.push({ ...sample, sampledAt: entry.sampledAt }); + } + samples.sort((left, right) => left.sampledAt - right.sampledAt); + return samples; +} + +function samplesCanInterpolate( + previous: QuakeMultiplayerPlayerHistorySample, + next: QuakeMultiplayerPlayerHistorySample, + options: QuakeMultiplayerHistoricalPlayerLookupOptions, +): boolean { + if (!previous.alive || !next.alive) return false; + if (previous.spawnId !== next.spawnId) return false; + const maxGap = normalizePositiveNumber( + options.maxInterpolationGapMs, + QUAKE_MULTIPLAYER_SNAPSHOT_HISTORY_MAX_INTERPOLATION_GAP_MS, + ); + if (next.sampledAt - previous.sampledAt > maxGap) return false; + const maxDiscontinuityDistance = normalizePositiveNumber( + options.maxDiscontinuityDistance, + QUAKE_MULTIPLAYER_SNAPSHOT_HISTORY_TELEPORT_DISTANCE, + ); + return distance3(previous.origin, next.origin) <= maxDiscontinuityDistance; +} + +function cloneVec3(vector: QuakeMultiplayerVec3): QuakeMultiplayerVec3 { + return [vector[0], vector[1], vector[2]]; +} + +function lerpVec3(a: QuakeMultiplayerVec3, b: QuakeMultiplayerVec3, t: number): QuakeMultiplayerVec3 { + return [ + lerp(a[0], b[0], t), + lerp(a[1], b[1], t), + lerp(a[2], b[2], t), + ]; +} + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +function distance3(a: QuakeMultiplayerVec3, b: QuakeMultiplayerVec3): number { + return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); +} + +function clamp01(value: number): number { + if (value <= 0) return 0; + if (value >= 1) return 1; + return value; +} + +function normalizePositiveNumber(value: number | undefined, fallback: number): number { + return Number.isFinite(value) && value !== undefined && value > 0 ? value : fallback; +} diff --git a/src/runtime/multiplayer/items.ts b/src/runtime/multiplayer/items.ts index 55855c9..0b59870 100644 --- a/src/runtime/multiplayer/items.ts +++ b/src/runtime/multiplayer/items.ts @@ -6,6 +6,7 @@ import type { QuakeMultiplayerPickupDefinition, QuakeMultiplayerVec3, } from "./protocol"; +import { QUAKE_PLAYER_MINS_Z } from "../constants"; const QUAKE_MULTIPLAYER_MAX_AMMO = { shells: 100, @@ -23,11 +24,52 @@ 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; +const QUAKE_MULTIPLAYER_MIN_DEATH_HEALTH = -99; +const QUAKE_MULTIPLAYER_DROPPED_BACKPACK_LIFETIME_MS = 120_000; +const QUAKE_MULTIPLAYER_DROPPED_BACKPACK_MODEL_PATH = "progs/backpack.mdl"; +const QUAKE_MULTIPLAYER_DEATH_CLEARED_ARTIFACT_FLAGS = [ + 524_288, // IT_INVISIBILITY + 1_048_576, // IT_INVULNERABILITY + 2_097_152, // IT_SUIT + 4_194_304, // IT_QUAD +] as const; + +const QUAKE_MULTIPLAYER_WEAPON_ITEM_FLAGS: Record = { + shotgun: 1, + supershotgun: 2, + nailgun: 4, + supernailgun: 8, + grenadelauncher: 16, + rocketlauncher: 32, + lightning: 64, + axe: 4096, +}; + +const QUAKE_MULTIPLAYER_WEAPON_PICKUP_SWITCH_RANK: Record = { + lightning: 1, + rocketlauncher: 2, + supernailgun: 3, + grenadelauncher: 4, + supershotgun: 5, + nailgun: 6, + shotgun: 7, + axe: 7, +}; export interface QuakeMultiplayerDamageOptions { applyHealth?: boolean; } +export interface QuakeMultiplayerBestWeaponOptions { + allowLightning?: boolean; +} + +export interface QuakeMultiplayerDroppedBackpackOptions { + entityIndex: number; + now?: number; + player: QuakeMultiplayerAuthoritativePlayerState; +} + export function createQuakeMultiplayerInitialInventory(): QuakeMultiplayerInventoryState { return { health: 100, @@ -48,6 +90,7 @@ export function createQuakeMultiplayerInitialInventory(): QuakeMultiplayerInvent export function quakeMultiplayerInventoryCanAcceptPickupEffect( inventory: QuakeMultiplayerInventoryState, effect: QuakeMultiplayerPickupEffect, + now = Date.now(), ): boolean { if (inventory.health <= 0) return false; if (effect.health !== undefined && inventory.health < (effect.healthMax ?? 100)) return true; @@ -66,7 +109,7 @@ export function quakeMultiplayerInventoryCanAcceptPickupEffect( effect.powerup && !inventory.powerups.some((powerup) => powerup.finishedField === effect.powerup?.finishedField && - powerup.finishedAt > Date.now() + powerup.finishedAt > now ) ) { return true; @@ -74,6 +117,13 @@ export function quakeMultiplayerInventoryCanAcceptPickupEffect( return false; } +export function quakeMultiplayerPickupAlwaysAcceptsTouch( + pickup: Pick, +): boolean { + if (pickup.classname === "item_backpack") return true; + return pickup.classname.startsWith("weapon_") && pickup.lifecycle?.action !== "leave"; +} + export function quakeMultiplayerPlayerCanReachPickup( player: QuakeMultiplayerAuthoritativePlayerState, pickup: QuakeMultiplayerPickupDefinition, @@ -139,12 +189,60 @@ export function quakeMultiplayerPickupStateRespawned( }; } +export function quakeMultiplayerDroppedBackpackDefinition( + options: QuakeMultiplayerDroppedBackpackOptions, +): QuakeMultiplayerPickupDefinition | null { + const now = options.now ?? Date.now(); + const inventory = quakeMultiplayerPlayerInventory(options.player); + const totalAmmo = inventory.shells + inventory.nails + inventory.rockets + inventory.cells; + if (totalAmmo <= 0) return null; + const effect: QuakeMultiplayerPickupEffect = {}; + if (inventory.shells > 0) effect.shells = inventory.shells; + if (inventory.nails > 0) effect.nails = inventory.nails; + if (inventory.rockets > 0) effect.rockets = inventory.rockets; + if (inventory.cells > 0) effect.cells = inventory.cells; + const activeWeapon = inventory.activeWeapon.trim().toLowerCase(); + const activeWeaponFlag = QUAKE_MULTIPLAYER_WEAPON_ITEM_FLAGS[activeWeapon]; + if (activeWeaponFlag !== undefined && inventory.weapons.includes(activeWeapon)) { + effect.weapon = { + id: activeWeapon, + itemFlag: activeWeaponFlag, + select: true, + }; + } + const origin: QuakeMultiplayerVec3 = [ + options.player.origin[0], + options.player.origin[1], + options.player.origin[2] + QUAKE_PLAYER_MINS_Z, + ]; + return { + pickupId: `dropped-backpack:${options.player.playerId}:${options.entityIndex}:${now}`, + entityIndex: options.entityIndex, + classname: "item_backpack", + origin, + effect, + feedback: { + message: "You get the backpack", + soundPath: "weapons/lock4.wav", + }, + lifecycle: { + action: "remove", + condition: "pickup", + }, + modelPath: QUAKE_MULTIPLAYER_DROPPED_BACKPACK_MODEL_PATH, + removeAt: now + QUAKE_MULTIPLAYER_DROPPED_BACKPACK_LIFETIME_MS, + runtime: true, + }; +} + export function quakeMultiplayerApplyPickupEffect( inventory: QuakeMultiplayerInventoryState, effect: QuakeMultiplayerPickupEffect, now = Date.now(), ): QuakeMultiplayerInventoryState { const next = quakeMultiplayerPruneExpiredPowerups(inventory, now); + const previousBestWeapon = quakeMultiplayerInventoryBestWeapon(next); + const activeWeaponWasBest = next.activeWeapon === previousBestWeapon; if (effect.health !== undefined) { next.health = Math.min(effect.healthMax ?? 100, next.health + effect.health); } @@ -160,10 +258,18 @@ export function quakeMultiplayerApplyPickupEffect( next.nails = clampAmmo(next.nails + (effect.nails ?? 0), QUAKE_MULTIPLAYER_MAX_AMMO.nails); next.rockets = clampAmmo(next.rockets + (effect.rockets ?? 0), QUAKE_MULTIPLAYER_MAX_AMMO.rockets); next.cells = clampAmmo(next.cells + (effect.cells ?? 0), QUAKE_MULTIPLAYER_MAX_AMMO.cells); + let switchedByPickupWeapon = false; if (effect.weapon) { - if (!next.weapons.includes(effect.weapon.id)) next.weapons = [...next.weapons, effect.weapon.id]; + const weaponId = effect.weapon.id.trim().toLowerCase(); + if (!next.weapons.includes(weaponId)) next.weapons = [...next.weapons, weaponId]; if (effect.weapon.itemFlag !== undefined) next.itemFlags |= effect.weapon.itemFlag; - if (effect.weapon.select) next.activeWeapon = effect.weapon.id; + if (effect.weapon.select && quakeMultiplayerPickupWeaponOutranksActive(next, weaponId)) { + next.activeWeapon = weaponId; + switchedByPickupWeapon = true; + } + } + if (!switchedByPickupWeapon && !effect.weapon?.select && activeWeaponWasBest) { + next.activeWeapon = quakeMultiplayerInventoryBestWeapon(next); } if (effect.key && !next.keys.includes(effect.key)) next.keys = [...next.keys, effect.key]; if (effect.powerup) { @@ -206,7 +312,10 @@ export function quakeMultiplayerApplyDamageToInventory( } } if (options.applyHealth !== false) { - next.health = Math.max(0, next.health - Math.max(0, Math.ceil(rawDamage - armorDamage))); + next.health = Math.max( + QUAKE_MULTIPLAYER_MIN_DEATH_HEALTH, + next.health - Math.max(0, Math.ceil(rawDamage - armorDamage)), + ); } return next; } @@ -259,6 +368,34 @@ export function quakeMultiplayerPruneExpiredPowerups( return next; } +export function quakeMultiplayerInventoryWithoutPowerup( + inventory: QuakeMultiplayerInventoryState, + finishedField: string, +): QuakeMultiplayerInventoryState { + const next = cloneQuakeMultiplayerInventory(inventory); + const removed = next.powerups.filter((powerup) => powerup.finishedField === finishedField); + if (!removed.length) return next; + next.powerups = next.powerups.filter((powerup) => powerup.finishedField !== finishedField); + const activeFlags = new Set(next.powerups.map((powerup) => powerup.itemFlag)); + for (const powerup of removed) { + if (!activeFlags.has(powerup.itemFlag)) next.itemFlags &= ~powerup.itemFlag; + } + return next; +} + +export function quakeMultiplayerInventoryWithoutDeathPowerups( + inventory: QuakeMultiplayerInventoryState, +): QuakeMultiplayerInventoryState { + const next = cloneQuakeMultiplayerInventory(inventory); + const removedFlags = new Set([ + ...QUAKE_MULTIPLAYER_DEATH_CLEARED_ARTIFACT_FLAGS, + ...next.powerups.map((powerup) => powerup.itemFlag), + ]); + next.powerups = []; + for (const itemFlag of removedFlags) next.itemFlags &= ~itemFlag; + return next; +} + export function quakeMultiplayerConsumeWeaponAmmo( inventory: QuakeMultiplayerInventoryState, weapon: string, @@ -324,6 +461,34 @@ export function quakeMultiplayerConsumeLightningDischargeCells( return next; } +export function quakeMultiplayerInventoryBestWeapon( + inventory: QuakeMultiplayerInventoryState, + options: QuakeMultiplayerBestWeaponOptions = {}, +): string { + const allowLightning = options.allowLightning ?? true; + const priority = [ + ...(allowLightning ? ["lightning"] : []), + "supernailgun", + "supershotgun", + "nailgun", + "shotgun", + "axe", + ]; + return priority.find((weapon) => quakeMultiplayerInventoryCanSelectWeapon(inventory, weapon)) ?? "axe"; +} + +export function quakeMultiplayerInventoryWithBestWeaponIfCurrentAmmoEmpty( + inventory: QuakeMultiplayerInventoryState, + options: QuakeMultiplayerBestWeaponOptions = {}, +): QuakeMultiplayerInventoryState { + const next = cloneQuakeMultiplayerInventory(inventory); + if (quakeMultiplayerInventoryCanSelectWeapon(next, next.activeWeapon)) return next; + return { + ...next, + activeWeapon: quakeMultiplayerInventoryBestWeapon(next, options), + }; +} + export function quakeMultiplayerInventoryCanSelectWeapon( inventory: QuakeMultiplayerInventoryState, weapon: string, @@ -387,6 +552,19 @@ function cloneQuakeMultiplayerInventory( }; } +function quakeMultiplayerPickupWeaponOutranksActive( + inventory: QuakeMultiplayerInventoryState, + weapon: string, +): boolean { + if (!quakeMultiplayerInventoryCanSelectWeapon(inventory, weapon)) return false; + return quakeMultiplayerWeaponPickupSwitchRank(weapon) < + quakeMultiplayerWeaponPickupSwitchRank(inventory.activeWeapon); +} + +function quakeMultiplayerWeaponPickupSwitchRank(weapon: string): number { + return QUAKE_MULTIPLAYER_WEAPON_PICKUP_SWITCH_RANK[weapon.trim().toLowerCase()] ?? 7; +} + function clampAmmo(value: number, max: number): number { return Math.max(0, Math.min(max, value)); } diff --git a/src/runtime/multiplayer/loopback.ts b/src/runtime/multiplayer/loopback.ts index c8bb1f6..51de058 100644 --- a/src/runtime/multiplayer/loopback.ts +++ b/src/runtime/multiplayer/loopback.ts @@ -8,10 +8,13 @@ import type { QuakeMultiplayerAuthoritativePickupState, QuakeMultiplayerAuthoritativePlayerState, QuakeMultiplayerClientEnvelope, + QuakeMultiplayerFireDecision, QuakeMultiplayerGameplayDefinitions, + QuakeMultiplayerLocalInputIntent, QuakeMultiplayerMapGameplayFacts, QuakeMultiplayerMatchSettings, QuakeMultiplayerPickupDefinition, + QuakeMultiplayerProjectileState, QuakeMultiplayerPlayerPresenceStatus, QuakeMultiplayerRoomCompatibilityKey, QuakeMultiplayerRoomEnvelope, @@ -28,11 +31,15 @@ import { QUAKE_MULTIPLAYER_DEATHMATCH_RESPAWN_DELAY_MS, quakeMultiplayerDeathmatchFireFromPlayer, quakeMultiplayerDeathmatchFragDeltaForKill, - quakeMultiplayerDeathmatchHitscanHit, + quakeMultiplayerDeathmatchLagCompensationMs, quakeMultiplayerDeathmatchLightningDischarge, - quakeMultiplayerDeathmatchSpawnOrder, quakeMultiplayerDeathmatchPlayerWithDamageMomentum, + quakeMultiplayerDeathmatchProjectileSplashHitsAtImpact, + quakeMultiplayerDeathmatchProjectileWorldSplashHits, + quakeMultiplayerDeathmatchSelectSpawnPoint, + quakeMultiplayerDeathmatchSpawnOrder, quakeMultiplayerDeathmatchSplashHits, + quakeMultiplayerDeathmatchVisibleHitDecision, quakeMultiplayerDeathmatchWeaponCooldownMs, quakeMultiplayerDeathmatchWeaponDamage, rejectQuakeMultiplayerClientDamageIntent, @@ -44,7 +51,12 @@ import { quakeMultiplayerConsumeLightningDischargeCells, quakeMultiplayerConsumeWeaponAmmo, quakeMultiplayerDamageMultiplierForInventory, + quakeMultiplayerDroppedBackpackDefinition, quakeMultiplayerInventoryCanAcceptPickupEffect, + quakeMultiplayerInventoryWithBestWeaponIfCurrentAmmoEmpty, + quakeMultiplayerInventoryWithoutDeathPowerups, + quakeMultiplayerInventoryWithoutPowerup, + quakeMultiplayerPickupAlwaysAcceptsTouch, quakeMultiplayerPlayerCanReachPickup, quakeMultiplayerPlayerInventory, quakeMultiplayerPlayerPowerupActive, @@ -98,12 +110,25 @@ import { createQuakeMultiplayerRoomPlayerSimulationState, pauseQuakeMultiplayerRoomPlayerSimulation, queueQuakeMultiplayerRoomInput, + validateQuakeMultiplayerRoomFireInputHistory, type QuakeMultiplayerRoomPlayerSimulationState, } from "./simulation"; +import { + quakeMultiplayerHistoricalCombatPlayers, + recordQuakeMultiplayerSnapshotHistory, + type QuakeMultiplayerSnapshotHistory, +} from "./history"; +import { + advanceQuakeMultiplayerServerProjectile, + createQuakeMultiplayerServerProjectile, + quakeMultiplayerServerProjectileWeaponSupported, + type QuakeMultiplayerServerProjectile, +} from "./projectileAuthority"; export interface QuakeLoopbackMultiplayerSessionOptions { roomId?: string; now?: () => number; + random?: () => number; asyncDispatch?: boolean; maxMessageAgeMs?: number; maxFutureSkewMs?: number; @@ -141,6 +166,7 @@ export function createQuakeLoopbackMultiplayerSession( ): QuakeMultiplayerSessionAdapter { const roomId = options.roomId ?? "loopback"; const now = options.now ?? (() => Date.now()); + const random = options.random ?? Math.random; const snapshotIntervalMs = options.snapshotIntervalMs ?? QUAKE_MULTIPLAYER_ROOM_SNAPSHOT_INTERVAL_MS; const simulationTickMs = options.simulationTickMs ?? QUAKE_MULTIPLAYER_ROOM_SIMULATION_TICK_MS; const heartbeatIntervalMs = options.heartbeatIntervalMs ?? QUAKE_MULTIPLAYER_ROOM_HEARTBEAT_INTERVAL_MS; @@ -158,9 +184,12 @@ export function createQuakeLoopbackMultiplayerSession( let gameplayFacts: QuakeMultiplayerMapGameplayFacts | null = null; let playerState: QuakeMultiplayerAuthoritativePlayerState | null = null; let playerSimulationState: QuakeMultiplayerRoomPlayerSimulationState | null = null; + let spawnPoints: QuakeMultiplayerSpawnPoint[] = []; + let spawnCursor = 0; let pickupDefinitions = new Map(); let pickupStates = new Map(); let worldDefinitions = new Map(); + let serverProjectiles = new Map(); let lastFireAt = -Infinity; let clientAuthorityState: QuakeMultiplayerClientAuthorityState | null = null; let roomReady = false; @@ -172,12 +201,15 @@ export function createQuakeLoopbackMultiplayerSession( let lastSeenAt = -Infinity; let lastRoomPingAt: number | undefined; let lastRoomPingId: string | undefined; + let projectileSequence = 0; + let dynamicPickupSequence = 1_000_000; let currentStatus: QuakeMultiplayerSessionStatus = { state: "closed", mode: "loopback", }; const respawnTimers = new Map>(); const pickupRespawnTimers = new Map>(); + const pickupRemovalTimers = new Map>(); const targetDispatchTimers = new Map>(); const moverStateTimers = new Map>(); const moverStates = new Map(); @@ -188,6 +220,7 @@ export function createQuakeLoopbackMultiplayerSession( const triggerNextTouchAt = new Map(); const triggerCounterRemaining = new Map(); const triggerShootHealth = new Map(); + let snapshotHistory: QuakeMultiplayerSnapshotHistory = []; const adapter: QuakeMultiplayerSessionAdapter = { mode: "loopback", @@ -204,6 +237,8 @@ export function createQuakeLoopbackMultiplayerSession( matchStatus = "active"; presenceStatus = "active"; gameplayFacts = null; + spawnPoints = []; + spawnCursor = 0; playerState = createLoopbackPlayerState(roomKey, clientId, displayName, playerColor, now()); playerSimulationState = createQuakeMultiplayerRoomPlayerSimulationState({ playerId: playerState.playerId, @@ -212,6 +247,10 @@ export function createQuakeLoopbackMultiplayerSession( pickupDefinitions = new Map(); pickupStates = new Map(); worldDefinitions = new Map(); + serverProjectiles = new Map(); + clearTimers(respawnTimers); + clearTimers(pickupRespawnTimers); + clearTimers(pickupRemovalTimers); clearTimers(targetDispatchTimers); clearTimers(moverStateTimers); moverStates.clear(); @@ -222,6 +261,7 @@ export function createQuakeLoopbackMultiplayerSession( triggerNextTouchAt.clear(); triggerCounterRemaining.clear(); triggerShootHealth.clear(); + snapshotHistory = []; lastFireAt = -Infinity; clientAuthorityState = null; roomReady = false; @@ -229,6 +269,8 @@ export function createQuakeLoopbackMultiplayerSession( lastSeenAt = -Infinity; lastRoomPingAt = undefined; lastRoomPingId = undefined; + projectileSequence = 0; + dynamicPickupSequence = 1_000_000; currentStatus = { state: "connected", mode: "loopback", @@ -249,9 +291,12 @@ export function createQuakeLoopbackMultiplayerSession( gameplayFacts = null; playerState = null; playerSimulationState = null; + spawnPoints = []; + spawnCursor = 0; pickupDefinitions = new Map(); pickupStates = new Map(); worldDefinitions = new Map(); + serverProjectiles = new Map(); clientAuthorityState = null; roomReady = false; stopSnapshotTicker(); @@ -260,6 +305,7 @@ export function createQuakeLoopbackMultiplayerSession( clearMatchRestartTimer(); clearTimers(respawnTimers); clearTimers(pickupRespawnTimers); + clearTimers(pickupRemovalTimers); clearTimers(targetDispatchTimers); clearTimers(moverStateTimers); moverStates.clear(); @@ -320,9 +366,12 @@ export function createQuakeLoopbackMultiplayerSession( : matchSettings; presenceStatus = "active"; const trustedDefinitions = trustedGameplayDefinitionsForRoom(); - const initialSpawn = quakeLoopbackInitialSpawnPoint( - trustedDefinitions?.deathmatchSpawns ?? message.payload.deathmatchSpawns, + spawnPoints = quakeMultiplayerDeathmatchSpawnOrder( + trustedDefinitions?.deathmatchSpawns ?? message.payload.deathmatchSpawns ?? [], ); + spawnCursor = 0; + playerState = null; + const initialSpawn = nextLoopbackSpawnPoint(); playerState = createLoopbackPlayerState(roomKey, clientId, displayName, playerColor, now(), initialSpawn); playerSimulationState = createQuakeMultiplayerRoomPlayerSimulationState({ playerId: playerState.playerId, @@ -359,15 +408,10 @@ export function createQuakeLoopbackMultiplayerSession( }); break; case "client.input": - if (!quakeMultiplayerPresenceAcceptsInput(presenceStatus)) { - pauseLoopbackPlayerSimulation(); - return; - } - if (playerState && playerSimulationState) { - const result = queueQuakeMultiplayerRoomInput(playerSimulationState, message.payload.input); - playerSimulationState = result.state; - if (result.accepted) startSimulationTicker(); - } + queueLoopbackPlayerInputs([message.payload.input]); + break; + case "client.inputBatch": + queueLoopbackPlayerInputs(message.payload.inputs); break; case "client.fire": advanceLoopbackSimulation(now()); @@ -555,23 +599,41 @@ export function createQuakeLoopbackMultiplayerSession( if (!roomKey) return; enterIntermissionIfTimeLimitReached("snapshot"); pruneExpiredPowerups(); - lastScheduledSnapshotAt = now(); + const sampledAt = now(); + lastScheduledSnapshotAt = sampledAt; tick += 1; + const roomTime = currentRoomTime(); + const players = loopbackSnapshotPlayers(); + recordSnapshotHistory(sampledAt, players); emitRoomEnvelope("room.snapshot", { roomId, tick, - roomTime: currentRoomTime(), + roomTime, match: { status: matchStatus, - clockMs: currentRoomTime(), + clockMs: roomTime, ...matchSettings, }, - players: loopbackSnapshotPlayers(), + players, + dynamicPickups: dynamicPickupDefinitions(), pickups: [...pickupStates.values()], + projectiles: [...serverProjectiles.values()].map(quakeMultiplayerProjectileStateFromServer), lastWorldEventSequence: worldEventSequence, }); } + function recordSnapshotHistory( + sampledAt: number, + players = loopbackSnapshotPlayers(), + ): void { + snapshotHistory = recordQuakeMultiplayerSnapshotHistory(snapshotHistory, { + sampledAt, + roomTime: currentRoomTime(), + tick, + players, + }); + } + function handleClientPong(message: Extract): void { if (!playerState || message.payload.pingId !== lastRoomPingId) return; const pingMs = quakeMultiplayerPingMsFromPong(now(), message.payload.echoedSentAt); @@ -616,9 +678,90 @@ export function createQuakeLoopbackMultiplayerSession( damage: hazard.damage, damageSource: hazard.kind, eventId: `hazard-${hazard.kind}-${hazard.damagedAt}`, + now: hazard.damagedAt, }) || appliedHazardDamage; } - return result.advancedTicks > 0 || appliedHazardDamage; + const advancedProjectiles = advanceLoopbackServerProjectiles(timestamp); + const changed = result.advancedTicks > 0 || appliedHazardDamage || advancedProjectiles; + if (changed) recordSnapshotHistory(timestamp); + return changed; + } + + function advanceLoopbackServerProjectiles(timestamp: number): boolean { + if (!serverProjectiles.size) return false; + let advanced = false; + for (const [projectileId, projectile] of [...serverProjectiles]) { + const result = advanceQuakeMultiplayerServerProjectile(projectile, { + collisionWorld: options.trustedSceneMovement?.collisionWorld, + now: timestamp, + players: loopbackSnapshotPlayers(), + }); + if (result.type === "active") { + serverProjectiles.set(projectileId, result.projectile); + advanced = true; + continue; + } + serverProjectiles.delete(projectileId); + advanced = true; + if (result.type === "expired") { + emitRoomEvent({ + eventType: "projectile.impacted", + eventId: `projectile-expired-${projectileId}`, + roomTime: currentRoomTime(timestamp), + projectileId, + ownerPlayerId: result.projectile.ownerPlayerId, + weapon: result.projectile.weapon, + origin: result.projectile.origin, + impactKind: "world", + playerDamageCount: 0, + }); + continue; + } + emitRoomEvent({ + eventType: "projectile.impacted", + eventId: `projectile-impacted-${projectileId}`, + roomTime: currentRoomTime(timestamp), + projectileId, + ownerPlayerId: result.projectile.ownerPlayerId, + weapon: result.projectile.weapon, + origin: result.impact.origin, + impactKind: result.impact.kind, + playerDamageCount: result.impact.damageHits.length, + ...(result.impact.targetPlayerId ? { targetPlayerId: result.impact.targetPlayerId } : {}), + }); + const owner = loopbackSnapshotPlayers() + .find((player) => player.playerId === result.projectile.ownerPlayerId); + const ownerInventory = owner + ? quakeMultiplayerPruneExpiredPowerups(quakeMultiplayerPlayerInventory(owner), timestamp) + : null; + const damageMultiplier = ownerInventory + ? quakeMultiplayerDamageMultiplierForInventory(ownerInventory, timestamp) + : 1; + for (const damageHit of result.impact.damageHits) { + const target = loopbackCurrentPlayerForDamage(damageHit.target); + const damage = damageHit.damage * damageMultiplier; + if (target.playerId === playerState?.playerId) { + applyLocalRoomDamage({ + attackerPlayerId: result.projectile.ownerPlayerId, + damage, + damageSource: result.projectile.weapon, + eventId: `${projectileId}-${target.playerId}`, + inflictorOrigin: result.impact.origin, + now: timestamp, + }); + } else { + applySimulatedRoomDamage(target, { + attackerPlayerId: result.projectile.ownerPlayerId, + damage, + damageSource: result.projectile.weapon, + eventId: `${projectileId}-${target.playerId}`, + inflictorOrigin: result.impact.origin, + now: timestamp, + }); + } + } + } + return advanced; } function startSimulationTicker(): void { @@ -714,6 +857,33 @@ export function createQuakeLoopbackMultiplayerSession( return [...players.values()]; } + function nextLoopbackSpawnPoint(): QuakeMultiplayerSpawnPoint | undefined { + const selection = quakeMultiplayerDeathmatchSelectSpawnPoint( + spawnPoints, + loopbackSnapshotPlayers(), + { random }, + ); + if (!selection) return undefined; + spawnCursor = selection.nextCursor; + return selection.spawn; + } + + function loopbackCombatPlayersForFire( + attackerPlayerId: string, + targetTime: number, + ): QuakeMultiplayerAuthoritativePlayerState[] { + return quakeMultiplayerHistoricalCombatPlayers(snapshotHistory, loopbackSnapshotPlayers(), { + attackerPlayerId, + targetTime, + }); + } + + function loopbackCurrentPlayerForDamage( + target: QuakeMultiplayerAuthoritativePlayerState, + ): QuakeMultiplayerAuthoritativePlayerState { + return loopbackSnapshotPlayers().find((player) => player.playerId === target.playerId) ?? target; + } + function registerPickupDefinitions(definitions: readonly QuakeMultiplayerPickupDefinition[]): void { for (const definition of definitions) { if (pickupDefinitions.has(definition.entityIndex)) continue; @@ -727,6 +897,53 @@ export function createQuakeLoopbackMultiplayerSession( } } + function dynamicPickupDefinitions(): QuakeMultiplayerPickupDefinition[] { + return [...pickupDefinitions.values()].filter((definition) => definition.runtime === true); + } + + function dropPlayerBackpack(player: QuakeMultiplayerAuthoritativePlayerState, timestamp: number): void { + const definition = quakeMultiplayerDroppedBackpackDefinition({ + player, + entityIndex: dynamicPickupSequence++, + now: timestamp, + }); + if (!definition) return; + const pickup: QuakeMultiplayerAuthoritativePickupState = { + pickupId: definition.pickupId, + entityIndex: definition.entityIndex, + available: true, + updatedAt: timestamp, + }; + pickupDefinitions.set(definition.entityIndex, definition); + pickupStates.set(definition.entityIndex, pickup); + emitRoomEvent({ + eventType: "pickup.dropped", + eventId: `pickup-drop-${definition.entityIndex}-${timestamp}`, + roomTime: currentRoomTime(timestamp), + sourcePlayerId: player.playerId, + definition, + pickup, + }); + if (definition.removeAt !== undefined) schedulePickupRemoval(definition.entityIndex, definition.removeAt); + } + + function removePickupDefinition(entityIndex: number): void { + pickupDefinitions.delete(entityIndex); + pickupStates.delete(entityIndex); + const removalTimer = pickupRemovalTimers.get(entityIndex); + if (removalTimer) clearTimeout(removalTimer); + pickupRemovalTimers.delete(entityIndex); + const respawnTimer = pickupRespawnTimers.get(entityIndex); + if (respawnTimer) clearTimeout(respawnTimer); + pickupRespawnTimers.delete(entityIndex); + } + + function clearRuntimePickupDefinitions(): void { + for (const definition of dynamicPickupDefinitions()) { + removePickupDefinition(definition.entityIndex); + } + } + function registerWorldDefinitions(definitions: readonly QuakeMultiplayerWorldDefinition[]): void { for (const definition of definitions) { if (worldDefinitions.has(definition.entityIndex)) continue; @@ -739,9 +956,11 @@ export function createQuakeLoopbackMultiplayerSession( if (!acceptActivePresenceIntent(message.messageId)) return; if (!acceptActiveMatchIntent(message.messageId)) return; const timestamp = now(); - const attackerInventory = quakeMultiplayerPruneExpiredPowerups( - quakeMultiplayerPlayerInventory(playerState), - timestamp, + const attackerInventory = quakeMultiplayerInventoryWithBestWeaponIfCurrentAmmoEmpty( + quakeMultiplayerPruneExpiredPowerups( + quakeMultiplayerPlayerInventory(playerState), + timestamp, + ), ); const authoritativeFire = quakeMultiplayerDeathmatchFireFromPlayer( quakeMultiplayerPlayerWithInventory(playerState, attackerInventory), @@ -757,6 +976,19 @@ export function createQuakeLoopbackMultiplayerSession( }); return; } + const inputHistoryValidation = validateQuakeMultiplayerRoomFireInputHistory( + playerSimulationState, + authoritativeFire, + ); + if (!inputHistoryValidation.ok) { + emitReject({ + code: "stale", + message: `Multiplayer fire timestamp is outside accepted input history (${inputHistoryValidation.reason}).`, + recoverable: true, + rejectedMessageId: message.messageId, + }); + return; + } const nextFireAt = lastFireAt + cooldownMs; if (timestamp < nextFireAt) { emitReject({ @@ -775,8 +1007,8 @@ export function createQuakeLoopbackMultiplayerSession( players: loopbackSnapshotPlayers(), }); if (lightningDischarge) { - const inventory = quakeMultiplayerConsumeLightningDischargeCells(attackerInventory); - if (!inventory) { + const consumedInventory = quakeMultiplayerConsumeLightningDischargeCells(attackerInventory); + if (!consumedInventory) { emitReject({ code: "unsupported", message: `Not enough ammo for ${authoritativeFire.weapon}.`, @@ -785,6 +1017,7 @@ export function createQuakeLoopbackMultiplayerSession( }); return; } + const inventory = quakeMultiplayerInventoryWithBestWeaponIfCurrentAmmoEmpty(consumedInventory); lastFireAt = timestamp; playerState = { ...quakeMultiplayerPlayerWithInventory(playerState, inventory), @@ -799,6 +1032,12 @@ export function createQuakeLoopbackMultiplayerSession( fireKind: authoritativeFire.fireKind, origin: authoritativeFire.origin, direction: authoritativeFire.direction, + decision: { + outcome: "discharge", + playerDamageCount: lightningDischarge.hits.length, + reason: "lightning-discharge", + targetRewindMs: 0, + }, }); const damageMultiplier = quakeMultiplayerDamageMultiplierForInventory(inventory, timestamp); for (const hit of lightningDischarge.hits) { @@ -822,11 +1061,11 @@ export function createQuakeLoopbackMultiplayerSession( emitSnapshot(); return; } - const inventory = quakeMultiplayerConsumeWeaponAmmo( + const consumedInventory = quakeMultiplayerConsumeWeaponAmmo( attackerInventory, authoritativeFire.weapon, ); - if (!inventory) { + if (!consumedInventory) { emitReject({ code: "unsupported", message: `Not enough ammo for ${authoritativeFire.weapon}.`, @@ -835,30 +1074,83 @@ export function createQuakeLoopbackMultiplayerSession( }); return; } + const inventory = quakeMultiplayerInventoryWithBestWeaponIfCurrentAmmoEmpty(consumedInventory); lastFireAt = timestamp; playerState = { ...quakeMultiplayerPlayerWithInventory(playerState, inventory), updatedAt: timestamp, }; - emitRoomEvent({ - eventType: "player.fired", - eventId: `fire-${message.messageId}`, - roomTime: currentRoomTime(), - playerId: playerState.playerId, - weapon: authoritativeFire.weapon, - fireKind: authoritativeFire.fireKind, - origin: authoritativeFire.origin, - direction: authoritativeFire.direction, - }); - const hit = quakeMultiplayerDeathmatchHitscanHit( + const damageMultiplier = quakeMultiplayerDamageMultiplierForInventory(inventory, timestamp); + const broadcastFired = (decision: QuakeMultiplayerFireDecision): void => { + if (!playerState) return; + emitRoomEvent({ + eventType: "player.fired", + eventId: `fire-${message.messageId}`, + roomTime: currentRoomTime(), + playerId: playerState.playerId, + weapon: authoritativeFire.weapon, + fireKind: authoritativeFire.fireKind, + origin: authoritativeFire.origin, + direction: authoritativeFire.direction, + decision, + }); + }; + if (quakeMultiplayerServerProjectileWeaponSupported(authoritativeFire.weapon)) { + const projectile = createQuakeMultiplayerServerProjectile({ + fire: authoritativeFire, + now: timestamp, + ownerPlayerId: playerState.playerId, + projectileId: `projectile-${message.messageId}-${++projectileSequence}`, + }); + if (projectile) { + serverProjectiles.set(projectile.projectileId, projectile); + broadcastFired({ + outcome: "projectile-spawned", + playerDamageCount: 0, + reason: "server-projectile-spawned", + targetRewindMs: 0, + }); + emitRoomEvent({ + eventType: "projectile.spawned", + eventId: `projectile-spawned-${projectile.projectileId}`, + roomTime: currentRoomTime(), + projectile: quakeMultiplayerProjectileStateFromServer(projectile), + }); + startSimulationTicker(); + emitSnapshot(); + return; + } + } + const targetRewindMs = quakeMultiplayerDeathmatchLagCompensationMs(playerState); + const combatPlayers = loopbackCombatPlayersForFire(playerState.playerId, timestamp - targetRewindMs); + const hitDecision = quakeMultiplayerDeathmatchVisibleHitDecision( authoritativeFire, - loopbackSnapshotPlayers(), + combatPlayers, playerState.playerId, + options.trustedSceneMovement?.collisionWorld, ); + const hit = hitDecision.hit; const worldHit = quakeMultiplayerShootableWorldHit(authoritativeFire, worldDefinitions.values()); if (worldHit && (!hit || worldHit.distance <= hit.distance)) { - const damage = quakeMultiplayerDeathmatchWeaponDamage(authoritativeFire.weapon) * - quakeMultiplayerDamageMultiplierForInventory(inventory, timestamp); + const worldSplashHits = quakeMultiplayerDeathmatchProjectileSplashHitsAtImpact( + authoritativeFire, + worldHit.impact, + combatPlayers, + playerState.playerId, + options.trustedSceneMovement?.collisionWorld, + undefined, + ); + broadcastFired({ + blockedCandidateCount: hitDecision.blockedCandidateCount, + candidateCount: hitDecision.candidateCount, + outcome: "hit-world", + playerDamageCount: worldSplashHits.length, + reason: "world-before-player", + targetEntityIndex: worldHit.definition.entityIndex, + targetRewindMs, + worldHitDistance: worldHit.distance, + }); + const damage = quakeMultiplayerDeathmatchWeaponDamage(authoritativeFire.weapon) * damageMultiplier; if (worldHit.definition.kind === "mover") { applyShootableMoverDamage( worldHit.definition, @@ -874,18 +1166,48 @@ export function createQuakeLoopbackMultiplayerSession( damage, ); } + for (const damageHit of worldSplashHits) { + const target = loopbackCurrentPlayerForDamage(damageHit.target); + if (target.playerId === playerState.playerId) { + applyLocalPlayerDamage( + damageHit.damage * damageMultiplier, + message, + authoritativeFire.weapon, + damageHit.impact, + ); + } else { + applySimulatedPlayerDamage( + target, + damageHit.damage * damageMultiplier, + message, + authoritativeFire.weapon, + damageHit.impact, + ); + } + } emitSnapshot(); return; } if (hit) { - const damageMultiplier = quakeMultiplayerDamageMultiplierForInventory(inventory, timestamp); - for (const damageHit of quakeMultiplayerDeathmatchSplashHits( + const splashHits = quakeMultiplayerDeathmatchSplashHits( authoritativeFire, hit, - loopbackSnapshotPlayers(), + combatPlayers, playerState.playerId, - )) { - if (damageHit.target.playerId === playerState.playerId) { + options.trustedSceneMovement?.collisionWorld, + ); + broadcastFired({ + blockedCandidateCount: hitDecision.blockedCandidateCount, + candidateCount: hitDecision.candidateCount, + outcome: "hit-player", + playerDamageCount: splashHits.length, + reason: "player-direct", + targetPlayerId: hit.target.playerId, + targetRewindMs, + }); + for (const damageHit of splashHits) { + const target = loopbackCurrentPlayerForDamage(damageHit.target); + if (target.playerId === playerState.playerId) { applyLocalPlayerDamage( damageHit.damage * damageMultiplier, message, @@ -894,7 +1216,7 @@ export function createQuakeLoopbackMultiplayerSession( ); } else { applySimulatedPlayerDamage( - damageHit.target, + target, damageHit.damage * damageMultiplier, message, authoritativeFire.weapon, @@ -902,6 +1224,44 @@ export function createQuakeLoopbackMultiplayerSession( ); } } + } else { + const worldSplashHits = quakeMultiplayerDeathmatchProjectileWorldSplashHits( + authoritativeFire, + combatPlayers, + playerState.playerId, + options.trustedSceneMovement?.collisionWorld, + ); + broadcastFired({ + blockedCandidateCount: hitDecision.blockedCandidateCount, + candidateCount: hitDecision.candidateCount, + outcome: worldSplashHits.length > 0 ? "world-splash" : "miss", + playerDamageCount: worldSplashHits.length, + reason: worldSplashHits.length > 0 + ? "projectile-world-splash" + : authoritativeFire.fireKind === "projectile" && hitDecision.candidateCount === 0 + ? "no-world-impact" + : hitDecision.reason, + targetRewindMs, + }); + for (const damageHit of worldSplashHits) { + const target = loopbackCurrentPlayerForDamage(damageHit.target); + if (target.playerId === playerState.playerId) { + applyLocalPlayerDamage( + damageHit.damage * damageMultiplier, + message, + authoritativeFire.weapon, + damageHit.impact, + ); + } else { + applySimulatedPlayerDamage( + target, + damageHit.damage * damageMultiplier, + message, + authoritativeFire.weapon, + damageHit.impact, + ); + } + } } emitSnapshot(); } @@ -928,11 +1288,12 @@ export function createQuakeLoopbackMultiplayerSession( damageSource: string; eventId: string; inflictorOrigin?: QuakeMultiplayerVec3 | null; + now?: number; }): boolean { if (!playerState || !playerState.alive) return false; const damage = Math.max(0, input.damage); if (damage <= 0) return false; - const timestamp = now(); + const timestamp = input.now ?? now(); const playerInventory = quakeMultiplayerPruneExpiredPowerups(quakeMultiplayerPlayerInventory(playerState), timestamp); const invulnerable = quakeMultiplayerPlayerPowerupActive(playerState, "invincible_finished", timestamp); const inventory = quakeMultiplayerApplyDamageToInventory( @@ -940,15 +1301,18 @@ export function createQuakeLoopbackMultiplayerSession( damage, { applyHealth: !invulnerable }, ); - const died = inventory.health <= 0; - const fragDelta = died && input.attackerPlayerId - ? quakeMultiplayerDeathmatchFragDeltaForKill({ + const died = !invulnerable && inventory.health <= 0; + const resolvedInventory = died + ? quakeMultiplayerInventoryWithoutDeathPowerups(inventory) + : inventory; + const fragDelta = died + ? Math.min(0, quakeMultiplayerDeathmatchFragDeltaForKill({ attackerPlayerId: input.attackerPlayerId, victimPlayerId: playerState.playerId, - }) + })) : 0; const damagedPlayer = quakeMultiplayerDeathmatchPlayerWithDamageMomentum({ - player: quakeMultiplayerPlayerWithInventory(playerState, inventory), + player: quakeMultiplayerPlayerWithInventory(playerState, resolvedInventory), damage, inflictorOrigin: input.inflictorOrigin, }); @@ -960,7 +1324,12 @@ export function createQuakeLoopbackMultiplayerSession( updatedAt: timestamp, ...(died ? { respawnAt: timestamp + QUAKE_MULTIPLAYER_DEATHMATCH_RESPAWN_DELAY_MS } : {}), }; + if (invulnerable) { + emitSnapshot(); + return false; + } if (died) { + dropPlayerBackpack(playerState, timestamp); clearPickupOwnership(playerState.playerId, timestamp); if (playerSimulationState) { playerSimulationState = pauseQuakeMultiplayerRoomPlayerSimulation(playerSimulationState, timestamp); @@ -968,17 +1337,17 @@ export function createQuakeLoopbackMultiplayerSession( emitRoomEvent({ eventType: "player.killed", eventId: `kill-${input.eventId}`, - roomTime: currentRoomTime(), + roomTime: currentRoomTime(timestamp), victimPlayerId: playerState.playerId, ...(input.attackerPlayerId ? { attackerPlayerId: input.attackerPlayerId } : {}), damageSource: input.damageSource, }); - scheduleLocalRespawn(playerState.respawnAt ?? timestamp); + scheduleLocalRespawn(playerState.respawnAt ?? timestamp, timestamp); } else { emitRoomEvent({ eventType: "player.damaged", eventId: `damage-${input.eventId}`, - roomTime: currentRoomTime(), + roomTime: currentRoomTime(timestamp), victimPlayerId: playerState.playerId, ...(input.attackerPlayerId ? { attackerPlayerId: input.attackerPlayerId } : {}), damage, @@ -1014,9 +1383,10 @@ export function createQuakeLoopbackMultiplayerSession( damageSource: string; eventId: string; inflictorOrigin?: QuakeMultiplayerVec3 | null; + now?: number; }, ): void { - const timestamp = now(); + const timestamp = input.now ?? now(); const damage = Math.max(0, input.damage); if (damage <= 0) return; const targetInventory = quakeMultiplayerPruneExpiredPowerups(quakeMultiplayerPlayerInventory(target), timestamp); @@ -1026,20 +1396,31 @@ export function createQuakeLoopbackMultiplayerSession( damage, { applyHealth: !invulnerable }, ); - const died = inventory.health <= 0; + const died = !invulnerable && inventory.health <= 0; + const resolvedInventory = died + ? quakeMultiplayerInventoryWithoutDeathPowerups(inventory) + : inventory; + const targetFragDelta = died + ? Math.min(0, quakeMultiplayerDeathmatchFragDeltaForKill({ + attackerPlayerId: input.attackerPlayerId, + victimPlayerId: target.playerId, + })) + : 0; const damagedTarget = quakeMultiplayerDeathmatchPlayerWithDamageMomentum({ - player: quakeMultiplayerPlayerWithInventory(target, inventory), + player: quakeMultiplayerPlayerWithInventory(target, resolvedInventory), damage, inflictorOrigin: input.inflictorOrigin, }); const updatedTarget = { ...damagedTarget, alive: !died, + frags: target.frags + targetFragDelta, deaths: died ? target.deaths + 1 : target.deaths, updatedAt: timestamp, ...(died ? { respawnAt: timestamp + QUAKE_MULTIPLAYER_DEATHMATCH_RESPAWN_DELAY_MS } : {}), }; simulatedPlayerOverrides.set(target.playerId, updatedTarget); + if (invulnerable) return; if (died) { if (playerState && input.attackerPlayerId === playerState.playerId) { const fragDelta = quakeMultiplayerDeathmatchFragDeltaForKill({ @@ -1052,11 +1433,12 @@ export function createQuakeLoopbackMultiplayerSession( updatedAt: timestamp, }; } + dropPlayerBackpack(target, timestamp); clearPickupOwnership(target.playerId, timestamp); emitRoomEvent({ eventType: "player.killed", eventId: `kill-${input.eventId}`, - roomTime: currentRoomTime(), + roomTime: currentRoomTime(timestamp), victimPlayerId: target.playerId, ...(input.attackerPlayerId ? { attackerPlayerId: input.attackerPlayerId } : {}), damageSource: input.damageSource, @@ -1064,12 +1446,12 @@ export function createQuakeLoopbackMultiplayerSession( const matchEnded = playerState && input.attackerPlayerId === playerState.playerId ? enterIntermissionIfFragLimitReached(playerState, input.eventId) : false; - if (!matchEnded) scheduleSimulatedRespawn(target.playerId, updatedTarget.respawnAt ?? timestamp); + if (!matchEnded) scheduleSimulatedRespawn(target.playerId, updatedTarget.respawnAt ?? timestamp, timestamp); } else { emitRoomEvent({ eventType: "player.damaged", eventId: `damage-${input.eventId}`, - roomTime: currentRoomTime(), + roomTime: currentRoomTime(timestamp), victimPlayerId: target.playerId, ...(input.attackerPlayerId ? { attackerPlayerId: input.attackerPlayerId } : {}), damage, @@ -1086,12 +1468,33 @@ export function createQuakeLoopbackMultiplayerSession( for (const victim of loopbackSnapshotPlayers()) { if (victim.playerId === ownerPlayerId) continue; if (!quakeMultiplayerPlayerIntersectsTelefragVolume(victim, destinationOrigin)) continue; - if (quakeMultiplayerPlayerPowerupActive(victim, "invincible_finished", timestamp)) { + const victimInvulnerable = quakeMultiplayerPlayerPowerupActive(victim, "invincible_finished", timestamp); + const ownerInvulnerable = quakeMultiplayerPlayerPowerupActive(playerState, "invincible_finished", timestamp); + if (victimInvulnerable && ownerInvulnerable) { + const clearedVictim = quakeMultiplayerPlayerWithoutPowerup(victim, "invincible_finished"); + simulatedPlayerOverrides.set(victim.playerId, clearedVictim); + playerState = quakeMultiplayerPlayerWithoutPowerup(playerState, "invincible_finished"); + applySimulatedRoomDamage(clearedVictim, { + damage: QUAKE_MULTIPLAYER_TELEFRAG_DAMAGE, + damageSource: "teledeath3", + eventId: `telefrag-double-${eventId}-${victim.playerId}`, + now: timestamp, + }); + applyLocalRoomDamage({ + damage: QUAKE_MULTIPLAYER_TELEFRAG_DAMAGE, + damageSource: "teledeath3", + eventId: `telefrag-double-${eventId}-${ownerPlayerId}`, + now: timestamp, + }); + continue; + } + if (victimInvulnerable) { applyLocalRoomDamage({ attackerPlayerId: ownerPlayerId, damage: QUAKE_MULTIPLAYER_TELEFRAG_DAMAGE, damageSource: "teledeath2", eventId: `telefrag-deflect-${eventId}-${victim.playerId}`, + now: timestamp, }); continue; } @@ -1100,11 +1503,12 @@ export function createQuakeLoopbackMultiplayerSession( damage: QUAKE_MULTIPLAYER_TELEFRAG_DAMAGE, damageSource: "teledeath", eventId: `telefrag-${eventId}-${victim.playerId}`, + now: timestamp, }); } } - function scheduleLocalRespawn(respawnAt: number): void { + function scheduleLocalRespawn(respawnAt: number, currentNow = now()): void { const playerId = playerState?.playerId; if (!playerId) return; const previous = respawnTimers.get(playerId); @@ -1113,8 +1517,10 @@ export function createQuakeLoopbackMultiplayerSession( respawnTimers.delete(playerId); if (!playerState || playerState.playerId !== playerId) return; clearPickupOwnership(playerId, now()); + const spawn = nextLoopbackSpawnPoint(); playerState = { ...quakeMultiplayerPlayerWithInventory(playerState, createQuakeMultiplayerInitialInventory()), + ...(spawn ? { spawnId: spawn.spawnId, origin: spawn.origin, rotX: spawn.rotX, rotY: spawn.rotY } : {}), alive: true, velocity: [0, 0, 0], respawnAt: undefined, @@ -1132,11 +1538,11 @@ export function createQuakeLoopbackMultiplayerSession( player: playerState, }); emitSnapshot(); - }, Math.max(0, respawnAt - now())); + }, Math.max(0, respawnAt - currentNow)); respawnTimers.set(playerId, timer); } - function scheduleSimulatedRespawn(playerId: string, respawnAt: number): void { + function scheduleSimulatedRespawn(playerId: string, respawnAt: number, currentNow = now()): void { const previous = respawnTimers.get(playerId); if (previous) clearTimeout(previous); const timer = setTimeout(() => { @@ -1144,8 +1550,10 @@ export function createQuakeLoopbackMultiplayerSession( const player = simulatedPlayerOverrides.get(playerId); if (!player) return; clearPickupOwnership(playerId, now()); + const spawn = nextLoopbackSpawnPoint(); const respawned = { ...quakeMultiplayerPlayerWithInventory(player, createQuakeMultiplayerInitialInventory()), + ...(spawn ? { spawnId: spawn.spawnId, origin: spawn.origin, rotX: spawn.rotX, rotY: spawn.rotY } : {}), alive: true, velocity: [0, 0, 0], respawnAt: undefined, @@ -1159,7 +1567,7 @@ export function createQuakeLoopbackMultiplayerSession( player: respawned, }); emitSnapshot(); - }, Math.max(0, respawnAt - now())); + }, Math.max(0, respawnAt - currentNow)); respawnTimers.set(playerId, timer); } @@ -1183,7 +1591,10 @@ export function createQuakeLoopbackMultiplayerSession( } const timestamp = now(); const inventory = quakeMultiplayerPruneExpiredPowerups(quakeMultiplayerPlayerInventory(playerState), timestamp); - if (!quakeMultiplayerInventoryCanAcceptPickupEffect(inventory, definition.effect)) { + if ( + !quakeMultiplayerPickupAlwaysAcceptsTouch(definition) && + !quakeMultiplayerInventoryCanAcceptPickupEffect(inventory, definition.effect, timestamp) + ) { emitPickupRejected(message, definition, "not-needed"); return; } @@ -1222,6 +1633,9 @@ export function createQuakeLoopbackMultiplayerSession( } const targetDispatch = pickupTargetDispatchSource(definition); if (targetDispatch) scheduleTargetDispatch(targetDispatch, playerState.playerId, `pickup-${message.messageId}`); + if (definition.runtime && !leaveInPlace && updatedState.respawnAt === undefined) { + removePickupDefinition(definition.entityIndex); + } emitSnapshot(); } @@ -1352,11 +1766,13 @@ export function createQuakeLoopbackMultiplayerSession( return; } if (resolution.kind === "hurt") { - if (!acceptHurtTouch(resolution.definition.entityIndex, now())) return; + const timestamp = now(); + if (!acceptHurtTouch(resolution.definition.entityIndex, timestamp)) return; const applied = applyLocalRoomDamage({ damage: resolution.damage, damageSource: "trigger_hurt", eventId: `world-${message.messageId}`, + now: timestamp, }); if (applied) emitSnapshot(); return; @@ -1457,6 +1873,27 @@ export function createQuakeLoopbackMultiplayerSession( pickupRespawnTimers.set(entityIndex, timer); } + function schedulePickupRemoval(entityIndex: number, removeAt: number): void { + const previous = pickupRemovalTimers.get(entityIndex); + if (previous) clearTimeout(previous); + const timer = setTimeout(() => { + pickupRemovalTimers.delete(entityIndex); + const definition = pickupDefinitions.get(entityIndex); + const state = pickupStates.get(entityIndex); + if (!definition?.runtime || !state?.available) return; + emitRoomEvent({ + eventType: "pickup.expired", + eventId: `pickup-expired-${entityIndex}-${now()}`, + roomTime: currentRoomTime(), + pickupId: definition.pickupId, + entityIndex, + }); + removePickupDefinition(entityIndex); + emitSnapshot(); + }, Math.max(0, removeAt - now())); + pickupRemovalTimers.set(entityIndex, timer); + } + function clearPickupOwnership(playerId: string, timestamp = now()): void { for (const [entityIndex, state] of pickupStates) { const next = quakeMultiplayerPickupStateWithoutOwner(state, playerId, timestamp); @@ -1484,6 +1921,23 @@ export function createQuakeLoopbackMultiplayerSession( stopSimulationTicker(); } + function queueLoopbackPlayerInputs(inputs: readonly QuakeMultiplayerLocalInputIntent[]): void { + if (!quakeMultiplayerPresenceAcceptsInput(presenceStatus)) { + pauseLoopbackPlayerSimulation(); + return; + } + if (!playerState || !playerSimulationState) return; + let nextState = playerSimulationState; + let accepted = false; + for (const input of inputs) { + const result = queueQuakeMultiplayerRoomInput(nextState, input); + nextState = result.state; + accepted = accepted || result.accepted; + } + playerSimulationState = nextState; + if (accepted) startSimulationTicker(); + } + function enterIntermissionIfFragLimitReached( player: QuakeMultiplayerAuthoritativePlayerState, eventIdSeed: string, @@ -1557,6 +2011,7 @@ export function createQuakeLoopbackMultiplayerSession( matchStatus = "active"; clearTimers(respawnTimers); clearTimers(pickupRespawnTimers); + clearTimers(pickupRemovalTimers); clearTimers(targetDispatchTimers); clearTimers(moverStateTimers); simulatedPlayerOverrides.clear(); @@ -1567,6 +2022,7 @@ export function createQuakeLoopbackMultiplayerSession( triggerShootHealth.clear(); moverStates.clear(); moverShootHealth.clear(); + clearRuntimePickupDefinitions(); pickupStates = new Map(); for (const definition of pickupDefinitions.values()) { pickupStates.set(definition.entityIndex, { @@ -1783,7 +2239,6 @@ export function createQuakeLoopbackMultiplayerSession( cascadeDepth: number, activation: "touch" | "target" | "shoot", ): void { - if (definition.classname !== "func_button") return; const state = moverStates.get(definition.entityIndex) ?? "bottom"; if (state === "moving-up" || state === "top") return; moverStates.set(definition.entityIndex, "moving-up"); @@ -2027,9 +2482,9 @@ export function createQuakeLoopbackMultiplayerSession( } } - function currentRoomTime(): number { + function currentRoomTime(at = now()): number { const connectedAt = currentStatus.connectedAt ?? now(); - return Math.max(0, now() - connectedAt); + return Math.max(0, at - connectedAt); } return adapter; @@ -2070,12 +2525,6 @@ function createLoopbackPlayerState( }; } -function quakeLoopbackInitialSpawnPoint( - spawns: readonly QuakeMultiplayerSpawnPoint[] | undefined, -): QuakeMultiplayerSpawnPoint | undefined { - return spawns?.length ? quakeMultiplayerDeathmatchSpawnOrder(spawns)[0] : undefined; -} - function createLoopbackPlayerStateFromPose( roomKey: QuakeMultiplayerRoomCompatibilityKey, clientId: string, @@ -2150,6 +2599,35 @@ function createLoopbackDefaultSimulatedPlayer( }; } +function quakeMultiplayerProjectileStateFromServer( + projectile: QuakeMultiplayerServerProjectile, +): QuakeMultiplayerProjectileState { + return { + projectileId: projectile.projectileId, + ownerPlayerId: projectile.ownerPlayerId, + weapon: projectile.weapon, + origin: projectile.origin, + direction: projectile.direction, + speed: projectile.speed, + spawnedAt: projectile.spawnedAt, + updatedAt: projectile.updatedAt, + expiresAt: projectile.expiresAt, + }; +} + +function quakeMultiplayerPlayerWithoutPowerup( + player: QuakeMultiplayerAuthoritativePlayerState, + finishedField: string, +): QuakeMultiplayerAuthoritativePlayerState { + return quakeMultiplayerPlayerWithInventory( + player, + quakeMultiplayerInventoryWithoutPowerup( + quakeMultiplayerPlayerInventory(player), + finishedField, + ), + ); +} + function loopbackPlayerId(clientId: string): string { return `loopback:${clientId}`; } diff --git a/src/runtime/multiplayer/partyRoom.ts b/src/runtime/multiplayer/partyRoom.ts index 84c74ad..80dcda8 100644 --- a/src/runtime/multiplayer/partyRoom.ts +++ b/src/runtime/multiplayer/partyRoom.ts @@ -13,12 +13,14 @@ import { type QuakeMultiplayerAuthoritativePickupState, type QuakeMultiplayerAuthoritativePlayerState, type QuakeMultiplayerClientEnvelope, + type QuakeMultiplayerFireDecision, type QuakeMultiplayerGameplayDefinitions, type QuakeMultiplayerLocalInputIntent, type QuakeMultiplayerMapGameplayFacts, type QuakeMultiplayerMatchSettings, type QuakeMultiplayerMoverState, type QuakeMultiplayerPickupDefinition, + type QuakeMultiplayerProjectileState, type QuakeMultiplayerPlayerPresenceStatus, type QuakeMultiplayerRoomCompatibilityKey, type QuakeMultiplayerRoomEnvelope, @@ -52,12 +54,15 @@ import { QUAKE_MULTIPLAYER_DEATHMATCH_RESPAWN_DELAY_MS, quakeMultiplayerDeathmatchFireFromPlayer, quakeMultiplayerDeathmatchFragDeltaForKill, - quakeMultiplayerDeathmatchHitHasLineOfSight, - quakeMultiplayerDeathmatchHitscanHit, + quakeMultiplayerDeathmatchLagCompensationMs, quakeMultiplayerDeathmatchLightningDischarge, - quakeMultiplayerDeathmatchSpawnOrder, quakeMultiplayerDeathmatchPlayerWithDamageMomentum, + quakeMultiplayerDeathmatchProjectileSplashHitsAtImpact, + quakeMultiplayerDeathmatchProjectileWorldSplashHits, + quakeMultiplayerDeathmatchSelectSpawnPoint, + quakeMultiplayerDeathmatchSpawnOrder, quakeMultiplayerDeathmatchSplashHits, + quakeMultiplayerDeathmatchVisibleHitDecision, quakeMultiplayerDeathmatchWeaponCooldownMs, quakeMultiplayerDeathmatchWeaponDamage, rejectQuakeMultiplayerClientDamageIntent, @@ -69,7 +74,12 @@ import { quakeMultiplayerConsumeLightningDischargeCells, quakeMultiplayerConsumeWeaponAmmo, quakeMultiplayerDamageMultiplierForInventory, + quakeMultiplayerDroppedBackpackDefinition, quakeMultiplayerInventoryCanAcceptPickupEffect, + quakeMultiplayerInventoryWithBestWeaponIfCurrentAmmoEmpty, + quakeMultiplayerInventoryWithoutDeathPowerups, + quakeMultiplayerInventoryWithoutPowerup, + quakeMultiplayerPickupAlwaysAcceptsTouch, quakeMultiplayerPlayerCanReachPickup, quakeMultiplayerPlayerInventory, quakeMultiplayerPlayerPowerupActive, @@ -109,8 +119,20 @@ import { createQuakeMultiplayerRoomPlayerSimulationState, pauseQuakeMultiplayerRoomPlayerSimulation, queueQuakeMultiplayerRoomInput, + validateQuakeMultiplayerRoomFireInputHistory, type QuakeMultiplayerRoomPlayerSimulationState, } from "./simulation"; +import { + quakeMultiplayerHistoricalCombatPlayers, + recordQuakeMultiplayerSnapshotHistory, + type QuakeMultiplayerSnapshotHistory, +} from "./history"; +import { + advanceQuakeMultiplayerServerProjectile, + createQuakeMultiplayerServerProjectile, + quakeMultiplayerServerProjectileWeaponSupported, + type QuakeMultiplayerServerProjectile, +} from "./projectileAuthority"; import { CSSQUAKE_PRESENCE_ROOM_ID, createCssQuakePresenceUpdatePayload, @@ -133,6 +155,7 @@ interface CssQuakeConnectionState { } export interface CssQuakeMultiplayerRoomOptions { + random?: () => number; trustedGameplayDefinitions?: | QuakeMultiplayerGameplayDefinitions | ((roomKey: QuakeMultiplayerRoomCompatibilityKey) => QuakeMultiplayerGameplayDefinitions | null | undefined); @@ -191,14 +214,19 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { private readonly triggerShootHealth = new Map(); private readonly respawnTimers = new Map>(); private readonly pickupRespawnTimers = new Map>(); + private readonly pickupRemovalTimers = new Map>(); private readonly targetDispatchTimers = new Map>(); private readonly moverStateTimers = new Map>(); private readonly moverStates = new Map(); private readonly moverCollisionMotions = new Map(); private readonly moverCollisionOffsets = new Map(); private readonly moverShootHealth = new Map(); + private readonly serverProjectiles = new Map(); private readonly disconnectRemovalTimers = new Map>(); private readonly connectionRejectCounts = new Map(); + private snapshotHistory: QuakeMultiplayerSnapshotHistory = []; + private projectileSequence = 0; + private dynamicPickupSequence = 1_000_000; private matchRestartTimer: ReturnType | null = null; private roomSequence = 0; private tick = 0; @@ -320,6 +348,10 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { this.updateConnectionAuthority(sender, authority, receivedAt); this.queuePlayerInput(sender, message.payload.input); break; + case "client.inputBatch": + this.updateConnectionAuthority(sender, authority, receivedAt); + this.queuePlayerInputs(sender, message.payload.inputs); + break; case "client.fire": this.updateConnectionAuthority(sender, authority, receivedAt); this.advanceRoomSimulation(Date.now()); @@ -682,6 +714,10 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { } private queuePlayerInput(sender: Party.Connection, input: QuakeMultiplayerLocalInputIntent): void { + this.queuePlayerInputs(sender, [input]); + } + + private queuePlayerInputs(sender: Party.Connection, inputs: readonly QuakeMultiplayerLocalInputIntent[]): void { const state = this.connectionState(sender); if (!state?.playerId) return; if (!quakeMultiplayerPresenceAcceptsInput(state.presenceStatus)) { @@ -699,9 +735,15 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { now: Date.now(), lastAcceptedInputSequence: player.lastInputSequence, }); - const result = queueQuakeMultiplayerRoomInput(simulationState, input); - this.playerSimulationStates.set(player.playerId, result.state); - if (result.accepted) this.startSimulationTicker(); + let nextState = simulationState; + let accepted = false; + for (const input of inputs) { + const result = queueQuakeMultiplayerRoomInput(nextState, input); + nextState = result.state; + accepted = accepted || result.accepted; + } + this.playerSimulationStates.set(player.playerId, nextState); + if (accepted) this.startSimulationTicker(); } private advanceRoomSimulation(timestamp: number): boolean { @@ -737,10 +779,77 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { damage: hazard.damage, source: hazard.kind, eventId: `hazard-${hazard.kind}-${playerId}-${hazard.damagedAt}`, + now: hazard.damagedAt, }); } advanced = true; } + if (this.advanceServerProjectiles(timestamp)) advanced = true; + if (advanced) this.recordSnapshotHistory(timestamp); + return advanced; + } + + private advanceServerProjectiles(timestamp: number): boolean { + if (!this.serverProjectiles.size) return false; + let advanced = false; + for (const [projectileId, projectile] of [...this.serverProjectiles]) { + const result = advanceQuakeMultiplayerServerProjectile(projectile, { + collisionWorld: this.trustedSceneMovement?.collisionWorld, + now: timestamp, + players: this.players.values(), + }); + if (result.type === "active") { + this.serverProjectiles.set(projectileId, result.projectile); + advanced = true; + continue; + } + this.serverProjectiles.delete(projectileId); + advanced = true; + if (result.type === "expired") { + this.broadcastRoomEvent({ + eventType: "projectile.impacted", + eventId: `projectile-expired-${projectileId}`, + roomTime: this.roomTime(timestamp), + projectileId, + ownerPlayerId: result.projectile.ownerPlayerId, + weapon: result.projectile.weapon, + origin: result.projectile.origin, + impactKind: "world", + playerDamageCount: 0, + }); + continue; + } + this.broadcastRoomEvent({ + eventType: "projectile.impacted", + eventId: `projectile-impacted-${projectileId}`, + roomTime: this.roomTime(timestamp), + projectileId, + ownerPlayerId: result.projectile.ownerPlayerId, + weapon: result.projectile.weapon, + origin: result.impact.origin, + impactKind: result.impact.kind, + playerDamageCount: result.impact.damageHits.length, + ...(result.impact.targetPlayerId ? { targetPlayerId: result.impact.targetPlayerId } : {}), + }); + const owner = this.players.get(result.projectile.ownerPlayerId); + const ownerInventory = owner + ? quakeMultiplayerPruneExpiredPowerups(quakeMultiplayerPlayerInventory(owner), timestamp) + : null; + const damageMultiplier = ownerInventory + ? quakeMultiplayerDamageMultiplierForInventory(ownerInventory, timestamp) + : 1; + for (const damageHit of result.impact.damageHits) { + this.applyPlayerDamage({ + attackerPlayerId: result.projectile.ownerPlayerId, + victimPlayerId: damageHit.target.playerId, + damage: damageHit.damage * damageMultiplier, + source: result.projectile.weapon, + eventId: `${projectileId}-${damageHit.target.playerId}`, + inflictorOrigin: result.impact.origin, + now: timestamp, + }); + } + } return advanced; } @@ -754,10 +863,10 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { ); } - private pausePlayerSimulation(playerId: string): void { + private pausePlayerSimulation(playerId: string, now = Date.now()): void { const state = this.playerSimulationStates.get(playerId); if (!state) return; - this.playerSimulationStates.set(playerId, pauseQuakeMultiplayerRoomPlayerSimulation(state, Date.now())); + this.playerSimulationStates.set(playerId, pauseQuakeMultiplayerRoomPlayerSimulation(state, now)); } private handleFireIntent( @@ -771,7 +880,9 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { if (!this.acceptActivePresenceIntent(sender, message.messageId, state)) return; if (!this.acceptActiveMatchIntent(sender, message.messageId)) return; const now = Date.now(); - const attackerInventory = quakeMultiplayerPruneExpiredPowerups(quakeMultiplayerPlayerInventory(attacker), now); + const attackerInventory = quakeMultiplayerInventoryWithBestWeaponIfCurrentAmmoEmpty( + quakeMultiplayerPruneExpiredPowerups(quakeMultiplayerPlayerInventory(attacker), now), + ); const authoritativeFire = quakeMultiplayerDeathmatchFireFromPlayer( quakeMultiplayerPlayerWithInventory(attacker, attackerInventory), message.payload.fire, @@ -786,6 +897,19 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { }); return; } + const inputHistoryValidation = validateQuakeMultiplayerRoomFireInputHistory( + this.playerSimulationStates.get(attacker.playerId), + authoritativeFire, + ); + if (!inputHistoryValidation.ok) { + this.reject(sender, { + code: "stale", + message: `Multiplayer fire timestamp is outside accepted input history (${inputHistoryValidation.reason}).`, + recoverable: true, + rejectedMessageId: message.messageId, + }); + return; + } const nextFireAt = (this.lastFireAtByPlayer.get(attacker.playerId) ?? -Infinity) + cooldownMs; if (now < nextFireAt) { this.reject(sender, { @@ -804,8 +928,8 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { players: this.players.values(), }); if (lightningDischarge) { - const nextInventory = quakeMultiplayerConsumeLightningDischargeCells(attackerInventory); - if (!nextInventory) { + const consumedInventory = quakeMultiplayerConsumeLightningDischargeCells(attackerInventory); + if (!consumedInventory) { this.reject(sender, { code: "unsupported", message: `Not enough ammo for ${authoritativeFire.weapon}.`, @@ -814,6 +938,7 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { }); return; } + const nextInventory = quakeMultiplayerInventoryWithBestWeaponIfCurrentAmmoEmpty(consumedInventory); this.lastFireAtByPlayer.set(attacker.playerId, now); this.players.set(attacker.playerId, { ...quakeMultiplayerPlayerWithInventory(attacker, nextInventory), @@ -828,6 +953,12 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { fireKind: authoritativeFire.fireKind, origin: authoritativeFire.origin, direction: authoritativeFire.direction, + decision: { + outcome: "discharge", + playerDamageCount: lightningDischarge.hits.length, + reason: "lightning-discharge", + targetRewindMs: 0, + }, }); const damageMultiplier = quakeMultiplayerDamageMultiplierForInventory(nextInventory, now); for (const hit of lightningDischarge.hits) { @@ -838,13 +969,14 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { source: "lightning-discharge", eventId: `${message.messageId}-discharge-${hit.target.playerId}`, inflictorOrigin: attacker.origin, + now, }); } this.broadcastSnapshot(); return; } - const nextInventory = quakeMultiplayerConsumeWeaponAmmo(attackerInventory, authoritativeFire.weapon); - if (!nextInventory) { + const consumedInventory = quakeMultiplayerConsumeWeaponAmmo(attackerInventory, authoritativeFire.weapon); + if (!consumedInventory) { this.reject(sender, { code: "unsupported", message: `Not enough ammo for ${authoritativeFire.weapon}.`, @@ -853,30 +985,82 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { }); return; } + const nextInventory = quakeMultiplayerInventoryWithBestWeaponIfCurrentAmmoEmpty(consumedInventory); this.lastFireAtByPlayer.set(attacker.playerId, now); this.players.set(attacker.playerId, { ...quakeMultiplayerPlayerWithInventory(attacker, nextInventory), updatedAt: now, }); - this.broadcastRoomEvent({ - eventType: "player.fired", - eventId: `fire-${message.messageId}`, - roomTime: this.roomTime(), - playerId: attacker.playerId, - weapon: authoritativeFire.weapon, - fireKind: authoritativeFire.fireKind, - origin: authoritativeFire.origin, - direction: authoritativeFire.direction, - }); - const hit = quakeMultiplayerDeathmatchHitscanHit( + const damageMultiplier = quakeMultiplayerDamageMultiplierForInventory(nextInventory, now); + const broadcastFired = (decision: QuakeMultiplayerFireDecision): void => { + this.broadcastRoomEvent({ + eventType: "player.fired", + eventId: `fire-${message.messageId}`, + roomTime: this.roomTime(), + playerId: attacker.playerId, + weapon: authoritativeFire.weapon, + fireKind: authoritativeFire.fireKind, + origin: authoritativeFire.origin, + direction: authoritativeFire.direction, + decision, + }); + }; + if (quakeMultiplayerServerProjectileWeaponSupported(authoritativeFire.weapon)) { + const projectile = createQuakeMultiplayerServerProjectile({ + fire: authoritativeFire, + now, + ownerPlayerId: attacker.playerId, + projectileId: `projectile-${message.messageId}-${++this.projectileSequence}`, + }); + if (projectile) { + this.serverProjectiles.set(projectile.projectileId, projectile); + broadcastFired({ + outcome: "projectile-spawned", + playerDamageCount: 0, + reason: "server-projectile-spawned", + targetRewindMs: 0, + }); + this.broadcastRoomEvent({ + eventType: "projectile.spawned", + eventId: `projectile-spawned-${projectile.projectileId}`, + roomTime: this.roomTime(), + projectile: quakeMultiplayerProjectileStateFromServer(projectile), + }); + this.startSimulationTicker(); + this.broadcastSnapshot(); + return; + } + } + const targetRewindMs = quakeMultiplayerDeathmatchLagCompensationMs(attacker); + const combatPlayers = this.combatPlayersForFire(attacker.playerId, now - targetRewindMs); + const hitDecision = quakeMultiplayerDeathmatchVisibleHitDecision( authoritativeFire, - this.players.values(), + combatPlayers, attacker.playerId, + this.trustedSceneMovement?.collisionWorld, ); + const hit = hitDecision.hit; const worldHit = quakeMultiplayerShootableWorldHit(authoritativeFire, this.worldDefinitions.values()); if (worldHit && (!hit || worldHit.distance <= hit.distance)) { - const damage = quakeMultiplayerDeathmatchWeaponDamage(authoritativeFire.weapon) * - quakeMultiplayerDamageMultiplierForInventory(nextInventory, now); + const worldSplashHits = quakeMultiplayerDeathmatchProjectileSplashHitsAtImpact( + authoritativeFire, + worldHit.impact, + combatPlayers, + attacker.playerId, + this.trustedSceneMovement?.collisionWorld, + undefined, + ); + broadcastFired({ + blockedCandidateCount: hitDecision.blockedCandidateCount, + candidateCount: hitDecision.candidateCount, + outcome: "hit-world", + playerDamageCount: worldSplashHits.length, + reason: "world-before-player", + targetEntityIndex: worldHit.definition.entityIndex, + targetRewindMs, + worldHitDistance: worldHit.distance, + }); + const damage = quakeMultiplayerDeathmatchWeaponDamage(authoritativeFire.weapon) * damageMultiplier; if (worldHit.definition.kind === "mover") { this.applyShootableMoverDamage( worldHit.definition, @@ -892,28 +1076,70 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { damage, ); } + for (const damageHit of worldSplashHits) { + this.applyPlayerDamage({ + attackerPlayerId: attacker.playerId, + victimPlayerId: damageHit.target.playerId, + damage: damageHit.damage * damageMultiplier, + source: authoritativeFire.weapon, + eventId: `${message.messageId}-world-splash-${damageHit.target.playerId}`, + inflictorOrigin: damageHit.impact, + now, + }); + } this.broadcastSnapshot(); return; } if (!hit) { + const worldSplashHits = quakeMultiplayerDeathmatchProjectileWorldSplashHits( + authoritativeFire, + combatPlayers, + attacker.playerId, + this.trustedSceneMovement?.collisionWorld, + ); + broadcastFired({ + blockedCandidateCount: hitDecision.blockedCandidateCount, + candidateCount: hitDecision.candidateCount, + outcome: worldSplashHits.length > 0 ? "world-splash" : "miss", + playerDamageCount: worldSplashHits.length, + reason: worldSplashHits.length > 0 + ? "projectile-world-splash" + : authoritativeFire.fireKind === "projectile" && hitDecision.candidateCount === 0 + ? "no-world-impact" + : hitDecision.reason, + targetRewindMs, + }); + for (const damageHit of worldSplashHits) { + this.applyPlayerDamage({ + attackerPlayerId: attacker.playerId, + victimPlayerId: damageHit.target.playerId, + damage: damageHit.damage * damageMultiplier, + source: authoritativeFire.weapon, + eventId: `${message.messageId}-wall-splash-${damageHit.target.playerId}`, + inflictorOrigin: damageHit.impact, + now, + }); + } this.broadcastSnapshot(); return; } - if (!quakeMultiplayerDeathmatchHitHasLineOfSight( - authoritativeFire, - hit, - this.trustedSceneMovement?.collisionWorld, - )) { - this.broadcastSnapshot(); - return; - } - const damageMultiplier = quakeMultiplayerDamageMultiplierForInventory(nextInventory, now); - for (const damageHit of quakeMultiplayerDeathmatchSplashHits( + const splashHits = quakeMultiplayerDeathmatchSplashHits( authoritativeFire, hit, - this.players.values(), + combatPlayers, attacker.playerId, - )) { + this.trustedSceneMovement?.collisionWorld, + ); + broadcastFired({ + blockedCandidateCount: hitDecision.blockedCandidateCount, + candidateCount: hitDecision.candidateCount, + outcome: "hit-player", + playerDamageCount: splashHits.length, + reason: "player-direct", + targetPlayerId: hit.target.playerId, + targetRewindMs, + }); + for (const damageHit of splashHits) { this.applyPlayerDamage({ attackerPlayerId: attacker.playerId, victimPlayerId: damageHit.target.playerId, @@ -921,6 +1147,7 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { source: authoritativeFire.weapon, eventId: damageHit.direct ? message.messageId : `${message.messageId}-${damageHit.target.playerId}`, inflictorOrigin: authoritativeFire.fireKind === "projectile" ? damageHit.impact : attacker.origin, + now, }); } this.broadcastSnapshot(); @@ -933,12 +1160,13 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { source: string; eventId: string; inflictorOrigin?: QuakeMultiplayerVec3 | null; + now?: number; }): void { const victim = this.players.get(input.victimPlayerId); if (!victim || !victim.alive) return; const damage = Math.max(0, input.damage); if (damage <= 0) return; - const now = Date.now(); + const now = input.now ?? Date.now(); const victimInventory = quakeMultiplayerPruneExpiredPowerups(quakeMultiplayerPlayerInventory(victim), now); const invulnerable = quakeMultiplayerPlayerPowerupActive(victim, "invincible_finished", now); const nextInventory = quakeMultiplayerApplyDamageToInventory( @@ -946,15 +1174,18 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { damage, { applyHealth: !invulnerable }, ); - const died = nextInventory.health <= 0; - const victimFragDelta = died && input.attackerPlayerId + const died = !invulnerable && nextInventory.health <= 0; + const resolvedInventory = died + ? quakeMultiplayerInventoryWithoutDeathPowerups(nextInventory) + : nextInventory; + const victimFragDelta = died ? Math.min(0, quakeMultiplayerDeathmatchFragDeltaForKill({ attackerPlayerId: input.attackerPlayerId, victimPlayerId: victim.playerId, })) : 0; const damagedVictim = quakeMultiplayerDeathmatchPlayerWithDamageMomentum({ - player: quakeMultiplayerPlayerWithInventory(victim, nextInventory), + player: quakeMultiplayerPlayerWithInventory(victim, resolvedInventory), damage, inflictorOrigin: input.inflictorOrigin, }); @@ -967,6 +1198,10 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { ...(died ? { respawnAt: now + QUAKE_MULTIPLAYER_DEATHMATCH_RESPAWN_DELAY_MS } : {}), }; this.players.set(victim.playerId, updatedVictim); + if (invulnerable) { + this.broadcastSnapshot(); + return; + } if (died) { const attacker = input.attackerPlayerId ? this.players.get(input.attackerPlayerId) : undefined; let matchEnded = false; @@ -985,7 +1220,7 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { this.broadcastRoomEvent({ eventType: "player.killed", eventId: `kill-${input.eventId}`, - roomTime: this.roomTime(), + roomTime: this.roomTime(now), victimPlayerId: victim.playerId, ...(input.attackerPlayerId ? { attackerPlayerId: input.attackerPlayerId } : {}), damageSource: input.source, @@ -994,14 +1229,15 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { if (updatedAttacker && updatedAttacker.playerId !== victim.playerId) { matchEnded = this.enterIntermissionIfFragLimitReached(updatedAttacker, input.eventId); } + this.dropPlayerBackpack(victim, now); this.clearPickupOwnership(victim.playerId, now); - this.pausePlayerSimulation(victim.playerId); - if (!matchEnded) this.schedulePlayerRespawn(victim.playerId, updatedVictim.respawnAt ?? now); + this.pausePlayerSimulation(victim.playerId, now); + if (!matchEnded) this.schedulePlayerRespawn(victim.playerId, updatedVictim.respawnAt ?? now, now); } else { this.broadcastRoomEvent({ eventType: "player.damaged", eventId: `damage-${input.eventId}`, - roomTime: this.roomTime(), + roomTime: this.roomTime(now), victimPlayerId: victim.playerId, ...(input.attackerPlayerId ? { attackerPlayerId: input.attackerPlayerId } : {}), damage, @@ -1013,10 +1249,10 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { this.broadcastSnapshot(); } - private schedulePlayerRespawn(playerId: string, respawnAt: number): void { + private schedulePlayerRespawn(playerId: string, respawnAt: number, now = Date.now()): void { const previous = this.respawnTimers.get(playerId); if (previous) clearTimeout(previous); - const delay = Math.max(0, respawnAt - Date.now()); + const delay = Math.max(0, respawnAt - now); const timer = setTimeout(() => { this.respawnTimers.delete(playerId); this.respawnPlayer(playerId); @@ -1066,6 +1302,80 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { } } + private dynamicPickupDefinitions(): QuakeMultiplayerPickupDefinition[] { + return [...this.pickupDefinitions.values()].filter((definition) => definition.runtime === true); + } + + private dropPlayerBackpack( + player: QuakeMultiplayerAuthoritativePlayerState, + now: number, + ): void { + const definition = quakeMultiplayerDroppedBackpackDefinition({ + player, + entityIndex: this.dynamicPickupSequence++, + now, + }); + if (!definition) return; + const pickup: QuakeMultiplayerAuthoritativePickupState = { + pickupId: definition.pickupId, + entityIndex: definition.entityIndex, + available: true, + updatedAt: now, + }; + this.pickupDefinitions.set(definition.entityIndex, definition); + this.pickupStates.set(definition.entityIndex, pickup); + this.broadcastRoomEvent({ + eventType: "pickup.dropped", + eventId: `pickup-drop-${definition.entityIndex}-${now}`, + roomTime: this.roomTime(now), + sourcePlayerId: player.playerId, + definition, + pickup, + }); + if (definition.removeAt !== undefined) { + this.schedulePickupRemoval(definition.entityIndex, definition.removeAt); + } + } + + private schedulePickupRemoval(entityIndex: number, removeAt: number): void { + const previous = this.pickupRemovalTimers.get(entityIndex); + if (previous) clearTimeout(previous); + const timer = setTimeout(() => { + this.pickupRemovalTimers.delete(entityIndex); + const definition = this.pickupDefinitions.get(entityIndex); + const state = this.pickupStates.get(entityIndex); + if (!definition?.runtime || !state?.available) return; + this.broadcastRoomEvent({ + eventType: "pickup.expired", + eventId: `pickup-expired-${entityIndex}-${Date.now()}`, + roomTime: this.roomTime(), + pickupId: definition.pickupId, + entityIndex, + }); + this.removePickupDefinition(entityIndex); + this.broadcastSnapshot(); + }, Math.max(0, removeAt - Date.now())); + unrefTimer(timer); + this.pickupRemovalTimers.set(entityIndex, timer); + } + + private removePickupDefinition(entityIndex: number): void { + this.pickupDefinitions.delete(entityIndex); + this.pickupStates.delete(entityIndex); + const timer = this.pickupRemovalTimers.get(entityIndex); + if (timer) clearTimeout(timer); + this.pickupRemovalTimers.delete(entityIndex); + const respawnTimer = this.pickupRespawnTimers.get(entityIndex); + if (respawnTimer) clearTimeout(respawnTimer); + this.pickupRespawnTimers.delete(entityIndex); + } + + private clearRuntimePickupDefinitions(): void { + for (const definition of this.dynamicPickupDefinitions()) { + this.removePickupDefinition(definition.entityIndex); + } + } + private registerWorldDefinitions(definitions: readonly QuakeMultiplayerWorldDefinition[]): void { for (const definition of definitions) { if (this.worldDefinitions.has(definition.entityIndex)) continue; @@ -1100,7 +1410,10 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { } const now = Date.now(); const inventory = quakeMultiplayerPruneExpiredPowerups(quakeMultiplayerPlayerInventory(player), now); - if (!quakeMultiplayerInventoryCanAcceptPickupEffect(inventory, definition.effect)) { + if ( + !quakeMultiplayerPickupAlwaysAcceptsTouch(definition) && + !quakeMultiplayerInventoryCanAcceptPickupEffect(inventory, definition.effect, now) + ) { this.broadcastPickupRejected(player.playerId, message, definition, "not-needed"); return; } @@ -1140,6 +1453,9 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { } const targetDispatch = this.pickupTargetDispatchSource(definition); if (targetDispatch) this.scheduleTargetDispatch(targetDispatch, player.playerId, `pickup-${message.messageId}`); + if (definition.runtime && !leaveInPlace && updatedState.respawnAt === undefined) { + this.removePickupDefinition(definition.entityIndex); + } this.broadcastSnapshot(); } @@ -1244,12 +1560,14 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { return; } if (resolution.kind === "hurt") { - if (!this.acceptHurtTouch(resolution.definition.entityIndex, Date.now())) return; + const timestamp = Date.now(); + if (!this.acceptHurtTouch(resolution.definition.entityIndex, timestamp)) return; this.applyPlayerDamage({ victimPlayerId: player.playerId, damage: resolution.damage, source: "trigger_hurt", eventId: `world-${message.messageId}`, + now: timestamp, }); return; } @@ -1474,13 +1792,44 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { for (const victim of [...this.players.values()]) { if (victim.playerId === ownerPlayerId) continue; if (!quakeMultiplayerPlayerIntersectsTelefragVolume(victim, destinationOrigin)) continue; - if (quakeMultiplayerPlayerPowerupActive(victim, "invincible_finished", timestamp)) { + const owner = this.players.get(ownerPlayerId); + const victimInvulnerable = quakeMultiplayerPlayerPowerupActive(victim, "invincible_finished", timestamp); + const ownerInvulnerable = owner + ? quakeMultiplayerPlayerPowerupActive(owner, "invincible_finished", timestamp) + : false; + if (victimInvulnerable && owner && ownerInvulnerable) { + this.players.set( + victim.playerId, + quakeMultiplayerPlayerWithoutPowerup(victim, "invincible_finished"), + ); + this.players.set( + owner.playerId, + quakeMultiplayerPlayerWithoutPowerup(owner, "invincible_finished"), + ); + this.applyPlayerDamage({ + victimPlayerId: victim.playerId, + damage: QUAKE_MULTIPLAYER_TELEFRAG_DAMAGE, + source: "teledeath3", + eventId: `telefrag-double-${eventId}-${victim.playerId}`, + now: timestamp, + }); + this.applyPlayerDamage({ + victimPlayerId: owner.playerId, + damage: QUAKE_MULTIPLAYER_TELEFRAG_DAMAGE, + source: "teledeath3", + eventId: `telefrag-double-${eventId}-${owner.playerId}`, + now: timestamp, + }); + continue; + } + if (victimInvulnerable) { this.applyPlayerDamage({ attackerPlayerId: ownerPlayerId, victimPlayerId: ownerPlayerId, damage: QUAKE_MULTIPLAYER_TELEFRAG_DAMAGE, source: "teledeath2", eventId: `telefrag-deflect-${eventId}-${victim.playerId}`, + now: timestamp, }); continue; } @@ -1490,6 +1839,7 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { damage: QUAKE_MULTIPLAYER_TELEFRAG_DAMAGE, source: "teledeath", eventId: `telefrag-${eventId}-${victim.playerId}`, + now: timestamp, }); } } @@ -1569,7 +1919,6 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { cascadeDepth: number, activation: "touch" | "target" | "shoot", ): void { - if (definition.classname !== "func_button") return; const state = this.moverStates.get(definition.entityIndex) ?? "bottom"; if (state === "moving-up" || state === "top") return; this.moverStates.set(definition.entityIndex, "moving-up"); @@ -1977,6 +2326,7 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { this.moverShootHealth.clear(); this.clearTimeoutMap(this.respawnTimers); this.clearTimeoutMap(this.pickupRespawnTimers); + this.clearTimeoutMap(this.pickupRemovalTimers); this.clearTimeoutMap(this.targetDispatchTimers); this.clearTimeoutMap(this.moverStateTimers); this.clearTimeoutMap(this.disconnectRemovalTimers); @@ -1987,6 +2337,8 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { this.fetchedTrustedWorldDefinitions = null; this.trustedGameplayDefinitionsPromise = null; this.trustedSceneMovement = this.options.trustedSceneMovement ?? null; + this.snapshotHistory = []; + this.dynamicPickupSequence = 1_000_000; this.roomSequence = 0; this.tick = 0; this.worldEventSequence = 0; @@ -2113,24 +2465,59 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { if (!this.roomKey) return; this.enterIntermissionIfTimeLimitReached("snapshot"); this.pruneExpiredPlayerPowerups(); - this.lastScheduledSnapshotAt = Date.now(); + const sampledAt = Date.now(); + this.lastScheduledSnapshotAt = sampledAt; this.tick += 1; + const roomTime = this.roomTime(); + const players = [...this.players.values()]; + this.snapshotHistory = recordQuakeMultiplayerSnapshotHistory(this.snapshotHistory, { + sampledAt, + roomTime, + tick: this.tick, + players, + }); this.broadcast("room.snapshot", { roomId: this.room.id, tick: this.tick, - roomTime: this.roomTime(), + roomTime, match: { status: this.matchStatus, - clockMs: this.roomTime(), + clockMs: roomTime, ...this.matchSettings, }, - players: [...this.players.values()], + players, spectators: this.spectatorStates(), + dynamicPickups: this.dynamicPickupDefinitions(), pickups: [...this.pickupStates.values()], + projectiles: [...this.serverProjectiles.values()].map(quakeMultiplayerProjectileStateFromServer), lastWorldEventSequence: this.worldEventSequence, }, without); } + private recordSnapshotHistory(sampledAt: number): void { + if (!this.roomKey || !this.players.size) return; + this.snapshotHistory = recordQuakeMultiplayerSnapshotHistory(this.snapshotHistory, { + sampledAt, + roomTime: this.roomTime(), + tick: this.tick, + players: this.players.values(), + }); + } + + private combatPlayersForFire( + attackerPlayerId: string, + targetTime: number, + ): QuakeMultiplayerAuthoritativePlayerState[] { + return quakeMultiplayerHistoricalCombatPlayers( + this.snapshotHistory, + this.players.values(), + { + attackerPlayerId, + targetTime, + }, + ); + } + private spectatorStates(): QuakeMultiplayerRoomSpectatorState[] { const spectators: QuakeMultiplayerRoomSpectatorState[] = []; for (const state of this.connectionPlayers.values()) { @@ -2368,6 +2755,7 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { this.startedAt = timestamp; this.clearTimeoutMap(this.respawnTimers); this.clearTimeoutMap(this.pickupRespawnTimers); + this.clearTimeoutMap(this.pickupRemovalTimers); this.clearTimeoutMap(this.targetDispatchTimers); this.clearTimeoutMap(this.moverStateTimers); this.lastFireAtByPlayer.clear(); @@ -2379,6 +2767,7 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { this.resetMoverCollisionOffsets(); this.moverStates.clear(); this.moverShootHealth.clear(); + this.clearRuntimePickupDefinitions(); this.pickupStates.clear(); for (const definition of this.pickupDefinitions.values()) { this.pickupStates.set(definition.entityIndex, { @@ -2490,8 +2879,8 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { }) as QuakeMultiplayerRoomEnvelope; } - private roomTime(): number { - return Math.max(0, Date.now() - this.startedAt); + private roomTime(now = Date.now()): number { + return Math.max(0, now - this.startedAt); } private playerIdForClient(clientId: string): string { @@ -2500,9 +2889,14 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { private nextSpawnPoint(): QuakeMultiplayerSpawnPoint | null { if (!this.spawnPoints.length) return null; - const spawn = this.spawnPoints[this.spawnCursor % this.spawnPoints.length] ?? null; - this.spawnCursor++; - return spawn; + const selection = quakeMultiplayerDeathmatchSelectSpawnPoint( + this.spawnPoints, + this.players.values(), + this.options.random ? { random: this.options.random } : {}, + ); + if (!selection) return null; + this.spawnCursor = selection.nextCursor; + return selection.spawn; } private closeMalformed(connection: Party.Connection, reason: string): void { @@ -2608,6 +3002,35 @@ function isQuakeMultiplayerVec3Like(value: unknown): boolean { ); } +function quakeMultiplayerPlayerWithoutPowerup( + player: QuakeMultiplayerAuthoritativePlayerState, + finishedField: string, +): QuakeMultiplayerAuthoritativePlayerState { + return quakeMultiplayerPlayerWithInventory( + player, + quakeMultiplayerInventoryWithoutPowerup( + quakeMultiplayerPlayerInventory(player), + finishedField, + ), + ); +} + +function quakeMultiplayerProjectileStateFromServer( + projectile: QuakeMultiplayerServerProjectile, +): QuakeMultiplayerProjectileState { + return { + projectileId: projectile.projectileId, + ownerPlayerId: projectile.ownerPlayerId, + weapon: projectile.weapon, + origin: projectile.origin, + direction: projectile.direction, + speed: projectile.speed, + spawnedAt: projectile.spawnedAt, + updatedAt: projectile.updatedAt, + expiresAt: projectile.expiresAt, + }; +} + function firstHelloRoomKey(value: unknown): QuakeMultiplayerRoomCompatibilityKey | null { if (!isRecord(value) || value.type !== "client.hello" || !isRecord(value.roomKey)) return null; const roomKey = value.roomKey; diff --git a/src/runtime/multiplayer/presentation.ts b/src/runtime/multiplayer/presentation.ts index b884720..49007d1 100644 --- a/src/runtime/multiplayer/presentation.ts +++ b/src/runtime/multiplayer/presentation.ts @@ -21,6 +21,10 @@ type QuakeMultiplayerPlayerKilledEvent = Extract< QuakeMultiplayerSharedWorldEvent, { eventType: "player.killed" } >; +type QuakeMultiplayerPlayerFiredEvent = Extract< + QuakeMultiplayerSharedWorldEvent, + { eventType: "player.fired" } +>; export interface QuakeMultiplayerRemoteVisualHandle { element?: HTMLElement; @@ -51,8 +55,11 @@ interface QuakeMultiplayerRemotePlayerEntry { player: QuakeMultiplayerAuthoritativePlayerState; samples: QuakeMultiplayerRemoteInterpolationSample[]; visual: QuakeMultiplayerRemoteVisualHandle | null; + lastAttackAt?: number; + lastAttackWeapon?: string; lastPainAt?: number; deathAt?: number; + missingSince?: number; } export interface QuakeMultiplayerRemotePlayerPresenter { @@ -111,7 +118,7 @@ export function createQuakeMultiplayerRemotePlayerPresenter( syncRemotePlayer(player); } for (const playerId of [...players.keys()]) { - if (!seen.has(playerId)) removeRemotePlayer(playerId); + if (!seen.has(playerId)) markRemotePlayerMissing(playerId); } scheduleFrame(); } @@ -127,6 +134,7 @@ export function createQuakeMultiplayerRemotePlayerPresenter( }; players.set(player.playerId, entry); } + entry.missingSince = undefined; entry.player = player; entry.samples.push({ playerId: player.playerId, @@ -139,10 +147,14 @@ export function createQuakeMultiplayerRemotePlayerPresenter( }); if (player.alive) { if (!wasAlive || entry.deathAt !== undefined) { + entry.lastAttackAt = undefined; + entry.lastAttackWeapon = undefined; entry.lastPainAt = undefined; entry.deathAt = undefined; } } else if (wasAlive || entry.deathAt === undefined) { + entry.lastAttackAt = undefined; + entry.lastAttackWeapon = undefined; entry.lastPainAt = undefined; entry.deathAt = now(); } @@ -153,6 +165,8 @@ export function createQuakeMultiplayerRemotePlayerPresenter( function handleRoomEvent(event: QuakeMultiplayerSharedWorldEvent): void { if (event.eventType === "player.left") { removeRemotePlayer(event.playerId); + } else if (event.eventType === "player.fired") { + markRemotePlayerAttack(event); } else if (event.eventType === "player.damaged") { markRemotePlayerPain(event); } else if (event.eventType === "player.killed") { @@ -163,6 +177,14 @@ export function createQuakeMultiplayerRemotePlayerPresenter( } } + function markRemotePlayerAttack(event: QuakeMultiplayerPlayerFiredEvent): void { + const entry = players.get(event.playerId); + if (!entry || entry.player.clientId === options.localClientId || !entry.player.alive) return; + entry.lastAttackAt = now(); + entry.lastAttackWeapon = event.weapon; + scheduleFrame(); + } + function markRemotePlayerPain(event: QuakeMultiplayerPlayerDamagedEvent): void { const entry = players.get(event.victimPlayerId); if (!entry || entry.player.clientId === options.localClientId || !entry.player.alive) return; @@ -174,6 +196,8 @@ export function createQuakeMultiplayerRemotePlayerPresenter( function markRemotePlayerDeath(event: QuakeMultiplayerPlayerKilledEvent): void { const entry = players.get(event.victimPlayerId); if (!entry || entry.player.clientId === options.localClientId) return; + entry.lastAttackAt = undefined; + entry.lastAttackWeapon = undefined; entry.lastPainAt = undefined; entry.deathAt = now(); options.onPlayerKilled?.(event, entry.player); @@ -199,7 +223,16 @@ export function createQuakeMultiplayerRemotePlayerPresenter( if (!entry.visual) continue; } const state = interpolateQuakeMultiplayerRemoteState(playerId, entry.samples, renderAt, staleAfterMs); - if (!state) continue; + if (!state) { + if (entry.missingSince !== undefined && now() - entry.missingSince > staleAfterMs) { + removeRemotePlayer(playerId); + } + continue; + } + if (entry.missingSince !== undefined && state.stale && now() - entry.missingSince > staleAfterMs) { + removeRemotePlayer(playerId); + continue; + } entry.visual.setState(quakeMultiplayerRemoteStateWithEvents(entry, state)); } if (players.size) scheduleFrame(); @@ -216,13 +249,18 @@ export function createQuakeMultiplayerRemotePlayerPresenter( deathAt: entry.deathAt, }; } - if (entry.lastPainAt !== undefined) { - return { - ...state, - lastPainAt: entry.lastPainAt, - }; - } - return state; + return { + ...state, + ...(entry.lastPainAt !== undefined ? { lastPainAt: entry.lastPainAt } : {}), + ...(entry.lastAttackAt !== undefined ? { lastAttackAt: entry.lastAttackAt } : {}), + ...(entry.lastAttackWeapon ? { lastAttackWeapon: entry.lastAttackWeapon } : {}), + }; + } + + function markRemotePlayerMissing(playerId: string): void { + const entry = players.get(playerId); + if (!entry) return; + entry.missingSince ??= now(); } function removeRemotePlayer(playerId: string): void { diff --git a/src/runtime/multiplayer/projectileAuthority.ts b/src/runtime/multiplayer/projectileAuthority.ts new file mode 100644 index 0000000..0763600 --- /dev/null +++ b/src/runtime/multiplayer/projectileAuthority.ts @@ -0,0 +1,383 @@ +import type { QuakeCollisionWorld } from "../collision"; +import { COLLISION_EPSILON, QUAKE_COLLISION_UNIT_SCALE } from "../constants"; +import { + quakeMultiplayerDeathmatchProjectileSplashHitsAtImpact, + quakeMultiplayerDeathmatchSplashHits, + quakeMultiplayerDeathmatchVisibleHitDecision, + type QuakeMultiplayerDeathmatchSplashHit, +} from "./deathmatch"; +import type { + QuakeMultiplayerAuthoritativePlayerState, + QuakeMultiplayerFireIntent, + QuakeMultiplayerVec3, +} from "./protocol"; + +const QUAKE_MULTIPLAYER_PROJECTILE_NAIL_SPEED = 1000 * QUAKE_COLLISION_UNIT_SCALE; +const QUAKE_MULTIPLAYER_PROJECTILE_GRENADE_SPEED = 600 * QUAKE_COLLISION_UNIT_SCALE; +const QUAKE_MULTIPLAYER_PROJECTILE_ROCKET_SPEED = 1000 * QUAKE_COLLISION_UNIT_SCALE; +const QUAKE_MULTIPLAYER_PROJECTILE_NAIL_LIFETIME_MS = 6_000; +const QUAKE_MULTIPLAYER_PROJECTILE_GRENADE_LIFETIME_MS = 2_500; +const QUAKE_MULTIPLAYER_PROJECTILE_ROCKET_LIFETIME_MS = 5_000; +const QUAKE_MULTIPLAYER_PROJECTILE_GRENADE_VERTICAL_VELOCITY = 200 * QUAKE_COLLISION_UNIT_SCALE; +const QUAKE_MULTIPLAYER_PROJECTILE_GRENADE_GRAVITY = 800 * QUAKE_COLLISION_UNIT_SCALE; +const QUAKE_MULTIPLAYER_PROJECTILE_BOUNCE_OVERBOUNCE = 1.5; +const QUAKE_MULTIPLAYER_PROJECTILE_BOUNCE_STOP_EPSILON = 0.1 * QUAKE_COLLISION_UNIT_SCALE; + +export interface QuakeMultiplayerServerProjectile { + direction: QuakeMultiplayerVec3; + expiresAt: number; + fire: QuakeMultiplayerFireIntent; + gravity: number; + origin: QuakeMultiplayerVec3; + ownerPlayerId: string; + projectileId: string; + spawnedAt: number; + speed: number; + updatedAt: number; + velocity: QuakeMultiplayerVec3; + weapon: string; +} + +export type QuakeMultiplayerServerProjectileImpactKind = "player" | "world"; + +export interface QuakeMultiplayerServerProjectileImpact { + damageHits: QuakeMultiplayerDeathmatchSplashHit[]; + kind: QuakeMultiplayerServerProjectileImpactKind; + origin: QuakeMultiplayerVec3; + targetPlayerId?: string; +} + +export type QuakeMultiplayerServerProjectileAdvanceResult = + | { + type: "active"; + projectile: QuakeMultiplayerServerProjectile; + } + | { + type: "expired"; + projectile: QuakeMultiplayerServerProjectile; + } + | { + type: "impact"; + impact: QuakeMultiplayerServerProjectileImpact; + projectile: QuakeMultiplayerServerProjectile; + }; + +export function quakeMultiplayerServerProjectileWeaponSupported(weapon: string): boolean { + const normalized = weapon.trim().toLowerCase(); + return normalized === "nailgun" || + normalized === "supernailgun" || + normalized === "grenadelauncher" || + normalized === "rocketlauncher"; +} + +export function createQuakeMultiplayerServerProjectile(input: { + fire: QuakeMultiplayerFireIntent; + now: number; + ownerPlayerId: string; + projectileId: string; +}): QuakeMultiplayerServerProjectile | null { + const speed = quakeMultiplayerServerProjectileSpeed(input.fire.weapon); + const lifetimeMs = quakeMultiplayerServerProjectileLifetimeMs(input.fire.weapon); + const direction = normalizedVec3(input.fire.direction); + if (!direction || speed <= 0 || lifetimeMs <= 0 || input.fire.fireKind !== "projectile") return null; + const velocity = quakeMultiplayerServerProjectileVelocity(input.fire.weapon, direction, speed); + return { + direction, + expiresAt: input.now + lifetimeMs, + fire: { + ...input.fire, + direction, + }, + gravity: quakeMultiplayerServerProjectileGravity(input.fire.weapon), + origin: [...input.fire.origin] as QuakeMultiplayerVec3, + ownerPlayerId: input.ownerPlayerId, + projectileId: input.projectileId, + spawnedAt: input.now, + speed, + updatedAt: input.now, + velocity, + weapon: input.fire.weapon, + }; +} + +export function advanceQuakeMultiplayerServerProjectile( + projectile: QuakeMultiplayerServerProjectile, + input: { + collisionWorld?: Pick | null; + now: number; + players: Iterable; + }, +): QuakeMultiplayerServerProjectileAdvanceResult { + const players = [...input.players]; + if (input.now >= projectile.expiresAt) { + if (quakeMultiplayerServerProjectileExplodesOnExpire(projectile.weapon)) { + return { + type: "impact", + projectile: { ...projectile, updatedAt: input.now }, + impact: { + damageHits: quakeMultiplayerDeathmatchProjectileSplashHitsAtImpact( + projectile.fire, + projectile.origin, + players, + projectile.ownerPlayerId, + input.collisionWorld, + undefined, + ), + kind: "world", + origin: projectile.origin, + }, + }; + } + return { type: "expired", projectile: { ...projectile, updatedAt: input.now } }; + } + const dt = Math.max(0, (input.now - projectile.updatedAt) / 1000); + if (dt <= 0) return { type: "active", projectile }; + const nextVelocity: QuakeMultiplayerVec3 = [ + projectile.velocity[0], + projectile.velocity[1], + projectile.velocity[2] - projectile.gravity * dt, + ]; + const nextOrigin: QuakeMultiplayerVec3 = [ + projectile.origin[0] + nextVelocity[0] * dt, + projectile.origin[1] + nextVelocity[1] * dt, + projectile.origin[2] + nextVelocity[2] * dt, + ]; + const segmentDistance = distance3(projectile.origin, nextOrigin); + if (segmentDistance <= 0) { + return { + type: "active", + projectile: { + ...projectile, + updatedAt: input.now, + velocity: nextVelocity, + }, + }; + } + const segmentDirection = normalizedVec3([ + nextOrigin[0] - projectile.origin[0], + nextOrigin[1] - projectile.origin[1], + nextOrigin[2] - projectile.origin[2], + ]) ?? projectile.direction; + const segmentFire: QuakeMultiplayerFireIntent = { + ...projectile.fire, + direction: segmentDirection, + origin: projectile.origin, + range: segmentDistance, + }; + const hitDecision = quakeMultiplayerDeathmatchVisibleHitDecision( + segmentFire, + players, + projectile.ownerPlayerId, + input.collisionWorld, + ); + const worldImpact = quakeMultiplayerServerProjectileWorldImpact( + projectile.origin, + nextOrigin, + input.collisionWorld, + ); + const hit = hitDecision.hit; + if (worldImpact && (!hit || worldImpact.distance <= hit.distance)) { + if (quakeMultiplayerServerProjectileBounces(projectile.weapon)) { + return { + type: "active", + projectile: quakeMultiplayerServerProjectileBounced(projectile, worldImpact, nextVelocity, input.now), + }; + } + const damageHits = quakeMultiplayerDeathmatchProjectileSplashHitsAtImpact( + segmentFire, + worldImpact.origin, + players, + projectile.ownerPlayerId, + input.collisionWorld, + undefined, + ); + return { + type: "impact", + projectile: { + ...projectile, + origin: worldImpact.origin, + updatedAt: input.now, + velocity: nextVelocity, + }, + impact: { + damageHits, + kind: "world", + origin: worldImpact.origin, + }, + }; + } + if (hit) { + const damageHits = quakeMultiplayerDeathmatchSplashHits( + segmentFire, + hit, + players, + projectile.ownerPlayerId, + input.collisionWorld, + ); + return { + type: "impact", + projectile: { + ...projectile, + origin: hit.impact, + updatedAt: input.now, + velocity: nextVelocity, + }, + impact: { + damageHits, + kind: "player", + origin: hit.impact, + targetPlayerId: hit.target.playerId, + }, + }; + } + return { + type: "active", + projectile: { + ...projectile, + direction: normalizedVec3(nextVelocity) ?? projectile.direction, + origin: nextOrigin, + speed: Math.hypot(nextVelocity[0], nextVelocity[1], nextVelocity[2]), + updatedAt: input.now, + velocity: nextVelocity, + }, + }; +} + +function quakeMultiplayerServerProjectileSpeed(weapon: string): number { + const normalized = weapon.trim().toLowerCase(); + if (normalized === "nailgun" || normalized === "supernailgun") return QUAKE_MULTIPLAYER_PROJECTILE_NAIL_SPEED; + if (normalized === "grenadelauncher") return QUAKE_MULTIPLAYER_PROJECTILE_GRENADE_SPEED; + if (normalized === "rocketlauncher") return QUAKE_MULTIPLAYER_PROJECTILE_ROCKET_SPEED; + return 0; +} + +function quakeMultiplayerServerProjectileLifetimeMs(weapon: string): number { + const normalized = weapon.trim().toLowerCase(); + if (normalized === "nailgun" || normalized === "supernailgun") return QUAKE_MULTIPLAYER_PROJECTILE_NAIL_LIFETIME_MS; + if (normalized === "grenadelauncher") return QUAKE_MULTIPLAYER_PROJECTILE_GRENADE_LIFETIME_MS; + if (normalized === "rocketlauncher") return QUAKE_MULTIPLAYER_PROJECTILE_ROCKET_LIFETIME_MS; + return 0; +} + +function quakeMultiplayerServerProjectileGravity(weapon: string): number { + return weapon.trim().toLowerCase() === "grenadelauncher" + ? QUAKE_MULTIPLAYER_PROJECTILE_GRENADE_GRAVITY + : 0; +} + +function quakeMultiplayerServerProjectileVelocity( + weapon: string, + direction: QuakeMultiplayerVec3, + speed: number, +): QuakeMultiplayerVec3 { + if (weapon.trim().toLowerCase() !== "grenadelauncher") { + return [ + direction[0] * speed, + direction[1] * speed, + direction[2] * speed, + ]; + } + return [ + direction[0] * speed, + direction[1] * speed, + direction[2] * speed + QUAKE_MULTIPLAYER_PROJECTILE_GRENADE_VERTICAL_VELOCITY, + ]; +} + +function quakeMultiplayerServerProjectileBounces(weapon: string): boolean { + return weapon.trim().toLowerCase() === "grenadelauncher"; +} + +function quakeMultiplayerServerProjectileExplodesOnExpire(weapon: string): boolean { + return weapon.trim().toLowerCase() === "grenadelauncher"; +} + +function quakeMultiplayerServerProjectileBounced( + projectile: QuakeMultiplayerServerProjectile, + worldImpact: { normal?: QuakeMultiplayerVec3; origin: QuakeMultiplayerVec3 }, + velocity: QuakeMultiplayerVec3, + updatedAt: number, +): QuakeMultiplayerServerProjectile { + const normal = worldImpact.normal; + if (!normal) { + return { + ...projectile, + direction: [0, 0, 0], + origin: worldImpact.origin, + speed: 0, + updatedAt, + velocity: [0, 0, 0], + }; + } + const bounced = clipVelocity(velocity, normal, QUAKE_MULTIPLAYER_PROJECTILE_BOUNCE_OVERBOUNCE); + const speed = Math.hypot(bounced[0], bounced[1], bounced[2]); + const origin: QuakeMultiplayerVec3 = [ + worldImpact.origin[0] + normal[0] * COLLISION_EPSILON, + worldImpact.origin[1] + normal[1] * COLLISION_EPSILON, + worldImpact.origin[2] + normal[2] * COLLISION_EPSILON, + ]; + if (speed <= COLLISION_EPSILON) { + return { + ...projectile, + direction: [0, 0, 0], + origin, + speed: 0, + updatedAt, + velocity: [0, 0, 0], + }; + } + return { + ...projectile, + direction: normalizedVec3(bounced) ?? projectile.direction, + origin, + speed, + updatedAt, + velocity: bounced, + }; +} + +function quakeMultiplayerServerProjectileWorldImpact( + origin: QuakeMultiplayerVec3, + end: QuakeMultiplayerVec3, + collisionWorld?: Pick | null, +): { distance: number; normal?: QuakeMultiplayerVec3; origin: QuakeMultiplayerVec3 } | null { + if (!collisionWorld?.traceUse) return null; + const trace = collisionWorld.traceUse(origin, end); + if (!trace || trace.fraction >= 1) return null; + return { + distance: distance3(origin, trace.end), + ...(trace.planeNormal ? { normal: [trace.planeNormal[0], trace.planeNormal[1], trace.planeNormal[2]] } : {}), + origin: [trace.end[0], trace.end[1], trace.end[2]], + }; +} + +function normalizedVec3(value: QuakeMultiplayerVec3): QuakeMultiplayerVec3 | null { + const length = Math.hypot(value[0], value[1], value[2]); + if (!Number.isFinite(length) || length <= 0) return null; + return [value[0] / length, value[1] / length, value[2] / length]; +} + +function distance3(a: QuakeMultiplayerVec3, b: QuakeMultiplayerVec3): number { + return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); +} + +function clipVelocity( + velocity: QuakeMultiplayerVec3, + normal: QuakeMultiplayerVec3, + overbounce: number, +): QuakeMultiplayerVec3 { + const backoff = dot3(velocity, normal) * overbounce; + return [ + stopTinyVelocity(velocity[0] - normal[0] * backoff), + stopTinyVelocity(velocity[1] - normal[1] * backoff), + stopTinyVelocity(velocity[2] - normal[2] * backoff), + ]; +} + +function stopTinyVelocity(value: number): number { + return Math.abs(value) < QUAKE_MULTIPLAYER_PROJECTILE_BOUNCE_STOP_EPSILON ? 0 : value; +} + +function dot3(a: QuakeMultiplayerVec3, b: QuakeMultiplayerVec3): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} diff --git a/src/runtime/multiplayer/protocol.ts b/src/runtime/multiplayer/protocol.ts index d53385b..137818f 100644 --- a/src/runtime/multiplayer/protocol.ts +++ b/src/runtime/multiplayer/protocol.ts @@ -1,4 +1,5 @@ export const QUAKE_MULTIPLAYER_PROTOCOL_VERSION = 1 as const; +export const QUAKE_MULTIPLAYER_MAX_INPUT_BATCH_SIZE = 4; export type QuakeMultiplayerProtocolVersion = typeof QUAKE_MULTIPLAYER_PROTOCOL_VERSION; export type QuakeMultiplayerVec3 = readonly [number, number, number]; @@ -66,6 +67,9 @@ export interface QuakeMultiplayerPickupDefinition { effect: QuakeMultiplayerPickupEffect; lifecycle?: QuakeMultiplayerPickupLifecycle; feedback?: QuakeMultiplayerPickupFeedback; + modelPath?: string; + removeAt?: number; + runtime?: boolean; targetEntityIndexes?: readonly number[]; killtargetEntityIndexes?: readonly number[]; delayMs?: number; @@ -97,7 +101,11 @@ export type QuakeMultiplayerTriggerActivationClassname = | "trigger_counter" | "trigger_relay"; -export type QuakeMultiplayerMoverClassname = "func_button"; +export type QuakeMultiplayerMoverClassname = + | "func_button" + | "func_door" + | "func_door_secret" + | "func_plat"; export type QuakeMultiplayerMoverActivation = "touch" | "target" | "shoot"; export type QuakeMultiplayerMoverState = "moving-up" | "top" | "moving-down" | "bottom"; @@ -214,6 +222,7 @@ export type QuakeMultiplayerClientMessageType = | "client.hello" | "client.presence" | "client.input" + | "client.inputBatch" | "client.fire" | "client.damage" | "client.pickup" @@ -281,6 +290,36 @@ export interface QuakeMultiplayerFireIntent { range: number; } +export type QuakeMultiplayerFireDecisionOutcome = + | "discharge" + | "hit-player" + | "hit-world" + | "miss" + | "projectile-spawned" + | "world-splash"; + +export type QuakeMultiplayerFireDecisionReason = + | "line-of-sight-blocked" + | "lightning-discharge" + | "no-candidate" + | "no-world-impact" + | "player-direct" + | "server-projectile-spawned" + | "projectile-world-splash" + | "world-before-player"; + +export interface QuakeMultiplayerFireDecision { + outcome: QuakeMultiplayerFireDecisionOutcome; + reason: QuakeMultiplayerFireDecisionReason; + candidateCount?: number; + blockedCandidateCount?: number; + playerDamageCount?: number; + targetEntityIndex?: number; + targetPlayerId?: string; + targetRewindMs?: number; + worldHitDistance?: number; +} + export interface QuakeMultiplayerDamageIntent { damageSequence: number; damagedAt: number; @@ -288,6 +327,18 @@ export interface QuakeMultiplayerDamageIntent { source: string; } +export interface QuakeMultiplayerProjectileState { + projectileId: string; + ownerPlayerId: string; + weapon: string; + origin: QuakeMultiplayerVec3; + direction: QuakeMultiplayerVec3; + speed: number; + spawnedAt: number; + updatedAt: number; + expiresAt: number; +} + export interface QuakeMultiplayerPickupIntent { pickupSequence: number; requestedAt: number; @@ -417,6 +468,8 @@ export interface QuakeMultiplayerRemoteInterpolationState { renderRotX: number; renderRotY: number; alive: boolean; + lastAttackAt?: number; + lastAttackWeapon?: string; lastPainAt?: number; deathAt?: number; previous?: QuakeMultiplayerRemoteInterpolationSample; @@ -494,6 +547,25 @@ export type QuakeMultiplayerSharedWorldEvent = fireKind: QuakeMultiplayerFireKind; origin: QuakeMultiplayerVec3; direction: QuakeMultiplayerVec3; + decision?: QuakeMultiplayerFireDecision; + } + | { + eventType: "projectile.spawned"; + eventId: string; + roomTime: number; + projectile: QuakeMultiplayerProjectileState; + } + | { + eventType: "projectile.impacted"; + eventId: string; + roomTime: number; + projectileId: string; + ownerPlayerId: string; + weapon: string; + origin: QuakeMultiplayerVec3; + impactKind: "player" | "world"; + playerDamageCount: number; + targetPlayerId?: string; } | { eventType: "player.damaged"; @@ -547,6 +619,21 @@ export type QuakeMultiplayerSharedWorldEvent = roomTime: number; pickup: QuakeMultiplayerAuthoritativePickupState; } + | { + eventType: "pickup.dropped"; + eventId: string; + roomTime: number; + sourcePlayerId: string; + definition: QuakeMultiplayerPickupDefinition; + pickup: QuakeMultiplayerAuthoritativePickupState; + } + | { + eventType: "pickup.expired"; + eventId: string; + roomTime: number; + pickupId: string; + entityIndex: number; + } | { eventType: "world.use"; eventId: string; @@ -679,6 +766,11 @@ export interface QuakeMultiplayerClientInputPayload { input: QuakeMultiplayerLocalInputIntent; } +export interface QuakeMultiplayerClientInputBatchPayload { + clientId: string; + inputs: readonly QuakeMultiplayerLocalInputIntent[]; +} + export interface QuakeMultiplayerClientFirePayload { clientId: string; fire: QuakeMultiplayerFireIntent; @@ -751,7 +843,9 @@ export interface QuakeMultiplayerRoomSnapshotPayload { match: QuakeMultiplayerRoomMatchState; players: QuakeMultiplayerAuthoritativePlayerState[]; spectators?: QuakeMultiplayerRoomSpectatorState[]; + dynamicPickups?: QuakeMultiplayerPickupDefinition[]; pickups?: QuakeMultiplayerAuthoritativePickupState[]; + projectiles?: QuakeMultiplayerProjectileState[]; lastWorldEventSequence: number; } @@ -802,6 +896,11 @@ export type QuakeMultiplayerClientInputEnvelope = QuakeMultiplayerEnvelope< "client.input", QuakeMultiplayerClientInputPayload >; +export type QuakeMultiplayerClientInputBatchEnvelope = QuakeMultiplayerEnvelope< + "client", + "client.inputBatch", + QuakeMultiplayerClientInputBatchPayload +>; export type QuakeMultiplayerClientFireEnvelope = QuakeMultiplayerEnvelope< "client", "client.fire", @@ -878,6 +977,7 @@ export type QuakeMultiplayerClientEnvelope = | QuakeMultiplayerClientHelloEnvelope | QuakeMultiplayerClientPresenceEnvelope | QuakeMultiplayerClientInputEnvelope + | QuakeMultiplayerClientInputBatchEnvelope | QuakeMultiplayerClientFireEnvelope | QuakeMultiplayerClientDamageEnvelope | QuakeMultiplayerClientPickupEnvelope diff --git a/src/runtime/multiplayer/simulation.ts b/src/runtime/multiplayer/simulation.ts index c494d16..107fdf9 100644 --- a/src/runtime/multiplayer/simulation.ts +++ b/src/runtime/multiplayer/simulation.ts @@ -14,6 +14,7 @@ import { quakePlayerFallDamageFromVelocityZ } from "../playerPhysics"; import { quakeMultiplayerAdvancePlayerWithInputResult } from "./movement"; import type { QuakeMultiplayerAuthoritativePlayerState, + QuakeMultiplayerFireIntent, QuakeMultiplayerLocalInputIntent, QuakeMultiplayerVec3, } from "./protocol"; @@ -21,7 +22,9 @@ import type { export const QUAKE_MULTIPLAYER_ROOM_SIMULATION_TICK_MS = 50; export const QUAKE_MULTIPLAYER_MAX_ROOM_SIMULATION_CATCHUP_TICKS = 4; export const QUAKE_MULTIPLAYER_MAX_QUEUED_INPUTS = 8; +export const QUAKE_MULTIPLAYER_ACCEPTED_INPUT_HISTORY_LIMIT = 32; export const QUAKE_MULTIPLAYER_INPUT_HOLD_MS = 250; +export const QUAKE_MULTIPLAYER_FIRE_INPUT_HISTORY_TOLERANCE_MS = QUAKE_MULTIPLAYER_INPUT_HOLD_MS + 100; export const QUAKE_MULTIPLAYER_TELEPORT_BACKPEDAL_LOCK_MS = 700; export const QUAKE_MULTIPLAYER_DROWN_AIR_MS = 12_000; export const QUAKE_MULTIPLAYER_DROWN_DAMAGE_INTERVAL_MS = 1_000; @@ -39,6 +42,7 @@ export interface QuakeMultiplayerRoomPlayerSimulationState { grounded: boolean; floorZ?: number; fallVelocityZ?: number; + acceptedInputHistory: readonly QuakeMultiplayerLocalInputIntent[]; lastAcceptedInput?: QuakeMultiplayerLocalInputIntent; lastAcceptedInputSequence: number; lastSimulatedAt: number; @@ -70,6 +74,26 @@ export interface QuakeMultiplayerRoomInputQueueResult { state: QuakeMultiplayerRoomPlayerSimulationState; } +export type QuakeMultiplayerRoomFireInputHistoryRejectReason = + | "fire-after-input-history" + | "fire-before-input-history" + | "fire-between-input-history-gap"; + +export type QuakeMultiplayerRoomFireInputHistoryValidation = + | { + ok: true; + closestInputSequence?: number; + deltaMs?: number; + historySize: number; + } + | { + ok: false; + closestInputSequence?: number; + deltaMs?: number; + historySize: number; + reason: QuakeMultiplayerRoomFireInputHistoryRejectReason; + }; + export interface QuakeMultiplayerRoomSimulationAdvanceResult { advancedTicks: number; consumedInputSequences: number[]; @@ -99,6 +123,7 @@ export function createQuakeMultiplayerRoomPlayerSimulationState(input: { lastAcceptedInputSequence: input.lastAcceptedInputSequence ?? 0, lastSimulatedAt: input.now, lastSimulatedTick: input.lastSimulatedTick ?? 0, + acceptedInputHistory: [], pendingInputs: [], }; } @@ -120,11 +145,79 @@ export function queueQuakeMultiplayerRoomInput( accepted: true, state: { ...state, + acceptedInputHistory: appendQuakeMultiplayerAcceptedInputHistory(state.acceptedInputHistory, input), pendingInputs: pending.slice(-QUAKE_MULTIPLAYER_MAX_QUEUED_INPUTS), }, }; } +export function validateQuakeMultiplayerRoomFireInputHistory( + state: QuakeMultiplayerRoomPlayerSimulationState | null | undefined, + fire: QuakeMultiplayerFireIntent, + options: { + toleranceMs?: number; + } = {}, +): QuakeMultiplayerRoomFireInputHistoryValidation { + const history = quakeMultiplayerAcceptedInputHistoryForValidation(state); + if (history.length <= 0) return { ok: true, historySize: 0 }; + const toleranceMs = normalizePositiveNumber( + options.toleranceMs, + QUAKE_MULTIPLAYER_FIRE_INPUT_HISTORY_TOLERANCE_MS, + ); + const firedAt = fire.firedAt; + let closest = history[0]; + let closestDelta = Math.abs(firedAt - closest.sampledAt); + let earliest = history[0]; + let latest = history[0]; + for (const input of history.slice(1)) { + const delta = Math.abs(firedAt - input.sampledAt); + if (delta < closestDelta) { + closest = input; + closestDelta = delta; + } + if (input.sampledAt < earliest.sampledAt) earliest = input; + if (input.sampledAt > latest.sampledAt) latest = input; + } + if (closestDelta <= toleranceMs) { + return { + ok: true, + closestInputSequence: closest.inputSequence, + deltaMs: closestDelta, + historySize: history.length, + }; + } + const reason: QuakeMultiplayerRoomFireInputHistoryRejectReason = firedAt < earliest.sampledAt + ? "fire-before-input-history" + : firedAt > latest.sampledAt + ? "fire-after-input-history" + : "fire-between-input-history-gap"; + return { + ok: false, + closestInputSequence: closest.inputSequence, + deltaMs: closestDelta, + historySize: history.length, + reason, + }; +} + +function quakeMultiplayerAcceptedInputHistoryForValidation( + state: QuakeMultiplayerRoomPlayerSimulationState | null | undefined, +): QuakeMultiplayerLocalInputIntent[] { + if (!state) return []; + const bySequence = new Map(); + for (const input of state.acceptedInputHistory) bySequence.set(input.inputSequence, input); + if (state.lastAcceptedInput) bySequence.set(state.lastAcceptedInput.inputSequence, state.lastAcceptedInput); + return [...bySequence.values()].sort((left, right) => left.sampledAt - right.sampledAt); +} + +function appendQuakeMultiplayerAcceptedInputHistory( + history: readonly QuakeMultiplayerLocalInputIntent[], + input: QuakeMultiplayerLocalInputIntent, +): readonly QuakeMultiplayerLocalInputIntent[] { + const withoutReplacement = history.filter((candidate) => candidate.inputSequence !== input.inputSequence); + return [...withoutReplacement, input].slice(-QUAKE_MULTIPLAYER_ACCEPTED_INPUT_HISTORY_LIMIT); +} + export function pauseQuakeMultiplayerRoomPlayerSimulation( state: QuakeMultiplayerRoomPlayerSimulationState, now: number, diff --git a/src/runtime/multiplayer/validation.ts b/src/runtime/multiplayer/validation.ts index 1037404..2abe257 100644 --- a/src/runtime/multiplayer/validation.ts +++ b/src/runtime/multiplayer/validation.ts @@ -1,4 +1,5 @@ import { + QUAKE_MULTIPLAYER_MAX_INPUT_BATCH_SIZE, QUAKE_MULTIPLAYER_PROTOCOL_VERSION, sameQuakeMultiplayerRoomCompatibilityKey, } from "./protocol"; @@ -8,6 +9,9 @@ import type { QuakeMultiplayerClientEnvelope, QuakeMultiplayerClientMessageType, QuakeMultiplayerAuthoritativePickupState, + QuakeMultiplayerFireDecision, + QuakeMultiplayerFireDecisionOutcome, + QuakeMultiplayerFireDecisionReason, QuakeMultiplayerFireIntent, QuakeMultiplayerFireKind, QuakeMultiplayerInventoryState, @@ -41,6 +45,7 @@ const CLIENT_MESSAGE_TYPES = new Set([ "client.hello", "client.presence", "client.input", + "client.inputBatch", "client.fire", "client.damage", "client.pickup", @@ -97,6 +102,28 @@ const FIRE_KINDS = new Set([ "beam", ]); +const FIRE_DECISION_OUTCOMES = new Set([ + "discharge", + "hit-player", + "hit-world", + "miss", + "projectile-spawned", + "world-splash", +]); + +const FIRE_DECISION_REASONS = new Set([ + "line-of-sight-blocked", + "lightning-discharge", + "no-candidate", + "no-world-impact", + "player-direct", + "server-projectile-spawned", + "projectile-world-splash", + "world-before-player", +]); + +const PROJECTILE_IMPACT_KINDS = new Set(["player", "world"]); + const TRIGGER_ACTIVATION_CLASSNAMES = new Set([ "trigger_multiple", "trigger_once", @@ -107,6 +134,9 @@ const TRIGGER_ACTIVATION_CLASSNAMES = new Set([ const MOVER_CLASSNAMES = new Set([ "func_button", + "func_door", + "func_door_secret", + "func_plat", ]); export type QuakeMultiplayerValidationCode = @@ -264,6 +294,8 @@ function isClientPayload(type: QuakeMultiplayerMessageType, payload: unknown): b PRESENCE_STATUSES.has(payload.status as QuakeMultiplayerPlayerPresenceStatus); case "client.input": return isNonEmptyString(payload.clientId) && isLocalInputIntent(payload.input); + case "client.inputBatch": + return isNonEmptyString(payload.clientId) && isLocalInputBatch(payload.inputs); case "client.fire": return isNonEmptyString(payload.clientId) && isFireIntent(payload.fire); case "client.damage": @@ -303,8 +335,12 @@ function isRoomPayload(type: QuakeMultiplayerMessageType, payload: unknown): boo payload.players.every(isAuthoritativePlayerState) && (payload.spectators === undefined || (Array.isArray(payload.spectators) && payload.spectators.every(isRoomSpectatorState))) && + (payload.dynamicPickups === undefined || + (Array.isArray(payload.dynamicPickups) && payload.dynamicPickups.every(isPickupDefinition))) && (payload.pickups === undefined || (Array.isArray(payload.pickups) && payload.pickups.every(isAuthoritativePickupState))) && + (payload.projectiles === undefined || + (Array.isArray(payload.projectiles) && payload.projectiles.every(isProjectileState))) && isNonNegativeInteger(payload.lastWorldEventSequence); case "room.event": return isNonEmptyString(payload.roomId) && @@ -365,6 +401,19 @@ function isLocalInputIntent(value: unknown): value is QuakeMultiplayerLocalInput (value.activeWeapon === undefined || isNonEmptyString(value.activeWeapon)); } +function isLocalInputBatch(value: unknown): value is readonly QuakeMultiplayerLocalInputIntent[] { + if (!Array.isArray(value)) return false; + if (value.length <= 0 || value.length > QUAKE_MULTIPLAYER_MAX_INPUT_BATCH_SIZE) return false; + let previousSequence = -1; + for (const input of value) { + if (!isLocalInputIntent(input) || input.inputSequence <= previousSequence) { + return false; + } + previousSequence = input.inputSequence; + } + return true; +} + function isMoveIntent(value: unknown): value is QuakeMultiplayerMoveIntent { if (!isRecord(value)) return false; return Number.isFinite(value.forward) && Number.isFinite(value.side) && Number.isFinite(value.up); @@ -469,6 +518,9 @@ function isPickupDefinition(value: unknown): value is QuakeMultiplayerPickupDefi (value.feedback.message === undefined || isNonEmptyString(value.feedback.message)) && (value.feedback.soundPath === undefined || isNonEmptyString(value.feedback.soundPath)) )) && + (value.modelPath === undefined || isNonEmptyString(value.modelPath)) && + (value.removeAt === undefined || isNonNegativeFiniteNumber(value.removeAt)) && + (value.runtime === undefined || typeof value.runtime === "boolean") && (value.targetEntityIndexes === undefined || isNonNegativeIntegerArray(value.targetEntityIndexes)) && (value.killtargetEntityIndexes === undefined || isNonNegativeIntegerArray(value.killtargetEntityIndexes)) && (value.delayMs === undefined || isNonNegativeFiniteNumber(value.delayMs)) && @@ -597,6 +649,32 @@ function isRoomSpectatorState(value: unknown): value is QuakeMultiplayerRoomSpec (value.pingMs === undefined || isNonNegativeFiniteNumber(value.pingMs)); } +function isFireDecision(value: unknown): value is QuakeMultiplayerFireDecision { + if (!isRecord(value)) return false; + return FIRE_DECISION_OUTCOMES.has(value.outcome as QuakeMultiplayerFireDecisionOutcome) && + FIRE_DECISION_REASONS.has(value.reason as QuakeMultiplayerFireDecisionReason) && + (value.candidateCount === undefined || isNonNegativeFiniteNumber(value.candidateCount)) && + (value.blockedCandidateCount === undefined || isNonNegativeFiniteNumber(value.blockedCandidateCount)) && + (value.playerDamageCount === undefined || isNonNegativeFiniteNumber(value.playerDamageCount)) && + (value.targetEntityIndex === undefined || isNonNegativeInteger(value.targetEntityIndex)) && + (value.targetPlayerId === undefined || isNonEmptyString(value.targetPlayerId)) && + (value.targetRewindMs === undefined || isNonNegativeFiniteNumber(value.targetRewindMs)) && + (value.worldHitDistance === undefined || isNonNegativeFiniteNumber(value.worldHitDistance)); +} + +function isProjectileState(value: unknown): boolean { + if (!isRecord(value)) return false; + return isNonEmptyString(value.projectileId) && + isNonEmptyString(value.ownerPlayerId) && + isNonEmptyString(value.weapon) && + isVec3(value.origin) && + isVec3(value.direction) && + isNonNegativeFiniteNumber(value.speed) && + isNonNegativeFiniteNumber(value.spawnedAt) && + isNonNegativeFiniteNumber(value.updatedAt) && + isNonNegativeFiniteNumber(value.expiresAt); +} + function isMatchSettings(value: unknown): boolean { if (!isRecord(value)) return false; return (value.fragLimit === undefined || isPositiveInteger(value.fragLimit)) && @@ -631,7 +709,18 @@ function isSharedWorldEvent(value: unknown): value is QuakeMultiplayerSharedWorl isNonEmptyString(value.weapon) && FIRE_KINDS.has(value.fireKind as QuakeMultiplayerFireKind) && isVec3(value.origin) && - isVec3(value.direction); + isVec3(value.direction) && + (value.decision === undefined || isFireDecision(value.decision)); + case "projectile.spawned": + return isProjectileState(value.projectile); + case "projectile.impacted": + return isNonEmptyString(value.projectileId) && + isNonEmptyString(value.ownerPlayerId) && + isNonEmptyString(value.weapon) && + isVec3(value.origin) && + PROJECTILE_IMPACT_KINDS.has(value.impactKind as string) && + isNonNegativeFiniteNumber(value.playerDamageCount) && + (value.targetPlayerId === undefined || isNonEmptyString(value.targetPlayerId)); case "player.damaged": return isNonEmptyString(value.victimPlayerId) && (value.attackerPlayerId === undefined || isNonEmptyString(value.attackerPlayerId)) && @@ -664,6 +753,13 @@ function isSharedWorldEvent(value: unknown): value is QuakeMultiplayerSharedWorl isNonEmptyString(value.reason); case "pickup.respawned": return isAuthoritativePickupState(value.pickup); + case "pickup.dropped": + return isNonEmptyString(value.sourcePlayerId) && + isPickupDefinition(value.definition) && + isAuthoritativePickupState(value.pickup); + case "pickup.expired": + return isNonEmptyString(value.pickupId) && + isNonNegativeInteger(value.entityIndex); case "world.use": return isNonEmptyString(value.playerId) && (value.entityId === undefined || isNonEmptyString(value.entityId)) && diff --git a/src/runtime/multiplayer/world.ts b/src/runtime/multiplayer/world.ts index dbd74d5..4b145bd 100644 --- a/src/runtime/multiplayer/world.ts +++ b/src/runtime/multiplayer/world.ts @@ -56,6 +56,9 @@ const QUAKE_MULTIPLAYER_TRIGGER_ACTIVATION_CLASSNAMES = const QUAKE_MULTIPLAYER_MOVER_CLASSNAMES = new Set([ "func_button", + "func_door", + "func_door_secret", + "func_plat", ]); export interface QuakeMultiplayerSceneWorldSource { @@ -539,12 +542,15 @@ function quakeMultiplayerWorldDefinitionFromEntity( logic?.resolvedMover?.kind === entity.classname ) { const mover = logic.resolvedMover; + const endpointOrigins = quakeMultiplayerMoverEndpointOrigins(mover); + if (!endpointOrigins) return null; const target = entity.properties.target; const killtarget = entity.properties.killtarget; - const fromOrigin = quakeMultiplayerPointToRoom(mover.pos1Origin, options); - const toOrigin = quakeMultiplayerPointToRoom(mover.pos2Origin, options); + const fromOrigin = quakeMultiplayerPointToRoom(endpointOrigins.from, options); + const toOrigin = quakeMultiplayerPointToRoom(endpointOrigins.to, options); const delay = Math.max(0, quakeMultiplayerFiniteNumber(entity.properties.delay, 0)); - const waitMs = quakeMultiplayerSecondsToMs(mover.wait); + const wait = mover.wait ?? mover.waitAtTop; + const waitMs = quakeMultiplayerSecondsToMs(wait); return { kind: "mover", entityIndex: entity.index, @@ -557,8 +563,11 @@ function quakeMultiplayerWorldDefinitionFromEntity( shootActivates: Boolean(mover.callbacks.th_die), ...(mover.health !== undefined && mover.health > 0 ? { shootHealth: mover.health } : {}), speed: mover.speed, - moveMs: quakeMultiplayerMoverMoveMs(mover.travelDistance, mover.speed), - ...(mover.wait >= 0 ? { returnDelayMs: waitMs } : {}), + moveMs: quakeMultiplayerMoverMoveMs( + mover.travelDistance ?? quakeMultiplayerMoverEndpointDistance(endpointOrigins), + mover.speed, + ), + ...(wait !== undefined && wait >= 0 ? { returnDelayMs: waitMs } : {}), delayMs: quakeMultiplayerSecondsToMs(delay), fromOrigin, toOrigin, @@ -604,6 +613,30 @@ function quakeMultiplayerWorldDefinitionFromEntity( return null; } +function quakeMultiplayerMoverEndpointOrigins( + mover: NonNullable, +): { from: QuakeVertex; to: QuakeVertex } | null { + if (mover.pos1Origin && mover.pos2Origin) { + return { from: mover.pos1Origin, to: mover.pos2Origin }; + } + if (mover.kind === "func_plat" && (mover.bottomOrigin || mover.topOrigin)) { + const from = mover.bottomOrigin ?? mover.initialOrigin ?? mover.topOrigin; + const to = mover.topOrigin ?? mover.initialOrigin ?? mover.bottomOrigin; + return from && to ? { from, to } : null; + } + const from = mover.oldOrigin ?? mover.initialOrigin ?? mover.pos1Origin ?? mover.pos2Origin; + const to = mover.initialOrigin ?? mover.pos2Origin ?? mover.pos1Origin ?? mover.oldOrigin; + return from && to ? { from, to } : null; +} + +function quakeMultiplayerMoverEndpointDistance(input: { from: QuakeVertex; to: QuakeVertex }): number { + return Math.hypot( + input.to.x - input.from.x, + input.to.y - input.from.y, + input.to.z - input.from.z, + ); +} + function quakeMultiplayerWorldBoundsFromLogic( logic: QuakeMultiplayerWorldLogicEntity | undefined, options: QuakeMultiplayerSceneGameplayOptions & { pivot?: QuakeVertex }, @@ -1060,10 +1093,15 @@ type QuakeMultiplayerWorldLogicEntity = { resolvedMover?: { kind: string; speed: number; - wait: number; - pos1Origin: QuakeVertex; - pos2Origin: QuakeVertex; - travelDistance: number; + wait?: number; + waitAtTop?: number; + pos1Origin?: QuakeVertex; + pos2Origin?: QuakeVertex; + topOrigin?: QuakeVertex; + bottomOrigin?: QuakeVertex; + initialOrigin?: QuakeVertex; + oldOrigin?: QuakeVertex; + travelDistance?: number; activationSound?: string; health?: number; callbacks: { diff --git a/src/runtime/pickups.ts b/src/runtime/pickups.ts index 478645f..8c26e10 100644 --- a/src/runtime/pickups.ts +++ b/src/runtime/pickups.ts @@ -219,6 +219,7 @@ export interface QuakePickupController { applyAuthoritativePickup: (entityIndex: number, options?: QuakeAuthoritativePickupOptions) => boolean; applyAuthoritativeRespawn: (entityIndex: number) => boolean; clear: () => void; + clearRuntimePickups: () => void; debugStats: () => QuakePickupDebugStats; restoreProgress: (snapshot: QuakePickupProgressSnapshot) => void; snapshotProgress: () => QuakePickupProgressSnapshot; @@ -375,6 +376,12 @@ export function createQuakePickupController(options: QuakePickupControllerOption startAnimationLoop(); }; + const clearRuntimePickups = (): void => { + for (const pickup of [...pickups]) { + if (pickup.runtime) removeRuntimePickup(pickup); + } + }; + const addPickupState = (input: { effect: QuakePickupEffect; entity: QuakeEntity; @@ -714,6 +721,7 @@ export function createQuakePickupController(options: QuakePickupControllerOption applyAuthoritativePickup, applyAuthoritativeRespawn, clear, + clearRuntimePickups, debugStats, restoreProgress, snapshotProgress, @@ -759,6 +767,7 @@ const QUAKE_PICKUP_MODEL_PATHS: Record = { item_artifact_invulnerability: "progs/invulner.mdl", item_artifact_envirosuit: "progs/suit.mdl", item_artifact_invisibility: "progs/invisibl.mdl", + item_backpack: "progs/backpack.mdl", weapon_nailgun: "progs/g_nail.mdl", weapon_supernailgun: "progs/g_nail2.mdl", weapon_supershotgun: "progs/g_shot.mdl", diff --git a/src/runtime/weapons.ts b/src/runtime/weapons.ts index efffaf6..4c39a7f 100644 --- a/src/runtime/weapons.ts +++ b/src/runtime/weapons.ts @@ -621,10 +621,15 @@ function requiredString(value: string | undefined, label: string): string { return value; } -function quakeProjectileRenderYaw(yaw: number): number { +export function quakeProjectileRenderYaw(yaw: number): number { return quakeAliasModelRenderYaw(yaw); } +export function quakeWeaponProjectileModelPath(weapon: QuakeWeaponId): string | null { + const profile = QUAKE_WEAPON_FIRE_PROFILES[weapon]; + return profile?.kind === "projectile" ? profile.modelPath : null; +} + export function quakeWeaponFireProfileAuditFacts() { return { sourceRevision: QUAKE_PROGRAM_SOURCE_FACTS.revision, @@ -742,6 +747,7 @@ export function createQuakeWeaponsController({ let debugNextProjectileDamage: number | null = null; let projectileDebugCaptureEnabled = false; let projectileDebugCaptureEvents: QuakeWeaponProjectileDebugEvent[] = []; + let pendingFireEvent: QuakeWeaponFireEvent | null = null; const nextSoundAtByWeapon = new Map(); const nextCycleAnimationFrameByWeapon = new Map(); const projectiles = createQuakeProjectilesController({ @@ -758,6 +764,7 @@ export function createQuakeWeaponsController({ nextFireAt = -Infinity; nextNailRightSign = 1; debugNextProjectileDamage = null; + pendingFireEvent = null; debugClearProjectileCapture(); nextSoundAtByWeapon.clear(); nextCycleAnimationFrameByWeapon.clear(); @@ -779,10 +786,12 @@ export function createQuakeWeaponsController({ return false; } if (!quakeWeaponFireProfileIsRuntimeSupported(profile)) return false; + pendingFireEvent = null; nextFireAt = now + profile.cooldownMs; if (profile.kind === "beam" && profile.underwaterDischarge && getPlayerWaterLevel() > 1) { const hit = fireBeamUnderwaterDischarge(profile.underwaterDischarge); playWeaponFireAnimation(profile); + onFire?.(quakeWeaponFireEvent(profile, now)); if (hit) onHit(); syncCrosshairTarget(); return true; @@ -791,7 +800,9 @@ export function createQuakeWeaponsController({ playWeaponFireSound(profile, now); const hit = fireWeaponProfile(profile, now); playWeaponFireAnimation(profile); - onFire?.(quakeWeaponFireEvent(profile, now)); + const fireEvent = pendingFireEvent ?? quakeWeaponFireEvent(profile, now); + pendingFireEvent = null; + onFire?.(fireEvent); if (hit) onHit(); syncCrosshairTarget(); return true; @@ -1089,15 +1100,19 @@ export function createQuakeWeaponsController({ syncHud(); } - function quakeWeaponFireEvent(profile: QuakeRuntimeWeaponFireProfile, now: number): QuakeWeaponFireEvent { - const aim = weaponAimForFire(); + function quakeWeaponFireEvent( + profile: QuakeRuntimeWeaponFireProfile, + now: number, + override: Partial> = {}, + ): QuakeWeaponFireEvent { + const aim = override.direction && override.origin ? null : weaponAimForFire(); return { firedAt: now, fireKind: quakeMultiplayerFireKind(profile), weapon: profile.weapon, - origin: aim.ray.origin, - direction: aim.direction, - range: quakeWeaponFireEventRange(profile), + origin: override.origin ?? aim?.ray.origin ?? controls.getOrigin(), + direction: override.direction ?? aim?.direction ?? viewForwardDirection(), + range: override.range ?? quakeWeaponFireEventRange(profile), }; } @@ -1235,6 +1250,11 @@ export function createQuakeWeaponsController({ velocity, weapon: profile.weapon, }); + pendingFireEvent = quakeWeaponFireEvent(profile, now, { + origin, + direction: aim.direction, + range: quakeWeaponFireEventRange(profile), + }); projectiles.spawn({ damage, direction: aim.direction, diff --git a/test/browser/runMultiplayerDeepChecks.mjs b/test/browser/runMultiplayerDeepChecks.mjs index 174fda8..f772a8b 100644 --- a/test/browser/runMultiplayerDeepChecks.mjs +++ b/test/browser/runMultiplayerDeepChecks.mjs @@ -4,6 +4,7 @@ import { spawn } from "node:child_process"; import { setTimeout as sleep } from "node:timers/promises"; import { assertAssetState, readAssetManifest } from "../assets/checkAssetState.mjs"; +import { assertPreparedEntity, readPreparedScene } from "../assets/preparedAssets.mjs"; import { collectPageErrors, hasFlag, @@ -20,11 +21,104 @@ 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 DEBUG_ROOM_ID_MAX_LENGTH = 32; const CONTROLLED_DAMAGE_CENTER_DROP = 0.85; +const CONTROLLED_DAMAGE_HISTORY_SETTLE_MS = 220; +const CONTROLLED_DUEL_POSE_EPSILON = 0.2; +const CONTROLLED_DUEL_ROT_EPSILON = 1; +const CONTROLLED_DUEL_LANES_BY_MAP = { + e1m7: [ + [3.84, 0, 4.6], + [11.52, -8.48, 5.4], + [23.04, -8.48, 5.4], + [29.44, 0, 3.32], + [23.04, 8.48, 5.4], + [11.52, 8.48, 5.4], + [3.84, 0, 1.08], + ], +}; const CONTROLLED_WEAPONS = [ { weapon: "axe", damage: 20, distance: 1.2 }, { weapon: "shotgun", damage: 24, distance: 3.0 }, + { weapon: "supershotgun", damage: 56, distance: 3.0, pickup: true }, + { weapon: "nailgun", damage: 9, distance: 4.0, pickup: true }, + { weapon: "supernailgun", damage: 18, distance: 4.0, pickup: true }, ]; +const CONTROLLED_SUSTAINED_DAMAGE_SPECS = [ + { + weapon: "shotgun", + damage: 24, + direction: "a-to-b", + distance: 3.0, + intervalMs: 650, + expectedHealths: [76, 52, 28, 4], + }, + { + weapon: "nailgun", + damage: 9, + direction: "b-to-a", + distance: 4.0, + intervalMs: 260, + pickup: true, + expectedHealths: [91, 82, 73, 64, 55, 46], + }, +]; +const CONTROLLED_PROJECTILE_SPECS = [ + { + weapon: "rocketlauncher", + distance: 4.0, + expectedImpactKind: "player", + expectedPlayerDamageCount: 2, + expectedSelfDamage: 22, + expectedAttackerHealth: 78, + expectedVictimEventType: "player.killed", + expectedVictimHealth: -5, + }, + { + weapon: "grenadelauncher", + distance: 4.0, + expectedImpactKind: "player", + expectedPlayerDamageCount: 2, + expectedSelfDamage: 22, + expectedAttackerHealth: 78, + expectedVictimDamage: 94, + expectedVictimEventType: "player.damaged", + expectedVictimHealth: 6, + }, +]; +const REMOTE_ATTACK_FRAME_PREFIXES_BY_WEAPON = { + axe: ["axatt"], + shotgun: ["shotatt"], + supershotgun: ["shotatt"], + nailgun: ["nailatt"], + supernailgun: ["nailatt"], + grenadelauncher: ["rockatt"], + rocketlauncher: ["rockatt"], + lightning: ["light"], +}; +const SHAREWARE_MULTIPLAYER_MAPS = ["start", "e1m1", "e1m2", "e1m3", "e1m4", "e1m5", "e1m6", "e1m7", "e1m8"]; +const LOCAL_WORLD_MUTATION_MAP = "e1m1"; +const WORLD_INTERACTION_MAP = "e1m1"; +const WORLD_INTERACTION_CASE = { + doorEntity: 189, + expectedDoorClassname: "func_door_secret", + expectedDoorTriggeredModes: new Set(["opening", "open", "closing"]), + expectedTriggerClassname: "trigger_multiple", + inside: { x: 792, y: 512, z: 8 }, + label: "E1M1 trigger_multiple secret door", + targetname: "t8", + triggerEntity: 190, +}; +const SPAWN_ESCAPE_MAP = "e1m1"; +const SPAWN_ESCAPE_SAMPLES = 5; +const SPAWN_ESCAPE_MIN_DISTANCE = 0.75; +const SPAWN_ESCAPE_KEYS = ["w", "a", "d", "s", "w", "d", "a", "s"]; +const ROOM_LIFECYCLE_MAX_PLAYERS = 2; +const ROOM_LIFECYCLE_SPECTATOR_SLOTS = 8; +const REMOTE_POSE_ROT_EPSILON = 2; +const DAMAGE_OVERLAY_ACTIVE_TIMEOUT_MS = 1_000; +const DAMAGE_OVERLAY_CLEAR_TIMEOUT_MS = 1_500; +const DAMAGE_CUE_CLEAR_TIMEOUT_MS = 1_500; const args = process.argv.slice(2); if (hasFlag(args, "help") || hasFlag(args, "h")) { @@ -40,47 +134,108 @@ const common = parseCommonBrowserArgs(args, { }); const mapName = optionValue(args, "map", "e1m7").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 ?? "" : "")); +const externalMode = Boolean(externalAppUrl); +const extendedChecks = hasFlag(args, "extended"); const skipControlledDamage = hasFlag(args, "skip-controlled-damage"); +const skipControlledSustainedDamage = hasFlag(args, "skip-controlled-sustained-damage"); const skipControlledKill = hasFlag(args, "skip-controlled-kill"); +const skipControlledRespawn = hasFlag(args, "skip-controlled-respawn"); +const skipControlledProjectile = hasFlag(args, "skip-controlled-projectile"); +const skipSharedPickup = hasFlag(args, "skip-shared-pickup"); +const skipLocalWorldMutation = !extendedChecks || hasFlag(args, "skip-local-world-mutation"); +const skipWorldInteraction = !extendedChecks || hasFlag(args, "skip-world-interaction"); +const skipSpawnEscape = !extendedChecks || hasFlag(args, "skip-spawn-escape"); const skipReconnect = hasFlag(args, "skip-reconnect"); +const skipRoomLifecycle = !extendedChecks || hasFlag(args, "skip-room-lifecycle"); +const skipWrongMap = hasFlag(args, "skip-wrong-map"); +const skipMapReadiness = hasFlag(args, "skip-map-readiness"); const controlledWeaponNames = new Set(optionList(args, "weapons", CONTROLLED_WEAPONS.map((spec) => spec.weapon))); const controlledDirections = optionList(args, "directions", ["a-to-b", "b-to-a"]); +const readinessMaps = optionList(args, "readiness-maps", SHAREWARE_MULTIPLAYER_MAPS) + .map((item) => item.trim().toLowerCase()) + .filter(Boolean); +const requiredMaps = [mapName]; +if (!skipMapReadiness) { + for (const readinessMap of readinessMaps) { + if (!requiredMaps.includes(readinessMap)) requiredMaps.push(readinessMap); + } +} +if (!skipLocalWorldMutation && !requiredMaps.includes(LOCAL_WORLD_MUTATION_MAP)) { + requiredMaps.push(LOCAL_WORLD_MUTATION_MAP); +} +if (!skipWorldInteraction && !requiredMaps.includes(WORLD_INTERACTION_MAP)) { + requiredMaps.push(WORLD_INTERACTION_MAP); +} +if (!skipSpawnEscape && !requiredMaps.includes(SPAWN_ESCAPE_MAP)) { + requiredMaps.push(SPAWN_ESCAPE_MAP); +} +if (!skipWrongMap) { + const wrongMap = wrongMapProbeMap(mapName); + if (!requiredMaps.includes(wrongMap)) requiredMaps.push(wrongMap); +} 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(extendedChecks + ? "validates: core two-client multiplayer plus extended world/spawn/lifecycle checks" + : "validates: all-map two-client readiness, controlled A/B weapon damage, sustained browser damage, death/respawn recovery, shared pickup state, remote animation evidence, projectile visuals, reconnect no-duplicate state, wrong-map rejection"); console.log("classification: multiplayer deep 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, maps ${requiredMaps.join(",")}`); +} else { + if (externalPartyHost) throw new Error("--party-host is only supported with --url."); + console.log(`requires prepared assets: yes, maps ${requiredMaps.join(",")}`); + assertAssetState({ requiredMaps, 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 manifest = externalAppUrl ? await readRemoteAssetManifest(externalAppUrl, common.timeoutMs) : readAssetManifest(); +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); + if (externalAppUrl) { + await assertHttpReady(partyPresenceUrl(partyHost), common.timeoutMs); + } const chromium = await loadChromium(); browser = await chromium.launch({ headless: !common.headed }); const checks = []; + if (!skipMapReadiness) { + checks.push(await runMapReadinessCase({ + appUrl, + browser, + common, + externalMode, + manifest, + maps: readinessMaps, + partyHost, + })); + } if (!skipControlledDamage) { for (const spec of CONTROLLED_WEAPONS.filter((candidate) => controlledWeaponNames.has(candidate.weapon))) { for (const direction of controlledDirections) { @@ -89,6 +244,7 @@ try { browser, common, direction, + externalMode, mapName, manifest, partyHost, @@ -97,21 +253,128 @@ try { } } } + if (!skipControlledSustainedDamage) { + for (const spec of CONTROLLED_SUSTAINED_DAMAGE_SPECS.filter((candidate) => controlledWeaponNames.has(candidate.weapon))) { + checks.push(await runControlledSustainedDamageCase({ + appUrl, + browser, + common, + externalMode, + mapName, + manifest, + partyHost, + spec, + })); + } + } if (!skipControlledKill) { checks.push(await runControlledKillCase({ appUrl, browser, common, + externalMode, + mapName, + manifest, + partyHost, + })); + } + if (!skipControlledRespawn) { + checks.push(await runControlledRespawnCase({ + appUrl, + browser, + common, + externalMode, + mapName, + manifest, + partyHost, + })); + } + if (!skipControlledProjectile) { + for (const spec of CONTROLLED_PROJECTILE_SPECS) { + checks.push(await runControlledProjectileVisualCase({ + appUrl, + browser, + common, + externalMode, + mapName, + manifest, + partyHost, + spec, + })); + } + } + if (!skipSharedPickup) { + checks.push(await runSharedPickupStateCase({ + appUrl, + browser, + common, + externalMode, mapName, manifest, partyHost, })); } + if (!skipLocalWorldMutation) { + checks.push(await runLocalWorldMutationSuppressionCase({ + appUrl, + browser, + common, + externalMode, + mapName: LOCAL_WORLD_MUTATION_MAP, + manifest, + partyHost, + })); + } + if (!skipWorldInteraction) { + checks.push(await runWorldInteractionCase({ + appUrl, + browser, + common, + externalMode, + mapName: WORLD_INTERACTION_MAP, + manifest, + partyHost, + })); + } + if (!skipSpawnEscape) { + checks.push(await runSpawnEscapeCase({ + appUrl, + browser, + common, + externalMode, + mapName: SPAWN_ESCAPE_MAP, + manifest, + partyHost, + })); + } if (!skipReconnect) { checks.push(await runReconnectCase({ appUrl, browser, common, + externalMode, + mapName, + manifest, + partyHost, + })); + } + if (!skipRoomLifecycle) { + checks.push(await runRoomLifecycleCase({ + appUrl, + browser, + common, + externalMode, + mapName, + manifest, + partyHost, + })); + } + if (!skipWrongMap) { + checks.push(await runWrongMapCase({ + appUrl, + browser, + common, + externalMode, mapName, manifest, partyHost, @@ -144,11 +407,26 @@ Options: --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 + --url Use an already deployed app instead of starting local Vite. + --party-host PartyKit host for --url, without protocol. + --extended Include slower world/spawn/lifecycle checks. + --readiness-maps Map readiness list. Default: ${SHAREWARE_MULTIPLAYER_MAPS.join(",")} + --weapons Controlled damage weapons. Default: ${CONTROLLED_WEAPONS.map((spec) => spec.weapon).join(",")} --directions Controlled damage directions. Default: a-to-b,b-to-a --skip-controlled-damage Skip controlled A/B damage checks. + --skip-controlled-sustained-damage + Skip sustained browser damage checks. --skip-controlled-kill Skip controlled browser death/kill animation check. - --skip-reconnect Skip reconnect check.`); + --skip-controlled-respawn Skip controlled browser respawn recovery check. + --skip-controlled-projectile Skip controlled remote projectile presentation check. + --skip-shared-pickup Skip shared pickup state check. + --skip-local-world-mutation Skip extended local world-damage mutation check. + --skip-world-interaction Skip extended room-owned world trigger/mover check. + --skip-spawn-escape Skip extended E1M1 spawn escape sampling check. + --skip-reconnect Skip reconnect check. + --skip-room-lifecycle Skip extended browser spectator/room-full lifecycle check. + --skip-wrong-map Skip browser wrong-map rejection check. + --skip-map-readiness Skip all-map two-client browser room readiness check.`); } function optionList(args, name, fallback) { @@ -156,8 +434,177 @@ function optionList(args, name, fallback) { return raw.split(",").map((item) => item.trim()).filter(Boolean); } +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(/\/.*$/, ""); + } +} + +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 createCompactInvite(manifest, mapName, token = createRoomToken()) { + const mapNames = (manifest?.maps ?? []) + .filter((map) => map?.selectable !== false) + .map((map) => String(map.mapName ?? "").trim().toLowerCase()) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)); + const normalizedMapName = String(mapName ?? "").trim().toLowerCase(); + const index = mapNames.indexOf(normalizedMapName); + if (index < 0) throw new Error(`Map ${normalizedMapName} is not selectable in the deployed manifest.`); + const safeToken = roomTokenForCompactInvite(token); + const mapCode = index.toString(36).padStart(2, "0"); + return { + value: `${mapCode}${safeToken}au`, + internalRoom: `cssquake-auto-${normalizedMapName}-${safeToken}`, + }; +} + +function roomTokenForCompactInvite(value) { + const raw = String(value ?? "").trim().toLowerCase(); + const debugRoomMatch = raw.match(/^d([bcdfghjkmnpqrstvwxyz23456789]{8})(?:-|$)/); + if (debugRoomMatch?.[1]) return debugRoomMatch[1]; + const match = raw.match(/[bcdfghjkmnpqrstvwxyz23456789]{8}/); + return match?.[0] ?? createRoomToken(); +} + +function ignoreRequestFailure(url) { + try { + const parsed = new URL(url); + return parsed.hostname === "www.google-analytics.com" && parsed.pathname === "/g/collect"; + } catch { + return false; + } +} + +function partyPresenceUrl(host) { + const protocol = isLocalPartyHost(host) ? "http" : "https"; + return `${protocol}://${host}/parties/presence/global`; +} + +function isLocalPartyHost(host) { + return /^(127\.0\.0\.1|localhost|\[::1\])(?::\d+)?$/i.test(host); +} + +async function runMapReadinessCase(options) { + const failures = []; + const mapReports = []; + console.log(`map readiness: maps=${options.maps.join(",")}`); + + for (const readyMap of options.maps) { + const room = createDeepRoomName("mapready", readyMap); + const mapFailures = []; + const clients = []; + let snapshots = []; + console.log(`map readiness: ${readyMap} room=${room}`); + try { + for (let index = 0; index < 2; index += 1) { + const client = await openClient(options.browser, { + ...options, + clientIndex: index, + clientsCount: 2, + debugMultiplayer: true, + debugMultiplayerInputPaused: true, + mapName: readyMap, + maxPlayers: 2, + room, + }); + clients.push(client); + } + + try { + await Promise.all(clients.map((client) => + waitForClientReady(client, 2, options.common.timeoutMs, { allowInputPaused: true }) + )); + await waitForSnapshotPlayers(clients, 2, options.common.timeoutMs); + await waitForRemoteDomCounts(clients, 1, options.common.timeoutMs); + } catch (error) { + mapFailures.push(`readiness failed: ${errorMessage(error)}`); + } + + snapshots = await safeReadClientSnapshots(clients); + for (const [index, snapshot] of snapshots.entries()) { + const multiplayer = snapshot.stats?.multiplayer; + const playerCount = snapshot.trace?.lastSnapshot?.players?.length ?? 0; + if (!snapshot.stats) mapFailures.push(`client ${index} did not expose debug stats`); + if (multiplayer?.sessionState !== "connected") { + mapFailures.push(`client ${index} session state ${String(multiplayer?.sessionState)} did not equal connected`); + } + if (multiplayer?.helloAccepted !== true) { + mapFailures.push(`client ${index} helloAccepted ${String(multiplayer?.helloAccepted)} did not equal true`); + } + if (multiplayer?.lastReject?.code) { + mapFailures.push(`client ${index} received reject ${multiplayer.lastReject.code}`); + } + if ((multiplayer?.scoreboardRows ?? 0) < 2) { + mapFailures.push(`client ${index} scoreboard rows ${String(multiplayer?.scoreboardRows)} did not reach 2`); + } + if ((multiplayer?.remoteDomCount ?? 0) < 1) { + mapFailures.push(`client ${index} remote DOM count ${String(multiplayer?.remoteDomCount)} did not reach 1`); + } + if (playerCount < 2) { + mapFailures.push(`client ${index} room snapshot player count ${playerCount} did not reach 2`); + } + } + } catch (error) { + mapFailures.push(errorMessage(error)); + snapshots = await safeReadClientSnapshots(clients); + } finally { + mapReports.push({ + mapName: readyMap, + room, + pass: mapFailures.length === 0, + failures: mapFailures, + snapshots: snapshots.map(compactSnapshot), + clients: clients.map(compactClient), + }); + await Promise.all(clients.map((client) => client.context.close().catch(() => undefined))); + } + + failures.push(...mapFailures.map((failure) => `${readyMap}: ${failure}`)); + } + + return { + kind: "map-readiness", + pass: failures.length === 0, + failures, + mapCount: options.maps.length, + passedMapCount: mapReports.filter((report) => report.pass).length, + maps: mapReports, + clients: mapReports.flatMap((report) => report.clients), + }; +} + async function runControlledDamageCase(options) { - const room = `cssquake-deep-${options.mapName}-${options.spec.weapon}-${options.direction}-${createRoomToken(6)}`; + const room = createDeepRoomName("dmg", options.mapName, options.spec.weapon, options.direction); console.log(`controlled damage: ${options.direction} ${options.spec.weapon} room=${room}`); const clients = await Promise.all([ openClient(options.browser, { @@ -182,24 +629,64 @@ async function runControlledDamageCase(options) { 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 duelBaseOrigin = (await localSnapshotPlayer(attacker)).origin; + const pickup = options.spec.pickup + ? await pickupWeaponForControlledCase(attacker, options.spec.weapon, options.common.timeoutMs) + : null; + const pose = await setControlledDuelPose(clients, options.spec, options.direction, { + baseOrigin: duelBaseOrigin, + mapName: options.mapName, + timeoutMs: options.common.timeoutMs, + }); + await sleep(CONTROLLED_DAMAGE_HISTORY_SETTLE_MS); + await attacker.page.evaluate((weapon) => window.__cssQuakeDebug?.setWeapon?.(weapon), options.spec.weapon); 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 remotePoseSamples = []; + try { + remotePoseSamples.push({ + observer: "attacker", + target: "victim", + sample: await waitForRemoteVisualPose(attacker, victimPlayer.clientId, pose.victimRotY, options.common.timeoutMs), + }); + remotePoseSamples.push({ + observer: "victim", + target: "attacker", + sample: await waitForRemoteVisualPose(victim, attackerPlayer.clientId, pose.attackerRotY, options.common.timeoutMs), + }); + } catch (error) { + remotePoseSamples.push({ error: errorMessage(error) }); + } const before = await readClientSnapshot(victim); const beforeAttacker = await readClientSnapshot(attacker); + await clearRemoteFrameSamplesForAll(clients); const fireResult = await attacker.page.evaluate(() => window.__cssQuakeDebug?.fire?.() ?? null); + const damageOverlayActivePromise = fireResult === true + ? waitForDamageOverlayState(victim, true, DAMAGE_OVERLAY_ACTIVE_TIMEOUT_MS) + .catch((error) => ({ error: errorMessage(error) })) + : Promise.resolve(null); + const attackFramePromise = fireResult === true + ? waitForRemoteFramePrefixes( + victim, + attackerPlayer.clientId, + remoteAttackFramePrefixesForWeapon(options.spec.weapon), + 1_000, + ) + : Promise.resolve(false); + const impactParticlesPromise = fireResult === true + ? waitForImpactParticles(attacker, "blood", 1_000) + : Promise.resolve(null); + const victimPainFramePromise = fireResult === true + ? waitForRemoteFramePrefix(attacker, victimPlayer.clientId, "pain", 1_000) + : Promise.resolve(false); let event = null; const failures = []; if (fireResult !== true) { @@ -216,10 +703,41 @@ async function runControlledDamageCase(options) { } 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); + let damageOverlayActive = null; + let damageOverlayCleared = null; + let damageCueCleared = null; + if (event) { + damageOverlayActive = await damageOverlayActivePromise; + if (damageOverlayActive?.error) { + failures.push(`victim damage overlay did not activate: ${damageOverlayActive.error}`); + } else if (damageOverlayActive?.classicHudDamage !== true) { + failures.push("victim HUD damage cue did not activate"); + } else { + damageOverlayCleared = await waitForDamageOverlayState( + victim, + false, + DAMAGE_OVERLAY_CLEAR_TIMEOUT_MS, + ).catch((error) => ({ error: errorMessage(error) })); + if (damageOverlayCleared?.error) { + failures.push(`victim damage overlay did not clear after alive damage: ${damageOverlayCleared.error}`); + } + } + } else { + damageOverlayActive = await damageOverlayActivePromise; + } + const impactParticles = await impactParticlesPromise; + const attackSeen = await attackFramePromise; + const victimPainSeen = await victimPainFramePromise; + if (event && damageOverlayActive && !damageOverlayActive.error) { + damageCueCleared = await waitForHudDamageCueState( + victim, + false, + DAMAGE_CUE_CLEAR_TIMEOUT_MS, + ).catch((error) => ({ error: errorMessage(error) })); + if (damageCueCleared?.error) { + failures.push(`victim HUD damage cue did not clear after alive damage: ${damageCueCleared.error}`); + } + } const after = await readClientSnapshot(victim); const afterAttacker = await readClientSnapshot(attacker); if (event) { @@ -234,12 +752,19 @@ async function runControlledDamageCase(options) { } } const animation = remoteAnimationSummary(attacker.afterRemoteFrames ?? []); - if (event && !animation.names.some((name) => name.startsWith("pain"))) { + if (event && !victimPainSeen) { failures.push("attacker did not sample victim pain animation"); } + const attackAnimation = remoteAnimationSummary(victim.afterRemoteFrames ?? []); + if (event && !attackSeen) { + failures.push("victim did not sample attacker attack animation"); + } if (event && (impactParticles?.blood ?? 0) <= 0) { failures.push("attacker did not sample remote victim blood particles"); } + if (remotePoseSamples.some((sample) => sample.error)) { + failures.push(`remote pose metadata did not match controlled pose: ${JSON.stringify(remotePoseSamples)}`); + } return { kind: "controlled-damage", direction: options.direction, @@ -255,21 +780,27 @@ async function runControlledDamageCase(options) { afterAttacker: compactSnapshot(afterAttacker), event, fireResult, + damageOverlayActive, + damageOverlayCleared, + damageCueCleared, impactParticles, + remoteAttackAnimation: attackAnimation, + pickup, pose, + remotePoseSamples, attacker: compactClient(attacker), victim: compactClient(victim), remoteAnimation: animation, + victimPainSeen, }; } 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}`); +async function runControlledSustainedDamageCase(options) { + const room = createDeepRoomName("sustain", options.mapName, options.spec.weapon, options.spec.direction); + console.log(`sustained damage: ${options.spec.direction} ${options.spec.weapon} room=${room}`); const clients = await Promise.all([ openClient(options.browser, { ...options, @@ -293,78 +824,112 @@ async function runControlledKillCase(options) { 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]; + const attackerIndex = options.spec.direction === "a-to-b" ? 0 : 1; + const victimIndex = attackerIndex === 0 ? 1 : 0; + const attacker = clients[attackerIndex]; + const victim = clients[victimIndex]; + const duelBaseOrigin = (await localSnapshotPlayer(attacker)).origin; + const pickup = options.spec.pickup + ? await pickupWeaponForControlledCase(attacker, options.spec.weapon, options.common.timeoutMs) + : null; + let pose = await setControlledDuelPose(clients, options.spec, options.spec.direction, { + baseOrigin: duelBaseOrigin, + mapName: options.mapName, + timeoutMs: options.common.timeoutMs, + }); + await sleep(CONTROLLED_DAMAGE_HISTORY_SETTLE_MS); + await attacker.page.evaluate((weapon) => window.__cssQuakeDebug?.setWeapon?.(weapon), options.spec.weapon); + 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 attackerPlayer = await localSnapshotPlayer(attacker); - const victimPlayer = await localSnapshotPlayer(victim); const before = await readClientSnapshot(victim); const beforeAttacker = await readClientSnapshot(attacker); const failures = []; + const events = []; const fireResults = []; + const impactParticles = []; const poseUpdates = [pose]; - let killEvent = null; - for (let index = 0; index < 6; index += 1) { - pose = await setControlledDuelPose(clients, spec, "a-to-b"); + let painSeen = false; + for (let index = 0; index < options.spec.expectedHealths.length; index += 1) { + if (index > 0) await sleep(options.spec.intervalMs); + pose = await setControlledDuelPose(clients, options.spec, options.spec.direction, { + baseOrigin: duelBaseOrigin, + mapName: options.mapName, + timeoutMs: options.common.timeoutMs, + }); poseUpdates.push(pose); - await Promise.all(clients.map((client) => client.page.evaluate(() => window.__cssQuakeDebug?.syncMultiplayerPose?.()))); - await sleep(150); + await sleep(100); + const expectedHealth = options.spec.expectedHealths[index]; 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)}`); + if (fireResult !== true) { + failures.push(`debug fire ${index + 1} returned ${String(fireResult)}`); + continue; + } + const particlesPromise = waitForImpactParticles(attacker, "blood", 750); + const painFramePromise = waitForRemoteFramePrefix(attacker, victimPlayer.clientId, "pain", 750); try { - killEvent = await waitForPlayerEvent(clients, (candidate) => - candidate.eventType === "player.killed" && + const event = await waitForPlayerEvent(clients, (candidate) => + candidate.eventType === "player.damaged" && candidate.attackerPlayerId === attackerPlayer.playerId && candidate.victimPlayerId === victimPlayer.playerId && - candidate.damageSource === "shotgun", - 650, + candidate.damageSource === options.spec.weapon && + candidate.health === expectedHealth, + options.common.timeoutMs, ); - } catch { - // Keep firing until cumulative shotgun damage kills the victim. + events.push(event); + if (event.damage !== options.spec.damage) { + failures.push(`shot ${index + 1} expected damage ${options.spec.damage}, got ${String(event.damage)}`); + } + } catch (error) { + failures.push(`shot ${index + 1}: ${errorMessage(error)}`); } - if (killEvent) break; - await sleep(600); + const particles = await particlesPromise; + impactParticles.push(particles); + painSeen = (await painFramePromise) || painSeen; } - 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 finalHealth = options.spec.expectedHealths.at(-1); + if (after.stats?.playerHealth !== finalHealth) { + failures.push(`expected final victim local health ${finalHealth}, got ${String(after.stats?.playerHealth)}`); + } 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)}`); + if (victimSnapshotPlayer?.health !== finalHealth) { + failures.push(`expected final victim snapshot health ${finalHealth}, 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 (events.length !== options.spec.expectedHealths.length) { + failures.push(`expected ${options.spec.expectedHealths.length} damage events, got ${events.length}`); } - if (killEvent && (impactParticles?.blood ?? 0) <= 0) { - failures.push("attacker did not sample victim kill blood particles"); + const rejects = [...(after.trace.rejects ?? []), ...(afterAttacker.trace.rejects ?? [])]; + if (rejects.length) failures.push(`unexpected room rejects during sustained damage: ${JSON.stringify(rejects)}`); + if (!painSeen) failures.push("attacker did not sample victim pain animation during sustained damage"); + if (!impactParticles.some((particles) => (particles?.blood ?? 0) > 0)) { + failures.push("attacker did not sample blood particles during sustained damage"); } + const animation = remoteAnimationSummary(attacker.afterRemoteFrames ?? []); return { - kind: "controlled-kill", + kind: "controlled-sustained-damage", + direction: options.spec.direction, mapName: options.mapName, room, - weapon: "shotgun", + weapon: options.spec.weapon, + expectedDamage: options.spec.damage, + expectedHealths: options.spec.expectedHealths, pass: failures.length === 0, failures, before: compactSnapshot(before), beforeAttacker: compactSnapshot(beforeAttacker), after: compactSnapshot(after), afterAttacker: compactSnapshot(afterAttacker), - event: killEvent, + events, fireResults, impactParticles, + pickup, pose, poseUpdates, attacker: compactClient(attacker), @@ -376,68 +941,1309 @@ async function runControlledKillCase(options) { } } -async function runReconnectCase(options) { - const room = `cssquake-deep-reconnect-${options.mapName}-${createRoomToken(8)}`; - const clients = await Promise.all(Array.from({ length: 3 }, (_, index) => +async function runControlledKillCase(options) { + const spec = { weapon: "shotgun", damage: 24, distance: 3.0 }; + const room = createDeepRoomName("kill", options.mapName, "shotgun"); + console.log(`controlled kill: shotgun room=${room}`); + const clients = await Promise.all([ openClient(options.browser, { ...options, - clientIndex: index, - clientsCount: 3, + clientIndex: 0, + clientsCount: 2, debugMultiplayer: true, + debugMultiplayerInputPaused: true, room, - }) - )); - const failures = []; - let before = []; - let after = []; + }), + openClient(options.browser, { + ...options, + clientIndex: 1, + clientsCount: 2, + debugMultiplayer: true, + debugMultiplayerInputPaused: true, + room, + }), + ]); 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) { + 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", { + mapName: options.mapName, + timeoutMs: 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; + let backpackEvent = null; + let backpackStats = null; + await clearRemoteFrameSamples(attacker); + for (let index = 0; index < 6; index += 1) { + pose = await setControlledDuelPose(clients, spec, "a-to-b", { + mapName: options.mapName, + timeoutMs: options.common.timeoutMs, + }); + poseUpdates.push(pose); + 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 impactParticlesPromise = killEvent + ? waitForImpactParticles(attacker, "blood", 1_000) + : Promise.resolve(null); + const deathFramePromise = killEvent + ? waitForRemoteFramePrefix(attacker, victimPlayer.clientId, "deatha", 1_500) + : Promise.resolve(false); + if (killEvent) { + try { + backpackEvent = await waitForRoomEvent(clients, (candidate) => + candidate.eventType === "pickup.dropped" && + candidate.sourcePlayerId === victimPlayer.playerId, + 1_000, + ); + } catch (error) { + failures.push(`dropped backpack event: ${errorMessage(error)}`); + } + } + const impactParticles = await impactParticlesPromise; + const deathFrameSeen = await deathFramePromise; + const after = await readClientSnapshot(victim); + const afterAttacker = await readClientSnapshot(attacker); + if (backpackEvent) { + backpackStats = await attacker.page.evaluate(() => window.__cssQuakeDebug?.stats?.()?.pickups ?? null); + if (!backpackStats?.runtimeEntityIndexes?.includes(backpackEvent.definition.entityIndex)) { + failures.push(`dropped backpack ${backpackEvent.definition.entityIndex} was not registered as a runtime pickup`); + } + const snapshotHasBackpack = afterAttacker.trace.lastSnapshot?.dynamicPickups?.some((definition) => + definition.entityIndex === backpackEvent.definition.entityIndex + ); + if (!snapshotHasBackpack) { + failures.push(`dropped backpack ${backpackEvent.definition.entityIndex} was missing from the browser snapshot`); + } + } + 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 ?? 1) > 0) { + failures.push(`expected victim health <= 0 after kill, got ${String(victimSnapshotPlayer?.health)}`); + } + } + const animation = remoteAnimationSummary(attacker.afterRemoteFrames ?? []); + if (killEvent && !deathFrameSeen) { + 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, + backpackEvent, + backpackStats, + fireResults, + impactParticles, + deathFrameSeen, + pose, + poseUpdates, + attacker: compactClient(attacker), + victim: compactClient(victim), + remoteAnimation: animation, + }; + } finally { + await Promise.all(clients.map((client) => client.context.close().catch(() => undefined))); + } +} + +async function runControlledRespawnCase(options) { + const spec = { weapon: "shotgun", damage: 24, distance: 3.0 }; + const room = createDeepRoomName("respawn", options.mapName, "shotgun"); + console.log(`controlled respawn: 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", { + mapName: options.mapName, + timeoutMs: 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; + let deathOverlayActive = null; + let respawnOverlayCleared = null; + let respawnCueCleared = null; + await clearRemoteFrameSamples(attacker); + for (let index = 0; index < 6; index += 1) { + pose = await setControlledDuelPose(clients, spec, "a-to-b", { + mapName: options.mapName, + timeoutMs: options.common.timeoutMs, + }); + poseUpdates.push(pose); + 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 before respawn."); + const deathFramePromise = killEvent + ? waitForRemoteFramePrefix(attacker, victimPlayer.clientId, "deatha", 1_500) + : Promise.resolve(false); + if (killEvent) { + deathOverlayActive = await waitForDamageOverlayState( + victim, + true, + DAMAGE_OVERLAY_ACTIVE_TIMEOUT_MS, + ).catch((error) => ({ error: errorMessage(error) })); + if (deathOverlayActive?.error) { + failures.push(`victim death damage overlay did not activate: ${deathOverlayActive.error}`); + } else if (deathOverlayActive?.classicHudDamage !== true) { + failures.push("victim death HUD damage cue did not activate"); + } + } + const deathFrameSeen = await deathFramePromise; + const deathAnimation = remoteAnimationSummary(attacker.afterRemoteFrames ?? []); + if (killEvent && !deathFrameSeen) { + failures.push("attacker did not sample victim death animation before respawn"); + } + + let respawnEvent = null; + if (killEvent) { + try { + respawnEvent = await waitForPlayerEvent(clients, (candidate) => + candidate.eventType === "player.respawned" && + candidate.player?.playerId === victimPlayer.playerId, + options.common.timeoutMs, + ); + } catch (error) { + failures.push(`respawn: ${errorMessage(error)}`); + } + } + if (respawnEvent) { + await waitForSnapshotPlayerState(clients, { + alive: true, + health: 100, + playerId: victimPlayer.playerId, + }, options.common.timeoutMs); + await waitForRemoteDomCounts(clients, 1, options.common.timeoutMs); + respawnOverlayCleared = await waitForDamageOverlayState( + victim, + false, + DAMAGE_OVERLAY_CLEAR_TIMEOUT_MS, + ).catch((error) => ({ error: errorMessage(error) })); + if (respawnOverlayCleared?.error) { + failures.push(`victim damage overlay did not clear after respawn: ${respawnOverlayCleared.error}`); + } + respawnCueCleared = await waitForHudDamageCueState( + victim, + false, + DAMAGE_CUE_CLEAR_TIMEOUT_MS, + ).catch((error) => ({ error: errorMessage(error) })); + if (respawnCueCleared?.error) { + failures.push(`victim HUD damage cue did not clear after respawn: ${respawnCueCleared.error}`); + } + } + + const afterRespawn = await readClientSnapshot(victim); + const afterRespawnAttacker = await readClientSnapshot(attacker); + const respawnedVictim = snapshotPlayer(afterRespawn, victimPlayer.playerId); + const respawnedAttacker = snapshotPlayer(afterRespawn, attackerPlayer.playerId); + if (respawnEvent) { + if (!respawnedVictim?.alive) failures.push("victim snapshot did not mark player alive after respawn"); + if (respawnedVictim?.health !== 100) { + failures.push(`expected victim health 100 after respawn, got ${String(respawnedVictim?.health)}`); + } + if (afterRespawn.stats?.playerHealth !== 100) { + failures.push(`victim local health after respawn ${String(afterRespawn.stats?.playerHealth)} did not reset to 100`); + } + if (respawnedVictim?.deaths !== 1) { + failures.push(`expected victim deaths 1 after respawn, got ${String(respawnedVictim?.deaths)}`); + } + if (respawnedAttacker?.frags !== 1) { + failures.push(`expected attacker frags 1 after respawn, got ${String(respawnedAttacker?.frags)}`); + } + const attackerRemoteVictim = afterRespawnAttacker.remotePlayers + .filter((player) => player.clientId === victimPlayer.clientId); + if (attackerRemoteVictim.length !== 1) { + failures.push(`expected one remote victim DOM after respawn, got ${attackerRemoteVictim.length}`); + } else if (attackerRemoteVictim[0].hidden) { + failures.push("remote victim DOM stayed hidden after respawn"); + } + } + + let postRespawnDamage = null; + let postRespawnImpactParticles = null; + let postRespawnPainSeen = false; + if (respawnEvent) { + await sleep(650); + const duelBaseOrigin = (await localSnapshotPlayer(attacker)).origin; + pose = await setControlledDuelPose(clients, spec, "a-to-b", { + baseOrigin: duelBaseOrigin, + mapName: options.mapName, + timeoutMs: options.common.timeoutMs, + }); + poseUpdates.push(pose); + await sleep(CONTROLLED_DAMAGE_HISTORY_SETTLE_MS); + await clearRemoteFrameSamples(attacker); + const fireResult = await attacker.page.evaluate(() => window.__cssQuakeDebug?.fire?.() ?? null); + fireResults.push(fireResult); + if (fireResult !== true) { + failures.push(`post-respawn debug fire returned ${String(fireResult)}`); + } else { + const particlesPromise = waitForImpactParticles(attacker, "blood", 1_000); + const painFramePromise = waitForRemoteFramePrefix(attacker, victimPlayer.clientId, "pain", 1_000); + try { + postRespawnDamage = await waitForPlayerEvent(clients, (candidate) => + candidate.eventType === "player.damaged" && + candidate.attackerPlayerId === attackerPlayer.playerId && + candidate.victimPlayerId === victimPlayer.playerId && + candidate.damageSource === "shotgun" && + candidate.health === 76 && + candidate.roomTime > respawnEvent.roomTime, + options.common.timeoutMs, + ); + } catch (error) { + failures.push(`post-respawn damage: ${errorMessage(error)}`); + } + postRespawnImpactParticles = await particlesPromise; + postRespawnPainSeen = await painFramePromise; + } + } + + const afterPostDamage = await readClientSnapshot(victim); + const afterPostDamageAttacker = await readClientSnapshot(attacker); + const postDamageVictim = snapshotPlayer(afterPostDamage, victimPlayer.playerId); + if (postRespawnDamage) { + if (postRespawnDamage.damage !== 24) { + failures.push(`expected post-respawn damage 24, got ${String(postRespawnDamage.damage)}`); + } + if (postDamageVictim?.health !== 76) { + failures.push(`expected victim snapshot health 76 after post-respawn damage, got ${String(postDamageVictim?.health)}`); + } + if (afterPostDamage.stats?.playerHealth !== 76) { + failures.push(`expected victim local health 76 after post-respawn damage, got ${String(afterPostDamage.stats?.playerHealth)}`); + } + if ((postRespawnImpactParticles?.blood ?? 0) <= 0) { + failures.push("attacker did not sample blood particles after respawn damage"); + } + } + const rejects = [ + ...(afterRespawn.trace.rejects ?? []), + ...(afterRespawnAttacker.trace.rejects ?? []), + ...(afterPostDamage.trace.rejects ?? []), + ...(afterPostDamageAttacker.trace.rejects ?? []), + ]; + if (rejects.length) failures.push(`unexpected room rejects during respawn flow: ${JSON.stringify(rejects)}`); + const postAnimation = remoteAnimationSummary(attacker.afterRemoteFrames ?? []); + if (postRespawnDamage && !postRespawnPainSeen) { + failures.push("attacker did not sample victim pain animation after respawn damage"); + } + + return { + kind: "controlled-respawn", + mapName: options.mapName, + room, + weapon: "shotgun", + pass: failures.length === 0, + failures, + before: compactSnapshot(before), + beforeAttacker: compactSnapshot(beforeAttacker), + afterRespawn: compactSnapshot(afterRespawn), + afterRespawnAttacker: compactSnapshot(afterRespawnAttacker), + afterPostDamage: compactSnapshot(afterPostDamage), + afterPostDamageAttacker: compactSnapshot(afterPostDamageAttacker), + deathAnimation, + deathFrameSeen, + deathOverlayActive, + event: killEvent, + fireResults, + pose, + poseUpdates, + postRespawnDamage, + postRespawnImpactParticles, + postRespawnPainSeen, + respawnCueCleared, + respawnOverlayCleared, + respawnEvent, + postAnimation, + attacker: compactClient(attacker), + victim: compactClient(victim), + }; + } finally { + await Promise.all(clients.map((client) => client.context.close().catch(() => undefined))); + } +} + +async function runControlledProjectileVisualCase(options) { + const spec = options.spec; + const room = createDeepRoomName("proj", options.mapName, spec.weapon); + console.log(`controlled projectile visual: ${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 attacker = clients[0]; + const victim = clients[1]; + const duelBaseOrigin = (await localSnapshotPlayer(attacker)).origin; + const pickup = await pickupWeaponForControlledCase(attacker, spec.weapon, options.common.timeoutMs); + await waitForSnapshotPlayerWeapon(clients, pickup.player.clientId, spec.weapon, options.common.timeoutMs); + const pose = await setControlledDuelPose(clients, spec, "a-to-b", { + baseOrigin: duelBaseOrigin, + mapName: options.mapName, + timeoutMs: options.common.timeoutMs, + }); + await sleep(CONTROLLED_DAMAGE_HISTORY_SETTLE_MS); + await Promise.all(clients.map((client) => client.page.evaluate((weapon) => window.__cssQuakeDebug?.setWeapon?.(weapon), spec.weapon))); + + 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, spec.weapon, options.common.timeoutMs); + + const before = await readClientSnapshot(victim); + const beforeAttacker = await readClientSnapshot(attacker); + const failures = []; + await clearRemoteFrameSamplesForAll(clients); + const fireResult = await attacker.page.evaluate(() => window.__cssQuakeDebug?.fire?.() ?? null); + const attackFramePromise = fireResult === true + ? waitForRemoteFramePrefixes( + victim, + attackerPlayer.clientId, + remoteAttackFramePrefixesForWeapon(spec.weapon), + 1_000, + ) + : Promise.resolve(false); + if (fireResult !== true) failures.push(`debug fire returned ${String(fireResult)}`); + + let spawned = null; + let impacted = null; + let explosionSprite = null; + let visibleProjectile = null; + let movedProjectile = null; + let postImpact = null; + let postImpactAttacker = null; + let projectileFlightMs = null; + if (fireResult === true) { + try { + spawned = await waitForRoomEvent(clients, (candidate) => + candidate.eventType === "projectile.spawned" && + candidate.projectile?.ownerPlayerId === attackerPlayer.playerId && + candidate.projectile?.weapon === spec.weapon, + options.common.timeoutMs, + ); + const projectileResult = await waitForRemoteProjectilePresentationOrImpact( + victim, + clients, + spawned.projectile.projectileId, + options.common.timeoutMs, + ); + visibleProjectile = projectileResult.visibleProjectile; + movedProjectile = projectileResult.movedProjectile; + impacted = projectileResult.impacted; + projectileFlightMs = Number(impacted?.roomTime) - Number(spawned.roomTime); + postImpact = impacted ? await readClientSnapshot(victim) : null; + postImpactAttacker = impacted ? await readClientSnapshot(attacker) : null; + explosionSprite = await waitForExplosionSprite(victim, 1_000); + await waitForNoRemoteProjectileDom(victim, spawned.projectile.projectileId, 2_000); + } catch (error) { + failures.push(errorMessage(error)); + } + } + + const attackSeen = await attackFramePromise; + const finalAfter = await readClientSnapshot(victim); + const finalAfterAttacker = await readClientSnapshot(attacker); + const after = postImpact ?? finalAfter; + const afterAttacker = postImpactAttacker ?? finalAfterAttacker; + const attackAnimation = remoteAnimationSummary(victim.afterRemoteFrames ?? []); + const firedDecision = [ + ...(after.trace.roomEvents ?? []), + ...(afterAttacker.trace.roomEvents ?? []), + ].findLast((event) => + event?.eventType === "player.fired" && + event?.playerId === attackerPlayer.playerId && + event?.weapon === spec.weapon + )?.decision; + if (firedDecision?.outcome !== "projectile-spawned") { + failures.push(`expected projectile-spawned decision, got ${JSON.stringify(firedDecision ?? null)}`); + } + if (impacted) { + if (spec.expectedImpactKind && impacted.impactKind !== spec.expectedImpactKind) { + failures.push(`expected ${spec.weapon} impact kind ${spec.expectedImpactKind}, got ${String(impacted.impactKind)}`); + } + if (impacted.targetPlayerId !== victimPlayer.playerId) { + failures.push(`expected ${spec.weapon} impact target ${victimPlayer.playerId}, got ${String(impacted.targetPlayerId)}`); + } + if (spec.expectedPlayerDamageCount !== undefined && + impacted.playerDamageCount !== spec.expectedPlayerDamageCount) { + failures.push(`expected ${spec.weapon} player damage count ${spec.expectedPlayerDamageCount}, got ${String(impacted.playerDamageCount)}`); + } + } + const victimEvent = findPlayerDamageOutcome(after, { + attackerPlayerId: attackerPlayer.playerId, + damageSource: spec.weapon, + eventType: spec.expectedVictimEventType, + victimPlayerId: victimPlayer.playerId, + }); + if (!victimEvent) { + failures.push(`expected ${spec.weapon} ${spec.expectedVictimEventType} event for projectile victim`); + } else { + if (spec.expectedVictimDamage !== undefined && victimEvent.damage !== spec.expectedVictimDamage) { + failures.push(`expected ${spec.weapon} victim damage ${spec.expectedVictimDamage}, got ${String(victimEvent.damage)}`); + } + if ("health" in victimEvent && + spec.expectedVictimHealth !== undefined && + victimEvent.health !== spec.expectedVictimHealth) { + failures.push(`expected ${spec.weapon} victim event health ${spec.expectedVictimHealth}, got ${String(victimEvent.health)}`); + } + } + const victimSnapshotAfterImpact = snapshotPlayer(after, victimPlayer.playerId); + if (spec.expectedVictimHealth !== undefined) { + const snapshotHealth = victimSnapshotAfterImpact?.health ?? null; + if (snapshotHealth !== spec.expectedVictimHealth) { + failures.push(`expected ${spec.weapon} victim snapshot health ${spec.expectedVictimHealth}, got ${String(snapshotHealth)}`); + } + if (after.stats?.playerHealth !== spec.expectedVictimHealth) { + failures.push(`expected ${spec.weapon} victim local health ${spec.expectedVictimHealth}, got ${String(after.stats?.playerHealth)}`); + } + } + if (spec.expectedSelfDamage !== undefined) { + const selfEvent = findPlayerDamageOutcome(afterAttacker, { + attackerPlayerId: attackerPlayer.playerId, + damageSource: spec.weapon, + eventType: "player.damaged", + victimPlayerId: attackerPlayer.playerId, + }); + if (!selfEvent) { + failures.push(`expected ${spec.weapon} self-damage event`); + } else if (selfEvent.damage !== spec.expectedSelfDamage) { + failures.push(`expected ${spec.weapon} self damage ${spec.expectedSelfDamage}, got ${String(selfEvent.damage)}`); + } + } + if (spec.expectedAttackerHealth !== undefined && + afterAttacker.stats?.playerHealth !== spec.expectedAttackerHealth) { + failures.push(`expected ${spec.weapon} attacker local health ${spec.expectedAttackerHealth}, got ${String(afterAttacker.stats?.playerHealth)}`); + } + const projectilePresentationExpected = !Number.isFinite(projectileFlightMs) || projectileFlightMs >= 250; + if (spawned && !visibleProjectile && projectilePresentationExpected) { + failures.push("victim did not render remote projectile DOM before impact"); + } + if (spawned && !movedProjectile && projectilePresentationExpected) { + failures.push("victim remote projectile DOM did not receive a moved snapshot before impact"); + } + if (spawned && !attackSeen) failures.push("victim did not sample projectile attacker attack animation"); + if (impacted && !explosionSprite) failures.push("victim did not render projectile explosion sprite after impact"); + if (impacted && (finalAfter.stats?.multiplayer?.remoteProjectileDomCount ?? 0) !== 0) { + failures.push(`remote projectile DOM leaked after impact: ${finalAfter.stats?.multiplayer?.remoteProjectileDomCount}`); + } + + return { + kind: "controlled-projectile", + mapName: options.mapName, + room, + weapon: spec.weapon, + pass: failures.length === 0, + failures, + before: compactSnapshot(before), + beforeAttacker: compactSnapshot(beforeAttacker), + after: compactSnapshot(after), + afterAttacker: compactSnapshot(afterAttacker), + finalAfter: compactSnapshot(finalAfter), + finalAfterAttacker: compactSnapshot(finalAfterAttacker), + explosionSprite, + fireResult, + firedDecision, + impacted, + movedProjectile, + projectileFlightMs, + remoteAttackAnimation: attackAnimation, + pickup, + pose, + spawned, + visibleProjectile, + attacker: compactClient(attacker), + victim: compactClient(victim), + }; + } finally { + await Promise.all(clients.map((client) => client.context.close().catch(() => undefined))); + } +} + +async function runSharedPickupStateCase(options) { + const room = createDeepRoomName("pickup", options.mapName, "supershotgun"); + console.log(`shared pickup state: supershotgun 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 actor = clients[0]; + const observer = clients[1]; + const pickup = await pickupWeaponForControlledCase(actor, "supershotgun", options.common.timeoutMs); + try { + await waitForPickupSnapshotUnavailable(clients, pickup.pickupEntityIndex, options.common.timeoutMs); + } catch (error) { + const snapshots = await safeReadClientSnapshots(clients); + return { + kind: "shared-pickup", + mapName: options.mapName, + room, + weapon: "supershotgun", + pass: false, + failures: [`pickup unavailable snapshot: ${errorMessage(error)}`], + actor: compactClient(actor), + observer: compactClient(observer), + afterFirst: snapshots.map(compactSnapshot), + afterDuplicate: [], + duplicateRejected: null, + duplicateRequested: null, + pickup, + takenEvents: await uniqueRoomEvents(clients, (event) => + event.eventType === "pickup.taken" && + event.entityIndex === pickup.pickupEntityIndex + ), + }; + } + const afterFirst = await safeReadClientSnapshots(clients); + const firstTakenEvents = await uniqueRoomEvents(clients, (event) => + event.eventType === "pickup.taken" && + event.entityIndex === pickup.pickupEntityIndex + ); + + await sleep(300); + const duplicateRequested = await actor.page.evaluate((entityIndex) => + window.__cssQuakeDebug?.requestMultiplayerPickup?.(entityIndex) ?? false, + pickup.pickupEntityIndex, + ); + let duplicateRejected = null; + const failures = []; + if (!duplicateRequested) failures.push("duplicate pickup request did not leave the client"); + try { + duplicateRejected = await waitForRoomEvent(clients, (event) => + event.eventType === "pickup.rejected" && + event.entityIndex === pickup.pickupEntityIndex && + event.playerId === pickup.player.playerId && + event.reason === "unavailable", + 2_000, + ); + } catch (error) { + failures.push(`duplicate unavailable pickup rejection: ${errorMessage(error)}`); + } + await sleep(100); + const afterDuplicate = await safeReadClientSnapshots(clients); + const takenEvents = await uniqueRoomEvents(clients, (event) => + event.eventType === "pickup.taken" && + event.entityIndex === pickup.pickupEntityIndex + ); + const rejects = afterDuplicate.flatMap((snapshot) => snapshot.trace.rejects ?? []); + if (rejects.length) failures.push(`unexpected room rejects during shared pickup: ${JSON.stringify(rejects)}`); + if (firstTakenEvents.length !== 1) { + failures.push(`expected one first pickup.taken event, got ${firstTakenEvents.length}`); + } + if (takenEvents.length !== 1) { + failures.push(`expected duplicate pickup not to emit pickup.taken, got ${takenEvents.length}`); + } + for (const [index, snapshot] of afterDuplicate.entries()) { + const pickupState = snapshot.trace.lastSnapshot?.pickups?.find((candidate) => + candidate.entityIndex === pickup.pickupEntityIndex + ); + if (!pickupState) { + failures.push(`client ${index} snapshot missing pickup ${pickup.pickupEntityIndex}`); + } else if (pickupState.available !== false) { + failures.push(`client ${index} pickup ${pickup.pickupEntityIndex} availability ${String(pickupState.available)} after take`); + } + const pickupStats = snapshot.stats?.pickups ?? null; + if (!pickupStats?.pickedEntityIndexes?.includes(pickup.pickupEntityIndex)) { + failures.push(`client ${index} pickup stats did not mark ${pickup.pickupEntityIndex} picked`); + } + if (!pickupStats?.hiddenEntityIndexes?.includes(pickup.pickupEntityIndex)) { + failures.push(`client ${index} pickup stats did not hide ${pickup.pickupEntityIndex}`); + } + } + return { + kind: "shared-pickup", + mapName: options.mapName, + room, + weapon: "supershotgun", + pass: failures.length === 0, + failures, + actor: compactClient(actor), + observer: compactClient(observer), + afterFirst: afterFirst.map(compactSnapshot), + afterDuplicate: afterDuplicate.map(compactSnapshot), + duplicateRejected, + duplicateRequested, + pickup, + takenEvents, + }; + } finally { + await Promise.all(clients.map((client) => client.context.close().catch(() => undefined))); + } +} + +async function runLocalWorldMutationSuppressionCase(options) { + const room = createDeepRoomName("world", options.mapName); + console.log(`local world mutation suppression: ${options.mapName} room=${room}`); + const clients = [ + await openClient(options.browser, { + ...options, + clientIndex: 0, + clientsCount: 1, + debugMultiplayer: true, + debugMultiplayerInputPaused: true, + room, + }), + ]; + const client = clients[0]; + const failures = []; + let before = null; + let after = null; + let impact = null; + let target = null; + let snapshot = null; + try { + await waitForClientReady(client, 1, options.common.timeoutMs, { allowInputPaused: true }); + await waitForSnapshotPlayers(clients, 1, options.common.timeoutMs); + before = await client.page.evaluate(() => window.__cssQuakeDebug?.damageableBrushesStats?.() ?? null); + target = Array.isArray(before?.brushes) + ? before.brushes.find((brush) => Number.isInteger(brush.entityIndex) && Number.isFinite(brush.health)) + : null; + if (!target) { + failures.push(`map ${options.mapName} did not expose a damageable brush snapshot`); + } else { + impact = await client.page.evaluate((entityIndex) => + window.__cssQuakeDebug?.projectileImpact?.("rocketlauncher", entityIndex, 0, 0, 0, 200) ?? null, + target.entityIndex, + ); + after = await client.page.evaluate(() => window.__cssQuakeDebug?.damageableBrushesStats?.() ?? null); + const afterTarget = Array.isArray(after?.brushes) + ? after.brushes.find((brush) => brush.entityIndex === target.entityIndex) + : null; + if (!impact) { + failures.push(`debug projectile impact returned ${String(impact)}`); + } + if (!afterTarget) { + failures.push(`local projectile impact removed damageable brush ${target.entityIndex}`); + } else if (afterTarget.health !== target.health) { + failures.push(`local projectile impact mutated damageable brush ${target.entityIndex} health ${target.health} -> ${afterTarget.health}`); + } + } + snapshot = await readClientSnapshot(client); + return { + kind: "local-world-mutation", + mapName: options.mapName, + room, + pass: failures.length === 0, + failures, + after, + before, + clients: clients.map(compactClient), + impact, + snapshot: snapshot ? compactSnapshot(snapshot) : null, + target, + }; + } finally { + await Promise.all(clients.map((item) => item.context.close().catch(() => undefined))); + } +} + +async function runWorldInteractionCase(options) { + const testCase = worldInteractionCaseForPage(WORLD_INTERACTION_CASE); + const prepared = readPreparedScene(options.mapName); + const trigger = assertPreparedEntity(prepared, testCase.triggerEntity, testCase.expectedTriggerClassname); + const door = assertPreparedEntity(prepared, testCase.doorEntity, testCase.expectedDoorClassname); + const room = createDeepRoomName("world-interaction", options.mapName); + console.log(`room world interaction: ${testCase.label} room=${room}`); + const clients = [ + await openClient(options.browser, { + ...options, + clientIndex: 0, + clientsCount: 2, + debugMultiplayer: true, + debugMultiplayerInputPaused: true, + room, + }), + await openClient(options.browser, { + ...options, + clientIndex: 1, + clientsCount: 2, + debugMultiplayer: true, + debugMultiplayerInputPaused: true, + room, + }), + ]; + const actor = clients[0]; + const failures = []; + let before = []; + let after = []; + let pose = null; + let touch = null; + try { + if (trigger.properties?.target !== testCase.targetname) { + failures.push(`trigger ${testCase.triggerEntity} expected target ${testCase.targetname}, got ${trigger.properties?.target}`); + } + if (door.properties?.targetname !== testCase.targetname) { + failures.push(`door ${testCase.doorEntity} expected targetname ${testCase.targetname}, got ${door.properties?.targetname}`); + } + try { + await Promise.all(clients.map((client) => + waitForClientReady(client, 2, options.common.timeoutMs, { allowInputPaused: true }) + )); + await waitForSnapshotPlayers(clients, 2, options.common.timeoutMs); + } catch (error) { + before = await safeReadWorldInteractionSnapshots(clients, testCase); + failures.push(`clients did not become ready for world interaction: ${errorMessage(error)}`); + return { + kind: "world-interaction", + mapName: options.mapName, + room, + pass: false, + failures, + clients: clients.map(compactClient), + before, + prepared: { + door: { + classname: door.classname, + entityIndex: door.index, + targetname: door.properties?.targetname ?? null, + }, + trigger: { + classname: trigger.classname, + entityIndex: trigger.index, + target: trigger.properties?.target ?? null, + }, + }, + }; + } + before = await Promise.all(clients.map((client) => readWorldInteractionSnapshot(client, testCase))); + pose = await syncMultiplayerPoseAtQuakePoint(actor, testCase.inside, options.common.timeoutMs); + touch = await actor.page.evaluate(async (testCase) => { + const debug = window.__cssQuakeDebug; + if (!debug?.stats || !debug.setViewpos) return { ok: false, reason: "missing debug hooks" }; + const settle = async (ms = 80) => { + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + await new Promise((resolve) => setTimeout(resolve, ms)); + }; + const touchOk = debug.setViewpos( + testCase.inside.x, + testCase.inside.y, + testCase.inside.z, + undefined, + undefined, + { gameplay: true, stableViewmodel: true }, + ); + await settle(); + return { + clientId: debug.stats().multiplayer?.clientId ?? null, + ok: true, + origin: debug.stats().origin ?? null, + touchOk, + worldSequence: debug.stats().multiplayer?.worldSequence ?? null, + }; + }, testCase); + if (!touch?.touchOk) failures.push(`trigger touch failed: ${JSON.stringify(touch)}`); + await waitForWorldEvent(clients, { + entityIndex: testCase.triggerEntity, + eventType: "world.trigger", + }, options.common.timeoutMs); + await waitForWorldEvent(clients, { + eventType: "world.targets", + sourceEntityIndex: testCase.triggerEntity, + }, options.common.timeoutMs); + await waitForWorldEvent(clients, { + classname: testCase.expectedDoorClassname, + entityIndex: testCase.doorEntity, + eventType: "world.mover", + }, options.common.timeoutMs); + await waitForMoverMode(clients, testCase.doorEntity, testCase.expectedDoorTriggeredModes, options.common.timeoutMs); + after = await Promise.all(clients.map((client) => readWorldInteractionSnapshot(client, testCase))); + for (const [index, snapshot] of after.entries()) { + if (!snapshot.triggerEvent) failures.push(`client ${index} did not record world.trigger ${testCase.triggerEntity}`); + if (!snapshot.targetsEvent) failures.push(`client ${index} did not record world.targets from ${testCase.triggerEntity}`); + if (!snapshot.moverEvent) failures.push(`client ${index} did not record world.mover ${testCase.doorEntity}`); + if (!testCase.expectedDoorTriggeredModes.includes(snapshot.mover?.mode)) { + failures.push(`client ${index} mover ${testCase.doorEntity} stayed ${snapshot.mover?.mode ?? "missing"}`); + } + } + return { + kind: "world-interaction", + mapName: options.mapName, + room, + pass: failures.length === 0, + failures, + actor: compactClient(actor), + before, + after, + pose, + prepared: { + door: { + classname: door.classname, + entityIndex: door.index, + targetname: door.properties?.targetname ?? null, + }, + trigger: { + classname: trigger.classname, + entityIndex: trigger.index, + target: trigger.properties?.target ?? null, + }, + }, + touch, + }; + } finally { + await Promise.all(clients.map((client) => client.context.close().catch(() => undefined))); + } +} + +async function runSpawnEscapeCase(options) { + console.log(`spawn escape sampling: ${options.mapName} samples=${SPAWN_ESCAPE_SAMPLES}`); + const failures = []; + const samples = []; + for (let sampleIndex = 0; sampleIndex < SPAWN_ESCAPE_SAMPLES; sampleIndex += 1) { + const room = createDeepRoomName("spawn", options.mapName, String(sampleIndex + 1)); + const clients = [ + await openClient(options.browser, { + ...options, + clientIndex: 0, + clientsCount: 1, + debugMultiplayer: true, + room, + }), + ]; + const client = clients[0]; + const sampleFailures = []; + let before = null; + let after = null; + let movement = null; + let startPlayer = null; + try { + await waitForClientReady(client, 1, options.common.timeoutMs); + await waitForSnapshotPlayers(clients, 1, options.common.timeoutMs); + before = await readClientSnapshot(client); + startPlayer = await localSnapshotPlayer(client); + if (!Array.isArray(startPlayer.origin) || startPlayer.origin.length !== 3) { + sampleFailures.push("local snapshot player did not expose a valid start origin"); + } else { + movement = await driveSpawnEscapeSample(client, startPlayer, options.common.timeoutMs); + after = await readClientSnapshot(client); + const endPlayer = snapshotPlayer(after, startPlayer.playerId); + if (!endPlayer) { + sampleFailures.push("final snapshot did not include the local player"); + } else { + if (endPlayer.alive !== true) sampleFailures.push(`final authoritative player alive=${String(endPlayer.alive)}`); + if (Number(endPlayer.health) <= 0) sampleFailures.push(`final authoritative player health=${String(endPlayer.health)}`); + } + if (Number(after.stats?.playerHealth) <= 0) { + sampleFailures.push(`final local player health=${String(after.stats?.playerHealth)}`); + } + if (movement.maxLocalHorizontalDistance < SPAWN_ESCAPE_MIN_DISTANCE) { + sampleFailures.push(`local movement only ${movement.maxLocalHorizontalDistance.toFixed(3)} from spawn`); + } + if (movement.maxAuthoritativeHorizontalDistance < SPAWN_ESCAPE_MIN_DISTANCE) { + sampleFailures.push(`authoritative movement only ${movement.maxAuthoritativeHorizontalDistance.toFixed(3)} from spawn`); + } + if ((after.trace.rejects ?? []).length) { + sampleFailures.push(`room rejects while escaping spawn: ${JSON.stringify(after.trace.rejects)}`); + } + } + if (client.pageErrors.length) { + sampleFailures.push(`${client.pageErrors.length} page error(s) while escaping spawn`); + } + if (client.requestFailures.length) { + sampleFailures.push(`${client.requestFailures.length} request failure(s) while escaping spawn`); + } + } catch (error) { + sampleFailures.push(errorMessage(error)); + after = await safeReadClientSnapshots(clients).then((snapshots) => snapshots[0] ?? null); + } finally { + await Promise.all(clients.map((item) => item.context.close().catch(() => undefined))); + } + if (sampleFailures.length) { + failures.push(`sample ${sampleIndex + 1} room=${room}: ${sampleFailures.join("; ")}`); + } + samples.push({ + sample: sampleIndex + 1, + room, + pass: sampleFailures.length === 0, + failures: sampleFailures, + start: compactSpawnEscapePlayer(startPlayer), + movement, + before: before ? compactSnapshot(before) : null, + after: after ? compactSnapshot(after) : null, + client: compactClient(client), + }); + } + const uniqueSpawnIds = [...new Set(samples.map((sample) => sample.start?.spawnId).filter(Boolean))].sort(); + return { + kind: "spawn-escape", + mapName: options.mapName, + sampleCount: samples.length, + uniqueSpawnIds, + pass: failures.length === 0, + failures, + samples, + clients: samples.map((sample) => sample.client), + }; +} + +async function runReconnectCase(options) { + const room = createDeepRoomName("reconn", options.mapName); + 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`); - } + 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 runRoomLifecycleCase(options) { + const room = createDeepRoomName("lifecycle", options.mapName); + const failures = []; + const clients = []; + const playerClients = []; + const spectatorClients = []; + let overflowClient = null; + let snapshots = []; + console.log(`room lifecycle: maxPlayers=${ROOM_LIFECYCLE_MAX_PLAYERS} spectators=${ROOM_LIFECYCLE_SPECTATOR_SLOTS} room=${room}`); + try { + for (let index = 0; index < ROOM_LIFECYCLE_MAX_PLAYERS; index += 1) { + const client = await openClient(options.browser, { + ...options, + clientIndex: index, + clientsCount: ROOM_LIFECYCLE_MAX_PLAYERS, + debugMultiplayer: true, + maxPlayers: ROOM_LIFECYCLE_MAX_PLAYERS, + room, + }); + clients.push(client); + playerClients.push(client); + } + try { + await Promise.all(playerClients.map((client) => + waitForClientReady(client, ROOM_LIFECYCLE_MAX_PLAYERS, options.common.timeoutMs) + )); + await waitForSnapshotPlayers(playerClients, ROOM_LIFECYCLE_MAX_PLAYERS, options.common.timeoutMs); + await waitForRemoteDomCounts(playerClients, ROOM_LIFECYCLE_MAX_PLAYERS - 1, options.common.timeoutMs); + } catch (error) { + failures.push(`player readiness failed: ${errorMessage(error)}`); + } + + for (let index = 0; index < ROOM_LIFECYCLE_SPECTATOR_SLOTS; index += 1) { + const clientIndex = ROOM_LIFECYCLE_MAX_PLAYERS + index; + const client = await openClient(options.browser, { + ...options, + clientIndex, + clientsCount: ROOM_LIFECYCLE_MAX_PLAYERS, + debugMultiplayer: true, + maxPlayers: ROOM_LIFECYCLE_MAX_PLAYERS, + room, + }); + clients.push(client); + spectatorClients.push(client); + try { + await waitForSpectatorReady(client, index + 1, options.common.timeoutMs); + } catch (error) { + failures.push(`spectator ${index + 1} readiness failed: ${errorMessage(error)}`); + } + } + + try { + await Promise.all(playerClients.map((client) => + waitForSpectatorCount(client, ROOM_LIFECYCLE_SPECTATOR_SLOTS, options.common.timeoutMs) + )); + } catch (error) { + failures.push(`player spectator-count sync failed: ${errorMessage(error)}`); + } + + overflowClient = await openClient(options.browser, { + ...options, + clientIndex: ROOM_LIFECYCLE_MAX_PLAYERS + ROOM_LIFECYCLE_SPECTATOR_SLOTS, + clientsCount: ROOM_LIFECYCLE_MAX_PLAYERS, + debugMultiplayer: true, + maxPlayers: ROOM_LIFECYCLE_MAX_PLAYERS, + room, + }); + clients.push(overflowClient); + try { + await waitForMultiplayerReject(overflowClient, "room-full", options.common.timeoutMs); + } catch (error) { + failures.push(`overflow client did not receive room-full reject: ${errorMessage(error)}`); + } + + snapshots = await safeReadClientSnapshots(clients); + for (const [index, snapshot] of snapshots.entries()) { + const multiplayer = snapshot.stats?.multiplayer; + if (index < ROOM_LIFECYCLE_MAX_PLAYERS) { + if (multiplayer?.spectating) failures.push(`player client ${index} became spectator`); + if (multiplayer?.scoreboardRows !== ROOM_LIFECYCLE_MAX_PLAYERS) { + failures.push(`player client ${index} scoreboard rows ${String(multiplayer?.scoreboardRows)} did not equal ${ROOM_LIFECYCLE_MAX_PLAYERS}`); + } + if (multiplayer?.spectatorCount !== ROOM_LIFECYCLE_SPECTATOR_SLOTS) { + failures.push(`player client ${index} spectator count ${String(multiplayer?.spectatorCount)} did not equal ${ROOM_LIFECYCLE_SPECTATOR_SLOTS}`); + } + } else if (index < ROOM_LIFECYCLE_MAX_PLAYERS + ROOM_LIFECYCLE_SPECTATOR_SLOTS) { + if (multiplayer?.spectating !== true) failures.push(`spectator client ${index} did not enter spectating mode`); + if (!multiplayer?.spectatorFollowedPlayerId) failures.push(`spectator client ${index} did not choose a followed player`); + if (multiplayer?.scoreboardRows !== ROOM_LIFECYCLE_MAX_PLAYERS) { + failures.push(`spectator client ${index} scoreboard rows ${String(multiplayer?.scoreboardRows)} did not equal ${ROOM_LIFECYCLE_MAX_PLAYERS}`); + } + } else if (multiplayer?.lastReject?.code !== "room-full") { + failures.push(`overflow client lastReject ${String(multiplayer?.lastReject?.code)} did not equal room-full`); + } + } + + return { + kind: "room-lifecycle", + mapName: options.mapName, + room, + maxPlayers: ROOM_LIFECYCLE_MAX_PLAYERS, + spectatorSlots: ROOM_LIFECYCLE_SPECTATOR_SLOTS, + pass: failures.length === 0, + failures, + snapshots: snapshots.map(compactSnapshot), + clients: clients.map(compactClient), + }; + } finally { + await Promise.all(clients.map((client) => client.context.close().catch(() => undefined))); + } +} + +async function runWrongMapCase(options) { + const room = createDeepRoomName("wrongmap", options.mapName); + const wrongMap = wrongMapProbeMap(options.mapName); + const failures = []; + const clients = []; + let snapshots = []; + console.log(`wrong-map rejection: host=${options.mapName} joiner=${wrongMap} room=${room}`); + try { + const host = await openClient(options.browser, { + ...options, + clientIndex: 0, + clientsCount: 1, + debugMultiplayer: true, + maxPlayers: 1, + room, + }); + clients.push(host); + try { + await waitForClientReady(host, 1, options.common.timeoutMs); + await waitForSnapshotPlayers([host], 1, options.common.timeoutMs); + } catch (error) { + failures.push(`host readiness failed: ${errorMessage(error)}`); + } + + const joiner = await openClient(options.browser, { + ...options, + clientIndex: 1, + clientsCount: 1, + compactInviteMapName: options.mapName, + debugMultiplayer: true, + mapName: wrongMap, + maxPlayers: 1, + room, + }); + clients.push(joiner); + try { + await waitForMultiplayerReject(joiner, "wrong-map", options.common.timeoutMs); + } catch (error) { + failures.push(`wrong-map client did not receive wrong-map reject: ${errorMessage(error)}`); + } + + snapshots = await safeReadClientSnapshots(clients); + const hostSnapshot = snapshots[0]; + const rejectSnapshot = snapshots[1]; + if (hostSnapshot?.stats?.multiplayer?.sessionState !== "connected") { + failures.push(`host session state changed to ${String(hostSnapshot?.stats?.multiplayer?.sessionState)}`); + } + if (rejectSnapshot?.stats?.multiplayer?.lastReject?.code !== "wrong-map") { + failures.push(`wrong-map reject snapshot code ${String(rejectSnapshot?.stats?.multiplayer?.lastReject?.code)} did not equal wrong-map`); + } + if (rejectSnapshot?.stats?.multiplayer?.helloAccepted === true) { + failures.push("wrong-map joiner accepted hello after fatal reject"); } + return { - kind: "reconnect", - mapName: options.mapName, + kind: "wrong-map", + hostMapName: options.mapName, + wrongMapName: wrongMap, room, pass: failures.length === 0, failures, - before: before.map(compactSnapshot), - after: after.map(compactSnapshot), + snapshots: snapshots.map(compactSnapshot), clients: clients.map(compactClient), }; } finally { @@ -457,6 +2263,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(), @@ -481,16 +2288,21 @@ async function openClient(browser, options) { function clientUrl(options) { const url = new URL(options.appUrl); + const compactInviteMapName = options.compactInviteMapName ?? options.mapName; + const room = options.externalMode + ? createCompactInvite(options.manifest, compactInviteMapName, roomTokenForCompactInvite(options.room)).value + : options.room; url.searchParams.set("debug", "1"); url.searchParams.set("map", options.mapName); - url.searchParams.set("room", options.room); + url.searchParams.set("room", 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("maxPlayers", String(options.maxPlayers ?? options.clientsCount)); url.searchParams.set("disableEnemies", "1"); - if (options.debugMultiplayer) url.searchParams.set("debugMultiplayer", "party"); + if (options.externalMode) url.searchParams.set("multiplayer", "party"); + else if (options.debugMultiplayer) url.searchParams.set("debugMultiplayer", "party"); if (options.debugMultiplayerInputPaused) url.searchParams.set("debugMultiplayerInputPaused", "1"); return url.toString(); } @@ -510,6 +2322,99 @@ async function waitForClientReady(client, clientsCount, timeoutMs, options = {}) }, { minPlayers: clientsCount, allowInputPaused: Boolean(options.allowInputPaused) }, { timeout: timeoutMs }); } +async function waitForSpectatorReady(client, minSpectators, timeoutMs) { + await client.page.waitForFunction(({ minSpectators, minScoreboardRows }) => { + const stats = window.__cssQuakeDebug?.stats?.(); + return Boolean( + stats && + !stats.loading && + stats.multiplayer?.sessionState === "connected" && + stats.multiplayer?.helloAccepted === true && + stats.multiplayer?.spectating === true && + stats.multiplayer?.spectatorCount >= minSpectators && + stats.multiplayer?.spectatorFollowedPlayerId && + stats.multiplayer?.scoreboardRows >= minScoreboardRows + ); + }, { + minScoreboardRows: ROOM_LIFECYCLE_MAX_PLAYERS, + minSpectators, + }, { timeout: timeoutMs }); +} + +async function waitForSpectatorCount(client, spectatorCount, timeoutMs) { + await client.page.waitForFunction((spectatorCount) => { + const stats = window.__cssQuakeDebug?.stats?.(); + return stats?.multiplayer?.spectatorCount === spectatorCount; + }, spectatorCount, { timeout: timeoutMs }); +} + +async function waitForMultiplayerReject(client, code, timeoutMs) { + await client.page.waitForFunction((code) => { + const stats = window.__cssQuakeDebug?.stats?.(); + return stats?.multiplayer?.lastReject?.code === code; + }, code, { timeout: timeoutMs }); +} + +async function syncMultiplayerPoseAtQuakePoint(client, point, timeoutMs) { + const pose = await client.page.evaluate((point) => { + const debug = window.__cssQuakeDebug; + if (!debug?.stats || !debug.setViewpos) { + return { ok: false, reason: "missing debug hooks" }; + } + const setOk = debug.setViewpos( + point.x, + point.y, + point.z, + undefined, + undefined, + { stableViewmodel: true }, + ); + const synced = debug.syncMultiplayerPose?.() ?? false; + const stats = debug.stats(); + return { + clientId: stats.multiplayer?.clientId ?? null, + ok: true, + origin: stats.origin ?? null, + setOk, + synced, + }; + }, point); + if (!pose?.ok || !pose.setOk || !pose.synced) { + throw new Error(`Failed to sync multiplayer pose at ${JSON.stringify(point)}: ${JSON.stringify(pose)}`); + } + await waitForLocalAuthoritativePose(client, pose, timeoutMs); + return pose; +} + +async function waitForWorldEvent(clients, criteria, timeoutMs) { + await Promise.all(clients.map((client) => + client.page.waitForFunction((criteria) => { + const events = window.__cssQuakeMpDeepTrace?.roomEvents ?? []; + return events.some((event) => worldEventMatches(event, criteria)); + + function worldEventMatches(event, criteria) { + if (!event || event.eventType !== criteria.eventType) return false; + if (criteria.entityIndex !== undefined && event.entityIndex !== criteria.entityIndex) return false; + if (criteria.sourceEntityIndex !== undefined && event.sourceEntityIndex !== criteria.sourceEntityIndex) { + return false; + } + if (criteria.classname !== undefined && event.classname !== criteria.classname) return false; + return true; + } + }, criteria, { timeout: timeoutMs }) + )); +} + +async function waitForMoverMode(clients, entityIndex, modes, timeoutMs) { + await Promise.all(clients.map((client) => + client.page.waitForFunction(({ entityIndex, modes }) => { + const stats = window.__cssQuakeDebug?.stats?.(); + const mover = stats?.movers?.movers?.find((candidate) => candidate.entityIndex === entityIndex); + return modes.includes(mover?.mode); + }, { entityIndex, modes }, { timeout: timeoutMs }) + )); +} + async function waitForLocalInput(client, timeoutMs) { await client.page.waitForFunction(() => { const stats = window.__cssQuakeDebug?.stats?.(); @@ -540,6 +2445,98 @@ async function waitForSnapshotPlayers(clients, count, timeoutMs) { )); } +async function syncControlledDuelPose(clients, pose, timeoutMs) { + const synced = await Promise.all(clients.map((client) => + client.page.evaluate(() => window.__cssQuakeDebug?.syncMultiplayerPose?.() ?? false) + )); + if (!synced.every(Boolean)) { + throw new Error(`Controlled duel pose sync failed: ${JSON.stringify(synced)}`); + } + await waitForControlledDuelPose(clients, pose, timeoutMs); +} + +async function waitForControlledDuelPose(clients, pose, timeoutMs) { + const expected = { + attackerClientId: pose.attackerClientId, + victimClientId: pose.victimClientId, + attackerOrigin: pose.attackerOrigin, + victimOrigin: pose.victimOrigin, + attackerRotX: pose.attackerRotX, + attackerRotY: pose.attackerRotY, + victimRotX: pose.victimRotX, + victimRotY: pose.victimRotY, + originEpsilon: CONTROLLED_DUEL_POSE_EPSILON, + rotEpsilon: CONTROLLED_DUEL_ROT_EPSILON, + }; + await Promise.all(clients.map((client) => + client.page.waitForFunction((expected) => { + const players = window.__cssQuakeMpDeepTrace?.lastSnapshot?.players ?? []; + const attacker = players.find((player) => player.clientId === expected.attackerClientId); + const victim = players.find((player) => player.clientId === expected.victimClientId); + const originClose = (actual, wanted) => + Array.isArray(actual) && + Array.isArray(wanted) && + actual.length === 3 && + wanted.length === 3 && + actual.every((value, index) => Math.abs(Number(value) - Number(wanted[index])) <= expected.originEpsilon); + const angleClose = (actual, wanted) => { + if (!Number.isFinite(Number(actual)) || !Number.isFinite(Number(wanted))) return false; + const delta = Math.abs((((Number(actual) - Number(wanted) + 540) % 360) - 180)); + return delta <= expected.rotEpsilon; + }; + return Boolean( + attacker && + victim && + originClose(attacker.origin, expected.attackerOrigin) && + originClose(victim.origin, expected.victimOrigin) && + angleClose(attacker.rotX, expected.attackerRotX) && + angleClose(attacker.rotY, expected.attackerRotY) && + angleClose(victim.rotX, expected.victimRotX) && + angleClose(victim.rotY, expected.victimRotY) + ); + }, expected, { timeout: timeoutMs }) + )); +} + +async function waitForLocalAuthoritativePose(client, pose, timeoutMs) { + await client.page.waitForFunction(({ clientId, origin, originEpsilon }) => { + const players = window.__cssQuakeMpDeepTrace?.lastSnapshot?.players ?? []; + const player = players.find((candidate) => candidate.clientId === clientId); + if (!player || !Array.isArray(player.origin) || !Array.isArray(origin)) return false; + if (player.origin.length !== 3 || origin.length !== 3) return false; + return player.origin.every((value, index) => + Math.abs(Number(value) - Number(origin[index])) <= originEpsilon + ); + }, { + clientId: pose.clientId, + origin: pose.origin, + originEpsilon: CONTROLLED_DUEL_POSE_EPSILON, + }, { timeout: timeoutMs }); +} + +async function waitForSnapshotPlayerState(clients, criteria, timeoutMs) { + await Promise.all(clients.map((client) => + client.page.waitForFunction((criteria) => { + const players = window.__cssQuakeMpDeepTrace?.lastSnapshot?.players ?? []; + const player = players.find((candidate) => candidate.playerId === criteria.playerId); + if (!player) return false; + if (criteria.alive !== undefined && player.alive !== criteria.alive) return false; + if (criteria.health !== undefined && player.health !== criteria.health) return false; + return true; + }, criteria, { timeout: timeoutMs }) + )); +} + +async function waitForPickupSnapshotUnavailable(clients, entityIndex, timeoutMs) { + await Promise.all(clients.map((client) => + client.page.waitForFunction((entityIndex) => { + const pickup = (window.__cssQuakeMpDeepTrace?.lastSnapshot?.pickups ?? []) + .find((candidate) => candidate.entityIndex === entityIndex); + return pickup?.available === false; + }, entityIndex, { timeout: timeoutMs }) + )); +} + async function waitForRemoteDomCounts(clients, expected, timeoutMs) { await Promise.all(clients.map((client) => client.page.waitForFunction((minimum) => { @@ -550,16 +2547,18 @@ async function waitForRemoteDomCounts(clients, expected, timeoutMs) { )); } -async function setControlledDuelPose(clients, spec, direction) { +async function setControlledDuelPose(clients, spec, direction, options = {}) { 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 baseOrigin = await findControlledDuelBaseOrigin(clients, spec, options); + if (!Array.isArray(baseOrigin) || baseOrigin.length !== 3) { + throw new Error("Controlled duel pose could not read a stable base origin."); + } + const pose = await attacker.page.evaluate(({ origin, 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; @@ -570,22 +2569,33 @@ async function setControlledDuelPose(clients, spec, direction) { horizontalForward: [forward[0] / horizontalLength, forward[1] / horizontalLength, 0], rotX: next.cameraRotX, rotY: next.cameraRotY, + clientId: next.multiplayer?.clientId ?? null, }; - }, aimRotX); + }, { origin: baseOrigin, rotX: 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 }) => { + const victimPose = await victim.page.evaluate(({ origin, rotX, rotY }) => { window.__cssQuakeDebug?.setPose?.(origin, rotX, (rotY + 180) % 360, { gameplay: true, stableViewmodel: true, }); + const next = window.__cssQuakeDebug?.stats?.(); + return { + origin: next?.origin ?? origin, + rotX: next?.cameraRotX ?? rotX, + rotY: next?.cameraRotY ?? ((rotY + 180) % 360), + clientId: next?.multiplayer?.clientId ?? null, + }; }, { origin: victimOrigin, rotX: pose.rotX, rotY: pose.rotY }); - return { + const nextPose = { attackerIndex, victimIndex, + attackerClientId: pose.clientId, + victimClientId: victimPose.clientId, + baseOrigin, attackerOrigin: pose.origin, attackerForward: pose.forward, attackerHorizontalForward: pose.horizontalForward, @@ -594,10 +2604,150 @@ async function setControlledDuelPose(clients, spec, direction) { damageCenterDrop: CONTROLLED_DAMAGE_CENTER_DROP, distance: spec.distance, targetCenter: [victimOrigin[0], victimOrigin[1], victimOrigin[2] - CONTROLLED_DAMAGE_CENTER_DROP], - victimOrigin, + victimOrigin: victimPose.origin, + victimRotX: victimPose.rotX, + victimRotY: victimPose.rotY, + }; + await syncControlledDuelPose(clients, nextPose, options.timeoutMs ?? DEFAULT_TIMEOUT_MS); + return nextPose; +} + +async function findControlledDuelBaseOrigin(clients, spec, options = {}) { + const baseOriginCandidates = (await Promise.all(clients.map((client) => + client.page.evaluate(() => + (window.__cssQuakeMpDeepTrace?.lastSnapshot?.players ?? []) + .map((player) => player.origin) + .filter((origin) => Array.isArray(origin) && origin.length === 3) + ) + ))).flat(); + const fallbackOrigin = await clients[0].page.evaluate(() => window.__cssQuakeDebug?.stats?.().origin ?? null); + const mapLanes = CONTROLLED_DUEL_LANES_BY_MAP[String(options.mapName ?? "").toLowerCase()] ?? []; + const candidates = [ + ...mapLanes, + ...(Array.isArray(options.baseOrigin) && options.baseOrigin.length === 3 ? [options.baseOrigin] : []), + ...baseOriginCandidates.sort((left, right) => + left[0] - right[0] || left[1] - right[1] || left[2] - right[2] + ), + ...(Array.isArray(fallbackOrigin) && fallbackOrigin.length === 3 ? [fallbackOrigin] : []), + ]; + const unique = []; + const seen = new Set(); + for (const candidate of candidates) { + if (!Array.isArray(candidate) || candidate.length !== 3) continue; + const origin = candidate.map((value) => Number(value)); + if (!origin.every(Number.isFinite)) continue; + const key = origin.map((value) => value.toFixed(4)).join(","); + if (seen.has(key)) continue; + seen.add(key); + unique.push(origin); + } + for (const candidate of unique) { + if (await controlledDuelLaneIsClear(clients[0], candidate, spec)) return candidate; + } + return unique[0] ?? null; +} + +async function controlledDuelLaneIsClear(client, origin, spec) { + return await client.page.evaluate(({ centerDrop, origin, distance }) => { + const target = [origin[0], origin[1] + distance, origin[2] - centerDrop]; + const result = window.__cssQuakeDebug?.canDamage?.( + origin[0], + origin[1], + origin[2], + target[0], + target[1], + target[2], + ); + return result?.result === true; + }, { centerDrop: CONTROLLED_DAMAGE_CENTER_DROP, origin, distance: spec.distance }); +} + +async function pickupWeaponForControlledCase(client, weapon, timeoutMs) { + const pickupClassname = weaponPickupClassname(weapon); + if (!pickupClassname) throw new Error(`No pickup classname known for ${weapon}.`); + const pickupEntityIndex = await client.page.evaluate((pickupClassname) => { + const indexes = window.__cssQuakeDebug?.entityIndexes?.(pickupClassname) ?? []; + return Number.isInteger(indexes[0]) ? indexes[0] : null; + }, pickupClassname); + if (!Number.isInteger(pickupEntityIndex)) throw new Error(`Could not find ${pickupClassname} pickup entity.`); + const pickupPose = await client.page.evaluate((pickupEntityIndex) => { + const debug = window.__cssQuakeDebug; + const focused = debug?.focusEntity?.(pickupEntityIndex, 0, 90, 270); + if (!focused) return null; + const origin = debug.stats?.().origin ?? null; + if (!Array.isArray(origin) || origin.length !== 3) return null; + debug.setPose?.(origin, 90, 270, { gameplay: true, stableViewmodel: true }); + const poseSynced = debug.syncMultiplayerPose?.() ?? false; + return { + clientId: debug.stats?.().multiplayer?.clientId ?? null, + entityIndex: pickupEntityIndex, + origin: debug.stats?.().origin ?? null, + poseSynced, + }; + }, pickupEntityIndex); + if (!pickupPose?.poseSynced) { + throw new Error(`Failed to sync multiplayer pose for ${pickupClassname} (${pickupEntityIndex}).`); + } + await waitForLocalAuthoritativePose(client, pickupPose, timeoutMs); + const pickupRequest = await client.page.evaluate((pickupEntityIndex) => { + const debug = window.__cssQuakeDebug; + const collisionSynced = debug?.syncCollision?.() ?? false; + const pickupRequested = debug?.requestMultiplayerPickup?.(pickupEntityIndex) ?? false; + return { + collisionSynced, + origin: debug?.stats?.().origin ?? null, + pickupRequested, + }; + }, pickupEntityIndex); + if (!pickupRequest?.collisionSynced) { + throw new Error(`Failed to sync collision for ${pickupClassname} (${pickupEntityIndex}).`); + } + if (!pickupRequest.pickupRequested) { + throw new Error(`Failed to request multiplayer pickup for ${pickupClassname} (${pickupEntityIndex}).`); + } + const player = await localSnapshotPlayer(client); + let event; + try { + event = await waitForRoomEvent([client], (candidate) => + candidate.eventType === "pickup.taken" && + candidate.entityIndex === pickupEntityIndex && + candidate.playerId === player.playerId, + timeoutMs, + ); + } catch (error) { + const debug = await client.page.evaluate(() => ({ + inventory: window.__cssQuakeDebug?.inventory?.() ?? null, + pickupStats: window.__cssQuakeDebug?.pickupsStats?.() ?? null, + sent: window.__cssQuakeMpDeepTrace?.sent?.slice(-20) ?? [], + roomEvents: window.__cssQuakeMpDeepTrace?.roomEvents?.slice(-20) ?? [], + stats: window.__cssQuakeDebug?.stats?.() ?? null, + })); + throw new Error(`Timed out waiting for ${pickupClassname} pickup ${pickupEntityIndex}: ${errorMessage(error)} ${JSON.stringify(debug)}`); + } + return { + event, + pickupClassname, + pickupEntityIndex, + player, + pose: { + ...pickupPose, + collisionSynced: pickupRequest.collisionSynced, + pickupRequested: pickupRequest.pickupRequested, + requestOrigin: pickupRequest.origin, + }, }; } +function weaponPickupClassname(weapon) { + if (weapon === "grenadelauncher") return "weapon_grenadelauncher"; + if (weapon === "rocketlauncher") return "weapon_rocketlauncher"; + if (weapon === "nailgun") return "weapon_nailgun"; + if (weapon === "supernailgun") return "weapon_supernailgun"; + if (weapon === "supershotgun") return "weapon_supershotgun"; + if (weapon === "lightning") return "weapon_lightning"; + return null; +} + async function waitForPlayerEvent(clients, predicate, timeoutMs) { const started = Date.now(); while (Date.now() - started < timeoutMs) { @@ -611,15 +2761,185 @@ async function waitForPlayerEvent(clients, predicate, timeoutMs) { throw new Error("Timed out waiting for authoritative player event."); } +async function waitForRoomEvent(clients, predicate, timeoutMs) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + const match = await findRoomEvent(clients, predicate); + if (match) return match; + await sleep(50); + } + throw new Error("Timed out waiting for authoritative room event."); +} + +async function findRoomEvent(clients, predicate) { + for (const client of clients) { + const events = await client.page.evaluate(() => window.__cssQuakeMpDeepTrace?.roomEvents ?? []); + const match = events.findLast(predicate); + if (match) return match; + } + return null; +} + +async function uniqueRoomEvents(clients, predicate = () => true) { + const events = (await Promise.all(clients.map((client) => + client.page.evaluate(() => window.__cssQuakeMpDeepTrace?.roomEvents ?? []) + ))).flat(); + const unique = new Map(); + for (const event of events) { + if (!predicate(event)) continue; + unique.set(event.eventId ?? JSON.stringify(event), event); + } + return [...unique.values()]; +} + +async function waitForRemoteProjectileDom(client, projectileId, timeoutMs) { + await client.page.waitForFunction((projectileId) => { + const element = document.querySelector(`.remote-projectile[data-projectile-id="${CSS.escape(projectileId)}"]`); + const stats = window.__cssQuakeDebug?.stats?.(); + return Boolean( + element instanceof HTMLElement && + !element.hidden && + (stats?.multiplayer?.remoteProjectileDomCount ?? 0) >= 1 + ); + }, projectileId, { timeout: timeoutMs }); + return await client.page.evaluate((projectileId) => { + const element = document.querySelector(`.remote-projectile[data-projectile-id="${CSS.escape(projectileId)}"]`); + return element instanceof HTMLElement + ? { + className: element.className, + hidden: element.hidden, + ownerPlayerId: element.dataset.ownerPlayerId ?? null, + projectileId: element.dataset.projectileId ?? null, + origin: element.dataset.remoteProjectileOrigin ?? null, + } + : null; + }, projectileId); +} + +async function waitForRemoteProjectilePresentationOrImpact(client, clients, projectileId, timeoutMs) { + const started = Date.now(); + let visibleProjectile = null; + let movedProjectile = null; + let initialOrigin = null; + while (Date.now() - started < timeoutMs) { + const projectile = await readRemoteProjectileDom(client, projectileId); + if (projectile) { + if (!visibleProjectile) { + visibleProjectile = projectile; + initialOrigin = projectile.origin ?? ""; + } else if (!movedProjectile && projectile.origin !== "" && projectile.origin !== initialOrigin) { + movedProjectile = projectile; + } + } + const impacted = await findRoomEvent(clients, (candidate) => + candidate.eventType === "projectile.impacted" && + candidate.projectileId === projectileId + ); + if (impacted) return { impacted, movedProjectile, visibleProjectile }; + await sleep(25); + } + throw new Error(`Timed out waiting for projectile ${projectileId} impact.`); +} + +async function readRemoteProjectileDom(client, projectileId) { + return await client.page.evaluate((projectileId) => { + const element = document.querySelector(`.remote-projectile[data-projectile-id="${CSS.escape(projectileId)}"]`); + return element instanceof HTMLElement + ? { + className: element.className, + hidden: element.hidden, + ownerPlayerId: element.dataset.ownerPlayerId ?? null, + projectileId: element.dataset.projectileId ?? null, + origin: element.dataset.remoteProjectileOrigin ?? null, + } + : null; + }, projectileId); +} + +async function waitForDamageOverlayState(client, active, timeoutMs) { + await client.page.waitForFunction((active) => { + const overlay = document.querySelector("#quake-damage-overlay"); + if (!(overlay instanceof HTMLElement)) return false; + return overlay.classList.contains("quake-damage-overlay-active") === active; + }, active, { timeout: timeoutMs }); + return await readHudFeedback(client); +} + +async function waitForHudDamageCueState(client, active, timeoutMs) { + await client.page.waitForFunction((active) => { + const classicHud = document.querySelector("#quake-classic-hud"); + if (!(classicHud instanceof HTMLElement)) return false; + return classicHud.classList.contains("quake-hud-damage") === active; + }, active, { timeout: timeoutMs }); + return await readHudFeedback(client); +} + +async function readHudFeedback(client) { + return await client.page.evaluate(() => { + const overlay = document.querySelector("#quake-damage-overlay"); + const classicHud = document.querySelector("#quake-classic-hud"); + return { + bodyDead: document.body.classList.contains("quake-dead"), + classicHudDamage: classicHud instanceof HTMLElement + ? classicHud.classList.contains("quake-hud-damage") + : null, + damageOverlayActive: overlay instanceof HTMLElement + ? overlay.classList.contains("quake-damage-overlay-active") + : null, + }; + }); +} + +async function waitForNoRemoteProjectileDom(client, projectileId, timeoutMs) { + await client.page.waitForFunction((projectileId) => { + const element = document.querySelector(`.remote-projectile[data-projectile-id="${CSS.escape(projectileId)}"]`); + const stats = window.__cssQuakeDebug?.stats?.(); + return !element && (stats?.multiplayer?.remoteProjectileDomCount ?? 0) === 0; + }, projectileId, { timeout: timeoutMs }); +} + +async function waitForExplosionSprite(client, timeoutMs) { + const started = Date.now(); + let lastSample = null; + while (Date.now() - started < timeoutMs) { + lastSample = await client.page.evaluate(() => { + const sprites = Array.from(document.querySelectorAll(".quake-effect-sprite")) + .filter((element) => { + if (!(element instanceof HTMLElement)) return false; + const opacity = Number(element.style.opacity || window.getComputedStyle(element).opacity || "0"); + return opacity > 0.01; + }) + .map((element) => ({ + className: element.className, + opacity: element instanceof HTMLElement + ? Number(element.style.opacity || window.getComputedStyle(element).opacity || "0") + : 0, + })); + return { + count: sprites.length, + sprites, + }; + }); + if (lastSample.count > 0) return lastSample; + await sleep(25); + } + return lastSample; +} + async function waitForRemoteFramePrefix(client, remoteClientId, prefix, timeoutMs) { + return waitForRemoteFramePrefixes(client, remoteClientId, [prefix], timeoutMs); +} + +async function waitForRemoteFramePrefixes(client, remoteClientId, prefixes, timeoutMs) { const started = Date.now(); + const expectedPrefixes = prefixes.length ? prefixes : [""]; 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) + expectedPrefixes.some((prefix) => String(sample.frameName ?? "").startsWith(prefix)) )) { return true; } @@ -628,6 +2948,22 @@ async function waitForRemoteFramePrefix(client, remoteClientId, prefix, timeoutM return false; } +async function clearRemoteFrameSamplesForAll(clients) { + await Promise.all(clients.map((client) => clearRemoteFrameSamples(client))); +} + +async function clearRemoteFrameSamples(client) { + client.afterRemoteFrames = []; + await client.page.evaluate(() => { + const trace = window.__cssQuakeMpDeepTrace; + if (trace) trace.remoteFrames = []; + }); +} + +function remoteAttackFramePrefixesForWeapon(weapon) { + return REMOTE_ATTACK_FRAME_PREFIXES_BY_WEAPON[weapon] ?? []; +} + async function sampleRemoteFrames(client) { return await client.page.evaluate(() => { const trace = window.__cssQuakeMpDeepTrace; @@ -637,9 +2973,18 @@ async function sampleRemoteFrames(client) { sampledAt: performance.now(), playerId: element.dataset.playerId ?? null, clientId: element.dataset.clientId ?? null, + alive: element.dataset.remoteAlive ?? null, + appliedRotY: element.dataset.remoteAppliedRotY ?? null, + lastAttackAt: element.dataset.remoteLastAttackAt ?? null, + lastPainAt: element.dataset.remoteLastPainAt ?? null, hidden: element instanceof HTMLElement ? element.hidden : false, frameIndex: element.dataset.remoteFrameIndex ?? null, frameName: element.dataset.remoteFrameName ?? null, + origin: element.dataset.remoteOrigin ?? null, + renderAt: element.dataset.remoteRenderAt ?? null, + renderRotY: element.dataset.remoteRenderRotY ?? null, + stale: element.dataset.remoteStale ?? null, + visualRotY: element.dataset.remoteVisualRotY ?? null, }); } if (trace.remoteFrames.length > 500) trace.remoteFrames.splice(0, trace.remoteFrames.length - 500); @@ -647,6 +2992,54 @@ async function sampleRemoteFrames(client) { }); } +async function waitForRemoteVisualPose(client, remoteClientId, expectedRotY, timeoutMs) { + await client.page.waitForFunction(({ expectedRotY, remoteClientId, rotEpsilon }) => { + const element = document.querySelector(`[data-client-id="${CSS.escape(remoteClientId)}"]`); + if (!(element instanceof HTMLElement) || element.hidden) return false; + const visualRotY = Number(element.dataset.remoteVisualRotY); + const renderRotY = Number(element.dataset.remoteRenderRotY); + const alive = element.dataset.remoteAlive === "true"; + const stale = element.dataset.remoteStale === "true"; + const angleDelta = (left, right) => + Math.abs(((Number(left) - Number(right) + 540) % 360) - 180); + return alive && + !stale && + Number.isFinite(visualRotY) && + Number.isFinite(renderRotY) && + angleDelta(visualRotY, expectedRotY) <= rotEpsilon && + angleDelta(renderRotY, expectedRotY) <= rotEpsilon; + }, { + expectedRotY, + remoteClientId, + rotEpsilon: REMOTE_POSE_ROT_EPSILON, + }, { timeout: timeoutMs }); + return await readRemoteVisualPose(client, remoteClientId); +} + +async function readRemoteVisualPose(client, remoteClientId) { + return await client.page.evaluate((remoteClientId) => { + const element = document.querySelector(`[data-client-id="${CSS.escape(remoteClientId)}"]`); + return element instanceof HTMLElement + ? { + alive: element.dataset.remoteAlive ?? null, + appliedRotY: element.dataset.remoteAppliedRotY ?? null, + clientId: element.dataset.clientId ?? null, + frameIndex: element.dataset.remoteFrameIndex ?? null, + frameName: element.dataset.remoteFrameName ?? null, + hidden: element.hidden, + lastAttackAt: element.dataset.remoteLastAttackAt ?? null, + lastPainAt: element.dataset.remoteLastPainAt ?? null, + origin: element.dataset.remoteOrigin ?? null, + playerId: element.dataset.playerId ?? null, + renderAt: element.dataset.remoteRenderAt ?? null, + renderRotY: element.dataset.remoteRenderRotY ?? null, + stale: element.dataset.remoteStale ?? null, + visualRotY: element.dataset.remoteVisualRotY ?? null, + } + : null; + }, remoteClientId); +} + async function waitForImpactParticles(client, expectedKind, timeoutMs) { const started = Date.now(); let lastSample = null; @@ -696,13 +3089,42 @@ async function readClientSnapshot(client) { .map((element) => ({ playerId: element.dataset.playerId ?? null, clientId: element.dataset.clientId ?? null, + alive: element.dataset.remoteAlive ?? null, + appliedRotY: element.dataset.remoteAppliedRotY ?? null, frameIndex: element.dataset.remoteFrameIndex ?? null, frameName: element.dataset.remoteFrameName ?? null, hidden: element instanceof HTMLElement ? element.hidden : false, + lastAttackAt: element.dataset.remoteLastAttackAt ?? null, + lastPainAt: element.dataset.remoteLastPainAt ?? null, + origin: element.dataset.remoteOrigin ?? null, + renderAt: element.dataset.remoteRenderAt ?? null, + renderRotY: element.dataset.remoteRenderRotY ?? null, + stale: element.dataset.remoteStale ?? null, + visualRotY: element.dataset.remoteVisualRotY ?? null, + })); + const remoteProjectiles = Array.from(document.querySelectorAll(".remote-projectile")) + .map((element) => ({ + className: element.className, + hidden: element instanceof HTMLElement ? element.hidden : false, + ownerPlayerId: element.dataset.ownerPlayerId ?? null, + projectileId: element.dataset.projectileId ?? null, + origin: element.dataset.remoteProjectileOrigin ?? null, })); + const damageOverlay = document.querySelector("#quake-damage-overlay"); + const classicHud = document.querySelector("#quake-classic-hud"); return { + hud: { + bodyDead: document.body.classList.contains("quake-dead"), + classicHudDamage: classicHud instanceof HTMLElement + ? classicHud.classList.contains("quake-hud-damage") + : null, + damageOverlayActive: damageOverlay instanceof HTMLElement + ? damageOverlay.classList.contains("quake-damage-overlay-active") + : null, + }, stats, remotePlayers, + remoteProjectiles, trace: { events: trace.events ?? [], lastSnapshot: trace.lastSnapshot ?? null, @@ -717,6 +3139,148 @@ async function readClientSnapshot(client) { }); } +async function readWorldInteractionSnapshot(client, testCase) { + return await client.page.evaluate((testCase) => { + const stats = window.__cssQuakeDebug?.stats?.() ?? null; + const roomEvents = window.__cssQuakeMpDeepTrace?.roomEvents ?? []; + const mover = stats?.movers?.movers?.find((candidate) => candidate.entityIndex === testCase.doorEntity) ?? null; + const triggerEvent = roomEvents.find((event) => + event.eventType === "world.trigger" && + event.entityIndex === testCase.triggerEntity + ) ?? null; + const targetsEvent = roomEvents.find((event) => + event.eventType === "world.targets" && + event.sourceEntityIndex === testCase.triggerEntity + ) ?? null; + const moverEvent = roomEvents.find((event) => + event.eventType === "world.mover" && + event.entityIndex === testCase.doorEntity + ) ?? null; + return { + clientId: stats?.multiplayer?.clientId ?? null, + helloAccepted: stats?.multiplayer?.helloAccepted ?? null, + inputPaused: stats?.multiplayer?.inputPaused ?? null, + inputSequence: stats?.multiplayer?.inputSequence ?? null, + lastReject: stats?.multiplayer?.lastReject ?? null, + mover, + moverEvent, + origin: stats?.origin ?? null, + recentWorldEvents: stats?.multiplayer?.recentWorldEvents ?? [], + scoreboardRows: stats?.multiplayer?.scoreboardRows ?? null, + sentWorldMessages: (window.__cssQuakeMpDeepTrace?.sent ?? []) + .filter((message) => message.type === "client.world"), + sessionState: stats?.multiplayer?.sessionState ?? null, + targetsEvent, + tracePlayerCount: window.__cssQuakeMpDeepTrace?.lastSnapshot?.players?.length ?? null, + triggerEvent, + worldSequence: stats?.multiplayer?.worldSequence ?? null, + }; + }, testCase); +} + +async function safeReadWorldInteractionSnapshots(clients, testCase) { + const snapshots = []; + for (const client of clients) { + try { + snapshots.push(await readWorldInteractionSnapshot(client, testCase)); + } catch (error) { + snapshots.push({ + clientId: null, + error: errorMessage(error), + inputPaused: null, + inputSequence: null, + mover: null, + moverEvent: null, + origin: null, + recentWorldEvents: [], + sentWorldMessages: [], + targetsEvent: null, + triggerEvent: null, + worldSequence: null, + }); + } + } + return snapshots; +} + +async function driveSpawnEscapeSample(client, startPlayer, timeoutMs) { + const startedAt = Date.now(); + const startOrigin = startPlayer.origin; + let maxLocalHorizontalDistance = 0; + let maxAuthoritativeHorizontalDistance = 0; + const samples = []; + await client.page.evaluate(() => { + const debug = window.__cssQuakeDebug; + debug?.setWeapon?.("axe"); + debug?.setMultiplayerInputPaused?.(false); + const host = document.querySelector("#quake-app [tabindex='0']"); + if (host instanceof HTMLElement) host.focus({ preventScroll: true }); + }); + await waitForLocalInput(client, timeoutMs); + try { + for (const key of SPAWN_ESCAPE_KEYS) { + await client.page.keyboard.down(key); + await client.page.waitForTimeout(180); + const sample = await readSpawnEscapeMovementSample(client, startOrigin, startPlayer.playerId); + samples.push({ key, ...sample }); + maxLocalHorizontalDistance = Math.max(maxLocalHorizontalDistance, sample.localHorizontalDistance); + maxAuthoritativeHorizontalDistance = Math.max( + maxAuthoritativeHorizontalDistance, + sample.authoritativeHorizontalDistance, + ); + await client.page.keyboard.up(key); + await client.page.waitForTimeout(80); + } + await client.page.waitForTimeout(400); + const finalSample = await readSpawnEscapeMovementSample(client, startOrigin, startPlayer.playerId); + samples.push({ key: "settle", ...finalSample }); + maxLocalHorizontalDistance = Math.max(maxLocalHorizontalDistance, finalSample.localHorizontalDistance); + maxAuthoritativeHorizontalDistance = Math.max( + maxAuthoritativeHorizontalDistance, + finalSample.authoritativeHorizontalDistance, + ); + } finally { + await Promise.all(SPAWN_ESCAPE_KEYS.map((key) => client.page.keyboard.up(key).catch(() => undefined))); + } + return { + durationMs: Date.now() - startedAt, + maxLocalHorizontalDistance, + maxAuthoritativeHorizontalDistance, + samples, + }; +} + +async function readSpawnEscapeMovementSample(client, startOrigin, playerId) { + return await client.page.evaluate(({ playerId, startOrigin }) => { + const horizontalDistance = (left, right) => { + if (!Array.isArray(left) || !Array.isArray(right) || left.length < 2 || right.length < 2) return 0; + return Math.hypot(Number(left[0]) - Number(right[0]), Number(left[1]) - Number(right[1])); + }; + const stats = window.__cssQuakeDebug?.stats?.() ?? null; + const player = (window.__cssQuakeMpDeepTrace?.lastSnapshot?.players ?? []) + .find((candidate) => candidate.playerId === playerId) ?? null; + const localOrigin = stats?.origin ?? null; + const authoritativeOrigin = player?.origin ?? null; + return { + authoritativeHorizontalDistance: horizontalDistance(authoritativeOrigin, startOrigin), + authoritativeOrigin, + authoritativePlayer: player + ? { + alive: player.alive, + health: player.health, + inputSequence: player.inputSequence ?? player.lastInputSequence ?? null, + origin: player.origin, + spawnId: player.spawnId ?? null, + } + : null, + inputSequence: stats?.multiplayer?.inputSequence ?? null, + localHorizontalDistance: horizontalDistance(localOrigin, startOrigin), + localOrigin, + playerHealth: stats?.playerHealth ?? null, + }; + }, { playerId, startOrigin }); +} + async function safeReadClientSnapshots(clients) { const snapshots = []; for (const client of clients) { @@ -724,8 +3288,14 @@ async function safeReadClientSnapshots(clients) { snapshots.push(await readClientSnapshot(client)); } catch (error) { snapshots.push({ + hud: { + bodyDead: null, + classicHudDamage: null, + damageOverlayActive: null, + }, stats: null, remotePlayers: [], + remoteProjectiles: [], trace: { events: [], lastSnapshot: null, @@ -743,6 +3313,19 @@ async function safeReadClientSnapshots(clients) { return snapshots; } +function worldInteractionCaseForPage(testCase) { + return { + doorEntity: testCase.doorEntity, + expectedDoorClassname: testCase.expectedDoorClassname, + expectedDoorTriggeredModes: [...testCase.expectedDoorTriggeredModes], + expectedTriggerClassname: testCase.expectedTriggerClassname, + inside: { ...testCase.inside }, + label: testCase.label, + targetname: testCase.targetname, + triggerEntity: testCase.triggerEntity, + }; +} + function errorMessage(error) { return error instanceof Error ? error.message : String(error); } @@ -815,7 +3398,15 @@ function installMultiplayerTrace() { compact.payload = { activeWeapon: payload?.input?.activeWeapon ?? null, clientId: payload?.clientId ?? null, - inputSequence: payload?.inputSequence ?? null, + inputSequence: payload?.input?.inputSequence ?? null, + }; + } else if (message.type === "client.inputBatch") { + compact.payload = { + clientId: payload?.clientId ?? null, + inputCount: Array.isArray(payload?.inputs) ? payload.inputs.length : 0, + inputSequences: Array.isArray(payload?.inputs) + ? payload.inputs.map((input) => input?.inputSequence ?? null) + : [], }; } else if (message.type === "room.event") { compact.event = payload?.event ?? null; @@ -834,6 +3425,15 @@ function installMultiplayerTrace() { weapon: player.activeWeapon ?? player.inventory?.activeWeapon ?? null, })) : []; + compact.projectiles = Array.isArray(payload?.projectiles) + ? payload.projectiles.map((projectile) => ({ + origin: projectile.origin, + ownerPlayerId: projectile.ownerPlayerId, + projectileId: projectile.projectileId, + updatedAt: projectile.updatedAt, + weapon: projectile.weapon, + })) + : []; } return compact; } @@ -889,9 +3489,39 @@ function printSummary(report, artifact) { 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))}`); + console.log(`damage ${check.direction} ${check.weapon}: ${check.pass ? "pass" : "fail"} damage=${check.event?.damage ?? "n/a"} health=${check.event?.health ?? "n/a"} decision=${lastFireDecisionSummary(check)} victimFrames=${compactCounts(countAll(check.remoteAnimation.names))} attackerFrames=${compactCounts(countAll(check.remoteAttackAnimation?.names ?? []))}`); + } else if (check.kind === "map-readiness") { + const failedMaps = (check.maps ?? []).filter((sample) => !sample.pass).map((sample) => sample.mapName); + console.log(`map readiness: ${check.pass ? "pass" : "fail"} maps=${check.passedMapCount}/${check.mapCount}${failedMaps.length ? ` failed=${failedMaps.join(",")}` : ""}`); + } else if (check.kind === "controlled-sustained-damage") { + console.log(`sustained ${check.direction} ${check.weapon}: ${check.pass ? "pass" : "fail"} healths=${check.events.map((event) => event.health).join(",") || "n/a"} fires=${check.fireResults.map(String).join(",")} 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 if (check.kind === "controlled-respawn") { + console.log(`respawn ${check.weapon}: ${check.pass ? "pass" : "fail"} respawned=${check.respawnEvent ? "yes" : "no"} postDamage=${check.postRespawnDamage?.health ?? "n/a"} deathFrames=${compactCounts(countAll(check.deathAnimation.names))} postFrames=${compactCounts(countAll(check.postAnimation.names))}`); + } else if (check.kind === "controlled-projectile") { + console.log(`projectile ${check.weapon}: ${check.pass ? "pass" : "fail"} spawned=${check.spawned ? "yes" : "no"} visible=${check.visibleProjectile ? "yes" : "no"} moved=${check.movedProjectile ? "yes" : "no"} impact=${check.impacted?.impactKind ?? "n/a"} victimHealth=${check.after?.health ?? "n/a"} attackerHealth=${check.afterAttacker?.health ?? "n/a"} explosion=${check.explosionSprite ? "yes" : "no"} attackerFrames=${compactCounts(countAll(check.remoteAttackAnimation?.names ?? []))}`); + } else if (check.kind === "shared-pickup") { + console.log(`shared pickup ${check.weapon}: ${check.pass ? "pass" : "fail"} entity=${check.pickup?.pickupEntityIndex ?? "n/a"} taken=${check.takenEvents?.length ?? 0} duplicateReject=${check.duplicateRejected?.reason ?? "n/a"}`); + } else if (check.kind === "local-world-mutation") { + console.log(`local world mutation: ${check.pass ? "pass" : "fail"} entity=${check.target?.entityIndex ?? "n/a"} health=${check.target?.health ?? "n/a"} impact=${check.impact?.impactResult ?? "n/a"}`); + } else if (check.kind === "world-interaction") { + const mover = check.after?.[0]?.mover; + console.log(`world interaction ${check.mapName}: ${check.pass ? "pass" : "fail"} trigger=${check.prepared?.trigger?.entityIndex ?? "n/a"} mover=${mover?.entityIndex ?? check.prepared?.door?.entityIndex ?? "n/a"} mode=${mover?.mode ?? "n/a"}`); + } else if (check.kind === "spawn-escape") { + const maxLocal = Math.max(...(check.samples ?? []).map((sample) => + sample.movement?.maxLocalHorizontalDistance ?? 0 + )); + const maxAuthoritative = Math.max(...(check.samples ?? []).map((sample) => + sample.movement?.maxAuthoritativeHorizontalDistance ?? 0 + )); + console.log(`spawn escape ${check.mapName}: ${check.pass ? "pass" : "fail"} samples=${check.sampleCount ?? 0} uniqueSpawns=${check.uniqueSpawnIds?.length ?? 0} maxLocal=${maxLocal.toFixed(3)} maxRoom=${maxAuthoritative.toFixed(3)}`); + } else if (check.kind === "room-lifecycle") { + const overflow = check.snapshots?.at(-1)?.multiplayer?.lastReject?.code ?? "n/a"; + console.log(`room lifecycle: ${check.pass ? "pass" : "fail"} players=${check.maxPlayers} spectators=${check.spectatorSlots} overflow=${overflow}`); + } else if (check.kind === "wrong-map") { + const reject = check.snapshots?.[1]?.multiplayer?.lastReject?.code ?? "n/a"; + console.log(`wrong-map ${check.hostMapName}->${check.wrongMapName}: ${check.pass ? "pass" : "fail"} reject=${reject}`); } else { console.log(`${check.kind}: ${check.pass ? "pass" : "fail"}`); } @@ -900,6 +3530,38 @@ function printSummary(report, artifact) { if (artifact) console.log(`artifact: ${artifact}`); } +function lastFireDecisionSummary(check) { + const events = [ + ...(check.after?.playerEvents ?? []), + ...(check.afterAttacker?.playerEvents ?? []), + ]; + const fired = events.findLast((event) => + event?.eventType === "player.fired" && + event?.weapon === check.weapon + ); + const decision = fired?.decision; + return decision ? `${decision.outcome}:${decision.reason}` : "n/a"; +} + +function findPlayerDamageOutcome(snapshot, expected) { + const events = [ + ...(snapshot.trace?.playerEvents ?? []), + ...(snapshot.trace?.roomEvents ?? []), + ...(snapshot.playerEvents ?? []), + ...(snapshot.roomEvents ?? []), + ]; + return events.findLast((event) => + event?.eventType === expected.eventType && + event?.attackerPlayerId === expected.attackerPlayerId && + event?.victimPlayerId === expected.victimPlayerId && + event?.damageSource === expected.damageSource + ) ?? null; +} + +function snapshotPlayer(snapshot, playerId) { + return snapshot.trace?.lastSnapshot?.players?.find((player) => player.playerId === playerId) ?? null; +} + function compactClient(client) { return { index: client.index, @@ -909,19 +3571,48 @@ function compactClient(client) { }; } +function compactSpawnEscapePlayer(player) { + if (!player) return null; + return { + alive: player.alive, + clientId: player.clientId, + health: player.health, + origin: player.origin, + playerId: player.playerId, + spawnId: player.spawnId ?? null, + }; +} + function compactSnapshot(snapshot) { return { clientId: snapshot.stats?.multiplayer?.clientId ?? null, health: snapshot.stats?.playerHealth ?? null, multiplayer: { + helloAccepted: snapshot.stats?.multiplayer?.helloAccepted ?? null, inputPaused: snapshot.stats?.multiplayer?.inputPaused ?? null, inputSequence: snapshot.stats?.multiplayer?.inputSequence ?? null, lastReject: snapshot.stats?.multiplayer?.lastReject ?? null, remoteDomCount: snapshot.stats?.multiplayer?.remoteDomCount ?? null, + remoteProjectileCount: snapshot.stats?.multiplayer?.remoteProjectileCount ?? null, + remoteProjectileDomCount: snapshot.stats?.multiplayer?.remoteProjectileDomCount ?? null, + remoteVisibleProjectileDomCount: snapshot.stats?.multiplayer?.remoteVisibleProjectileDomCount ?? null, remoteVisibleDomCount: snapshot.stats?.multiplayer?.remoteVisibleDomCount ?? null, scoreboardRows: snapshot.stats?.multiplayer?.scoreboardRows ?? null, + sessionState: snapshot.stats?.multiplayer?.sessionState ?? null, + spectating: snapshot.stats?.multiplayer?.spectating ?? null, + spectatorCount: snapshot.stats?.multiplayer?.spectatorCount ?? null, + spectatorFollowedPlayerId: snapshot.stats?.multiplayer?.spectatorFollowedPlayerId ?? null, }, remotePlayers: snapshot.remotePlayers, + remoteProjectiles: snapshot.remoteProjectiles, + hud: snapshot.hud ?? null, + pickups: snapshot.stats?.pickups ?? null, + pickupStates: (snapshot.trace.lastSnapshot?.pickups ?? []).map((pickup) => ({ + entityIndex: pickup.entityIndex, + available: pickup.available, + respawnAt: pickup.respawnAt ?? null, + ownerPlayerIds: pickup.ownerPlayerIds ?? [], + })), playerEvents: snapshot.trace.playerEvents, received: snapshot.trace.received.slice(-20), rejects: snapshot.trace.rejects, @@ -1011,6 +3702,25 @@ function createRoomToken(length = 8) { return token; } +function wrongMapProbeMap(mapName) { + return String(mapName).trim().toLowerCase() === "e1m1" ? "e1m7" : "e1m1"; +} + +function createDeepRoomName(...parts) { + const token = createRoomToken(8); + const label = parts.map(compactRoomNamePart).filter(Boolean).join("-"); + return [`d${token}`, label].filter(Boolean).join("-").slice(0, DEBUG_ROOM_ID_MAX_LENGTH); +} + +function compactRoomNamePart(value) { + return String(value ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 8); +} + function colorForClient(index) { const colors = ["#f2a94b", "#4ba3ff", "#78d66b", "#e66b91"]; return colors[index % colors.length]; diff --git a/test/gameplay/weaponImpactParticles.test.mjs b/test/gameplay/weaponImpactParticles.test.mjs index 9808461..9f70e7d 100644 --- a/test/gameplay/weaponImpactParticles.test.mjs +++ b/test/gameplay/weaponImpactParticles.test.mjs @@ -34,10 +34,12 @@ function createWeaponsHarness({ collisionWorld = null, damageShootable = null, entities = null, + playerWaterLevel = 0, shootables, }) { const damageCalls = []; const explosionImpacts = []; + const fireEvents = []; const impacts = []; const wallImpacts = []; let hits = 0; @@ -61,11 +63,12 @@ function createWeaponsHarness({ getCollisionWorld: () => collisionWorld, getEntities: () => entitiesByIndex, getPlayerEyeHeight: () => 1.7, - getPlayerWaterLevel: () => 0, + getPlayerWaterLevel: () => playerWaterLevel, getShootables: () => shootables, hasViewmodel: () => true, onDamageImpact: (event) => { impacts.push(event); }, onExplosionImpact: (event) => { explosionImpacts.push(event); }, + onFire: (event) => { fireEvents.push(event); }, onHit: () => { hits += 1; }, onWallImpact: (event) => { wallImpacts.push(event); }, playFireAnimation: () => undefined, @@ -83,7 +86,7 @@ function createWeaponsHarness({ syncCrosshairTarget: () => undefined, syncHud: () => undefined, }); - return { damageCalls, explosionImpacts, impacts, hits: () => hits, wallImpacts, weapons }; + return { damageCalls, explosionImpacts, fireEvents, impacts, hits: () => hits, wallImpacts, weapons }; } function createExploboxEntity(index) { @@ -229,6 +232,37 @@ test("hitscan wall traces emit one aggregated gunshot wall-impact event", () => assert.equal(wallImpacts[0].weapon, "shotgun"); }); +test("projectile fire event uses the actual projectile spawn origin", () => { + const { fireEvents, weapons } = createWeaponsHarness({ + activeWeapon: "rocketlauncher", + shootables: [], + }); + weapons.debugSetProjectileCaptureEnabled(true); + + assert.equal(weapons.fire(1000), true); + + const fireCapture = weapons.debugProjectileCapture().events.find((event) => event.type === "fire"); + assert.ok(fireCapture); + assert.equal(fireEvents.length, 1); + assert.deepEqual(fireEvents[0].origin, fireCapture.origin); + assert.deepEqual(fireEvents[0].direction, fireCapture.direction); + assert.equal(fireEvents[0].weapon, "rocketlauncher"); +}); + +test("underwater lightning discharge emits a multiplayer fire event", () => { + const { fireEvents, weapons } = createWeaponsHarness({ + activeWeapon: "lightning", + playerWaterLevel: 2, + shootables: [], + }); + + assert.equal(weapons.fire(1000), true); + + assert.equal(fireEvents.length, 1); + assert.equal(fireEvents[0].weapon, "lightning"); + assert.equal(fireEvents[0].fireKind, "beam"); +}); + test("projectile splash-only damage emits explosion but not damage-impact events", () => { const { damageCalls, explosionImpacts, impacts, hits, wallImpacts, weapons } = createWeaponsHarness({ shootables: [ diff --git a/test/multiplayer/deathmatch.test.mjs b/test/multiplayer/deathmatch.test.mjs new file mode 100644 index 0000000..91ad2ca --- /dev/null +++ b/test/multiplayer/deathmatch.test.mjs @@ -0,0 +1,663 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { importTsModule } from "../importTsModule.mjs"; + +const deathmatch = await importTsModule("src/runtime/multiplayer/deathmatch.ts"); +const constants = await importTsModule("src/runtime/constants.ts"); + +const liveE1m7FollowupFire = { + weapon: "shotgun", + fireKind: "hitscan", + origin: [23.04, -8.48, 5.400625], + direction: [-0.9972888515115433, 0, -0.07358632108474347], + range: 64, +}; + +const liveE1m7FollowupHit = { + target: { + playerId: "party:client-prod-dyn-2", + clientId: "client-prod-dyn-2", + displayName: "Prod Dyn 2", + mapName: "e1m7", + origin: [10.383999999999983, -8.48, 5.400625], + velocity: [0, 0, 0], + rotX: 90, + rotY: 270, + health: 52, + armor: 0, + activeWeapon: "shotgun", + alive: true, + frags: 0, + deaths: 0, + lastInputSequence: 156, + updatedAt: 1782175462970, + }, + damage: 24, + distance: 12.684236077652141, + impact: [10.390152769817012, -8.48, 4.467238731275202], + lateralMiss: 0.08361295586370313, +}; + +const duelForwardDirection = [0.9781476007338057, 0, -0.20791169081775934]; + +test("frag delta matches Quake obituary scoring for player and world kills", () => { + assert.equal( + deathmatch.quakeMultiplayerDeathmatchFragDeltaForKill({ + attackerPlayerId: "attacker", + victimPlayerId: "victim", + }), + 1, + ); + assert.equal( + deathmatch.quakeMultiplayerDeathmatchFragDeltaForKill({ + attackerPlayerId: "victim", + victimPlayerId: "victim", + }), + -1, + ); + assert.equal( + deathmatch.quakeMultiplayerDeathmatchFragDeltaForKill({ + victimPlayerId: "victim", + }), + -1, + ); +}); + +test("deathmatch spawn selection skips spawn points with nearby players", () => { + const spawns = [ + { spawnId: "spawn-a", classname: "info_player_deathmatch", origin: [0, 0, 0], rotX: 90, rotY: 0 }, + { spawnId: "spawn-b", classname: "info_player_deathmatch", origin: [0.5, 0, 0], rotX: 90, rotY: 90 }, + { spawnId: "spawn-c", classname: "info_player_deathmatch", origin: [8, 0, 0], rotX: 90, rotY: 180 }, + ]; + + const selection = deathmatch.quakeMultiplayerDeathmatchSelectSpawnPoint( + spawns, + [playerFixture({ origin: [0, 0, 0] })], + { random: () => 0 }, + ); + + assert.equal(selection?.spawn.spawnId, "spawn-c"); + assert.equal(selection?.nextCursor, 3); +}); + +test("deathmatch spawn selection randomly chooses among source-reversed clear spots", () => { + const spawns = [ + { spawnId: "spawn-a-occupied", classname: "info_player_deathmatch", origin: [0, 0, 0], rotX: 90, rotY: 0 }, + { spawnId: "spawn-b-clear", classname: "info_player_deathmatch", origin: [8, 0, 0], rotX: 90, rotY: 90 }, + { spawnId: "spawn-c-clear", classname: "info_player_deathmatch", origin: [16, 0, 0], rotX: 90, rotY: 180 }, + ]; + + const firstClear = deathmatch.quakeMultiplayerDeathmatchSelectSpawnPoint( + spawns, + [playerFixture({ origin: [0, 0, 0] })], + { random: () => 0 }, + ); + const lastClear = deathmatch.quakeMultiplayerDeathmatchSelectSpawnPoint( + spawns, + [playerFixture({ origin: [0, 0, 0] })], + { random: () => 0.999999 }, + ); + + assert.equal(firstClear?.spawn.spawnId, "spawn-c-clear"); + assert.equal(firstClear?.nextCursor, 3); + assert.equal(lastClear?.spawn.spawnId, "spawn-b-clear"); + assert.equal(lastClear?.nextCursor, 2); +}); + +test("deathmatch spawn selection falls back to a random occupied spot when all points are occupied", () => { + const spawns = [ + { spawnId: "spawn-a", classname: "info_player_deathmatch", origin: [0, 0, 0], rotX: 90, rotY: 0 }, + { spawnId: "spawn-b", classname: "info_player_deathmatch", origin: [0.5, 0, 0], rotX: 90, rotY: 90 }, + ]; + + const selection = deathmatch.quakeMultiplayerDeathmatchSelectSpawnPoint( + spawns, + [playerFixture({ origin: [0, 0, 0] }), playerFixture({ origin: [2, 0, 0] })], + { random: () => 0.999999 }, + ); + + assert.equal(selection?.spawn.spawnId, "spawn-b"); + assert.equal(selection?.nextCursor, 2); +}); + +test("direct player hit accepts a late brush trace inside the target hit skin", () => { + const collisionWorld = { + traceUse: () => ({ + fraction: 0.9856583826296409, + end: [10.571572038585794, -8.48, 4.480625000000001], + planeNormal: [0, 0, 1], + entityIndex: 84, + modelIndex: 3, + classname: "func_wall", + }), + }; + + assert.equal( + deathmatch.quakeMultiplayerDeathmatchHitHasLineOfSight( + liveE1m7FollowupFire, + liveE1m7FollowupHit, + collisionWorld, + ), + true, + ); +}); + +test("direct player hit still rejects a wall trace before the target skin", () => { + const collisionWorld = { + traceUse: () => ({ + fraction: 0.5, + end: [16.7, -8.48, 4.93], + planeNormal: [1, 0, 0], + entityIndex: 900, + modelIndex: 9, + classname: "func_wall", + }), + }; + + assert.equal( + deathmatch.quakeMultiplayerDeathmatchHitHasLineOfSight( + liveE1m7FollowupFire, + liveE1m7FollowupHit, + collisionWorld, + ), + false, + ); +}); + +test("projectile direct player hit uses projectile target skin for late LOS traces", () => { + const collisionWorld = { + traceUse: () => ({ + fraction: 0.99, + end: [11.25, -8.48, 4.467238731275202], + planeNormal: [0, 0, 1], + entityIndex: 84, + modelIndex: 3, + classname: "func_wall", + }), + }; + + assert.equal( + deathmatch.quakeMultiplayerDeathmatchHitHasLineOfSight( + { ...liveE1m7FollowupFire, weapon: "rocketlauncher", fireKind: "projectile" }, + liveE1m7FollowupHit, + collisionWorld, + ), + true, + ); +}); + +test("projectile direct player hit still rejects traces outside projectile target skin", () => { + const collisionWorld = { + traceUse: () => ({ + fraction: 0.99, + end: [11.6, -8.48, 4.467238731275202], + planeNormal: [0, 0, 1], + entityIndex: 84, + modelIndex: 3, + classname: "func_wall", + }), + }; + + assert.equal( + deathmatch.quakeMultiplayerDeathmatchHitHasLineOfSight( + { ...liveE1m7FollowupFire, weapon: "rocketlauncher", fireKind: "projectile" }, + liveE1m7FollowupHit, + collisionWorld, + ), + false, + ); +}); + +function playerFixture(overrides = {}) { + return { + playerId: "player", + clientId: "client", + displayName: "Player", + mapName: "e1m7", + origin: [0, 0, 0], + velocity: [0, 0, 0], + rotX: 90, + rotY: 270, + health: 100, + armor: 0, + activeWeapon: "shotgun", + alive: true, + frags: 0, + deaths: 0, + lastInputSequence: 0, + updatedAt: 0, + ...overrides, + }; +} + +test("authoritative fire keeps server weapon and uses bounded submitted aim/origin hints", () => { + const player = playerFixture({ + activeWeapon: "shotgun", + origin: [5, 1, 2], + rotX: -78, + rotY: 180, + }); + const fire = deathmatch.quakeMultiplayerDeathmatchFireFromPlayer(player, { + weapon: "rocketlauncher", + fireKind: "projectile", + origin: [5.5, 1.25, 2], + direction: duelForwardDirection, + range: 1, + }); + + assert.equal(fire.weapon, "shotgun"); + assert.equal(fire.fireKind, "hitscan"); + assert.deepEqual(fire.origin, [5.5, 1.25, 2]); + assert.deepEqual(fire.direction, duelForwardDirection); + assert.equal(fire.range, 2048 * constants.QUAKE_COLLISION_UNIT_SCALE); +}); + +test("authoritative fire rejects a forged far submitted origin", () => { + const player = playerFixture({ + activeWeapon: "shotgun", + origin: [5, 1, 2], + rotX: -78, + rotY: 180, + }); + const fire = deathmatch.quakeMultiplayerDeathmatchFireFromPlayer(player, { + weapon: "shotgun", + fireKind: "hitscan", + origin: [20, 1, 2], + direction: duelForwardDirection, + range: 64, + }); + + assert.deepEqual(fire.origin, [5, 1, 2]); + assert.deepEqual(fire.direction, duelForwardDirection); +}); + +test("weapon cooldowns keep source timings distinct", () => { + assert.equal(deathmatch.quakeMultiplayerDeathmatchWeaponCooldownMs("nailgun"), 200); + assert.equal(deathmatch.quakeMultiplayerDeathmatchWeaponCooldownMs("supernailgun"), 200); + assert.equal(deathmatch.quakeMultiplayerDeathmatchWeaponCooldownMs("grenadelauncher"), 600); + assert.equal(deathmatch.quakeMultiplayerDeathmatchWeaponCooldownMs("rocketlauncher"), 800); + assert.equal(deathmatch.quakeMultiplayerDeathmatchWeaponCooldownMs("lightning"), 200); +}); + +test("authoritative fire kinds and ranges stay source-shaped per weapon", () => { + assert.equal(deathmatch.quakeMultiplayerDeathmatchFireKindForWeapon("shotgun"), "hitscan"); + assert.equal(deathmatch.quakeMultiplayerDeathmatchFireKindForWeapon("nailgun"), "projectile"); + assert.equal(deathmatch.quakeMultiplayerDeathmatchFireKindForWeapon("supernailgun"), "projectile"); + assert.equal(deathmatch.quakeMultiplayerDeathmatchFireKindForWeapon("grenadelauncher"), "projectile"); + assert.equal(deathmatch.quakeMultiplayerDeathmatchFireKindForWeapon("rocketlauncher"), "projectile"); + assert.equal(deathmatch.quakeMultiplayerDeathmatchFireKindForWeapon("lightning"), "beam"); + assert.equal( + deathmatch.quakeMultiplayerDeathmatchFireRangeForWeapon("shotgun"), + 2048 * constants.QUAKE_COLLISION_UNIT_SCALE, + ); + assert.equal( + deathmatch.quakeMultiplayerDeathmatchFireRangeForWeapon("nailgun"), + 1000 * 6 * constants.QUAKE_COLLISION_UNIT_SCALE, + ); + assert.equal( + deathmatch.quakeMultiplayerDeathmatchFireRangeForWeapon("grenadelauncher"), + 600 * 2.5 * constants.QUAKE_COLLISION_UNIT_SCALE, + ); + assert.equal( + deathmatch.quakeMultiplayerDeathmatchFireRangeForWeapon("rocketlauncher"), + 1000 * 5 * constants.QUAKE_COLLISION_UNIT_SCALE, + ); + assert.equal( + deathmatch.quakeMultiplayerDeathmatchFireRangeForWeapon("lightning"), + 600 * constants.QUAKE_COLLISION_UNIT_SCALE, + ); +}); + +test("visible hit selection skips a blocked nearer player and hits a farther visible player", () => { + const fire = { + weapon: "shotgun", + fireKind: "hitscan", + origin: [0, 0, -0.85], + direction: [1, 0, 0], + range: 64, + }; + const near = playerFixture({ playerId: "near", clientId: "near-client", origin: [2, 0, 0] }); + const far = playerFixture({ playerId: "far", clientId: "far-client", origin: [4, 0, 0] }); + const collisionWorld = { + traceUse: (_origin, impact) => impact[0] < 3 + ? { + fraction: 0.5, + end: [1, 0, -0.85], + planeNormal: [1, 0, 0], + entityIndex: 33, + modelIndex: 2, + classname: "func_wall", + } + : null, + }; + + const hit = deathmatch.quakeMultiplayerDeathmatchVisibleHit( + fire, + [near, far], + "attacker", + collisionWorld, + ); + + assert.equal(hit?.target.playerId, "far"); +}); + +test("player hit detection accepts upper-body aim against the Quake player hull", () => { + const fire = { + weapon: "shotgun", + fireKind: "hitscan", + origin: [0, 0, 0], + direction: [1, 0, 0], + range: 64, + }; + const target = playerFixture({ playerId: "target", clientId: "target-client", origin: [4, 0, 0] }); + + const hit = deathmatch.quakeMultiplayerDeathmatchVisibleHit( + fire, + [target], + "attacker", + null, + ); + + assert.equal(hit?.target.playerId, "target"); +}); + +test("player hit detection rewinds moving targets to match delayed remote rendering", () => { + const fire = { + weapon: "shotgun", + fireKind: "hitscan", + origin: [0, 0, -0.36], + direction: [1, 0, 0], + range: 64, + }; + const target = playerFixture({ + playerId: "moving", + clientId: "moving-client", + origin: [4, 1.4, 0], + velocity: [0, 14, 0], + }); + + assert.equal( + deathmatch.quakeMultiplayerDeathmatchVisibleHit( + fire, + [target], + "attacker", + null, + ), + null, + ); + assert.equal( + deathmatch.quakeMultiplayerDeathmatchVisibleHit( + fire, + [target], + "attacker", + null, + { targetRewindMs: 100 }, + )?.target.playerId, + "moving", + ); +}); + +test("projectile splash uses the impact point for indirect damage and momentum origin", () => { + const fire = { + weapon: "rocketlauncher", + fireKind: "projectile", + origin: [0, 0, 0], + direction: [1, 0, 0], + range: 64, + }; + const directTarget = playerFixture({ playerId: "direct", clientId: "direct-client", origin: [3, 0, 0] }); + const indirectTarget = playerFixture({ playerId: "indirect", clientId: "indirect-client", origin: [5, 0, 0] }); + const directHit = { + target: directTarget, + damage: 120, + distance: 2, + impact: [2, 0, 0], + lateralMiss: 0, + }; + + const hits = deathmatch.quakeMultiplayerDeathmatchSplashHits( + fire, + directHit, + [directTarget, indirectTarget], + "attacker", + ); + const indirect = hits.find((hit) => hit.target.playerId === "indirect"); + + assert.equal(indirect?.damage, 44); + assert.deepEqual(indirect?.impact, [2, 0, 0]); +}); + +test("grenade direct player impact uses radius damage, not fake direct rocket damage", () => { + const fire = { + weapon: "grenadelauncher", + fireKind: "projectile", + origin: [0, 0, 0], + direction: [1, 0, 0], + range: 64, + }; + const target = playerFixture({ playerId: "target", clientId: "target-client", origin: [3, 0, 0] }); + const directHit = { + target, + damage: 120, + distance: 3, + impact: [3, 0, -0.36], + lateralMiss: 0, + }; + + const hits = deathmatch.quakeMultiplayerDeathmatchSplashHits( + fire, + directHit, + [target], + "attacker", + ); + + assert.equal(hits.length, 1); + assert.equal(hits[0].target.playerId, "target"); + assert.equal(hits[0].direct, false); + assert.equal(hits[0].damage, 120); +}); + +test("projectile splash halves attacker self damage", () => { + const fire = { + weapon: "rocketlauncher", + fireKind: "projectile", + origin: [0, 0, 0], + direction: [1, 0, 0], + range: 64, + }; + const directTarget = playerFixture({ playerId: "direct", clientId: "direct-client", origin: [3, 0, 0] }); + const attacker = playerFixture({ playerId: "attacker", clientId: "attacker-client", origin: [0, 0, 0] }); + const directHit = { + target: directTarget, + damage: 120, + distance: 2, + impact: [2, 0, 0], + lateralMiss: 0, + }; + + const hits = deathmatch.quakeMultiplayerDeathmatchSplashHits( + fire, + directHit, + [directTarget, attacker], + "attacker", + ); + const selfSplash = hits.find((hit) => hit.target.playerId === "attacker"); + + assert.equal(selfSplash?.damage, 35); + assert.equal(selfSplash?.direct, false); + assert.deepEqual(selfSplash?.impact, [2, 0, 0]); +}); + +test("projectile wall impact splash damages nearby players without a direct hit", () => { + const fire = { + weapon: "rocketlauncher", + fireKind: "projectile", + origin: [0, 0, 0], + direction: [1, 0, 0], + range: 64, + }; + const nearMissTarget = playerFixture({ + playerId: "near-miss", + clientId: "near-miss-client", + origin: [3, 2, 0], + }); + const collisionWorld = { + traceUse: (origin, point) => origin[0] === 0 && point[0] > 10 + ? { + fraction: 3 / 64, + end: [3, 0, 0], + planeNormal: [-1, 0, 0], + entityIndex: 44, + modelIndex: 3, + classname: "func_wall", + } + : null, + }; + + assert.equal( + deathmatch.quakeMultiplayerDeathmatchVisibleHit( + fire, + [nearMissTarget], + "attacker", + collisionWorld, + ), + null, + ); + + const hits = deathmatch.quakeMultiplayerDeathmatchProjectileWorldSplashHits( + fire, + [nearMissTarget], + "attacker", + collisionWorld, + ); + + assert.equal(hits.length, 1); + assert.equal(hits[0].target.playerId, "near-miss"); + assert.equal(hits[0].damage, 69); + assert.deepEqual(hits[0].impact, [3, 0, 0]); +}); + +test("projectile wall splash rewinds moving targets to match delayed remote rendering", () => { + const fire = { + weapon: "rocketlauncher", + fireKind: "projectile", + origin: [0, 0, 0], + direction: [1, 0, 0], + range: 64, + }; + const movingTarget = playerFixture({ + playerId: "moving", + clientId: "moving-client", + origin: [3, 5, 0], + velocity: [0, 30, 0], + }); + const collisionWorld = { + traceUse: (origin, point) => origin[0] === 0 && point[0] > 10 + ? { + fraction: 3 / 64, + end: [3, 0, 0], + planeNormal: [-1, 0, 0], + entityIndex: 44, + modelIndex: 3, + classname: "func_wall", + } + : null, + }; + + assert.equal( + deathmatch.quakeMultiplayerDeathmatchProjectileWorldSplashHits( + fire, + [movingTarget], + "attacker", + collisionWorld, + ).length, + 0, + ); + + const hits = deathmatch.quakeMultiplayerDeathmatchProjectileWorldSplashHits( + fire, + [movingTarget], + "attacker", + collisionWorld, + { targetRewindMs: 100 }, + ); + + assert.equal(hits.length, 1); + assert.equal(hits[0].target.playerId, "moving"); + assert.equal(hits[0].damage, 69); +}); + +test("projectile splash keeps Quake radius damage above zero near the radius edge", () => { + const fire = { + weapon: "rocketlauncher", + fireKind: "projectile", + origin: [0, 0, 0], + direction: [1, 0, 0], + range: 64, + }; + const edgeTarget = playerFixture({ + playerId: "edge", + clientId: "edge-client", + origin: [3.2, 0, 0.36], + }); + const directHit = { + target: playerFixture({ playerId: "direct", clientId: "direct-client", origin: [0, 0, 0] }), + damage: 120, + distance: 0, + impact: [0, 0, 0], + lateralMiss: 0, + }; + + const hits = deathmatch.quakeMultiplayerDeathmatchSplashHits( + fire, + directHit, + [edgeTarget], + "attacker", + ); + + assert.equal(hits[0].target.playerId, "direct"); + assert.equal(hits[1].target.playerId, "edge"); + assert.equal(hits[1].damage, 40); +}); + +test("projectile splash rejects indirect targets without radius line of sight", () => { + const fire = { + weapon: "rocketlauncher", + fireKind: "projectile", + origin: [0, 0, 0], + direction: [1, 0, 0], + range: 64, + }; + const directTarget = playerFixture({ playerId: "direct", clientId: "direct-client", origin: [2, 0, 0] }); + const blockedTarget = playerFixture({ playerId: "blocked", clientId: "blocked-client", origin: [2, 3, 0] }); + const directHit = { + target: directTarget, + damage: 120, + distance: 2, + impact: [2, 0, 0], + lateralMiss: 0, + }; + const collisionWorld = { + traceUse: (_origin, point) => point[1] > 1 + ? { + fraction: 0.4, + end: [point[0], 1, point[2]], + planeNormal: [0, -1, 0], + entityIndex: 41, + modelIndex: 3, + classname: "func_wall", + } + : null, + }; + + const hits = deathmatch.quakeMultiplayerDeathmatchSplashHits( + fire, + directHit, + [directTarget, blockedTarget], + "attacker", + collisionWorld, + ); + + assert.equal(hits.some((hit) => hit.target.playerId === "blocked"), false); +}); diff --git a/test/multiplayer/harness.mjs b/test/multiplayer/harness.mjs index 2445108..3566f09 100644 --- a/test/multiplayer/harness.mjs +++ b/test/multiplayer/harness.mjs @@ -8,6 +8,7 @@ export const loopback = await importTsModule("src/runtime/multiplayer/loopback.t export const partyRoomModule = await importTsModule("src/runtime/multiplayer/partyRoom.ts"); export const presenceRoomModule = await importTsModule("src/runtime/multiplayer/presenceRoom.ts"); export const protocol = await importTsModule("src/runtime/multiplayer/protocol.ts"); +export const projectileAuthority = await importTsModule("src/runtime/multiplayer/projectileAuthority.ts"); export const reconciliation = await importTsModule("src/runtime/multiplayer/reconciliation.ts"); export const simulation = await importTsModule("src/runtime/multiplayer/simulation.ts"); export const validation = await importTsModule("src/runtime/multiplayer/validation.ts"); @@ -26,6 +27,9 @@ export const NORMALIZED_ROOM_KEY = { mapName: "e1m1", }; +const DEFAULT_DUEL_FIRE_DIRECTION = [0.9781476007338057, 0, -0.20791169081775934]; +const DEFAULT_DUEL_REVERSE_FIRE_DIRECTION = [-0.9781476007338057, 0, -0.20791169081775934]; + export function clientEnvelope(type, payload, options = {}) { return protocol.createQuakeMultiplayerEnvelope({ direction: "client", @@ -70,16 +74,34 @@ export function inputEnvelope(options = {}) { }, options); } +export function inputBatchEnvelope(options = {}) { + const clientId = options.clientId ?? "client-a"; + const inputSequences = options.inputSequences ?? [1, 2]; + return clientEnvelope("client.inputBatch", { + clientId, + inputs: inputSequences.map((inputSequence, index) => + createInput(inputSequence, { + sampledAt: options.sampledAt ?? options.sentAt ?? 100 + index * 10, + ...(options.input ?? {}), + ...(options.inputs?.[index] ?? {}), + })), + }, options); +} + export function fireEnvelope(options = {}) { + const clientId = options.clientId ?? "client-a"; + const defaultDirection = clientId === "client-b" + ? DEFAULT_DUEL_REVERSE_FIRE_DIRECTION + : DEFAULT_DUEL_FIRE_DIRECTION; return clientEnvelope("client.fire", { - clientId: options.clientId ?? "client-a", + clientId, fire: { fireSequence: options.fireSequence ?? 1, firedAt: options.sentAt ?? 100, weapon: "shotgun", fireKind: "hitscan", origin: [0, 0, 0], - direction: [1, 0, 0], + direction: defaultDirection, range: 1024, ...(options.fire ?? {}), }, @@ -188,6 +210,7 @@ export async function createLoopbackHarness(options = {}) { const messages = []; const session = loopback.createQuakeLoopbackMultiplayerSession({ now: options.nowProvider ?? (() => now), + random: () => 0.999999, asyncDispatch: false, heartbeatIntervalMs: false, simulationTickMs: false, diff --git a/test/multiplayer/history.test.mjs b/test/multiplayer/history.test.mjs new file mode 100644 index 0000000..574b2d0 --- /dev/null +++ b/test/multiplayer/history.test.mjs @@ -0,0 +1,188 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { importTsModule } from "../importTsModule.mjs"; + +const history = await importTsModule("src/runtime/multiplayer/history.ts"); + +function playerFixture(overrides = {}) { + return { + playerId: "player", + clientId: "client", + displayName: "Player", + mapName: "e1m7", + spawnId: "spawn-a", + origin: [0, 0, 0], + velocity: [0, 0, 0], + rotX: 90, + rotY: 270, + health: 100, + armor: 0, + activeWeapon: "shotgun", + alive: true, + frags: 0, + deaths: 0, + lastInputSequence: 0, + updatedAt: 0, + ...overrides, + }; +} + +test("snapshot history interpolates player state at an authoritative room time", () => { + let roomHistory = []; + roomHistory = history.recordQuakeMultiplayerSnapshotHistory(roomHistory, { + sampledAt: 1_000, + roomTime: 0, + tick: 1, + players: [playerFixture({ origin: [0, 0, 0], velocity: [10, 0, 0], updatedAt: 1_000 })], + }); + roomHistory = history.recordQuakeMultiplayerSnapshotHistory(roomHistory, { + sampledAt: 1_100, + roomTime: 100, + tick: 2, + players: [playerFixture({ origin: [1, 0, 0], velocity: [20, 0, 0], lastInputSequence: 4, updatedAt: 1_100 })], + }); + + const resolved = history.quakeMultiplayerHistoricalPlayerAt( + roomHistory, + playerFixture({ health: 76, armor: 25, updatedAt: 2_000 }), + 1_050, + ); + + assert.deepEqual(resolved?.origin, [0.5, 0, 0]); + assert.deepEqual(resolved?.velocity, [15, 0, 0]); + assert.equal(resolved?.health, 76); + assert.equal(resolved?.armor, 25); + assert.equal(resolved?.lastInputSequence, 4); + assert.equal(resolved?.updatedAt, 1_050); +}); + +test("snapshot history refuses to interpolate across death or respawn boundaries", () => { + let roomHistory = []; + roomHistory = history.recordQuakeMultiplayerSnapshotHistory(roomHistory, { + sampledAt: 1_000, + roomTime: 0, + tick: 1, + players: [playerFixture({ alive: true, origin: [0, 0, 0], updatedAt: 1_000 })], + }); + roomHistory = history.recordQuakeMultiplayerSnapshotHistory(roomHistory, { + sampledAt: 1_100, + roomTime: 100, + tick: 2, + players: [playerFixture({ alive: false, origin: [10, 0, 0], updatedAt: 1_100 })], + }); + + assert.equal( + history.quakeMultiplayerHistoricalPlayerAt(roomHistory, playerFixture(), 1_050), + null, + ); + + let respawnHistory = []; + respawnHistory = history.recordQuakeMultiplayerSnapshotHistory(respawnHistory, { + sampledAt: 2_000, + roomTime: 0, + tick: 1, + players: [playerFixture({ spawnId: "spawn-a", origin: [0, 0, 0], updatedAt: 2_000 })], + }); + respawnHistory = history.recordQuakeMultiplayerSnapshotHistory(respawnHistory, { + sampledAt: 2_100, + roomTime: 100, + tick: 2, + players: [playerFixture({ spawnId: "spawn-b", origin: [1, 0, 0], updatedAt: 2_100 })], + }); + + assert.equal( + history.quakeMultiplayerHistoricalPlayerAt(respawnHistory, playerFixture({ spawnId: "spawn-b" }), 2_050), + null, + ); +}); + +test("snapshot history refuses large discontinuity interpolation", () => { + let roomHistory = []; + roomHistory = history.recordQuakeMultiplayerSnapshotHistory(roomHistory, { + sampledAt: 1_000, + roomTime: 0, + tick: 1, + players: [playerFixture({ origin: [0, 0, 0], updatedAt: 1_000 })], + }); + roomHistory = history.recordQuakeMultiplayerSnapshotHistory(roomHistory, { + sampledAt: 1_100, + roomTime: 100, + tick: 2, + players: [playerFixture({ origin: [200, 0, 0], updatedAt: 1_100 })], + }); + + assert.equal( + history.quakeMultiplayerHistoricalPlayerAt(roomHistory, playerFixture(), 1_050, { + maxDiscontinuityDistance: 10, + }), + null, + ); +}); + +test("historical combat candidates rewind targets but not the attacker", () => { + let roomHistory = []; + roomHistory = history.recordQuakeMultiplayerSnapshotHistory(roomHistory, { + sampledAt: 1_000, + roomTime: 0, + tick: 1, + players: [ + playerFixture({ playerId: "attacker", clientId: "attacker-client", origin: [0, 0, 0], updatedAt: 1_000 }), + playerFixture({ playerId: "target", clientId: "target-client", origin: [4, 0, 0], updatedAt: 1_000 }), + ], + }); + roomHistory = history.recordQuakeMultiplayerSnapshotHistory(roomHistory, { + sampledAt: 1_100, + roomTime: 100, + tick: 2, + players: [ + playerFixture({ playerId: "attacker", clientId: "attacker-client", origin: [1, 0, 0], updatedAt: 1_100 }), + playerFixture({ playerId: "target", clientId: "target-client", origin: [4, 2, 0], updatedAt: 1_100 }), + ], + }); + + const currentAttacker = playerFixture({ + playerId: "attacker", + clientId: "attacker-client", + origin: [99, 0, 0], + updatedAt: 2_000, + }); + const currentTarget = playerFixture({ + playerId: "target", + clientId: "target-client", + origin: [4, 99, 0], + updatedAt: 2_000, + }); + const players = history.quakeMultiplayerHistoricalCombatPlayers( + roomHistory, + [currentAttacker, currentTarget], + { + attackerPlayerId: "attacker", + targetTime: 1_050, + }, + ); + + assert.deepEqual(players.find((player) => player.playerId === "attacker")?.origin, [99, 0, 0]); + assert.deepEqual(players.find((player) => player.playerId === "target")?.origin, [4, 1, 0]); +}); + +test("snapshot history prunes old samples by retention and entry cap", () => { + let roomHistory = []; + for (let index = 0; index < 5; index += 1) { + roomHistory = history.recordQuakeMultiplayerSnapshotHistory(roomHistory, { + sampledAt: 1_000 + index * 100, + roomTime: index * 100, + tick: index + 1, + players: [playerFixture({ origin: [index, 0, 0], updatedAt: 1_000 + index * 100 })], + }, { + maxEntries: 3, + retentionMs: 250, + }); + } + + assert.deepEqual(roomHistory.map((entry) => entry.tick), [3, 4, 5]); + assert.equal( + history.quakeMultiplayerHistoricalPlayerAt(roomHistory, playerFixture(), 1_050), + null, + ); +}); diff --git a/test/multiplayer/items.test.mjs b/test/multiplayer/items.test.mjs index 3c8e5b4..10eca11 100644 --- a/test/multiplayer/items.test.mjs +++ b/test/multiplayer/items.test.mjs @@ -50,3 +50,261 @@ test("pickup reach rejects a forged origin hint far from the authoritative playe false, ); }); + +test("damage applies armor save even when invulnerability blocks health damage", () => { + const inventory = items.quakeMultiplayerApplyDamageToInventory({ + ...items.createQuakeMultiplayerInitialInventory(), + health: 100, + armor: 50, + armorType: 0.8, + }, 24, { applyHealth: false }); + + assert.equal(inventory.health, 100); + assert.equal(inventory.armor, 30); + assert.equal(inventory.armorType, 0.8); +}); + +test("powerup pickup acceptance uses caller simulation time", () => { + const inventory = { + ...items.createQuakeMultiplayerInitialInventory(), + powerups: [{ + active: true, + activationField: "super_time", + finishedAt: 2_000, + finishedField: "super_damage_finished", + itemFlag: 524_288, + }], + }; + const effect = { + powerup: { + activationField: "super_time", + durationMs: 30_000, + finishedField: "super_damage_finished", + itemFlag: 524_288, + }, + }; + + assert.equal(items.quakeMultiplayerInventoryCanAcceptPickupEffect(inventory, effect, 1_999), false); + assert.equal(items.quakeMultiplayerInventoryCanAcceptPickupEffect(inventory, effect, 2_001), true); +}); + +test("removing a powerup clears only its item flag when no remaining powerup uses it", () => { + const inventory = { + ...items.createQuakeMultiplayerInitialInventory(), + itemFlags: 524_288 | 1_048_576 | 2_097_152 | 4_194_304, + powerups: [ + { + active: true, + activationField: "invincible_time", + finishedAt: 10_000, + finishedField: "invincible_finished", + itemFlag: 1_048_576, + }, + { + active: true, + activationField: "super_damage_time", + finishedAt: 10_000, + finishedField: "super_damage_finished", + itemFlag: 4_194_304, + }, + ], + }; + + const next = items.quakeMultiplayerInventoryWithoutPowerup(inventory, "invincible_finished"); + + assert.equal(next.itemFlags & 1_048_576, 0); + assert.equal(next.itemFlags & 4_194_304, 4_194_304); + assert.deepEqual(next.powerups.map((powerup) => powerup.finishedField), ["super_damage_finished"]); +}); + +test("death clears active artifact powerups and their item flags", () => { + const inventory = { + ...items.createQuakeMultiplayerInitialInventory(), + itemFlags: 1_048_576 | 4_194_304, + powerups: [ + { + active: true, + activationField: "invincible_time", + finishedAt: 10_000, + finishedField: "invincible_finished", + itemFlag: 1_048_576, + }, + { + active: true, + activationField: "super_damage_time", + finishedAt: 10_000, + finishedField: "super_damage_finished", + itemFlag: 4_194_304, + }, + ], + }; + + const next = items.quakeMultiplayerInventoryWithoutDeathPowerups(inventory); + + assert.equal(next.itemFlags & 1_048_576, 0); + assert.equal(next.itemFlags & 2_097_152, 0); + assert.equal(next.itemFlags & 4_194_304, 0); + assert.equal(next.itemFlags & 524_288, 0); + assert.deepEqual(next.powerups, []); +}); + +test("best weapon follows Quake source priority when the current weapon has no ammo", () => { + const inventory = { + ...items.createQuakeMultiplayerInitialInventory(), + activeWeapon: "shotgun", + weapons: ["axe", "shotgun", "supershotgun", "nailgun", "supernailgun", "rocketlauncher"], + shells: 0, + nails: 2, + rockets: 4, + cells: 0, + }; + + assert.equal(items.quakeMultiplayerInventoryBestWeapon(inventory), "supernailgun"); + + const next = items.quakeMultiplayerInventoryWithBestWeaponIfCurrentAmmoEmpty(inventory); + assert.equal(next.activeWeapon, "supernailgun"); + assert.deepEqual(next.weapons, inventory.weapons); +}); + +test("best weapon falls back to axe when no carried weapon has ammo", () => { + const inventory = { + ...items.createQuakeMultiplayerInitialInventory(), + activeWeapon: "shotgun", + weapons: ["axe", "shotgun", "rocketlauncher"], + shells: 0, + rockets: 0, + }; + + assert.equal(items.quakeMultiplayerInventoryBestWeapon(inventory), "axe"); + assert.equal(items.quakeMultiplayerInventoryWithBestWeaponIfCurrentAmmoEmpty(inventory).activeWeapon, "axe"); +}); + +test("best weapon follows Quake W_BestWeapon by not auto-selecting explosive weapons", () => { + const inventory = { + ...items.createQuakeMultiplayerInitialInventory(), + activeWeapon: "shotgun", + weapons: ["axe", "shotgun", "grenadelauncher", "rocketlauncher"], + shells: 0, + rockets: 5, + }; + + assert.equal(items.quakeMultiplayerInventoryBestWeapon(inventory), "axe"); + assert.equal(items.quakeMultiplayerInventoryWithBestWeaponIfCurrentAmmoEmpty(inventory).activeWeapon, "axe"); +}); + +test("ammo pickup switches to a newly usable best weapon only when the active weapon was already best", () => { + const inventory = { + ...items.createQuakeMultiplayerInitialInventory(), + activeWeapon: "shotgun", + weapons: ["axe", "shotgun", "supernailgun"], + shells: 25, + nails: 0, + }; + + const switched = items.quakeMultiplayerApplyPickupEffect(inventory, { nails: 25 }, 1_000); + assert.equal(switched.nails, 25); + assert.equal(switched.activeWeapon, "supernailgun"); + + const manualWeapon = items.quakeMultiplayerApplyPickupEffect({ + ...inventory, + activeWeapon: "axe", + }, { nails: 25 }, 1_000); + assert.equal(manualWeapon.nails, 25); + assert.equal(manualWeapon.activeWeapon, "axe"); +}); + +test("weapon pickup uses Quake deathmatch rank instead of always forcing the new weapon active", () => { + const inventory = { + ...items.createQuakeMultiplayerInitialInventory(), + activeWeapon: "rocketlauncher", + weapons: ["axe", "shotgun", "rocketlauncher"], + shells: 10, + nails: 0, + rockets: 5, + }; + + const worsePickup = items.quakeMultiplayerApplyPickupEffect(inventory, { + nails: 30, + weapon: { id: "nailgun", itemFlag: 4, select: true }, + }, 1_000); + assert.equal(worsePickup.nails, 30); + assert.equal(worsePickup.weapons.includes("nailgun"), true); + assert.equal(worsePickup.activeWeapon, "rocketlauncher"); + + const betterPickup = items.quakeMultiplayerApplyPickupEffect(worsePickup, { + cells: 15, + weapon: { id: "lightning", itemFlag: 64, select: true }, + }, 1_000); + assert.equal(betterPickup.cells, 15); + assert.equal(betterPickup.weapons.includes("lightning"), true); + assert.equal(betterPickup.activeWeapon, "lightning"); +}); + +test("source touch acceptance only bypasses not-needed for backpacks and non-leave weapon pickups", () => { + assert.equal(items.quakeMultiplayerPickupAlwaysAcceptsTouch({ + classname: "item_backpack", + }), true); + assert.equal(items.quakeMultiplayerPickupAlwaysAcceptsTouch({ + classname: "weapon_rocketlauncher", + lifecycle: { action: "respawn", condition: "deathmatch", delayMs: 30_000 }, + }), true); + assert.equal(items.quakeMultiplayerPickupAlwaysAcceptsTouch({ + classname: "weapon_rocketlauncher", + lifecycle: { action: "leave", condition: "deathmatch == 2" }, + }), false); + assert.equal(items.quakeMultiplayerPickupAlwaysAcceptsTouch({ + classname: "item_rockets", + lifecycle: { action: "respawn", condition: "deathmatch", delayMs: 30_000 }, + }), false); +}); + +test("dropped backpack definition carries source deathmatch ammo and current weapon", () => { + const player = createPlayer({ + playerId: "party:client-b", + origin: [4, 0, 1], + inventory: { + ...items.createQuakeMultiplayerInitialInventory(), + activeWeapon: "rocketlauncher", + weapons: ["axe", "shotgun", "rocketlauncher"], + shells: 3, + rockets: 7, + }, + }); + + const definition = items.quakeMultiplayerDroppedBackpackDefinition({ + player, + entityIndex: 1_000_000, + now: 12_000, + }); + + assert.ok(definition, "expected backpack definition"); + assert.equal(definition.classname, "item_backpack"); + assert.equal(definition.runtime, true); + assert.equal(definition.modelPath, "progs/backpack.mdl"); + assert.equal(definition.removeAt, 132_000); + assert.equal(definition.effect.shells, 3); + assert.equal(definition.effect.rockets, 7); + assert.deepEqual(definition.effect.weapon, { + id: "rocketlauncher", + itemFlag: 32, + select: true, + }); +}); + +test("dropped backpack definition is omitted when the victim carries no ammo", () => { + const player = createPlayer({ + inventory: { + ...items.createQuakeMultiplayerInitialInventory(), + shells: 0, + }, + }); + + assert.equal( + items.quakeMultiplayerDroppedBackpackDefinition({ + player, + entityIndex: 1_000_001, + now: 12_000, + }), + null, + ); +}); diff --git a/test/multiplayer/movement.test.mjs b/test/multiplayer/movement.test.mjs index 1078a3e..a5bc29e 100644 --- a/test/multiplayer/movement.test.mjs +++ b/test/multiplayer/movement.test.mjs @@ -47,6 +47,30 @@ test("room simulation consumes queued inputs in sequence order across fixed tick assert.deepEqual(third.state.pendingInputs, []); }); +test("room simulation records bounded accepted input history", () => { + const player = createPlayer(); + let state = simulation.createQuakeMultiplayerRoomPlayerSimulationState({ + playerId: player.playerId, + now: 0, + }); + for ( + let inputSequence = 1; + inputSequence <= simulation.QUAKE_MULTIPLAYER_ACCEPTED_INPUT_HISTORY_LIMIT + 3; + inputSequence += 1 + ) { + state = simulation.queueQuakeMultiplayerRoomInput(state, createInput(inputSequence)).state; + } + + assert.equal(state.acceptedInputHistory.length, simulation.QUAKE_MULTIPLAYER_ACCEPTED_INPUT_HISTORY_LIMIT); + assert.equal(state.acceptedInputHistory[0].inputSequence, 4); + assert.equal(state.acceptedInputHistory.at(-1).inputSequence, 35); + + state = simulation.queueQuakeMultiplayerRoomInput(state, createInput(35, { sampledAt: 999 })).state; + assert.equal(state.acceptedInputHistory.length, simulation.QUAKE_MULTIPLAYER_ACCEPTED_INPUT_HISTORY_LIMIT); + assert.equal(state.acceptedInputHistory.at(-1).inputSequence, 35); + assert.equal(state.acceptedInputHistory.at(-1).sampledAt, 999); +}); + test("room simulation still holds the last accepted input after the queue drains", () => { const player = createPlayer(); let state = simulation.createQuakeMultiplayerRoomPlayerSimulationState({ diff --git a/test/multiplayer/presentation.test.mjs b/test/multiplayer/presentation.test.mjs index 2da9147..5aab2b1 100644 --- a/test/multiplayer/presentation.test.mjs +++ b/test/multiplayer/presentation.test.mjs @@ -6,7 +6,7 @@ import { importTsModule } from "../importTsModule.mjs"; const presentation = await importTsModule("src/runtime/multiplayer/presentation.ts"); -function createRemotePresenterHarness() { +function createRemotePresenterHarness(options = {}) { let now = 1_000; const callbacks = new Map(); const damageEvents = []; @@ -23,6 +23,7 @@ function createRemotePresenterHarness() { onPlayerKilled: (event, player) => killEvents.push({ event, player }), now: () => now, renderDelayMs: 0, + ...(options.staleAfterMs !== undefined ? { staleAfterMs: options.staleAfterMs } : {}), requestFrame: (callback) => { const handle = callbacks.size + 1; callbacks.set(handle, callback); @@ -122,6 +123,103 @@ test("remote presenter ignores local player damage for remote visuals", () => { assert.equal(harness.damageEvents.length, 0); }); +test("remote presenter marks remote player attack frames from fired events", () => { + 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_150); + harness.presenter.handleRoomMessage(roomEvent({ + eventType: "player.fired", + eventId: "fire-1", + roomTime: 1_150, + playerId: "remote-player", + weapon: "nailgun", + fireKind: "projectile", + origin: [10, 20, 30], + direction: [1, 0, 0], + })); + harness.flushFrame(); + + assert.equal(harness.visualStates.at(-1).state.lastAttackAt, 1_150); + assert.equal(harness.visualStates.at(-1).state.lastAttackWeapon, "nailgun"); +}); + +test("remote presenter keeps later attack evidence after an earlier pain marker", () => { + 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_100); + harness.presenter.handleRoomMessage(roomEvent({ + eventType: "player.damaged", + eventId: "damage-1", + roomTime: 1_100, + victimPlayerId: "remote-player", + attackerPlayerId: "local-player", + damage: 12, + health: 88, + armor: 0, + damageSource: "shotgun", + })); + harness.flushFrame(); + + harness.setNow(1_500); + harness.presenter.handleRoomMessage(roomEvent({ + eventType: "player.fired", + eventId: "fire-1", + roomTime: 1_500, + playerId: "remote-player", + weapon: "rocketlauncher", + fireKind: "projectile", + origin: [10, 20, 30], + direction: [1, 0, 0], + })); + harness.flushFrame(); + + assert.equal(harness.visualStates.at(-1).state.lastPainAt, 1_100); + assert.equal(harness.visualStates.at(-1).state.lastAttackAt, 1_500); + assert.equal(harness.visualStates.at(-1).state.lastAttackWeapon, "rocketlauncher"); +}); + +test("remote presenter ignores local player fired events for remote visuals", () => { + const harness = createRemotePresenterHarness(); + const localPlayer = createPlayer({ + playerId: "local-player", + clientId: "local-client", + updatedAt: 1_000, + }); + + harness.presenter.handleRoomMessage(roomSnapshot([localPlayer])); + + harness.presenter.handleRoomMessage(roomEvent({ + eventType: "player.fired", + eventId: "fire-local", + roomTime: 1_150, + playerId: "local-player", + weapon: "shotgun", + fireKind: "hitscan", + origin: [0, 0, 0], + direction: [1, 0, 0], + })); + + assert.equal(harness.visualStates.length, 0); +}); + test("remote presenter reports remote player kills before death state is applied", () => { const harness = createRemotePresenterHarness(); const remotePlayer = createPlayer({ @@ -152,3 +250,28 @@ test("remote presenter reports remote player kills before death state is applied assert.equal(harness.visualStates.at(-1).state.alive, false); assert.equal(harness.visualStates.at(-1).state.deathAt, 1_200); }); + +test("remote presenter preserves a visual through one transient missing snapshot", () => { + const harness = createRemotePresenterHarness({ staleAfterMs: 100 }); + 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_050); + harness.presenter.handleRoomMessage(roomSnapshot([])); + harness.flushFrame(); + + assert.deepEqual(harness.removed, []); + assert.equal(harness.visualStates.at(-1).playerId, "remote-player"); + + harness.setNow(1_201); + harness.flushFrame(); + + assert.deepEqual(harness.removed, ["remote-player"]); +}); diff --git a/test/multiplayer/protocol.test.mjs b/test/multiplayer/protocol.test.mjs index c63698c..d39e381 100644 --- a/test/multiplayer/protocol.test.mjs +++ b/test/multiplayer/protocol.test.mjs @@ -7,8 +7,10 @@ import { authority, clientEnvelope, createLoopbackHarness, + createPlayer, fireEnvelope, helloEnvelope, + inputBatchEnvelope, inputEnvelope, latestMessage, matchEnvelope, @@ -16,12 +18,17 @@ import { pickupEnvelope, presenceEnvelope, protocol, + projectileAuthority, validation, + waitForMessage, worldEnvelope, } from "./harness.mjs"; import { importTsModule } from "../importTsModule.mjs"; const facts = await importTsModule("src/runtime/multiplayer/facts.ts"); +const items = await importTsModule("src/runtime/multiplayer/items.ts"); + +const DUEL_FORWARD_DIRECTION = [0.9781476007338057, 0, -0.20791169081775934]; class FakePartyConnection { constructor(id) { @@ -108,6 +115,9 @@ const weaponPickupAmmo = { lightning: { cells: 25 }, }; +const QUAD_ITEM_FLAG = 4_194_304; +const INVULNERABILITY_ITEM_FLAG = 1_048_576; + function weaponPickupDefinition(weapon) { return { pickupId: `weapon-${weapon}`, @@ -125,8 +135,50 @@ function weaponPickupDefinition(weapon) { }; } -function connectDuelRoom({ id, pickupDefinitions = [], spawnDistance = 4 }) { - const deathmatchSpawns = [ +function quadPickupDefinition({ entityIndex = 1999, durationMs = 30_000, origin = [0, 0, 0] } = {}) { + return { + pickupId: `powerup-quad-${entityIndex}`, + entityIndex, + classname: "item_artifact_super_damage", + origin, + effect: { + powerup: { + activationField: "super_damage_time", + durationMs, + finishedField: "super_damage_finished", + itemFlag: QUAD_ITEM_FLAG, + itemFlagExpression: "IT_QUAD", + }, + }, + }; +} + +function invulnerabilityPickupDefinition({ entityIndex = 2999, durationMs = 30_000, origin = [0, 0, 0] } = {}) { + return { + pickupId: `powerup-invulnerability-${entityIndex}`, + entityIndex, + classname: "item_artifact_invulnerability", + origin, + effect: { + powerup: { + activationField: "invincible_time", + durationMs, + finishedField: "invincible_finished", + itemFlag: INVULNERABILITY_ITEM_FLAG, + }, + }, + }; +} + +function connectDuelRoom({ + id, + deathmatchSpawns, + matchSettings = { fragLimit: 1 }, + pickupDefinitions = [], + roomOptions = {}, + spawnDistance = 4, +}) { + const spawns = deathmatchSpawns ?? [ { spawnId: "spawn-a", classname: "info_player_deathmatch", @@ -143,12 +195,16 @@ function connectDuelRoom({ id, pickupDefinitions = [], spawnDistance = 4 }) { }, ]; const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ - deathmatchSpawns, + deathmatchSpawns: spawns, pickupDefinitions, }); const { room, createConnection } = createFakePartyRoom(id); const RoomClass = partyRoomModule.default; - const partyRoom = new RoomClass(room, { trustedGameplayDefinitions: gameplayDefinitions }); + const partyRoom = new RoomClass(room, { + random: () => 0.999999, + trustedGameplayDefinitions: gameplayDefinitions, + ...roomOptions, + }); const alice = createConnection("alice"); const bob = createConnection("bob"); partyRoom.onConnect(alice); @@ -159,7 +215,7 @@ function connectDuelRoom({ id, pickupDefinitions = [], spawnDistance = 4 }) { messageId: `hello-a-${id}`, sequence: 1, sentAt: Date.now(), - matchSettings: { fragLimit: 1 }, + matchSettings, })), alice); partyRoom.onMessage(JSON.stringify(helloEnvelope({ clientId: "client-b", @@ -167,14 +223,106 @@ function connectDuelRoom({ id, pickupDefinitions = [], spawnDistance = 4 }) { messageId: `hello-b-${id}`, sequence: 1, sentAt: Date.now(), - matchSettings: { fragLimit: 1 }, + matchSettings, })), bob); return { alice, bob, partyRoom }; } +function connectTripleRoom({ id, roomOptions = {}, spawns }) { + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: spawns, + pickupDefinitions: [], + }); + const { room, createConnection } = createFakePartyRoom(id); + const RoomClass = partyRoomModule.default; + const partyRoom = new RoomClass(room, { + random: () => 0.999999, + trustedGameplayDefinitions: gameplayDefinitions, + ...roomOptions, + }); + const alice = createConnection("alice"); + const bob = createConnection("bob"); + const cara = createConnection("cara"); + partyRoom.onConnect(alice); + partyRoom.onConnect(bob); + partyRoom.onConnect(cara); + const clients = [ + { clientId: "client-a", connection: alice, displayName: "Alice" }, + { clientId: "client-b", connection: bob, displayName: "Bob" }, + { clientId: "client-c", connection: cara, displayName: "Cara" }, + ]; + for (const [index, client] of clients.entries()) { + partyRoom.onMessage(JSON.stringify(helloEnvelope({ + clientId: client.clientId, + displayName: client.displayName, + messageId: `hello-${client.clientId}-${id}`, + sequence: 1, + sentAt: Date.now(), + matchSettings: { fragLimit: 99, maxPlayers: 4 }, + })), client.connection); + } + return { alice, bob, cara, partyRoom }; +} + function cleanupDuelRoom(partyRoom, alice, bob) { - partyRoom.onClose(alice); - partyRoom.onClose(bob); + cleanupPartyRoomConnections(partyRoom, alice, bob); +} + +function cleanupPartyRoomConnections(partyRoom, ...connections) { + for (const connection of connections) partyRoom.onClose(connection); +} + +function setPartyRoomPlayerWeapon(partyRoom, clientId, weapon) { + const playerId = `party:${clientId}`; + const player = partyRoom.players.get(playerId); + assert.ok(player, `expected player ${playerId}`); + const inventory = items.quakeMultiplayerPlayerInventory(player); + inventory.weapons = [...new Set([...inventory.weapons, weapon])]; + inventory.activeWeapon = weapon; + inventory.shells = Math.max(inventory.shells, 50); + inventory.nails = Math.max(inventory.nails, 50); + inventory.rockets = Math.max(inventory.rockets, 50); + inventory.cells = Math.max(inventory.cells, 50); + partyRoom.players.set(playerId, items.quakeMultiplayerPlayerWithInventory(player, inventory)); +} + +function setPartyRoomPlayerQuad(partyRoom, clientId, finishedAt) { + const playerId = `party:${clientId}`; + const player = partyRoom.players.get(playerId); + assert.ok(player, `expected player ${playerId}`); + const inventory = items.quakeMultiplayerPlayerInventory(player); + inventory.itemFlags |= QUAD_ITEM_FLAG; + inventory.powerups = [ + ...inventory.powerups.filter((powerup) => powerup.finishedField !== "super_damage_finished"), + { + active: true, + activationField: "super_damage_time", + finishedAt, + finishedField: "super_damage_finished", + itemFlag: QUAD_ITEM_FLAG, + itemFlagExpression: "IT_QUAD", + }, + ]; + partyRoom.players.set(playerId, items.quakeMultiplayerPlayerWithInventory(player, inventory)); +} + +function setPartyRoomPlayerInvulnerable(partyRoom, clientId, finishedAt) { + const playerId = `party:${clientId}`; + const player = partyRoom.players.get(playerId); + assert.ok(player, `expected player ${playerId}`); + const inventory = items.quakeMultiplayerPlayerInventory(player); + inventory.itemFlags |= INVULNERABILITY_ITEM_FLAG; + inventory.powerups = [ + ...inventory.powerups.filter((powerup) => powerup.finishedField !== "invincible_finished"), + { + active: true, + activationField: "invincible_time", + finishedAt, + finishedField: "invincible_finished", + itemFlag: INVULNERABILITY_ITEM_FLAG, + }, + ]; + partyRoom.players.set(playerId, items.quakeMultiplayerPlayerWithInventory(player, inventory)); } function pickupWeapon(partyRoom, connection, { clientId, sequence, weapon }) { @@ -230,6 +378,39 @@ test("client hello validates and establishes authority state before other client assert.equal(authorityResult.state.lastEnvelopeSequence, 1); }); +test("client input batches validate only when bounded and strictly ordered", () => { + const batch = inputBatchEnvelope({ + sequence: 2, + inputSequences: [1, 2, 3, 4], + sentAt: 120, + }); + const valid = validation.validateQuakeMultiplayerClientEnvelope(batch, { + roomKey: NORMALIZED_ROOM_KEY, + now: 120, + }); + assert.equal(valid.ok, true); + + for (const [name, inputs] of [ + ["empty", []], + ["oversized", [1, 2, 3, 4, 5].map((inputSequence) => ({ ...batch.payload.inputs[0], inputSequence }))], + ["unordered", [1, 3, 2].map((inputSequence) => ({ ...batch.payload.inputs[0], inputSequence }))], + ]) { + const invalid = validation.validateQuakeMultiplayerClientEnvelope({ + ...batch, + messageId: `invalid-batch-${name}`, + payload: { + ...batch.payload, + inputs, + }, + }, { + roomKey: NORMALIZED_ROOM_KEY, + now: 120, + }); + assert.equal(invalid.ok, false, name); + assert.equal(invalid.code, "malformed", name); + } +}); + test("multiplayer match settings clamp max players to launch cap", () => { assert.equal(protocol.QUAKE_MULTIPLAYER_MAX_PLAYERS_CAP, 4); assert.deepEqual( @@ -316,6 +497,113 @@ test("party room accepts a fifth capped player as a spectator", () => { assert.deepEqual(overflow.closed.at(-1), { code: 1008, reason: "reject:room-full" }); }); +test("party room queues ordered input batches into the player simulation state", () => { + const { room, createConnection } = createFakePartyRoom("input-batch-room"); + const RoomClass = partyRoomModule.default; + const partyRoom = new RoomClass(room); + const connection = createConnection("connection-a"); + try { + partyRoom.onConnect(connection); + partyRoom.onMessage(JSON.stringify(helloEnvelope({ + messageId: "batch-hello", + sequence: 1, + sentAt: Date.now(), + })), connection); + + partyRoom.onMessage(JSON.stringify(inputBatchEnvelope({ + messageId: "batch-inputs", + sequence: 2, + inputSequences: [1, 2, 3], + sentAt: Date.now(), + })), connection); + + assert.equal(connection.state.authority.lastIntentSequences.input, 3); + const simulationState = partyRoom.playerSimulationStates.get("party:client-a"); + assert.ok(simulationState); + assert.deepEqual(simulationState.pendingInputs.map((input) => input.inputSequence), [1, 2, 3]); + assert.deepEqual(simulationState.acceptedInputHistory.map((input) => input.inputSequence), [1, 2, 3]); + } finally { + cleanupPartyRoomConnections(partyRoom, connection); + } +}); + +test("party room accepts fire timestamps near accepted input history", () => { + const { alice, bob, partyRoom } = connectDuelRoom({ id: "fire-input-history-accept" }); + try { + const base = Date.now(); + partyRoom.onMessage(JSON.stringify(inputBatchEnvelope({ + clientId: "client-a", + messageId: "fire-history-inputs", + sequence: 2, + sentAt: base, + inputSequences: [1, 2], + inputs: [ + { sampledAt: base, rotX: -78, rotY: 0 }, + { sampledAt: base + 50, rotX: -78, rotY: 0 }, + ], + })), alice); + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-history-valid", + sequence: 3, + sentAt: base + 60, + fireSequence: 1, + fire: { + firedAt: base + 55, + }, + })), alice); + + const damage = roomEvents(alice, "player.damaged") + .find((event) => event.attackerPlayerId === "party:client-a" && event.victimPlayerId === "party:client-b"); + assert.ok(damage, "expected accepted fire timestamp to damage the remote player"); + assert.equal(damage.damage, 24); + assert.equal(alice.messages.some((message) => message.type === "room.reject"), false); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room rejects fire timestamps outside accepted input history", () => { + const { alice, bob, partyRoom } = connectDuelRoom({ id: "fire-input-history-reject" }); + try { + const base = Date.now(); + partyRoom.onMessage(JSON.stringify(inputBatchEnvelope({ + clientId: "client-a", + messageId: "fire-history-reject-inputs", + sequence: 2, + sentAt: base, + inputSequences: [1, 2], + inputs: [ + { sampledAt: base, rotX: -78, rotY: 0 }, + { sampledAt: base + 50, rotX: -78, rotY: 0 }, + ], + })), alice); + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-history-too-late", + sequence: 3, + sentAt: base + 60, + fireSequence: 1, + fire: { + firedAt: base + 1_000, + }, + })), alice); + + const reject = latestConnectionMessage(alice, "room.reject"); + assert.equal(reject.payload.code, "stale"); + assert.equal(reject.payload.recoverable, true); + assert.equal(reject.payload.rejectedMessageId, "fire-history-too-late"); + assert.match(reject.payload.message, /fire-after-input-history/); + assert.equal( + roomEvents(alice, "player.damaged") + .some((event) => event.attackerPlayerId === "party:client-a" && event.victimPlayerId === "party:client-b"), + false, + ); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + test("party room closes a connection after repeated recoverable rejects", () => { const { room, createConnection } = createFakePartyRoom(); const RoomClass = partyRoomModule.default; @@ -368,7 +656,10 @@ test("party room applies authoritative fire damage in both player directions", ( }); const { room, createConnection } = createFakePartyRoom("fire-damage-room"); const RoomClass = partyRoomModule.default; - const partyRoom = new RoomClass(room, { trustedGameplayDefinitions: gameplayDefinitions }); + const partyRoom = new RoomClass(room, { + random: () => 0.999999, + trustedGameplayDefinitions: gameplayDefinitions, + }); const alice = createConnection("alice"); const bob = createConnection("bob"); partyRoom.onConnect(alice); @@ -402,6 +693,13 @@ test("party room applies authoritative fire damage in both player directions", ( assert.equal(damageAtoB.damage, 24); assert.equal(damageAtoB.health, 76); assert.equal(damageAtoB.damageSource, "shotgun"); + const firedAtoB = roomEvents(alice, "player.fired").find((event) => event.eventId === "fire-fire-a"); + assert.equal(firedAtoB?.decision?.outcome, "hit-player"); + assert.equal(firedAtoB?.decision?.reason, "player-direct"); + assert.equal(firedAtoB?.decision?.targetPlayerId, "party:client-b"); + assert.equal(firedAtoB?.decision?.candidateCount, 1); + assert.equal(firedAtoB?.decision?.blockedCandidateCount, 0); + assert.equal(firedAtoB?.decision?.playerDamageCount, 1); partyRoom.onMessage(JSON.stringify(fireEnvelope({ clientId: "client-b", @@ -416,20 +714,215 @@ test("party room applies authoritative fire damage in both player directions", ( assert.equal(damageBtoA.damage, 24); assert.equal(damageBtoA.health, 76); assert.equal(damageBtoA.damageSource, "shotgun"); + const firedBtoA = roomEvents(alice, "player.fired").find((event) => event.eventId === "fire-fire-b"); + assert.equal(firedBtoA?.decision?.outcome, "hit-player"); + assert.equal(firedBtoA?.decision?.reason, "player-direct"); + assert.equal(firedBtoA?.decision?.targetPlayerId, "party:client-a"); + assert.equal(firedBtoA?.decision?.candidateCount, 1); + assert.equal(firedBtoA?.decision?.blockedCandidateCount, 0); + assert.equal(firedBtoA?.decision?.playerDamageCount, 1); 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 source-order armor save but suppresses health damage while the victim is invulnerable", () => { + const { alice, bob, partyRoom } = connectDuelRoom({ id: "invulnerable-victim" }); + try { + const bobPlayer = partyRoom.players.get("party:client-b"); + assert.ok(bobPlayer, "expected bob player"); + const inventory = items.quakeMultiplayerPlayerInventory(bobPlayer); + inventory.health = 100; + inventory.armor = 50; + inventory.armorType = 0.8; + inventory.powerups = [{ + active: true, + activationField: "invincible_time", + finishedAt: Date.now() + 10_000, + finishedField: "invincible_finished", + itemFlag: INVULNERABILITY_ITEM_FLAG, + }]; + partyRoom.players.set("party:client-b", items.quakeMultiplayerPlayerWithInventory(bobPlayer, inventory)); + + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-invulnerable-victim", + sequence: 2, + fireSequence: 1, + sentAt: Date.now(), + })), alice); + + assert.equal( + roomEvents(alice, "player.damaged").some((event) => event.victimPlayerId === "party:client-b"), + false, + ); + assert.equal( + roomEvents(alice, "player.killed").some((event) => event.victimPlayerId === "party:client-b"), + false, + ); + const victim = latestSnapshotPlayerForClient(alice, "client-b"); + assert.equal(victim.health, 100); + assert.equal(victim.armor, 30); + assert.equal(victim.alive, true); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room double-invulnerable telefrag clears protection and kills both players like Quake teledeath3", () => { + const { alice, bob, partyRoom } = connectDuelRoom({ id: "double-invulnerable-telefrag" }); + try { + const now = Date.now(); + const victim = partyRoom.players.get("party:client-b"); + assert.ok(victim, "expected victim"); + setPartyRoomPlayerInvulnerable(partyRoom, "client-a", now + 10_000); + setPartyRoomPlayerInvulnerable(partyRoom, "client-b", now + 10_000); + + partyRoom.applyTeleportDeath("party:client-a", victim.origin, "double-invulnerable-telefrag"); + + const kills = roomEvents(alice, "player.killed") + .filter((event) => event.damageSource === "teledeath3"); + assert.equal(kills.length, 2); + assert.equal(kills.some((event) => event.victimPlayerId === "party:client-a"), true); + assert.equal(kills.some((event) => event.victimPlayerId === "party:client-b"), true); + + const aliceSnapshot = latestSnapshotPlayerForClient(alice, "client-a"); + const bobSnapshot = latestSnapshotPlayerForClient(alice, "client-b"); + assert.equal(aliceSnapshot.alive, false); + assert.equal(bobSnapshot.alive, false); + assert.equal(aliceSnapshot.frags, -1); + assert.equal(bobSnapshot.frags, -1); + assert.equal(aliceSnapshot.deaths, 1); + assert.equal(bobSnapshot.deaths, 1); + assert.equal( + aliceSnapshot.inventory.powerups.some((powerup) => powerup.finishedField === "invincible_finished"), + false, + ); + assert.equal( + bobSnapshot.inventory.powerups.some((powerup) => powerup.finishedField === "invincible_finished"), + false, + ); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room subtracts a victim frag for world/environment kills", () => { + const { alice, bob, partyRoom } = connectDuelRoom({ id: "world-kill-frag-penalty" }); + try { + partyRoom.applyPlayerDamage({ + victimPlayerId: "party:client-b", + damage: 150, + source: "trigger_hurt", + eventId: "world-kill-frag-penalty", + now: Date.now(), + }); + + const kill = roomEvents(alice, "player.killed") + .find((event) => event.victimPlayerId === "party:client-b"); + assert.ok(kill, "expected environment kill event"); + assert.equal(kill.attackerPlayerId, undefined); + assert.equal(kill.damageSource, "trigger_hurt"); + const victim = latestSnapshotPlayerForClient(alice, "client-b"); + assert.equal(victim.alive, false); + assert.equal(victim.frags, -1); + assert.equal(victim.deaths, 1); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room clears active artifact powerups immediately on player death", () => { + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "death-clears-powerups", + matchSettings: { fragLimit: 99 }, + }); + try { + const now = Date.now(); + setPartyRoomPlayerQuad(partyRoom, "client-b", now + 10_000); + const bobPlayer = partyRoom.players.get("party:client-b"); + assert.ok(bobPlayer, "expected bob player"); + const inventory = items.quakeMultiplayerPlayerInventory(bobPlayer); + inventory.health = 10; + partyRoom.players.set("party:client-b", items.quakeMultiplayerPlayerWithInventory(bobPlayer, inventory)); + + partyRoom.applyPlayerDamage({ + attackerPlayerId: "party:client-a", + victimPlayerId: "party:client-b", + damage: 24, + source: "shotgun", + eventId: "death-clears-powerups", + now, + }); + + const victim = latestSnapshotPlayerForClient(alice, "client-b"); + assert.equal(victim.alive, false); + assert.equal(victim.inventory.itemFlags & QUAD_ITEM_FLAG, 0); + assert.equal( + victim.inventory.powerups.some((powerup) => powerup.finishedField === "super_damage_finished"), + false, + ); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room respawns at a clear deathmatch spawn instead of the occupied cursor spawn", () => { + 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: [8, 0, 0], rotX: -78, rotY: 180 }, + { spawnId: "spawn-c-occupied", classname: "info_player_deathmatch", origin: [0.5, 0, 0], rotX: -78, rotY: 90 }, + { spawnId: "spawn-d-clear", classname: "info_player_deathmatch", origin: [16, 0, 0], rotX: -78, rotY: 270 }, + ]; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "respawn-clear-spawn", + deathmatchSpawns, + matchSettings: { fragLimit: 99 }, + }); + try { + partyRoom.applyPlayerDamage({ + attackerPlayerId: "party:client-a", + victimPlayerId: "party:client-b", + damage: 150, + source: "shotgun", + eventId: "respawn-clear-spawn-kill", + now: Date.now(), + }); + partyRoom.respawnPlayer("party:client-b"); + + const respawn = roomEvents(alice, "player.respawned") + .find((event) => event.player?.playerId === "party:client-b"); + assert.ok(respawn, "expected respawn event"); + assert.equal(respawn.player.spawnId, "spawn-d-clear"); + assert.deepEqual(respawn.player.origin, [16, 0, 0]); + const bobSnapshot = latestSnapshotPlayerForClient(alice, "client-b"); + assert.equal(bobSnapshot.spawnId, "spawn-d-clear"); + assert.deepEqual(bobSnapshot.origin, [16, 0, 0]); + assert.equal(bobSnapshot.alive, true); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + 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: "axe", damage: 20, pickup: false, 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" }, + { weapon: "grenadelauncher", damage: 87, pickup: true, spawnDistance: 4, eventType: "player.damaged", health: 13 }, + { weapon: "rocketlauncher", pickup: true, spawnDistance: 4, eventType: "player.killed", health: -5 }, ]; for (const spec of cases) { @@ -450,14 +943,7 @@ test("party room applies authoritative weapon damage after weapon pickups", () = 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); + setPartyRoomPlayerWeapon(partyRoom, "client-a", spec.weapon); } partyRoom.onMessage(JSON.stringify(fireEnvelope({ @@ -469,6 +955,32 @@ test("party room applies authoritative weapon damage after weapon pickups", () = fire: { weapon: spec.weapon }, })), alice); + const serverProjectile = projectileAuthority.quakeMultiplayerServerProjectileWeaponSupported(spec.weapon); + if (serverProjectile) { + const fired = roomEvents(alice, "player.fired") + .find((candidate) => candidate.eventId === `fire-fire-${spec.weapon}`); + assert.equal(fired?.decision?.outcome, "projectile-spawned", `${spec.weapon} should spawn a server projectile`); + const spawned = roomEvents(alice, "projectile.spawned") + .find((candidate) => candidate.projectile.weapon === spec.weapon); + assert.ok(spawned, `expected projectile.spawned for ${spec.weapon}`); + assert.equal( + roomEvents(alice, spec.eventType) + .some((candidate) => + candidate.attackerPlayerId === "party:client-a" && + candidate.victimPlayerId === "party:client-b" && + candidate.damageSource === spec.weapon + ), + false, + `${spec.weapon} should not apply damage in the same tick as fire`, + ); + partyRoom.advanceRoomSimulation(Date.now() + 400); + const impact = roomEvents(alice, "projectile.impacted") + .find((candidate) => candidate.weapon === spec.weapon); + assert.ok(impact, `expected projectile.impacted for ${spec.weapon}`); + assert.equal(impact.impactKind, "player", `${spec.weapon} should impact the player`); + assert.equal(impact.targetPlayerId, "party:client-b", `${spec.weapon} impact target`); + } + const event = roomEvents(alice, spec.eventType) .find((candidate) => candidate.attackerPlayerId === "party:client-a" && @@ -483,7 +995,7 @@ test("party room applies authoritative weapon damage after weapon pickups", () = 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(victim.health, spec.health, `${spec.weapon} death 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`); @@ -493,268 +1005,2768 @@ test("party room applies authoritative weapon damage after weapon pickups", () = } }); -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 }); - assert.equal(firstResult.ok, false); - assert.equal(firstResult.reject.code, "not-authorized"); - assert.equal(firstResult.reject.recoverable, false); +test("party room weapon pickup keeps a better current weapon by Quake deathmatch rank", () => { + const nailgunPickup = weaponPickupDefinition("nailgun"); + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "weapon-pickup-rank-switch", + pickupDefinitions: [nailgunPickup], + }); + try { + const player = partyRoom.players.get("party:client-a"); + assert.ok(player, "expected player"); + const inventory = items.quakeMultiplayerPlayerInventory(player); + inventory.activeWeapon = "rocketlauncher"; + inventory.weapons = ["axe", "shotgun", "rocketlauncher"]; + inventory.rockets = 5; + inventory.nails = 0; + partyRoom.players.set("party:client-a", items.quakeMultiplayerPlayerWithInventory(player, inventory)); - const helloResult = authority.validateQuakeMultiplayerClientAuthority( - helloEnvelope({ sequence: 1, sentAt: 100 }), - null, - { now: 100 }, - ); - assert.equal(helloResult.ok, true); + partyRoom.onMessage(JSON.stringify(pickupEnvelope({ + clientId: "client-a", + messageId: "pickup-nailgun-rank-switch", + sequence: 2, + pickupSequence: 1, + sentAt: Date.now(), + pickup: { + entityIndex: nailgunPickup.entityIndex, + origin: [0, 0, 0], + }, + })), alice); - const swappedClient = inputEnvelope({ - clientId: "client-b", - sequence: 2, - inputSequence: 1, - sentAt: 130, - }); - const swappedResult = authority.validateQuakeMultiplayerClientAuthority(swappedClient, helloResult.state, { - now: 130, - }); - assert.equal(swappedResult.ok, false); - assert.equal(swappedResult.reject.code, "not-authorized"); - assert.equal(swappedResult.reject.recoverable, false); + const pickup = roomEvents(alice, "pickup.taken") + .find((event) => event.entityIndex === nailgunPickup.entityIndex); + assert.ok(pickup, "expected nailgun pickup"); + const snapshot = latestSnapshotPlayerForClient(alice, "client-a"); + assert.equal(snapshot.inventory.weapons.includes("nailgun"), true); + assert.equal(snapshot.inventory.nails, 25); + assert.equal(snapshot.inventory.activeWeapon, "rocketlauncher"); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } }); -test("client authority rejects replayed envelope and intent sequences independently", () => { - const helloResult = authority.validateQuakeMultiplayerClientAuthority( - helloEnvelope({ sequence: 1, sentAt: 100 }), - null, - { now: 100 }, - ); - assert.equal(helloResult.ok, true); - - const inputOne = inputEnvelope({ sequence: 2, inputSequence: 1, sentAt: 120 }); - const inputOneResult = authority.validateQuakeMultiplayerClientAuthority(inputOne, helloResult.state, { now: 120 }); - assert.equal(inputOneResult.ok, true); +test("party room accepts already-owned respawning weapon pickup at full ammo like Quake deathmatch", () => { + const nailgunPickup = { + ...weaponPickupDefinition("nailgun"), + lifecycle: { action: "respawn", condition: "deathmatch", delayMs: 30_000 }, + }; + const originalNow = Date.now; + let now = 4_500_000; + Date.now = () => now; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "weapon-pickup-full-ammo-respawn", + pickupDefinitions: [nailgunPickup], + }); + try { + const player = partyRoom.players.get("party:client-a"); + assert.ok(player, "expected player"); + const inventory = items.quakeMultiplayerPlayerInventory(player); + inventory.activeWeapon = "rocketlauncher"; + inventory.weapons = ["axe", "shotgun", "nailgun", "rocketlauncher"]; + inventory.nails = 200; + inventory.rockets = 5; + partyRoom.players.set("party:client-a", items.quakeMultiplayerPlayerWithInventory(player, inventory)); - const replayedEnvelope = inputEnvelope({ sequence: 2, inputSequence: 2, sentAt: 140 }); - const replayedEnvelopeResult = authority.validateQuakeMultiplayerClientAuthority( - replayedEnvelope, - inputOneResult.state, - { now: 140 }, - ); - assert.equal(replayedEnvelopeResult.ok, false); - assert.equal(replayedEnvelopeResult.reject.code, "stale"); + partyRoom.onMessage(JSON.stringify(pickupEnvelope({ + clientId: "client-a", + messageId: "pickup-owned-full-nailgun", + sequence: 2, + pickupSequence: 1, + sentAt: now, + pickup: { + entityIndex: nailgunPickup.entityIndex, + origin: [0, 0, 0], + }, + })), alice); - const replayedIntent = inputEnvelope({ sequence: 3, inputSequence: 1, sentAt: 150 }); - const replayedIntentResult = authority.validateQuakeMultiplayerClientAuthority( - replayedIntent, - inputOneResult.state, - { now: 150 }, - ); - assert.equal(replayedIntentResult.ok, false); - assert.equal(replayedIntentResult.reject.code, "stale"); - assert.match(replayedIntentResult.reject.message, /input sequence/); + const pickup = roomEvents(alice, "pickup.taken") + .find((event) => event.entityIndex === nailgunPickup.entityIndex); + assert.ok(pickup, "expected already-owned full-ammo weapon pickup to be taken"); + assert.equal(pickup.leaveInPlace, false); + const snapshot = latestConnectionMessage(alice, "room.snapshot"); + const pickupState = snapshot.payload.pickups.find((candidate) => + candidate.entityIndex === nailgunPickup.entityIndex + ); + assert.equal(pickupState?.available, false); + assert.equal(pickupState?.respawnAt, now + 30_000); + const playerSnapshot = latestSnapshotPlayerForClient(alice, "client-a"); + assert.equal(playerSnapshot.inventory.nails, 200); + assert.equal(playerSnapshot.inventory.activeWeapon, "rocketlauncher"); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + Date.now = originalNow; + } }); -test("client authority accepts immediate presence transitions", () => { - const helloResult = authority.validateQuakeMultiplayerClientAuthority( - helloEnvelope({ sequence: 1, sentAt: 100 }), - null, - { now: 100 }, - ); - assert.equal(helloResult.ok, true); - - const pausedResult = authority.validateQuakeMultiplayerClientAuthority( - presenceEnvelope("input-paused", { sequence: 2, messageId: "presence-paused", sentAt: 120 }), - helloResult.state, - { now: 120 }, - ); - assert.equal(pausedResult.ok, true); +test("party room ammo pickup selects a newly usable best weapon when the active weapon was best", () => { + const nailsPickup = { + pickupId: "item-spikes-auto-best", + entityIndex: 4010, + classname: "item_spikes", + origin: [0, 0, 0], + effect: { nails: 25 }, + }; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "ammo-pickup-auto-best-weapon", + pickupDefinitions: [nailsPickup], + }); + try { + const attacker = partyRoom.players.get("party:client-a"); + assert.ok(attacker, "expected attacker"); + const inventory = items.quakeMultiplayerPlayerInventory(attacker); + inventory.activeWeapon = "shotgun"; + inventory.weapons = ["axe", "shotgun", "supernailgun"]; + inventory.shells = 25; + inventory.nails = 0; + partyRoom.players.set("party:client-a", items.quakeMultiplayerPlayerWithInventory(attacker, inventory)); - const activeResult = authority.validateQuakeMultiplayerClientAuthority( - presenceEnvelope("active", { sequence: 3, messageId: "presence-active", sentAt: 121 }), + partyRoom.onMessage(JSON.stringify(pickupEnvelope({ + clientId: "client-a", + messageId: "pickup-nails-auto-best", + sequence: 2, + pickupSequence: 1, + sentAt: Date.now(), + pickup: { + entityIndex: nailsPickup.entityIndex, + origin: [0, 0, 0], + }, + })), alice); + + const pickup = roomEvents(alice, "pickup.taken") + .find((event) => event.entityIndex === nailsPickup.entityIndex); + assert.ok(pickup, "expected nails pickup"); + const player = latestSnapshotPlayerForClient(alice, "client-a"); + assert.equal(player.inventory.nails, 25); + assert.equal(player.inventory.activeWeapon, "supernailgun"); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room auto-selects the source best weapon when the active weapon has no ammo before fire", () => { + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "auto-best-weapon-before-fire", + spawnDistance: 4, + }); + try { + const attacker = partyRoom.players.get("party:client-a"); + assert.ok(attacker, "expected attacker"); + const inventory = items.quakeMultiplayerPlayerInventory(attacker); + inventory.activeWeapon = "nailgun"; + inventory.weapons = ["axe", "shotgun", "nailgun"]; + inventory.shells = 25; + inventory.nails = 0; + partyRoom.players.set("party:client-a", items.quakeMultiplayerPlayerWithInventory(attacker, inventory)); + + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-auto-best-before", + sequence: 2, + fireSequence: 1, + sentAt: Date.now(), + fire: { weapon: "nailgun" }, + })), alice); + + const fired = roomEvents(alice, "player.fired") + .find((event) => event.eventId === "fire-fire-auto-best-before"); + assert.equal(fired?.weapon, "shotgun"); + assert.equal(fired?.decision?.outcome, "hit-player"); + const damage = roomEvents(alice, "player.damaged") + .find((event) => event.victimPlayerId === "party:client-b"); + assert.ok(damage, "expected auto-selected shotgun to damage Bob"); + assert.equal(damage.damage, 24); + assert.equal(damage.health, 76); + const attackerSnapshot = latestSnapshotPlayerForClient(alice, "client-a"); + assert.equal(attackerSnapshot.inventory.activeWeapon, "shotgun"); + assert.equal(attackerSnapshot.inventory.nails, 0); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room switches to axe after consuming the last shell instead of getting stuck on an empty shotgun", () => { + const originalNow = Date.now; + let now = 3_000_000; + Date.now = () => now; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "auto-best-weapon-after-last-shell", + spawnDistance: 1.2, + }); + try { + const attacker = partyRoom.players.get("party:client-a"); + assert.ok(attacker, "expected attacker"); + const inventory = items.quakeMultiplayerPlayerInventory(attacker); + inventory.activeWeapon = "shotgun"; + inventory.weapons = ["axe", "shotgun"]; + inventory.shells = 1; + partyRoom.players.set("party:client-a", items.quakeMultiplayerPlayerWithInventory(attacker, inventory)); + + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-last-shell", + sequence: 2, + fireSequence: 1, + sentAt: now, + })), alice); + + const firstFired = roomEvents(alice, "player.fired") + .find((event) => event.eventId === "fire-fire-last-shell"); + assert.equal(firstFired?.weapon, "shotgun"); + const afterLastShell = latestSnapshotPlayerForClient(alice, "client-a"); + assert.equal(afterLastShell.inventory.shells, 0); + assert.equal(afterLastShell.inventory.activeWeapon, "axe"); + + now += 500; + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-after-last-shell", + sequence: 3, + fireSequence: 2, + sentAt: now, + })), alice); + + const secondFired = roomEvents(alice, "player.fired") + .find((event) => event.eventId === "fire-fire-after-last-shell"); + assert.equal(secondFired?.weapon, "axe"); + const axeDamage = roomEvents(alice, "player.damaged") + .find((event) => + event.eventId === "damage-fire-after-last-shell" && + event.damageSource === "axe" + ); + assert.ok(axeDamage, "expected axe fire after empty shotgun"); + assert.equal(axeDamage.damage, 20); + assert.equal(axeDamage.health, 56); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + Date.now = originalNow; + } +}); + +test("party room drops and removes a source-style backpack on player death", () => { + const originalNow = Date.now; + let now = 4_000_000; + Date.now = () => now; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "player-death-dropped-backpack", + matchSettings: { fragLimit: 99 }, + spawnDistance: 4, + }); + try { + const victim = partyRoom.players.get("party:client-b"); + assert.ok(victim, "expected victim"); + const victimInventory = items.quakeMultiplayerPlayerInventory(victim); + victimInventory.activeWeapon = "rocketlauncher"; + victimInventory.weapons = ["axe", "shotgun", "rocketlauncher"]; + victimInventory.shells = 4; + victimInventory.rockets = 7; + partyRoom.players.set("party:client-b", items.quakeMultiplayerPlayerWithInventory(victim, victimInventory)); + + partyRoom.applyPlayerDamage({ + attackerPlayerId: "party:client-a", + victimPlayerId: "party:client-b", + damage: 150, + source: "rocketlauncher", + eventId: "death-backpack", + now, + }); + + const dropped = roomEvents(alice, "pickup.dropped").find((event) => + event.sourcePlayerId === "party:client-b" + ); + assert.ok(dropped, "expected dropped backpack event"); + assert.equal(dropped.definition.classname, "item_backpack"); + assert.equal(dropped.definition.runtime, true); + assert.equal(dropped.definition.effect.shells, 4); + assert.equal(dropped.definition.effect.rockets, 7); + assert.equal(dropped.definition.effect.weapon.id, "rocketlauncher"); + assert.equal(dropped.pickup.available, true); + + const dropSnapshot = latestConnectionMessage(alice, "room.snapshot"); + assert.equal( + dropSnapshot.payload.dynamicPickups.some((definition) => + definition.entityIndex === dropped.definition.entityIndex + ), + true, + ); + assert.equal( + dropSnapshot.payload.pickups.some((pickup) => + pickup.entityIndex === dropped.definition.entityIndex && pickup.available + ), + true, + ); + + const taker = partyRoom.players.get("party:client-a"); + assert.ok(taker, "expected taker"); + const takerInventory = items.quakeMultiplayerPlayerInventory(taker); + takerInventory.shells = 0; + takerInventory.rockets = 0; + takerInventory.weapons = ["axe", "shotgun"]; + partyRoom.players.set("party:client-a", items.quakeMultiplayerPlayerWithInventory({ + ...taker, + origin: dropped.definition.origin, + }, takerInventory)); + + now += 100; + partyRoom.onMessage(JSON.stringify(pickupEnvelope({ + clientId: "client-a", + messageId: "pickup-dropped-backpack", + sequence: 2, + pickupSequence: 1, + sentAt: now, + pickup: { + entityIndex: dropped.definition.entityIndex, + origin: dropped.definition.origin, + }, + })), alice); + + const taken = roomEvents(alice, "pickup.taken").find((event) => + event.entityIndex === dropped.definition.entityIndex + ); + assert.ok(taken, "expected dynamic backpack pickup event"); + assert.equal(taken.leaveInPlace, false); + const afterPickup = latestSnapshotPlayerForClient(alice, "client-a"); + assert.equal(afterPickup.inventory.shells, 4); + assert.equal(afterPickup.inventory.rockets, 7); + assert.equal(afterPickup.inventory.weapons.includes("rocketlauncher"), true); + assert.equal(afterPickup.inventory.activeWeapon, "rocketlauncher"); + + const pickupSnapshot = latestConnectionMessage(alice, "room.snapshot"); + assert.equal( + pickupSnapshot.payload.dynamicPickups.some((definition) => + definition.entityIndex === dropped.definition.entityIndex + ), + false, + ); + assert.equal( + pickupSnapshot.payload.pickups.some((pickup) => + pickup.entityIndex === dropped.definition.entityIndex + ), + false, + ); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + Date.now = originalNow; + } +}); + +test("party room accepts grenade refire at the source 600ms cooldown", () => { + const originalNow = Date.now; + let now = 2_000_000; + Date.now = () => now; + const { alice, bob, partyRoom } = connectDuelRoom({ id: "grenade-source-cooldown" }); + try { + setPartyRoomPlayerWeapon(partyRoom, "client-a", "grenadelauncher"); + for (let index = 0; index < 2; index += 1) { + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: `fire-grenade-cooldown-${index}`, + sequence: 2 + index, + fireSequence: 1 + index, + sentAt: now, + fire: { direction: [-1, 0, 0] }, + })), alice); + now += 600; + } + + const fired = roomEvents(alice, "player.fired") + .filter((event) => + event.playerId === "party:client-a" && + event.weapon === "grenadelauncher" + ); + assert.equal(fired.length, 2); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + Date.now = originalNow; + } +}); + +test("party room applies repeated authoritative damage until death across sustained-fire weapons", () => { + const cases = [ + { weapon: "axe", spawnDistance: 1.2, stepMs: 500, damagedHealths: [80, 60, 40, 20], killHealth: 0 }, + { weapon: "shotgun", spawnDistance: 4, stepMs: 500, damagedHealths: [76, 52, 28, 4], killHealth: -20 }, + { weapon: "supershotgun", spawnDistance: 4, stepMs: 700, damagedHealths: [44], killHealth: -12 }, + { weapon: "nailgun", spawnDistance: 4, stepMs: 200, damagedHealths: [91, 82, 73, 64, 55, 46, 37, 28, 19, 10, 1], killHealth: -8 }, + { weapon: "supernailgun", spawnDistance: 4, stepMs: 200, damagedHealths: [82, 64, 46, 28, 10], killHealth: -8 }, + { weapon: "lightning", spawnDistance: 4, stepMs: 200, damagedHealths: [70, 40, 10], killHealth: -20 }, + ]; + const originalNow = Date.now; + let now = 1_000_000; + Date.now = () => now; + try { + for (const spec of cases) { + const { alice, bob, partyRoom } = connectDuelRoom({ + id: `repeated-${spec.weapon}`, + spawnDistance: spec.spawnDistance, + }); + try { + setPartyRoomPlayerWeapon(partyRoom, "client-a", spec.weapon); + for (let index = 0; index <= spec.damagedHealths.length; index += 1) { + now += spec.stepMs; + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: `fire-repeated-${spec.weapon}-${index}`, + sequence: 2 + index, + fireSequence: 1 + index, + sentAt: now, + fire: { weapon: spec.weapon }, + })), alice); + if (projectileAuthority.quakeMultiplayerServerProjectileWeaponSupported(spec.weapon)) { + now += 400; + partyRoom.advanceRoomSimulation(now); + } + const expectedHealth = spec.damagedHealths[index]; + if (expectedHealth !== undefined) { + const event = roomEvents(alice, "player.damaged") + .find((candidate) => + candidate.victimPlayerId === "party:client-b" && + candidate.damageSource === spec.weapon && + candidate.health === expectedHealth + ); + assert.ok(event, `expected repeated ${spec.weapon} damage ${index + 1}`); + assert.equal(event.health, expectedHealth, `${spec.weapon} health after shot ${index + 1}`); + assert.equal(latestSnapshotPlayerForClient(alice, "client-b").health, expectedHealth); + } else { + const event = roomEvents(alice, "player.killed") + .find((candidate) => + candidate.victimPlayerId === "party:client-b" && + candidate.damageSource === spec.weapon + ); + assert.ok(event, `expected repeated ${spec.weapon} kill`); + const victim = latestSnapshotPlayerForClient(alice, "client-b"); + assert.equal(victim.alive, false, `${spec.weapon} victim alive after kill`); + assert.equal(victim.health, spec.killHealth, `${spec.weapon} victim health after kill`); + } + } + 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); + now += 10_000; + } + } + } finally { + Date.now = originalNow; + } +}); + +test("party room applies damage when LOS trace only clips the target skin", () => { + const collisionWorld = { + traceUse: () => ({ + fraction: 0.9856583826296409, + end: [3.92, 0, -0.82], + planeNormal: [0, 0, 1], + entityIndex: 84, + modelIndex: 3, + classname: "func_wall", + }), + }; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "late-target-skin-los", + roomOptions: { + trustedSceneMovement: { + collisionWorld, + playerEyeHeight: 1.0, + }, + }, + spawnDistance: 4, + }); + try { + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-late-target-skin-los", + sequence: 2, + fireSequence: 1, + sentAt: Date.now(), + })), alice); + + const event = roomEvents(alice, "player.damaged") + .find((candidate) => + candidate.attackerPlayerId === "party:client-a" && + candidate.victimPlayerId === "party:client-b" && + candidate.damageSource === "shotgun" + ); + assert.ok(event, "expected late target-skin LOS trace to allow damage"); + assert.equal(event.damage, 24); + assert.equal(event.health, 76); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room uses fire payload aim when the authoritative pose is one input behind", () => { + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "fresh-fire-aim-stale-pose", + spawnDistance: 4, + }); + try { + const attacker = partyRoom.players.get("party:client-a"); + assert.ok(attacker, "expected attacker"); + partyRoom.players.set("party:client-a", { + ...attacker, + rotX: -78, + rotY: 180, + }); + + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-fresh-aim-stale-pose", + sequence: 2, + fireSequence: 1, + sentAt: Date.now(), + fire: { direction: DUEL_FORWARD_DIRECTION }, + })), alice); + + const event = roomEvents(alice, "player.damaged") + .find((candidate) => + candidate.attackerPlayerId === "party:client-a" && + candidate.victimPlayerId === "party:client-b" + ); + assert.ok(event, "expected fresh fire aim to damage despite stale authoritative yaw"); + assert.equal(event.damage, 24); + assert.equal(event.health, 76); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room uses a bounded fire origin hint when the authoritative origin is one input behind", () => { + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "fresh-fire-origin-stale-pose", + spawnDistance: 4, + }); + try { + const victim = partyRoom.players.get("party:client-b"); + assert.ok(victim, "expected victim"); + partyRoom.players.set("party:client-b", { + ...victim, + origin: [victim.origin[0], 0.9, victim.origin[2]], + }); + + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-fresh-origin-stale-pose", + sequence: 2, + fireSequence: 1, + sentAt: Date.now(), + fire: { origin: [0, 0.4, 0] }, + })), alice); + + const event = roomEvents(alice, "player.damaged") + .find((candidate) => + candidate.attackerPlayerId === "party:client-a" && + candidate.victimPlayerId === "party:client-b" + ); + assert.ok(event, "expected bounded fire origin hint to damage despite stale authoritative origin"); + assert.equal(event.damage, 24); + assert.equal(event.health, 76); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room rewinds hit tests from authoritative snapshot history instead of current velocity", () => { + const originalNow = Date.now; + let now = 10_000; + Date.now = () => now; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "historical-hit-stopped-target", + spawnDistance: 4, + }); + try { + const attacker = partyRoom.players.get("party:client-a"); + const target = partyRoom.players.get("party:client-b"); + assert.ok(attacker, "expected attacker"); + assert.ok(target, "expected target"); + partyRoom.players.set("party:client-a", { + ...attacker, + origin: [0, 0, 0], + velocity: [0, 0, 0], + updatedAt: now, + }); + partyRoom.players.set("party:client-b", { + ...target, + origin: [4, 0, 0], + velocity: [0, 0, 0], + updatedAt: now, + }); + partyRoom.broadcastSnapshot(); + + now += 100; + partyRoom.players.set("party:client-b", { + ...partyRoom.players.get("party:client-b"), + origin: [4, 1.4, 0], + velocity: [0, 0, 0], + updatedAt: now, + }); + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-historical-stopped-target", + sequence: 2, + fireSequence: 1, + sentAt: now, + fire: { + origin: [0, 0, -0.36], + direction: [1, 0, 0], + }, + })), alice); + + const event = roomEvents(alice, "player.damaged") + .find((candidate) => + candidate.attackerPlayerId === "party:client-a" && + candidate.victimPlayerId === "party:client-b" && + candidate.damageSource === "shotgun" + ); + assert.ok(event, "expected historical target sample to receive damage"); + assert.equal(event.damage, 24); + assert.equal(event.health, 76); + assert.equal(latestSnapshotPlayerForClient(alice, "client-b").health, 76); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + Date.now = originalNow; + } +}); + +test("party room still blocks damage when LOS trace hits a real wall", () => { + const collisionWorld = { + traceUse: () => ({ + fraction: 0.5, + end: [2, 0, -0.5], + planeNormal: [1, 0, 0], + entityIndex: 900, + modelIndex: 9, + classname: "func_wall", + }), + }; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "mid-wall-los", + roomOptions: { + trustedSceneMovement: { + collisionWorld, + playerEyeHeight: 1.0, + }, + }, + spawnDistance: 4, + }); + try { + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-mid-wall-los", + sequence: 2, + fireSequence: 1, + sentAt: Date.now(), + })), alice); + + const event = roomEvents(alice, "player.damaged") + .find((candidate) => + candidate.attackerPlayerId === "party:client-a" && + candidate.victimPlayerId === "party:client-b" + ); + assert.equal(event, undefined); + const bobPlayer = latestSnapshotPlayerForClient(alice, "client-b"); + assert.equal(bobPlayer.health, 100); + const fired = roomEvents(alice, "player.fired").find((candidate) => + candidate.eventId === "fire-fire-mid-wall-los" + ); + assert.equal(fired?.decision?.outcome, "miss"); + assert.equal(fired?.decision?.reason, "line-of-sight-blocked"); + assert.equal(fired?.decision?.candidateCount, 1); + assert.equal(fired?.decision?.blockedCandidateCount, 1); + assert.equal(fired?.decision?.playerDamageCount, 0); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room damages a farther visible player when a nearer candidate is blocked", () => { + const collisionWorld = { + traceUse: (_origin, impact) => impact[0] < 3 + ? { + fraction: 0.5, + end: [1, 0, -0.5], + planeNormal: [1, 0, 0], + entityIndex: 44, + modelIndex: 2, + classname: "func_wall", + } + : null, + }; + const { alice, bob, cara, partyRoom } = connectTripleRoom({ + id: "blocked-nearer-visible-farther", + roomOptions: { + trustedSceneMovement: { + collisionWorld, + playerEyeHeight: 1.0, + }, + }, + spawns: [ + { spawnId: "spawn-a", classname: "info_player_deathmatch", origin: [0, 0, 0], rotX: -78, rotY: 0 }, + { spawnId: "spawn-b", classname: "info_player_deathmatch", origin: [2, 0, 0], rotX: -78, rotY: 180 }, + { spawnId: "spawn-c", classname: "info_player_deathmatch", origin: [4, 0, 0], rotX: -78, rotY: 180 }, + ], + }); + try { + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-blocked-near-visible-far", + sequence: 2, + fireSequence: 1, + sentAt: Date.now(), + })), alice); + + const damagedEvents = roomEvents(alice, "player.damaged"); + assert.equal(damagedEvents.some((event) => event.victimPlayerId === "party:client-b"), false); + const farEvent = damagedEvents.find((event) => event.victimPlayerId === "party:client-c"); + assert.ok(farEvent, "expected farther visible player to take damage"); + assert.equal(farEvent.damage, 24); + assert.equal(farEvent.health, 76); + const fired = roomEvents(alice, "player.fired").find((candidate) => + candidate.eventId === "fire-fire-blocked-near-visible-far" + ); + assert.equal(fired?.decision?.outcome, "hit-player"); + assert.equal(fired?.decision?.reason, "player-direct"); + assert.equal(fired?.decision?.targetPlayerId, "party:client-c"); + assert.equal(fired?.decision?.candidateCount, 2); + assert.equal(fired?.decision?.blockedCandidateCount, 1); + assert.equal(fired?.decision?.playerDamageCount, 1); + assert.equal(latestSnapshotPlayerForClient(alice, "client-b").health, 100); + assert.equal(latestSnapshotPlayerForClient(alice, "client-c").health, 76); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupPartyRoomConnections(partyRoom, alice, bob, cara); + } +}); + +test("party room blocks indirect projectile splash through walls", () => { + const collisionWorld = { + traceUse: (_origin, point) => point[1] > 1 + ? { + fraction: 0.4, + end: [point[0], 1, point[2]], + planeNormal: [0, -1, 0], + entityIndex: 45, + modelIndex: 3, + classname: "func_wall", + } + : null, + }; + const { alice, bob, cara, partyRoom } = connectTripleRoom({ + id: "projectile-splash-wall", + roomOptions: { + trustedSceneMovement: { + collisionWorld, + playerEyeHeight: 1.0, + }, + }, + spawns: [ + { spawnId: "spawn-a", classname: "info_player_deathmatch", origin: [0, 0, 0], rotX: -78, rotY: 0 }, + { spawnId: "spawn-b", classname: "info_player_deathmatch", origin: [3, 0, 0], rotX: -78, rotY: 180 }, + { spawnId: "spawn-c", classname: "info_player_deathmatch", origin: [3, 2, 0], rotX: -78, rotY: 180 }, + ], + }); + try { + setPartyRoomPlayerWeapon(partyRoom, "client-a", "rocketlauncher"); + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-splash-wall", + sequence: 2, + fireSequence: 1, + sentAt: Date.now(), + })), alice); + partyRoom.advanceRoomSimulation(Date.now() + 400); + + const damagedEvents = roomEvents(alice, "player.damaged"); + const killedEvents = roomEvents(alice, "player.killed"); + assert.ok(killedEvents.some((event) => event.victimPlayerId === "party:client-b")); + assert.equal(damagedEvents.some((event) => event.victimPlayerId === "party:client-c"), false); + assert.equal(killedEvents.some((event) => event.victimPlayerId === "party:client-c"), false); + assert.equal(latestSnapshotPlayerForClient(alice, "client-c").health, 100); + assert.equal(latestSnapshotPlayerForClient(alice, "client-c").alive, true); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupPartyRoomConnections(partyRoom, alice, bob, cara); + } +}); + +test("party room applies projectile wall-impact splash without a direct player hit", () => { + const collisionWorld = { + traceUse: (origin, point) => origin[0] === 0 && point[0] > 10 + ? { + fraction: 3 / 64, + end: [3, 0, 0], + planeNormal: [-1, 0, 0], + entityIndex: 44, + modelIndex: 3, + classname: "func_wall", + } + : null, + }; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "projectile-wall-splash", + roomOptions: { + trustedSceneMovement: { + collisionWorld, + playerEyeHeight: 1.0, + }, + }, + }); + try { + setPartyRoomPlayerWeapon(partyRoom, "client-a", "rocketlauncher"); + const bobPlayer = partyRoom.players.get("party:client-b"); + assert.ok(bobPlayer, "expected bob player"); + partyRoom.players.set("party:client-b", { + ...bobPlayer, + origin: [3, 2, 0], + updatedAt: Date.now(), + }); + + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-wall-splash", + sequence: 2, + fireSequence: 1, + sentAt: Date.now(), + fire: { + direction: [1, 0, 0], + }, + })), alice); + partyRoom.advanceRoomSimulation(Date.now() + 2_000); + + const damagedEvents = roomEvents(alice, "player.damaged"); + const bobDamage = damagedEvents.find((event) => event.victimPlayerId === "party:client-b"); + const aliceDamage = damagedEvents.find((event) => event.victimPlayerId === "party:client-a"); + assert.ok(bobDamage, "expected wall splash to damage nearby non-direct target"); + assert.equal(bobDamage.damage, 69); + assert.equal(bobDamage.health, 31); + assert.ok(aliceDamage, "expected wall splash to apply half self damage"); + assert.equal(aliceDamage.damage, 22); + assert.equal(aliceDamage.health, 78); + assert.equal(latestSnapshotPlayerForClient(alice, "client-b").health, 31); + assert.equal(latestSnapshotPlayerForClient(alice, "client-a").health, 78); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room applies projectile quad damage from impact-time attacker state", () => { + const originalNow = Date.now; + let now = 3_000_000; + Date.now = () => now; + const cases = [ + { + id: "quad-expired-before-impact", + setup: (partyRoom) => setPartyRoomPlayerQuad(partyRoom, "client-a", now + 50), + expectedDamage: 9, + expectedHealth: 91, + }, + { + id: "quad-picked-up-before-impact", + setup: () => {}, + beforeImpact: (partyRoom) => setPartyRoomPlayerQuad(partyRoom, "client-a", now + 10_000), + expectedDamage: 36, + expectedHealth: 64, + }, + ]; + + try { + for (const spec of cases) { + const { alice, bob, partyRoom } = connectDuelRoom({ + id: spec.id, + spawnDistance: 4, + }); + try { + setPartyRoomPlayerWeapon(partyRoom, "client-a", "nailgun"); + spec.setup(partyRoom); + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: `fire-${spec.id}`, + sequence: 2, + fireSequence: 1, + sentAt: now, + })), alice); + + assert.equal( + roomEvents(alice, "player.damaged") + .some((candidate) => candidate.damageSource === "nailgun"), + false, + "nail projectile should not damage on the fire tick", + ); + spec.beforeImpact?.(partyRoom); + now += 400; + partyRoom.advanceRoomSimulation(now); + + const event = roomEvents(alice, "player.damaged") + .find((candidate) => + candidate.attackerPlayerId === "party:client-a" && + candidate.victimPlayerId === "party:client-b" && + candidate.damageSource === "nailgun" + ); + assert.ok(event, `expected nailgun damage for ${spec.id}`); + assert.equal(event.damage, spec.expectedDamage, spec.id); + assert.equal(event.health, spec.expectedHealth, spec.id); + assert.equal(event.roomTime, 400, spec.id); + const impact = roomEvents(alice, "projectile.impacted") + .find((candidate) => candidate.weapon === "nailgun"); + assert.equal(impact?.roomTime, 400, spec.id); + assert.equal(latestSnapshotPlayerForClient(alice, "client-b").health, spec.expectedHealth); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } + now += 1_000; + } + } finally { + Date.now = originalNow; + } +}); + +test("party room applies delayed projectile victim powerups at simulation impact time", () => { + const originalNow = Date.now; + const fireNow = 3_100_000; + Date.now = () => fireNow; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "projectile-victim-powerup-impact-time", + spawnDistance: 4, + }); + try { + setPartyRoomPlayerWeapon(partyRoom, "client-a", "nailgun"); + setPartyRoomPlayerInvulnerable(partyRoom, "client-b", fireNow + 50); + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-projectile-victim-powerup-impact-time", + sequence: 2, + fireSequence: 1, + sentAt: fireNow, + fire: { + weapon: "nailgun", + fireKind: "projectile", + }, + })), alice); + + partyRoom.advanceRoomSimulation(fireNow + 400); + + const event = roomEvents(alice, "player.damaged") + .find((candidate) => + candidate.attackerPlayerId === "party:client-a" && + candidate.victimPlayerId === "party:client-b" && + candidate.damageSource === "nailgun" + ); + assert.ok(event, "expected expired victim invulnerability not to block delayed projectile damage"); + assert.equal(event.damage, 9); + assert.equal(event.health, 91); + assert.equal(event.roomTime, 400); + const impact = roomEvents(alice, "projectile.impacted") + .find((candidate) => candidate.weapon === "nailgun"); + assert.equal(impact?.roomTime, 400); + assert.equal(latestSnapshotPlayerForClient(alice, "client-b").health, 91); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + Date.now = originalNow; + } +}); + +test("server grenade projectile advances through delayed arc impact damage", () => { + const projectile = projectileAuthority.createQuakeMultiplayerServerProjectile({ + fire: { + fireSequence: 1, + firedAt: 100, + fireKind: "projectile", + weapon: "grenadelauncher", + origin: [0, 0, 0], + direction: DUEL_FORWARD_DIRECTION, + range: 1024, + }, + now: 100, + ownerPlayerId: "party:client-a", + projectileId: "grenade-arc-1", + }); + assert.ok(projectile, "expected grenade launcher to create a server projectile"); + assert.equal(projectile.weapon, "grenadelauncher"); + assert.equal(projectileAuthority.quakeMultiplayerServerProjectileWeaponSupported("grenadelauncher"), true); + assert.ok(projectile.gravity > 0, "expected grenade projectile to carry gravity"); + assert.ok(projectile.velocity[2] > projectile.direction[2] * projectile.speed, "expected grenade launch kick"); + + const target = createPlayer({ + playerId: "party:client-b", + clientId: "client-b", + displayName: "Bob", + origin: [4, 0, 0], + rotX: -78, + rotY: 180, + updatedAt: 100, + }); + const immediate = projectileAuthority.advanceQuakeMultiplayerServerProjectile(projectile, { + collisionWorld: null, + now: 100, + players: [target], + }); + assert.equal(immediate.type, "active", "grenade should not damage on the fire tick"); + + const delayed = projectileAuthority.advanceQuakeMultiplayerServerProjectile(projectile, { + collisionWorld: null, + now: 500, + players: [target], + }); + assert.equal(delayed.type, "impact"); + assert.equal(delayed.impact.kind, "player"); + assert.equal(delayed.impact.targetPlayerId, "party:client-b"); + const hit = delayed.impact.damageHits.find((candidate) => candidate.target.playerId === "party:client-b"); + assert.ok(hit, "expected delayed grenade impact to damage target"); + assert.equal(hit.damage, 87); + assert.equal(hit.direct, false); +}); + +test("server grenade projectile bounces on world impact and explodes on fuse expiry", () => { + const projectile = projectileAuthority.createQuakeMultiplayerServerProjectile({ + fire: { + fireSequence: 1, + firedAt: 100, + fireKind: "projectile", + weapon: "grenadelauncher", + origin: [0, 0, 1], + direction: [1, 0, 0], + range: 1024, + }, + now: 100, + ownerPlayerId: "party:client-a", + projectileId: "grenade-bounce-1", + }); + assert.ok(projectile, "expected grenade launcher to create a server projectile"); + const fallingProjectile = { + ...projectile, + direction: [0.24253562503633297, 0, -0.9701425001453319], + gravity: 0, + speed: Math.hypot(2, 0, -8), + velocity: [2, 0, -8], + }; + const collisionWorld = { + traceUse: (origin, end) => { + if (origin[2] <= 0 || end[2] > 0) return null; + const fraction = origin[2] / (origin[2] - end[2]); + return { + fraction, + end: [ + origin[0] + (end[0] - origin[0]) * fraction, + origin[1] + (end[1] - origin[1]) * fraction, + 0, + ], + planeNormal: [0, 0, 1], + entityIndex: 44, + modelIndex: 3, + classname: "func_floor", + }; + }, + }; + + const bounced = projectileAuthority.advanceQuakeMultiplayerServerProjectile(fallingProjectile, { + collisionWorld, + now: 300, + players: [], + }); + assert.equal(bounced.type, "active"); + assert.ok(bounced.projectile.origin[2] > 0, "expected bounced grenade to be offset off the impact plane"); + assert.ok(bounced.projectile.velocity[2] > 0, "expected bounced grenade to reflect upward"); + + const target = createPlayer({ + playerId: "party:client-b", + clientId: "client-b", + displayName: "Bob", + origin: [bounced.projectile.origin[0], bounced.projectile.origin[1], 0], + updatedAt: 300, + }); + const expired = projectileAuthority.advanceQuakeMultiplayerServerProjectile(bounced.projectile, { + collisionWorld: null, + now: bounced.projectile.expiresAt + 1, + players: [target], + }); + assert.equal(expired.type, "impact"); + assert.equal(expired.impact.kind, "world"); + const hit = expired.impact.damageHits.find((candidate) => candidate.target.playerId === "party:client-b"); + assert.ok(hit, "expected grenade fuse explosion to apply splash damage"); + assert.equal(hit.direct, false); + assert.ok(hit.damage > 0); +}); + +test("party room snapshots active server projectile positions", () => { + const originalNow = Date.now; + let now = 2_500_000; + Date.now = () => now; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "projectile-snapshot-position", + spawnDistance: 20, + }); + try { + setPartyRoomPlayerWeapon(partyRoom, "client-a", "rocketlauncher"); + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-projectile-snapshot-position", + sequence: 2, + fireSequence: 1, + sentAt: now, + fire: { + weapon: "rocketlauncher", + }, + })), alice); + + const spawned = roomEvents(alice, "projectile.spawned") + .find((candidate) => candidate.projectile.weapon === "rocketlauncher"); + assert.ok(spawned, "expected projectile.spawned event"); + const initialSnapshot = latestConnectionMessage(alice, "room.snapshot"); + const initialProjectile = initialSnapshot.payload.projectiles + ?.find((candidate) => candidate.projectileId === spawned.projectile.projectileId); + assert.ok(initialProjectile, "expected initial snapshot to carry active projectile"); + assert.deepEqual(initialProjectile.origin, spawned.projectile.origin); + + now += 100; + partyRoom.advanceRoomSimulation(now); + partyRoom.broadcastSnapshot(); + const movedSnapshot = latestConnectionMessage(alice, "room.snapshot"); + const movedProjectile = movedSnapshot.payload.projectiles + ?.find((candidate) => candidate.projectileId === spawned.projectile.projectileId); + assert.ok(movedProjectile, "expected later snapshot to keep active projectile"); + assert.equal(movedProjectile.updatedAt, now); + assert.ok( + movedProjectile.origin[0] > initialProjectile.origin[0], + "expected active projectile snapshot origin to advance", + ); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + Date.now = originalNow; + 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 }); + assert.equal(firstResult.ok, false); + assert.equal(firstResult.reject.code, "not-authorized"); + assert.equal(firstResult.reject.recoverable, false); + + const helloResult = authority.validateQuakeMultiplayerClientAuthority( + helloEnvelope({ sequence: 1, sentAt: 100 }), + null, + { now: 100 }, + ); + assert.equal(helloResult.ok, true); + + const swappedClient = inputEnvelope({ + clientId: "client-b", + sequence: 2, + inputSequence: 1, + sentAt: 130, + }); + const swappedResult = authority.validateQuakeMultiplayerClientAuthority(swappedClient, helloResult.state, { + now: 130, + }); + assert.equal(swappedResult.ok, false); + assert.equal(swappedResult.reject.code, "not-authorized"); + assert.equal(swappedResult.reject.recoverable, false); +}); + +test("client authority rejects replayed envelope and intent sequences independently", () => { + const helloResult = authority.validateQuakeMultiplayerClientAuthority( + helloEnvelope({ sequence: 1, sentAt: 100 }), + null, + { now: 100 }, + ); + assert.equal(helloResult.ok, true); + + const inputOne = inputEnvelope({ sequence: 2, inputSequence: 1, sentAt: 120 }); + const inputOneResult = authority.validateQuakeMultiplayerClientAuthority(inputOne, helloResult.state, { now: 120 }); + assert.equal(inputOneResult.ok, true); + + const replayedEnvelope = inputEnvelope({ sequence: 2, inputSequence: 2, sentAt: 140 }); + const replayedEnvelopeResult = authority.validateQuakeMultiplayerClientAuthority( + replayedEnvelope, + inputOneResult.state, + { now: 140 }, + ); + assert.equal(replayedEnvelopeResult.ok, false); + assert.equal(replayedEnvelopeResult.reject.code, "stale"); + + const replayedIntent = inputEnvelope({ sequence: 3, inputSequence: 1, sentAt: 150 }); + const replayedIntentResult = authority.validateQuakeMultiplayerClientAuthority( + replayedIntent, + inputOneResult.state, + { now: 150 }, + ); + assert.equal(replayedIntentResult.ok, false); + assert.equal(replayedIntentResult.reject.code, "stale"); + assert.match(replayedIntentResult.reject.message, /input sequence/); +}); + +test("client authority accepts rapid ordered input samples without rate-window rejects", () => { + const helloResult = authority.validateQuakeMultiplayerClientAuthority( + helloEnvelope({ sequence: 1, sentAt: 100 }), + null, + { now: 100 }, + ); + assert.equal(helloResult.ok, true); + + const firstInputResult = authority.validateQuakeMultiplayerClientAuthority( + inputEnvelope({ sequence: 2, inputSequence: 1, sentAt: 120 }), + helloResult.state, + { now: 120 }, + ); + assert.equal(firstInputResult.ok, true); + + const bunchedInputResult = authority.validateQuakeMultiplayerClientAuthority( + inputEnvelope({ sequence: 3, inputSequence: 2, sentAt: 124 }), + firstInputResult.state, + { now: 124 }, + ); + assert.equal(bunchedInputResult.ok, true); + assert.equal(bunchedInputResult.state.lastIntentSequences.input, 2); +}); + +test("client authority advances input intent sequence from ordered batches", () => { + const helloResult = authority.validateQuakeMultiplayerClientAuthority( + helloEnvelope({ sequence: 1, sentAt: 100 }), + null, + { now: 100 }, + ); + assert.equal(helloResult.ok, true); + + const batchResult = authority.validateQuakeMultiplayerClientAuthority( + inputBatchEnvelope({ sequence: 2, inputSequences: [1, 2, 3], sentAt: 120 }), + helloResult.state, + { now: 120 }, + ); + assert.equal(batchResult.ok, true); + assert.equal(batchResult.state.lastIntentSequences.input, 3); + + const replayedIntent = authority.validateQuakeMultiplayerClientAuthority( + inputBatchEnvelope({ sequence: 3, inputSequences: [2, 3], sentAt: 140 }), + batchResult.state, + { now: 140 }, + ); + assert.equal(replayedIntent.ok, false); + assert.equal(replayedIntent.reject.code, "stale"); + assert.match(replayedIntent.reject.message, /input sequence/); +}); + +test("client authority accepts immediate presence transitions", () => { + const helloResult = authority.validateQuakeMultiplayerClientAuthority( + helloEnvelope({ sequence: 1, sentAt: 100 }), + null, + { now: 100 }, + ); + assert.equal(helloResult.ok, true); + + const pausedResult = authority.validateQuakeMultiplayerClientAuthority( + presenceEnvelope("input-paused", { sequence: 2, messageId: "presence-paused", sentAt: 120 }), + helloResult.state, + { now: 120 }, + ); + assert.equal(pausedResult.ok, true); + + const activeResult = authority.validateQuakeMultiplayerClientAuthority( + presenceEnvelope("active", { sequence: 3, messageId: "presence-active", sentAt: 121 }), pausedResult.state, { now: 121 }, ); - assert.equal(activeResult.ok, true); + assert.equal(activeResult.ok, true); +}); + +test("party room keeps hello authority while trusted gameplay definitions are pending", async () => { + const { room, createConnection } = createFakePartyRoom(); + const RoomClass = partyRoomModule.default; + let resolveTrustedDefinitions; + const trustedDefinitions = new Promise((resolve) => { + resolveTrustedDefinitions = resolve; + }); + const partyRoom = new RoomClass(room, { + trustedGameplayDefinitionsFetcher: () => trustedDefinitions, + }); + const connection = createConnection("pending-hello-connection"); + + partyRoom.onConnect(connection); + const helloResult = partyRoom.onMessage(JSON.stringify(helloEnvelope({ + messageId: "pending-hello", + sequence: 1, + sentAt: Date.now(), + })), connection); + partyRoom.onMessage(JSON.stringify(presenceEnvelope("active", { + messageId: "presence-while-hello-pending", + sequence: 2, + sentAt: Date.now(), + })), connection); + + assert.equal(connection.closed.length, 0); + assert.equal(connection.messages.some((message) => + message.type === "room.reject" && + message.payload.code === "not-authorized" + ), false); + assert.equal(connection.state.authority.lastEnvelopeSequence, 2); + + resolveTrustedDefinitions({ + gameplayFacts: { + factsVersion: 1, + factsHash: "0000000000000000", + deathmatchSpawnCount: 0, + pickupCount: 0, + }, + deathmatchSpawns: [], + pickupDefinitions: [], + }); + await Promise.resolve(helloResult); + + assert.equal(connection.state.playerId, "party:client-a"); + assert.equal(connection.state.authority.lastEnvelopeSequence, 2); +}); + +test("room wrong-map rejects validate even when their room key differs", () => { + const reject = protocol.createQuakeMultiplayerEnvelope({ + direction: "room", + type: "room.reject", + roomKey: { + ...NORMALIZED_ROOM_KEY, + mapName: "e1m2", + sceneUrl: "/q/e1m2.json", + }, + sequence: 1, + sentAt: 100, + payload: { + code: "wrong-map", + message: "Room is running a different map.", + recoverable: false, + rejectedMessageId: "client-hello-1", + }, + }); + + const result = validation.validateQuakeMultiplayerRoomEnvelope(reject, { + roomKey: NORMALIZED_ROOM_KEY, + now: 100, + }); + assert.equal(result.ok, true); +}); + +test("room player fired events validate optional fire decisions", () => { + const event = protocol.createQuakeMultiplayerEnvelope({ + direction: "room", + type: "room.event", + roomKey: NORMALIZED_ROOM_KEY, + sequence: 1, + sentAt: 100, + payload: { + roomId: "room-fired-decision", + tick: 1, + sequence: 1, + event: { + eventType: "player.fired", + eventId: "fire-with-decision", + roomTime: 100, + playerId: "party:client-a", + weapon: "shotgun", + fireKind: "hitscan", + origin: [0, 0, 0], + direction: [1, 0, 0], + decision: { + blockedCandidateCount: 1, + candidateCount: 1, + outcome: "miss", + playerDamageCount: 0, + reason: "line-of-sight-blocked", + targetRewindMs: 100, + }, + }, + }, + }); + const result = validation.validateQuakeMultiplayerRoomEnvelope(event, { + roomKey: NORMALIZED_ROOM_KEY, + now: 100, + }); + assert.equal(result.ok, true); + + const invalid = validation.validateQuakeMultiplayerRoomEnvelope({ + ...event, + payload: { + ...event.payload, + event: { + ...event.payload.event, + decision: { + ...event.payload.event.decision, + reason: "not-a-real-reason", + }, + }, + }, + }, { + roomKey: NORMALIZED_ROOM_KEY, + now: 100, + }); + assert.equal(invalid.ok, false); + assert.equal(invalid.code, "malformed"); +}); + +test("room projectile lifecycle events validate authoritative projectile state", () => { + const spawned = protocol.createQuakeMultiplayerEnvelope({ + direction: "room", + type: "room.event", + roomKey: NORMALIZED_ROOM_KEY, + sequence: 1, + sentAt: 100, + payload: { + roomId: "room-projectile-events", + tick: 1, + sequence: 1, + event: { + eventType: "projectile.spawned", + eventId: "projectile-spawned-1", + roomTime: 100, + projectile: { + projectileId: "projectile-1", + ownerPlayerId: "party:client-a", + weapon: "rocketlauncher", + origin: [0, 0, 0], + direction: [1, 0, 0], + speed: 15.625, + spawnedAt: 100, + updatedAt: 100, + expiresAt: 5100, + }, + }, + }, + }); + assert.equal(validation.validateQuakeMultiplayerRoomEnvelope(spawned, { + roomKey: NORMALIZED_ROOM_KEY, + now: 100, + }).ok, true); + + const snapshot = protocol.createQuakeMultiplayerEnvelope({ + direction: "room", + type: "room.snapshot", + roomKey: NORMALIZED_ROOM_KEY, + sequence: 2, + sentAt: 150, + payload: { + roomId: "room-projectile-events", + tick: 2, + roomTime: 150, + match: { + status: "active", + clockMs: 150, + }, + players: [], + spectators: [], + pickups: [], + projectiles: [spawned.payload.event.projectile], + lastWorldEventSequence: 0, + }, + }); + assert.equal(validation.validateQuakeMultiplayerRoomEnvelope(snapshot, { + roomKey: NORMALIZED_ROOM_KEY, + now: 150, + }).ok, true); + + const invalidSnapshot = validation.validateQuakeMultiplayerRoomEnvelope({ + ...snapshot, + payload: { + ...snapshot.payload, + projectiles: [{ + ...snapshot.payload.projectiles[0], + speed: -1, + }], + }, + }, { + roomKey: NORMALIZED_ROOM_KEY, + now: 150, + }); + assert.equal(invalidSnapshot.ok, false); + assert.equal(invalidSnapshot.code, "malformed"); + + const impacted = protocol.createQuakeMultiplayerEnvelope({ + direction: "room", + type: "room.event", + roomKey: NORMALIZED_ROOM_KEY, + sequence: 2, + sentAt: 200, + payload: { + roomId: "room-projectile-events", + tick: 2, + sequence: 2, + event: { + eventType: "projectile.impacted", + eventId: "projectile-impacted-1", + roomTime: 200, + projectileId: "projectile-1", + ownerPlayerId: "party:client-a", + weapon: "rocketlauncher", + origin: [4, 0, 0], + impactKind: "player", + playerDamageCount: 1, + targetPlayerId: "party:client-b", + }, + }, + }); + assert.equal(validation.validateQuakeMultiplayerRoomEnvelope(impacted, { + roomKey: NORMALIZED_ROOM_KEY, + now: 200, + }).ok, true); + + const invalid = validation.validateQuakeMultiplayerRoomEnvelope({ + ...impacted, + payload: { + ...impacted.payload, + event: { + ...impacted.payload.event, + impactKind: "ceiling", + }, + }, + }, { + roomKey: NORMALIZED_ROOM_KEY, + now: 200, + }); + assert.equal(invalid.ok, false); + assert.equal(invalid.code, "malformed"); +}); + +test("loopback session emits hello snapshot, presence event, and suppresses paused input", async () => { + const harness = await createLoopbackHarness({ color: "#00ffaa" }); + const { messages, session, status } = harness; + try { + assert.equal(status.state, "connected"); + assert.equal(status.mode, "loopback"); + assert.equal(messages.length, 0); + + session.send(helloEnvelope({ + color: "#00ffaa", + messageId: "hello-1", + sequence: 1, + sentAt: harness.now(), + })); + + const helloSnapshot = latestMessage(messages, "room.snapshot"); + assert.equal(helloSnapshot.payload.players.length, 1); + assert.equal(helloSnapshot.payload.players[0].playerId, "loopback:client-a"); + assert.equal(helloSnapshot.payload.players[0].displayName, "Alice"); + assert.equal(helloSnapshot.payload.players[0].lastInputSequence, 0); + + harness.advanceNow(120); + session.send(presenceEnvelope("input-paused", { + messageId: "presence-1", + sequence: 2, + sentAt: harness.now(), + })); + + const presenceEvent = latestMessage(messages, "room.event"); + assert.equal(presenceEvent.payload.event.eventType, "player.presence"); + assert.equal(presenceEvent.payload.event.playerId, "loopback:client-a"); + assert.equal(presenceEvent.payload.event.status, "input-paused"); + + const pausedSnapshot = latestMessage(messages, "room.snapshot"); + assert.equal(pausedSnapshot.payload.players[0].lastInputSequence, 0); + const messageCountBeforePausedInput = messages.length; + + harness.advanceNow(20); + session.send(inputEnvelope({ sequence: 3, inputSequence: 1, sentAt: harness.now() })); + assert.equal(messages.length, messageCountBeforePausedInput); + + harness.advanceNow(120); + session.send(presenceEnvelope("active", { + messageId: "presence-2", + sequence: 4, + sentAt: harness.now(), + })); + + const activeEvent = latestMessage(messages, "room.event"); + assert.equal(activeEvent.payload.event.status, "active"); + } finally { + harness.disconnect(); + } +}); + +test("loopback session rejects paused mutation intents", async () => { + const harness = await createLoopbackHarness({ now: 2000 }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-paused", sequence: 1, sentAt: harness.now() })); + + harness.advanceNow(120); + session.send(presenceEnvelope("backgrounded", { + messageId: "presence-backgrounded", + sequence: 2, + sentAt: harness.now(), + })); + assert.equal(latestMessage(messages, "room.event").payload.event.status, "backgrounded"); + + const mutationCases = [ + { + messageId: "paused-fire", + envelope: () => fireEnvelope({ sequence: 3, fireSequence: 1, sentAt: harness.now() }), + advanceMs: 30, + }, + { + messageId: "paused-pickup", + envelope: () => pickupEnvelope({ sequence: 4, pickupSequence: 1, sentAt: harness.now() }), + advanceMs: 160, + }, + { + messageId: "paused-world", + envelope: () => worldEnvelope({ sequence: 5, worldSequence: 1, sentAt: harness.now() }), + advanceMs: 1, + }, + { + messageId: "paused-match", + envelope: () => matchEnvelope({ sequence: 6, matchSequence: 1, sentAt: harness.now() }), + advanceMs: 250, + }, + ]; + + const firstMutationMessageCount = messages.length; + for (const testCase of mutationCases) { + harness.advanceNow(testCase.advanceMs); + session.send(testCase.envelope()); + const reject = latestMessage(messages, "room.reject"); + assert.equal(reject.payload.rejectedMessageId, testCase.messageId); + assert.equal(reject.payload.code, "unsupported"); + assert.equal(reject.payload.recoverable, true); + assert.match(reject.payload.message, /input is paused/); + } + assert.equal(messages.filter((message) => message.type === "room.reject").length, mutationCases.length); + assert.equal( + messages.slice(firstMutationMessageCount).filter((message) => message.type === "room.event").length, + 0, + ); + } finally { + harness.disconnect(); + } +}); + +test("loopback session rejects fire timestamps outside accepted input history", async () => { + const harness = await createLoopbackHarness({ now: 3000 }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-fire-history", sequence: 1, sentAt: harness.now() })); + session.send(inputBatchEnvelope({ + messageId: "loopback-fire-history-inputs", + sequence: 2, + sentAt: harness.now(), + inputSequences: [1, 2], + inputs: [ + { sampledAt: harness.now(), rotX: -78, rotY: 0 }, + { sampledAt: harness.now() + 50, rotX: -78, rotY: 0 }, + ], + })); + harness.advanceNow(80); + session.send(fireEnvelope({ + messageId: "loopback-fire-history-too-late", + sequence: 3, + sentAt: harness.now(), + fireSequence: 1, + fire: { + firedAt: harness.now() + 1_000, + }, + })); + + const reject = latestMessage(messages, "room.reject"); + assert.equal(reject.payload.code, "stale"); + assert.equal(reject.payload.recoverable, true); + assert.equal(reject.payload.rejectedMessageId, "loopback-fire-history-too-late"); + assert.match(reject.payload.message, /fire-after-input-history/); + } finally { + harness.disconnect(); + } +}); + +test("loopback session uses fire payload aim when the authoritative pose is one input behind", async () => { + const remotePlayer = createPlayer({ + playerId: "remote-player", + clientId: "remote-client", + displayName: "Remote", + origin: [4, 0, 0], + rotX: -78, + rotY: 180, + updatedAt: 5000, + }); + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-local", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 180, + }], + pickupDefinitions: [], + }); + const harness = await createLoopbackHarness({ + now: 5000, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + simulatedPlayers: () => [remotePlayer], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-fresh-aim", sequence: 1, sentAt: harness.now() })); + harness.advanceNow(120); + session.send(fireEnvelope({ + messageId: "fire-loopback-fresh-aim", + sequence: 2, + fireSequence: 1, + sentAt: harness.now(), + fire: { direction: DUEL_FORWARD_DIRECTION }, + })); + + const event = latestMessage(messages, "room.event").payload.event; + assert.equal(event.eventType, "player.damaged"); + assert.equal(event.victimPlayerId, "remote-player"); + assert.equal(event.damage, 24); + assert.equal(event.health, 76); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } +}); + +test("loopback session uses a bounded fire origin hint when the authoritative origin is one input behind", async () => { + const remotePlayer = createPlayer({ + playerId: "remote-player", + clientId: "remote-client", + displayName: "Remote", + origin: [4, 0.9, 0], + rotX: -78, + rotY: 180, + updatedAt: 5050, + }); + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-local", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [], + }); + const harness = await createLoopbackHarness({ + now: 5050, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + simulatedPlayers: () => [remotePlayer], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-fresh-origin", sequence: 1, sentAt: harness.now() })); + harness.advanceNow(120); + session.send(fireEnvelope({ + messageId: "fire-loopback-fresh-origin", + sequence: 2, + fireSequence: 1, + sentAt: harness.now(), + fire: { origin: [0, 0.4, 0] }, + })); + + const event = latestMessage(messages, "room.event").payload.event; + assert.equal(event.eventType, "player.damaged"); + assert.equal(event.victimPlayerId, "remote-player"); + assert.equal(event.damage, 24); + assert.equal(event.health, 76); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } +}); + +test("loopback session applies damage when LOS trace only clips the target skin", async () => { + const remotePlayer = createPlayer({ + playerId: "remote-player", + clientId: "remote-client", + displayName: "Remote", + origin: [4, 0, 0], + rotX: -78, + rotY: 180, + updatedAt: 5000, + }); + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-local", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [], + }); + const harness = await createLoopbackHarness({ + now: 5000, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + trustedSceneMovement: { + collisionWorld: { + traceUse: () => ({ + fraction: 0.985, + end: [3.92, 0, -0.82], + planeNormal: [0, 0, 1], + entityIndex: 84, + modelIndex: 3, + classname: "func_wall", + }), + }, + playerEyeHeight: 1.0, + }, + simulatedPlayers: () => [remotePlayer], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-late-los", sequence: 1, sentAt: harness.now() })); + harness.advanceNow(120); + session.send(fireEnvelope({ + messageId: "fire-loopback-late-los", + sequence: 2, + fireSequence: 1, + sentAt: harness.now(), + })); + + const event = latestMessage(messages, "room.event").payload.event; + assert.equal(event.eventType, "player.damaged"); + assert.equal(event.victimPlayerId, "remote-player"); + assert.equal(event.damage, 24); + assert.equal(event.health, 76); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } +}); + +test("loopback session applies source-order armor save but suppresses health damage while simulated victim is invulnerable", async () => { + const remoteInventory = { + ...items.createQuakeMultiplayerInitialInventory(), + health: 100, + armor: 50, + armorType: 0.8, + powerups: [{ + active: true, + activationField: "invincible_time", + finishedAt: 15_000, + finishedField: "invincible_finished", + itemFlag: INVULNERABILITY_ITEM_FLAG, + }], + }; + const remotePlayer = items.quakeMultiplayerPlayerWithInventory( + createPlayer({ + playerId: "remote-player", + clientId: "remote-client", + displayName: "Remote", + origin: [4, 0, 0], + rotX: -78, + rotY: 180, + updatedAt: 5_200, + }), + remoteInventory, + ); + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-local", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [], + }); + const harness = await createLoopbackHarness({ + now: 5_200, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + simulatedPlayers: () => [remotePlayer], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-invulnerable", sequence: 1, sentAt: harness.now() })); + harness.advanceNow(120); + session.send(fireEnvelope({ + messageId: "fire-loopback-invulnerable", + sequence: 2, + fireSequence: 1, + sentAt: harness.now(), + })); + + const events = messages + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event); + assert.equal(events.some((event) => + event.eventType === "player.damaged" && event.victimPlayerId === "remote-player" + ), false); + assert.equal(events.some((event) => + event.eventType === "player.killed" && event.victimPlayerId === "remote-player" + ), false); + const snapshot = latestMessage(messages, "room.snapshot"); + const remoteSnapshot = snapshot.payload.players.find((player) => player.playerId === "remote-player"); + assert.equal(remoteSnapshot?.health, 100); + assert.equal(remoteSnapshot?.armor, 30); + assert.equal(remoteSnapshot?.alive, true); + assert.ok( + (remoteSnapshot?.velocity?.some((value) => Math.abs(value) > 0) ?? false), + "expected invulnerable target to still receive source-style damage momentum", + ); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } }); -test("party room keeps hello authority while trusted gameplay definitions are pending", async () => { - const { room, createConnection } = createFakePartyRoom(); - const RoomClass = partyRoomModule.default; - let resolveTrustedDefinitions; - const trustedDefinitions = new Promise((resolve) => { - resolveTrustedDefinitions = resolve; +test("loopback session double-invulnerable telefrag clears protection and kills both players like Quake teledeath3", async () => { + const invulnerabilityPickup = invulnerabilityPickupDefinition(); + const remoteInventory = { + ...items.createQuakeMultiplayerInitialInventory(), + itemFlags: INVULNERABILITY_ITEM_FLAG, + powerups: [{ + active: true, + activationField: "invincible_time", + finishedAt: 15_000, + finishedField: "invincible_finished", + itemFlag: INVULNERABILITY_ITEM_FLAG, + }], + }; + const remotePlayer = items.quakeMultiplayerPlayerWithInventory( + createPlayer({ + playerId: "remote-player", + clientId: "remote-client", + displayName: "Remote", + origin: [4, 0, 0], + rotX: -78, + rotY: 180, + updatedAt: 5_200, + }), + remoteInventory, + ); + const teleportDefinition = { + kind: "teleport", + entityIndex: 700, + classname: "trigger_teleport", + destinationEntityIndex: 701, + destinationOrigin: [4, 0, 0], + destinationRotX: -78, + destinationRotY: 180, + }; + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-local", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [invulnerabilityPickup], }); - const partyRoom = new RoomClass(room, { - trustedGameplayDefinitionsFetcher: () => trustedDefinitions, + const harness = await createLoopbackHarness({ + now: 5_200, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + trustedWorldDefinitions: [teleportDefinition], + simulatedPlayers: () => [remotePlayer], + }, }); - const connection = createConnection("pending-hello-connection"); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-double-telefrag", sequence: 1, sentAt: harness.now() })); + harness.advanceNow(120); + session.send(pickupEnvelope({ + messageId: "pickup-loopback-invulnerability", + sequence: 2, + pickupSequence: 1, + sentAt: harness.now(), + pickup: { + entityIndex: invulnerabilityPickup.entityIndex, + origin: [0, 0, 0], + }, + })); + assert.equal( + messages.some((message) => + message.type === "room.event" && + message.payload.event.eventType === "pickup.taken" && + message.payload.event.entityIndex === invulnerabilityPickup.entityIndex + ), + true, + ); - partyRoom.onConnect(connection); - const helloResult = partyRoom.onMessage(JSON.stringify(helloEnvelope({ - messageId: "pending-hello", - sequence: 1, - sentAt: Date.now(), - })), connection); - partyRoom.onMessage(JSON.stringify(presenceEnvelope("active", { - messageId: "presence-while-hello-pending", - sequence: 2, - sentAt: Date.now(), - })), connection); + harness.advanceNow(120); + session.send(worldEnvelope({ + messageId: "world-loopback-double-telefrag", + sequence: 3, + worldSequence: 1, + sentAt: harness.now(), + intent: { + intentType: "teleport", + entityIndex: teleportDefinition.entityIndex, + destinationEntityIndex: teleportDefinition.destinationEntityIndex, + origin: [0, 0, 0], + velocity: [0, 0, 0], + }, + })); - assert.equal(connection.closed.length, 0); - assert.equal(connection.messages.some((message) => - message.type === "room.reject" && - message.payload.code === "not-authorized" - ), false); - assert.equal(connection.state.authority.lastEnvelopeSequence, 2); + const kills = messages + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event) + .filter((event) => event.eventType === "player.killed" && event.damageSource === "teledeath3"); + assert.equal(kills.length, 2); + assert.equal(kills.some((event) => event.victimPlayerId === "loopback:client-a"), true); + assert.equal(kills.some((event) => event.victimPlayerId === "remote-player"), true); - resolveTrustedDefinitions({ - gameplayFacts: { - factsVersion: 1, - factsHash: "0000000000000000", - deathmatchSpawnCount: 0, - pickupCount: 0, + const snapshot = latestMessage(messages, "room.snapshot"); + const localSnapshot = snapshot.payload.players.find((player) => player.playerId === "loopback:client-a"); + const remoteSnapshot = snapshot.payload.players.find((player) => player.playerId === "remote-player"); + assert.equal(localSnapshot?.alive, false); + assert.equal(remoteSnapshot?.alive, false); + assert.equal(localSnapshot?.frags, -1); + assert.equal(remoteSnapshot?.frags, -1); + assert.equal(localSnapshot?.deaths, 1); + assert.equal(remoteSnapshot?.deaths, 1); + assert.equal( + localSnapshot?.inventory.powerups.some((powerup) => powerup.finishedField === "invincible_finished"), + false, + ); + assert.equal( + remoteSnapshot?.inventory.powerups.some((powerup) => powerup.finishedField === "invincible_finished"), + false, + ); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } +}); + +test("loopback session clears local active artifact powerups immediately on death", async () => { + const quadPickup = quadPickupDefinition({ durationMs: 30_000 }); + const hurtDefinition = { + kind: "hurt", + entityIndex: 6_001, + classname: "trigger_hurt", + damage: 150, + }; + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-loopback-local-death", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [quadPickup], + }); + const harness = await createLoopbackHarness({ + now: 5_400, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + trustedWorldDefinitions: [hurtDefinition], }, - deathmatchSpawns: [], - pickupDefinitions: [], }); - await Promise.resolve(helloResult); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-local-death-powerups", sequence: 1, sentAt: harness.now() })); + harness.advanceNow(120); + session.send(pickupEnvelope({ + messageId: "pickup-loopback-local-death-quad", + sequence: 2, + pickupSequence: 1, + sentAt: harness.now(), + pickup: { entityIndex: quadPickup.entityIndex, origin: [0, 0, 0] }, + })); + assert.equal( + messages.some((message) => + message.type === "room.event" && + message.payload.event.eventType === "pickup.taken" && + message.payload.event.entityIndex === quadPickup.entityIndex + ), + true, + ); - assert.equal(connection.state.playerId, "party:client-a"); - assert.equal(connection.state.authority.lastEnvelopeSequence, 2); + harness.advanceNow(120); + session.send(worldEnvelope({ + messageId: "world-loopback-local-death-powerups", + sequence: 3, + worldSequence: 1, + sentAt: harness.now(), + intent: { + entityIndex: hurtDefinition.entityIndex, + origin: [0, 0, 0], + }, + })); + + const kill = messages + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event) + .find((event) => + event.eventType === "player.killed" && + event.victimPlayerId === "loopback:client-a" && + event.damageSource === "trigger_hurt" + ); + assert.ok(kill, "expected local trigger_hurt death"); + const snapshot = latestMessage(messages, "room.snapshot"); + const localSnapshot = snapshot.payload.players.find((player) => player.playerId === "loopback:client-a"); + assert.equal(localSnapshot?.alive, false); + assert.equal(localSnapshot?.inventory.itemFlags & QUAD_ITEM_FLAG, 0); + assert.equal( + localSnapshot?.inventory.powerups.some((powerup) => powerup.finishedField === "super_damage_finished"), + false, + ); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } }); -test("room wrong-map rejects validate even when their room key differs", () => { - const reject = protocol.createQuakeMultiplayerEnvelope({ - direction: "room", - type: "room.reject", - roomKey: { - ...NORMALIZED_ROOM_KEY, - mapName: "e1m2", - sceneUrl: "/q/e1m2.json", +test("loopback session rewinds hit tests from authoritative snapshot history instead of current velocity", async () => { + let remotePlayer = createPlayer({ + playerId: "remote-player", + clientId: "remote-client", + displayName: "Remote", + origin: [4, 0, 0], + velocity: [0, 0, 0], + rotX: -78, + rotY: 180, + updatedAt: 7_000, + }); + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-local", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [], + }); + const harness = await createLoopbackHarness({ + now: 7_000, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + simulatedPlayers: () => [remotePlayer], }, - sequence: 1, - sentAt: 100, - payload: { - code: "wrong-map", - message: "Room is running a different map.", - recoverable: false, - rejectedMessageId: "client-hello-1", + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-history-hit", sequence: 1, sentAt: harness.now() })); + remotePlayer = { + ...remotePlayer, + origin: [4, 1.4, 0], + velocity: [0, 0, 0], + updatedAt: 7_100, + }; + harness.advanceNow(100); + session.send(fireEnvelope({ + messageId: "fire-loopback-history-hit", + sequence: 2, + fireSequence: 1, + sentAt: harness.now(), + fire: { + origin: [0, 0, -0.36], + direction: [1, 0, 0], + }, + })); + + const event = messages + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event) + .find((candidate) => + candidate.eventType === "player.damaged" && + candidate.victimPlayerId === "remote-player" + ); + assert.ok(event, "expected historical loopback target sample to receive damage"); + assert.equal(event.damage, 24); + assert.equal(event.health, 76); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } +}); + +test("loopback session blocks damage when LOS trace hits a real wall", async () => { + const remotePlayer = createPlayer({ + playerId: "remote-player", + clientId: "remote-client", + displayName: "Remote", + origin: [4, 0, 0], + rotX: -78, + rotY: 180, + updatedAt: 5500, + }); + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-local", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [], + }); + const harness = await createLoopbackHarness({ + now: 5500, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + trustedSceneMovement: { + collisionWorld: { + traceUse: () => ({ + fraction: 0.5, + end: [2, 0, -0.5], + planeNormal: [1, 0, 0], + entityIndex: 900, + modelIndex: 9, + classname: "func_wall", + }), + }, + playerEyeHeight: 1.0, + }, + simulatedPlayers: () => [remotePlayer], }, }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-wall-los", sequence: 1, sentAt: harness.now() })); + harness.advanceNow(120); + const beforeCount = messages.length; + session.send(fireEnvelope({ + messageId: "fire-loopback-wall-los", + sequence: 2, + fireSequence: 1, + sentAt: harness.now(), + })); - const result = validation.validateQuakeMultiplayerRoomEnvelope(reject, { - roomKey: NORMALIZED_ROOM_KEY, - now: 100, + const newEvents = messages + .slice(beforeCount) + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event); + assert.equal(newEvents.some((event) => event.eventType === "player.damaged"), false); + const snapshot = latestMessage(messages, "room.snapshot"); + const remoteSnapshot = snapshot.payload.players.find((player) => player.playerId === "remote-player"); + assert.equal(remoteSnapshot?.health, 100); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } +}); + +test("loopback session damages a farther visible simulated player when a nearer candidate is blocked", async () => { + const nearPlayer = createPlayer({ + playerId: "near-player", + clientId: "near-client", + displayName: "Near", + origin: [2, 0, 0], + rotX: -78, + rotY: 180, + updatedAt: 6000, }); - assert.equal(result.ok, true); + const farPlayer = createPlayer({ + playerId: "far-player", + clientId: "far-client", + displayName: "Far", + origin: [4, 0, 0], + rotX: -78, + rotY: 180, + updatedAt: 6000, + }); + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-local", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [], + }); + const harness = await createLoopbackHarness({ + now: 6000, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + trustedSceneMovement: { + collisionWorld: { + traceUse: (_origin, impact) => impact[0] < 3 + ? { + fraction: 0.5, + end: [1, 0, -0.5], + planeNormal: [1, 0, 0], + entityIndex: 44, + modelIndex: 2, + classname: "func_wall", + } + : null, + }, + playerEyeHeight: 1.0, + }, + simulatedPlayers: () => [nearPlayer, farPlayer], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-visible-far", sequence: 1, sentAt: harness.now() })); + harness.advanceNow(120); + session.send(fireEnvelope({ + messageId: "fire-loopback-visible-far", + sequence: 2, + fireSequence: 1, + sentAt: harness.now(), + })); + + const events = messages + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event); + assert.equal(events.some((event) => + event.eventType === "player.damaged" && event.victimPlayerId === "near-player" + ), false); + const farEvent = events.find((event) => + event.eventType === "player.damaged" && event.victimPlayerId === "far-player" + ); + assert.ok(farEvent, "expected farther visible simulated player to take damage"); + assert.equal(farEvent.damage, 24); + assert.equal(farEvent.health, 76); + const snapshot = latestMessage(messages, "room.snapshot"); + const nearSnapshot = snapshot.payload.players.find((player) => player.playerId === "near-player"); + const farSnapshot = snapshot.payload.players.find((player) => player.playerId === "far-player"); + assert.equal(nearSnapshot?.health, 100); + assert.equal(farSnapshot?.health, 76); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } }); -test("loopback session emits hello snapshot, presence event, and suppresses paused input", async () => { - const harness = await createLoopbackHarness({ color: "#00ffaa" }); - const { messages, session, status } = harness; +test("loopback session blocks indirect projectile splash through walls", async () => { + const rocketPickup = weaponPickupDefinition("rocketlauncher"); + const directPlayer = createPlayer({ + playerId: "direct-player", + clientId: "direct-client", + displayName: "Direct", + origin: [3, 0, 0], + rotX: -78, + rotY: 180, + updatedAt: 6500, + }); + const blockedPlayer = createPlayer({ + playerId: "blocked-player", + clientId: "blocked-client", + displayName: "Blocked", + origin: [3, 2, 0], + rotX: -78, + rotY: 180, + updatedAt: 6500, + }); + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-local", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [rocketPickup], + }); + const harness = await createLoopbackHarness({ + now: 6500, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + trustedSceneMovement: { + collisionWorld: { + traceUse: (_origin, point) => point[1] > 1 + ? { + fraction: 0.4, + end: [point[0], 1, point[2]], + planeNormal: [0, -1, 0], + entityIndex: 45, + modelIndex: 3, + classname: "func_wall", + } + : null, + }, + playerEyeHeight: 1.0, + }, + simulationTickMs: 1, + simulatedPlayers: () => [directPlayer, blockedPlayer], + }, + }); + const { messages, session } = harness; try { - assert.equal(status.state, "connected"); - assert.equal(status.mode, "loopback"); - assert.equal(messages.length, 0); - - session.send(helloEnvelope({ - color: "#00ffaa", - messageId: "hello-1", - sequence: 1, + session.send(helloEnvelope({ messageId: "hello-loopback-splash-wall", sequence: 1, sentAt: harness.now() })); + harness.advanceNow(120); + session.send(pickupEnvelope({ + messageId: "pickup-loopback-rocket", + sequence: 2, + pickupSequence: 1, + sentAt: harness.now(), + pickup: { entityIndex: rocketPickup.entityIndex, origin: [0, 0, 0] }, + })); + harness.advanceNow(200); + session.send(fireEnvelope({ + messageId: "fire-loopback-splash-wall", + sequence: 3, + fireSequence: 1, sentAt: harness.now(), })); + harness.advanceNow(400); + await waitForMessage(messages, (message) => + message.type === "room.event" && + message.payload.event.eventType === "projectile.impacted" + ); - const helloSnapshot = latestMessage(messages, "room.snapshot"); - assert.equal(helloSnapshot.payload.players.length, 1); - assert.equal(helloSnapshot.payload.players[0].playerId, "loopback:client-a"); - assert.equal(helloSnapshot.payload.players[0].displayName, "Alice"); - assert.equal(helloSnapshot.payload.players[0].lastInputSequence, 0); + const events = messages + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event); + assert.ok(events.some((event) => + event.eventType === "player.killed" && event.victimPlayerId === "direct-player" + )); + assert.equal(events.some((event) => + (event.eventType === "player.damaged" || event.eventType === "player.killed") && + event.victimPlayerId === "blocked-player" + ), false); + const snapshot = latestMessage(messages, "room.snapshot"); + const blockedSnapshot = snapshot.payload.players.find((player) => player.playerId === "blocked-player"); + assert.equal(blockedSnapshot?.health, 100); + assert.equal(blockedSnapshot?.alive, true); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } +}); +test("loopback session applies projectile wall-impact splash without a direct player hit", async () => { + const rocketPickup = weaponPickupDefinition("rocketlauncher"); + const nearMissPlayer = createPlayer({ + playerId: "near-miss-player", + clientId: "near-miss-client", + displayName: "Near Miss", + origin: [3, 2, 0], + rotX: -78, + rotY: 180, + updatedAt: 6900, + }); + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-local", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [rocketPickup], + }); + const harness = await createLoopbackHarness({ + now: 6900, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + trustedSceneMovement: { + collisionWorld: { + traceUse: (origin, point) => origin[0] === 0 && point[0] > 10 + ? { + fraction: 3 / 64, + end: [3, 0, 0], + planeNormal: [-1, 0, 0], + entityIndex: 44, + modelIndex: 3, + classname: "func_wall", + } + : null, + }, + playerEyeHeight: 1.0, + }, + simulationTickMs: 1, + simulatedPlayers: () => [nearMissPlayer], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-wall-splash", sequence: 1, sentAt: harness.now() })); harness.advanceNow(120); - session.send(presenceEnvelope("input-paused", { - messageId: "presence-1", + session.send(pickupEnvelope({ + messageId: "pickup-loopback-wall-splash-rocket", sequence: 2, + pickupSequence: 1, sentAt: harness.now(), + pickup: { entityIndex: rocketPickup.entityIndex, origin: [0, 0, 0] }, })); - - const presenceEvent = latestMessage(messages, "room.event"); - assert.equal(presenceEvent.payload.event.eventType, "player.presence"); - assert.equal(presenceEvent.payload.event.playerId, "loopback:client-a"); - assert.equal(presenceEvent.payload.event.status, "input-paused"); - - const pausedSnapshot = latestMessage(messages, "room.snapshot"); - assert.equal(pausedSnapshot.payload.players[0].lastInputSequence, 0); - const messageCountBeforePausedInput = messages.length; - - harness.advanceNow(20); - session.send(inputEnvelope({ sequence: 3, inputSequence: 1, sentAt: harness.now() })); - assert.equal(messages.length, messageCountBeforePausedInput); - - harness.advanceNow(120); - session.send(presenceEnvelope("active", { - messageId: "presence-2", - sequence: 4, + harness.advanceNow(200); + session.send(fireEnvelope({ + messageId: "fire-loopback-wall-splash", + sequence: 3, + fireSequence: 1, sentAt: harness.now(), + fire: { + direction: [1, 0, 0], + }, })); + harness.advanceNow(2_000); + await waitForMessage(messages, (message) => + message.type === "room.event" && + message.payload.event.eventType === "projectile.impacted" + ); - const activeEvent = latestMessage(messages, "room.event"); - assert.equal(activeEvent.payload.event.status, "active"); + const events = messages + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event); + const targetDamage = events.find((event) => + event.eventType === "player.damaged" && event.victimPlayerId === "near-miss-player" + ); + const selfDamage = events.find((event) => + event.eventType === "player.damaged" && event.victimPlayerId === "loopback:client-a" + ); + assert.ok(targetDamage, "expected wall splash to damage nearby simulated target"); + assert.equal(targetDamage.damage, 69); + assert.equal(targetDamage.health, 31); + assert.ok(selfDamage, "expected wall splash to apply half self damage"); + assert.equal(selfDamage.damage, 22); + assert.equal(selfDamage.health, 78); + const snapshot = latestMessage(messages, "room.snapshot"); + const targetSnapshot = snapshot.payload.players.find((player) => player.playerId === "near-miss-player"); + const selfSnapshot = snapshot.payload.players.find((player) => player.playerId === "loopback:client-a"); + assert.equal(targetSnapshot?.health, 31); + assert.equal(selfSnapshot?.health, 78); + assert.equal(messages.some((message) => message.type === "room.reject"), false); } finally { harness.disconnect(); } }); -test("loopback session rejects paused mutation intents", async () => { - const harness = await createLoopbackHarness({ now: 2000 }); +test("loopback session applies projectile quad damage from impact-time attacker state", async () => { + const cases = [ + { + id: "loopback-quad-expired-before-impact", + quadDurationMs: 250, + pickupQuadBeforeFire: true, + expectedDamage: 9, + expectedHealth: 91, + }, + { + id: "loopback-quad-picked-up-before-impact", + quadDurationMs: 30_000, + pickupQuadBeforeFire: false, + expectedDamage: 36, + expectedHealth: 64, + }, + ]; + + for (const spec of cases) { + const nailgunPickup = weaponPickupDefinition("nailgun"); + const quadPickup = quadPickupDefinition({ durationMs: spec.quadDurationMs }); + const remotePlayer = createPlayer({ + playerId: `remote-${spec.id}`, + clientId: `remote-client-${spec.id}`, + displayName: "Remote", + origin: [8, 0, 0], + rotX: -78, + rotY: 180, + updatedAt: 8_000, + }); + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: `spawn-${spec.id}`, + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [nailgunPickup, quadPickup], + }); + const harness = await createLoopbackHarness({ + now: 8_000, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + simulationTickMs: 1, + simulatedPlayers: () => [remotePlayer], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ + messageId: `hello-${spec.id}`, + sequence: 1, + sentAt: harness.now(), + })); + harness.advanceNow(120); + session.send(pickupEnvelope({ + messageId: `pickup-nailgun-${spec.id}`, + sequence: 2, + pickupSequence: 1, + sentAt: harness.now(), + pickup: { entityIndex: nailgunPickup.entityIndex, origin: [0, 0, 0] }, + })); + harness.advanceNow(160); + if (spec.pickupQuadBeforeFire) { + session.send(pickupEnvelope({ + messageId: `pickup-quad-before-fire-${spec.id}`, + sequence: 3, + pickupSequence: 2, + sentAt: harness.now(), + pickup: { entityIndex: quadPickup.entityIndex, origin: [0, 0, 0] }, + })); + harness.advanceNow(160); + } + session.send(fireEnvelope({ + messageId: `fire-${spec.id}`, + sequence: 4, + fireSequence: 1, + sentAt: harness.now(), + fire: { + weapon: "nailgun", + fireKind: "projectile", + direction: [1, 0, 0], + }, + })); + if (!spec.pickupQuadBeforeFire) { + harness.advanceNow(160); + session.send(pickupEnvelope({ + messageId: `pickup-quad-before-impact-${spec.id}`, + sequence: 5, + pickupSequence: 2, + sentAt: harness.now(), + pickup: { entityIndex: quadPickup.entityIndex, origin: [0, 0, 0] }, + })); + } + assert.ok( + messages.some((message) => + message.type === "room.event" && + message.payload.event.eventType === "pickup.taken" && + message.payload.event.entityIndex === quadPickup.entityIndex + ), + `expected loopback quad pickup to be accepted for ${spec.id}`, + ); + const preImpactSnapshot = latestMessage(messages, "room.snapshot"); + const preImpactLocalPlayer = preImpactSnapshot.payload.players + .find((player) => player.playerId === "loopback:client-a"); + assert.equal( + items.quakeMultiplayerDamageMultiplierForInventory(preImpactLocalPlayer?.inventory, harness.now()), + 4, + `expected loopback local quad to be active before impact for ${spec.id}`, + ); + harness.advanceNow(700); + await waitForMessage(messages, (message) => + message.type === "room.event" && + message.payload.event.eventType === "projectile.impacted" && + message.payload.event.weapon === "nailgun" + ); + + const events = messages + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event); + const damage = events.find((event) => + event.eventType === "player.damaged" && + event.victimPlayerId === remotePlayer.playerId && + event.damageSource === "nailgun" + ); + assert.ok(damage, `expected loopback nailgun damage for ${spec.id}`); + assert.equal(damage.damage, spec.expectedDamage, spec.id); + assert.equal(damage.health, spec.expectedHealth, spec.id); + const snapshot = latestMessage(messages, "room.snapshot"); + const remoteSnapshot = snapshot.payload.players.find((player) => player.playerId === remotePlayer.playerId); + assert.equal(remoteSnapshot?.health, spec.expectedHealth, spec.id); + assert.deepEqual(messages.filter((message) => message.type === "room.reject"), [], spec.id); + } finally { + harness.disconnect(); + } + } +}); + +test("loopback session publishes a dynamic backpack when a simulated player dies", async () => { + const remotePlayer = createPlayer({ + playerId: "remote-drop-backpack", + clientId: "remote-client-drop-backpack", + displayName: "Remote", + origin: [4, 0, 0], + rotX: -78, + rotY: 180, + health: 10, + inventory: { + ...items.createQuakeMultiplayerInitialInventory(), + health: 10, + itemFlags: items.createQuakeMultiplayerInitialInventory().itemFlags | QUAD_ITEM_FLAG, + activeWeapon: "rocketlauncher", + weapons: ["axe", "shotgun", "rocketlauncher"], + shells: 2, + rockets: 5, + powerups: [{ + active: true, + activationField: "super_damage_time", + finishedAt: 15_000, + finishedField: "super_damage_finished", + itemFlag: QUAD_ITEM_FLAG, + itemFlagExpression: "IT_QUAD", + }], + }, + updatedAt: 5_000, + }); + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-loopback-drop-backpack", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [], + }); + const harness = await createLoopbackHarness({ + now: 5_000, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + simulatedPlayers: () => [remotePlayer], + }, + }); const { messages, session } = harness; try { - session.send(helloEnvelope({ messageId: "hello-paused", sequence: 1, sentAt: harness.now() })); - + session.send(helloEnvelope({ messageId: "hello-loopback-drop-backpack", sequence: 1, sentAt: harness.now() })); harness.advanceNow(120); - session.send(presenceEnvelope("backgrounded", { - messageId: "presence-backgrounded", + session.send(fireEnvelope({ + messageId: "fire-loopback-drop-backpack", sequence: 2, + fireSequence: 1, sentAt: harness.now(), })); - assert.equal(latestMessage(messages, "room.event").payload.event.status, "backgrounded"); - const mutationCases = [ - { - messageId: "paused-fire", - envelope: () => fireEnvelope({ sequence: 3, fireSequence: 1, sentAt: harness.now() }), - advanceMs: 30, - }, - { - messageId: "paused-pickup", - envelope: () => pickupEnvelope({ sequence: 4, pickupSequence: 1, sentAt: harness.now() }), - advanceMs: 160, - }, - { - messageId: "paused-world", - envelope: () => worldEnvelope({ sequence: 5, worldSequence: 1, sentAt: harness.now() }), - advanceMs: 1, - }, - { - messageId: "paused-match", - envelope: () => matchEnvelope({ sequence: 6, matchSequence: 1, sentAt: harness.now() }), - advanceMs: 250, - }, - ]; + const dropped = messages + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event) + .find((event) => event.eventType === "pickup.dropped"); + assert.ok(dropped, "expected loopback pickup.dropped event"); + assert.equal(dropped.definition.classname, "item_backpack"); + assert.equal(dropped.definition.runtime, true); + assert.equal(dropped.definition.effect.shells, 2); + assert.equal(dropped.definition.effect.rockets, 5); + assert.equal(dropped.definition.effect.weapon.id, "rocketlauncher"); - const firstMutationMessageCount = messages.length; - for (const testCase of mutationCases) { - harness.advanceNow(testCase.advanceMs); - session.send(testCase.envelope()); - const reject = latestMessage(messages, "room.reject"); - assert.equal(reject.payload.rejectedMessageId, testCase.messageId); - assert.equal(reject.payload.code, "unsupported"); - assert.equal(reject.payload.recoverable, true); - assert.match(reject.payload.message, /input is paused/); - } - assert.equal(messages.filter((message) => message.type === "room.reject").length, mutationCases.length); + const snapshot = latestMessage(messages, "room.snapshot"); assert.equal( - messages.slice(firstMutationMessageCount).filter((message) => message.type === "room.event").length, - 0, + snapshot.payload.dynamicPickups.some((definition) => + definition.entityIndex === dropped.definition.entityIndex + ), + true, + ); + const remoteSnapshot = snapshot.payload.players.find((player) => player.playerId === "remote-drop-backpack"); + assert.equal(remoteSnapshot?.alive, false); + assert.equal(remoteSnapshot?.inventory.itemFlags & QUAD_ITEM_FLAG, 0); + assert.equal( + remoteSnapshot?.inventory.powerups.some((powerup) => powerup.finishedField === "super_damage_finished"), + false, ); } finally { harness.disconnect(); @@ -921,3 +3933,246 @@ test("loopback ignores touch prediction misses without room rejects", async () = harness.disconnect(); } }); + +test("loopback target dispatch activates non-button movers", async () => { + const triggerDefinition = { + kind: "trigger", + entityIndex: 190, + classname: "trigger_multiple", + bounds: { + mins: [-1, -1, 0], + maxs: [1, 1, 2], + }, + touchActivates: true, + useActivates: false, + shootActivates: false, + oneShot: false, + delayMs: 0, + waitMs: 0, + targetEntityIndexes: [189], + }; + const moverDefinition = { + kind: "mover", + entityIndex: 189, + classname: "func_door_secret", + bounds: { + mins: [2, -1, 0], + maxs: [3, 1, 2], + }, + touchActivates: false, + useActivates: true, + shootActivates: false, + speed: 50, + moveMs: 200, + delayMs: 0, + fromOrigin: [0, 0, 0], + toOrigin: [1, 0, 0], + targetEntityIndexes: [], + }; + const deathmatchSpawns = [{ + spawnId: "spawn-trigger", + classname: "info_player_deathmatch", + origin: [0, 0, 1], + rotX: 0, + rotY: 0, + }]; + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns, + pickupDefinitions: [], + }); + const harness = await createLoopbackHarness({ + now: 4100, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + trustedWorldDefinitions: [triggerDefinition, moverDefinition], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ + messageId: "hello-world-non-button-mover", + sequence: 1, + sentAt: harness.now(), + })); + + harness.advanceNow(120); + session.send(worldEnvelope({ + messageId: "world-non-button-mover", + sequence: 2, + worldSequence: 1, + sentAt: harness.now(), + intent: { + entityIndex: triggerDefinition.entityIndex, + origin: [0, 0, 1], + }, + })); + + const events = messages + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event); + const trigger = events.find((event) => + event.eventType === "world.trigger" && + event.entityIndex === triggerDefinition.entityIndex + ); + const targets = events.find((event) => + event.eventType === "world.targets" && + event.sourceEntityIndex === triggerDefinition.entityIndex + ); + const mover = events.find((event) => + event.eventType === "world.mover" && + event.entityIndex === moverDefinition.entityIndex + ); + + assert.ok(trigger, "expected trigger event"); + assert.ok(targets, "expected target dispatch event"); + assert.ok(mover, "expected target mover event"); + assert.equal(mover.classname, "func_door_secret"); + assert.equal(mover.activation, "target"); + assert.equal(mover.state, "moving-up"); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } +}); + +test("party room target dispatch activates relay chains and target teleporters", () => { + const triggerDefinition = { + kind: "trigger", + entityIndex: 100, + classname: "trigger_multiple", + bounds: { + mins: [-1, -1, 0], + maxs: [1, 1, 2], + }, + touchActivates: true, + useActivates: false, + shootActivates: false, + oneShot: false, + delayMs: 0, + waitMs: 0, + targetEntityIndexes: [101, 102], + }; + const relayDefinition = { + kind: "trigger", + entityIndex: 101, + classname: "trigger_relay", + touchActivates: false, + useActivates: true, + shootActivates: false, + oneShot: false, + delayMs: 0, + waitMs: 0, + targetEntityIndexes: [103], + }; + const teleportDefinition = { + kind: "teleport", + entityIndex: 102, + classname: "trigger_teleport", + touchRequiresActivation: true, + activationWindowMs: 200, + destinationEntityIndex: 900, + destinationOrigin: [8, 0, 1], + destinationRotX: 90, + destinationRotY: 180, + }; + const moverDefinition = { + kind: "mover", + entityIndex: 103, + classname: "func_plat", + bounds: { + mins: [2, -1, 0], + maxs: [3, 1, 2], + }, + touchActivates: false, + useActivates: true, + shootActivates: false, + speed: 50, + moveMs: 200, + delayMs: 0, + fromOrigin: [0, 0, 0], + toOrigin: [0, 0, 1], + targetEntityIndexes: [], + }; + const { alice, partyRoom } = connectDuelRoom({ + id: "party-target-relay-teleport", + deathmatchSpawns: [ + { + spawnId: "spawn-target-a", + classname: "info_player_deathmatch", + origin: [0, 0, 1], + rotX: 90, + rotY: 0, + }, + { + spawnId: "spawn-target-b", + classname: "info_player_deathmatch", + origin: [4, 0, 1], + rotX: 90, + rotY: 180, + }, + ], + roomOptions: { + trustedWorldDefinitions: [ + triggerDefinition, + relayDefinition, + teleportDefinition, + moverDefinition, + ], + }, + }); + + partyRoom.onMessage(JSON.stringify(worldEnvelope({ + clientId: "client-a", + messageId: "world-party-target-relay-teleport", + sequence: 2, + worldSequence: 1, + sentAt: Date.now(), + intent: { + entityIndex: triggerDefinition.entityIndex, + origin: [0, 0, 1], + }, + })), alice); + + const events = alice.messages + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event); + const sourceTrigger = events.find((event) => + event.eventType === "world.trigger" && + event.entityIndex === triggerDefinition.entityIndex && + event.activation === "touch" + ); + const sourceTargets = events.find((event) => + event.eventType === "world.targets" && + event.sourceEntityIndex === triggerDefinition.entityIndex + ); + const relayTrigger = events.find((event) => + event.eventType === "world.trigger" && + event.entityIndex === relayDefinition.entityIndex && + event.activation === "target" + ); + const relayTargets = events.find((event) => + event.eventType === "world.targets" && + event.sourceEntityIndex === relayDefinition.entityIndex + ); + const teleportUse = events.find((event) => + event.eventType === "world.use" && + event.entityIndex === teleportDefinition.entityIndex + ); + const mover = events.find((event) => + event.eventType === "world.mover" && + event.entityIndex === moverDefinition.entityIndex + ); + + assert.ok(sourceTrigger, "expected source trigger event"); + assert.ok(sourceTargets, "expected source target dispatch event"); + assert.deepEqual(sourceTargets.targetEntityIndexes, [101, 102]); + assert.ok(relayTrigger, "expected relay trigger event"); + assert.ok(relayTargets, "expected relay target dispatch event"); + assert.deepEqual(relayTargets.targetEntityIndexes, [103]); + assert.ok(teleportUse, "expected target teleporter activation event"); + assert.ok(mover, "expected chained target mover event"); + assert.equal(mover.classname, "func_plat"); + assert.equal(mover.activation, "target"); + assert.equal(mover.state, "moving-up"); + assert.equal(alice.messages.some((message) => message.type === "room.reject"), false); +}); diff --git a/test/multiplayer/world.test.mjs b/test/multiplayer/world.test.mjs index b7f5b45..7ca6ec7 100644 --- a/test/multiplayer/world.test.mjs +++ b/test/multiplayer/world.test.mjs @@ -1,10 +1,68 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; import test from "node:test"; +import { fileURLToPath } from "node:url"; import { createPlayer } from "./harness.mjs"; import { importTsModule } from "../importTsModule.mjs"; const world = await importTsModule("src/runtime/multiplayer/world.ts"); +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +const PREPARED_SHAREWARE_MAPS = [ + "start", + "e1m1", + "e1m2", + "e1m3", + "e1m4", + "e1m5", + "e1m6", + "e1m7", + "e1m8", +]; + +const MIN_WORLD_DEFINITION_COUNTS = { + start: 31, + e1m1: 55, + e1m2: 57, + e1m3: 93, + e1m4: 90, + e1m5: 70, + e1m6: 99, + e1m7: 31, + e1m8: 24, +}; + +const SUPPORTED_SHARED_WORLD_TARGET_CLASSNAMES = new Set([ + "trigger_teleport", + "trigger_changelevel", + "trigger_hurt", + "trigger_push", + "trigger_multiple", + "trigger_once", + "trigger_secret", + "trigger_counter", + "trigger_relay", + "func_button", + "func_door", + "func_door_secret", + "func_plat", +]); + +const CLASSIFIED_UNSHARED_TARGET_CLASSNAMES = new Set([ + "event_lightning", + "func_train", + "light", + "monster_army", + "monster_demon1", + "monster_knight", + "monster_ogre", + "monster_shambler", + "monster_wizard", + "monster_zombie", + "trap_spikeshooter", +]); function triggerDefinition(overrides = {}) { return { @@ -36,6 +94,77 @@ function touchIntent(overrides = {}) { }; } +function readPreparedScene(mapName) { + const scenePath = path.join(projectRoot, "build/generated/public/q", `${mapName}.json`); + return JSON.parse(readFileSync(scenePath, "utf8")); +} + +function assertFiniteVec3(value, label) { + assert.equal(Array.isArray(value), true, `${label} must be an array`); + assert.equal(value.length, 3, `${label} must have three components`); + for (const component of value) { + assert.equal(Number.isFinite(component), true, `${label} has non-finite component ${String(component)}`); + } +} + +test("prepared shareware maps derive trusted multiplayer world definitions without shared-target holes", () => { + const failures = []; + for (const mapName of PREPARED_SHAREWARE_MAPS) { + const scene = readPreparedScene(mapName); + const entityByIndex = new Map(scene.entities.map((entity) => [entity.index, entity])); + const definitions = world.quakeMultiplayerWorldDefinitionsFromScene(scene, {}); + const definitionsByIndex = new Map(definitions.map((definition) => [definition.entityIndex, definition])); + + if (definitions.length < MIN_WORLD_DEFINITION_COUNTS[mapName]) { + failures.push(`${mapName}: expected at least ${MIN_WORLD_DEFINITION_COUNTS[mapName]} world definitions, got ${definitions.length}`); + } + if (definitionsByIndex.size !== definitions.length) { + failures.push(`${mapName}: duplicate world definition entity indexes`); + } + + for (const definition of definitions) { + if (definition.bounds) { + assertFiniteVec3(definition.bounds.mins, `${mapName}:${definition.entityIndex} bounds.mins`); + assertFiniteVec3(definition.bounds.maxs, `${mapName}:${definition.entityIndex} bounds.maxs`); + } + if (definition.kind === "mover") { + assertFiniteVec3(definition.fromOrigin, `${mapName}:${definition.entityIndex} fromOrigin`); + assertFiniteVec3(definition.toOrigin, `${mapName}:${definition.entityIndex} toOrigin`); + if (!Number.isFinite(definition.speed) || definition.speed <= 0) { + failures.push(`${mapName}:${definition.entityIndex}: invalid mover speed ${String(definition.speed)}`); + } + if (!Number.isFinite(definition.moveMs) || definition.moveMs < 0) { + failures.push(`${mapName}:${definition.entityIndex}: invalid mover duration ${String(definition.moveMs)}`); + } + } else if (definition.kind === "teleport") { + assertFiniteVec3(definition.destinationOrigin, `${mapName}:${definition.entityIndex} destinationOrigin`); + } else if (definition.kind === "hurt" && (!Number.isFinite(definition.damage) || definition.damage <= 0)) { + failures.push(`${mapName}:${definition.entityIndex}: invalid trigger_hurt damage ${String(definition.damage)}`); + } else if (definition.kind === "push") { + assertFiniteVec3(definition.velocity, `${mapName}:${definition.entityIndex} push velocity`); + } else if (definition.kind === "changelevel" && !definition.targetMap) { + failures.push(`${mapName}:${definition.entityIndex}: missing changelevel target map`); + } + + const targetIndexes = [ + ...(definition.targetEntityIndexes ?? []), + ...(definition.killtargetEntityIndexes ?? []), + ]; + for (const targetIndex of targetIndexes) { + if (definitionsByIndex.has(targetIndex)) continue; + const targetClassname = entityByIndex.get(targetIndex)?.classname ?? ""; + if (SUPPORTED_SHARED_WORLD_TARGET_CLASSNAMES.has(targetClassname)) { + failures.push(`${mapName}:${definition.entityIndex} targets unsupported missing shared entity ${targetIndex} ${targetClassname}`); + } else if (!CLASSIFIED_UNSHARED_TARGET_CLASSNAMES.has(targetClassname)) { + failures.push(`${mapName}:${definition.entityIndex} targets unclassified non-shared entity ${targetIndex} ${targetClassname}`); + } + } + } + } + + assert.deepEqual(failures, []); +}); + 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] }), diff --git a/test/runtime/entities.test.mjs b/test/runtime/entities.test.mjs new file mode 100644 index 0000000..714be39 --- /dev/null +++ b/test/runtime/entities.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { importTsModule } from "../importTsModule.mjs"; + +const entities = await importTsModule("src/runtime/entities.ts"); + +function entityWithSpawnflags(spawnflags) { + return { + index: 1, + classname: "item_shells", + properties: { + spawnflags: String(spawnflags), + }, + }; +} + +test("deathmatch spawn filtering ignores skill-only exclusion flags", () => { + const deathmatchOnly = entityWithSpawnflags(256 | 512 | 1024); + + assert.equal(entities.shouldSpawnQuakeEntityForGameMode(deathmatchOnly, { skill: 0 }), false); + assert.equal(entities.shouldSpawnQuakeEntityForGameMode(deathmatchOnly, { skill: 1 }), false); + assert.equal(entities.shouldSpawnQuakeEntityForGameMode(deathmatchOnly, { skill: 2 }), false); + assert.equal(entities.shouldSpawnQuakeEntityForGameMode(deathmatchOnly, { deathmatch: true }), true); +}); + +test("deathmatch spawn filtering rejects NOT_DEATHMATCH entities", () => { + assert.equal( + entities.shouldSpawnQuakeEntityForGameMode(entityWithSpawnflags(2048), { deathmatch: true }), + false, + ); +}); From e948d76ee09989ed15d828fb34185a79d7010063 Mon Sep 17 00:00:00 2001 From: agustin-lowpoly Date: Tue, 23 Jun 2026 15:47:47 -0300 Subject: [PATCH 4/5] Skip generated world asset check when assets are absent --- test/multiplayer/world.test.mjs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/test/multiplayer/world.test.mjs b/test/multiplayer/world.test.mjs index 7ca6ec7..65317d4 100644 --- a/test/multiplayer/world.test.mjs +++ b/test/multiplayer/world.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; @@ -94,9 +94,16 @@ function touchIntent(overrides = {}) { }; } +function preparedScenePath(mapName) { + return path.join(projectRoot, "build/generated/public/q", `${mapName}.json`); +} + +function hasPreparedSharewareScenes() { + return PREPARED_SHAREWARE_MAPS.every((mapName) => existsSync(preparedScenePath(mapName))); +} + function readPreparedScene(mapName) { - const scenePath = path.join(projectRoot, "build/generated/public/q", `${mapName}.json`); - return JSON.parse(readFileSync(scenePath, "utf8")); + return JSON.parse(readFileSync(preparedScenePath(mapName), "utf8")); } function assertFiniteVec3(value, label) { @@ -107,7 +114,11 @@ function assertFiniteVec3(value, label) { } } -test("prepared shareware maps derive trusted multiplayer world definitions without shared-target holes", () => { +test("prepared shareware maps derive trusted multiplayer world definitions without shared-target holes", (t) => { + if (!hasPreparedSharewareScenes()) { + t.skip("requires generated shareware scene JSON; run pnpm prepare:quake first"); + return; + } const failures = []; for (const mapName of PREPARED_SHAREWARE_MAPS) { const scene = readPreparedScene(mapName); From 3f534b77235645f01c5173fa693c18e0d944c086 Mon Sep 17 00:00:00 2001 From: agustin-lowpoly Date: Tue, 23 Jun 2026 16:00:03 -0300 Subject: [PATCH 5/5] Split multiplayer protocol tests --- test/multiplayer/deathmatch.test.mjs | 26 +- test/multiplayer/loopbackSession.test.mjs | 1511 +++++++ test/multiplayer/partyRoomCombat.test.mjs | 1155 +++++ test/multiplayer/partyRoomHarness.mjs | 366 ++ test/multiplayer/partyRoomLifecycle.test.mjs | 275 ++ .../multiplayer/partyRoomProjectiles.test.mjs | 423 ++ test/multiplayer/partyRoomWorld.test.mjs | 149 + test/multiplayer/protocol.test.mjs | 3769 +---------------- 8 files changed, 3903 insertions(+), 3771 deletions(-) create mode 100644 test/multiplayer/loopbackSession.test.mjs create mode 100644 test/multiplayer/partyRoomCombat.test.mjs create mode 100644 test/multiplayer/partyRoomHarness.mjs create mode 100644 test/multiplayer/partyRoomLifecycle.test.mjs create mode 100644 test/multiplayer/partyRoomProjectiles.test.mjs create mode 100644 test/multiplayer/partyRoomWorld.test.mjs diff --git a/test/multiplayer/deathmatch.test.mjs b/test/multiplayer/deathmatch.test.mjs index 91ad2ca..d47737c 100644 --- a/test/multiplayer/deathmatch.test.mjs +++ b/test/multiplayer/deathmatch.test.mjs @@ -6,7 +6,7 @@ import { importTsModule } from "../importTsModule.mjs"; const deathmatch = await importTsModule("src/runtime/multiplayer/deathmatch.ts"); const constants = await importTsModule("src/runtime/constants.ts"); -const liveE1m7FollowupFire = { +const lateBrushTraceFireFixture = { weapon: "shotgun", fireKind: "hitscan", origin: [23.04, -8.48, 5.400625], @@ -14,11 +14,11 @@ const liveE1m7FollowupFire = { range: 64, }; -const liveE1m7FollowupHit = { +const lateBrushTraceHitFixture = { target: { - playerId: "party:client-prod-dyn-2", - clientId: "client-prod-dyn-2", - displayName: "Prod Dyn 2", + playerId: "party:target-fixture", + clientId: "target-fixture", + displayName: "Target Fixture", mapName: "e1m7", origin: [10.383999999999983, -8.48, 5.400625], velocity: [0, 0, 0], @@ -135,8 +135,8 @@ test("direct player hit accepts a late brush trace inside the target hit skin", assert.equal( deathmatch.quakeMultiplayerDeathmatchHitHasLineOfSight( - liveE1m7FollowupFire, - liveE1m7FollowupHit, + lateBrushTraceFireFixture, + lateBrushTraceHitFixture, collisionWorld, ), true, @@ -157,8 +157,8 @@ test("direct player hit still rejects a wall trace before the target skin", () = assert.equal( deathmatch.quakeMultiplayerDeathmatchHitHasLineOfSight( - liveE1m7FollowupFire, - liveE1m7FollowupHit, + lateBrushTraceFireFixture, + lateBrushTraceHitFixture, collisionWorld, ), false, @@ -179,8 +179,8 @@ test("projectile direct player hit uses projectile target skin for late LOS trac assert.equal( deathmatch.quakeMultiplayerDeathmatchHitHasLineOfSight( - { ...liveE1m7FollowupFire, weapon: "rocketlauncher", fireKind: "projectile" }, - liveE1m7FollowupHit, + { ...lateBrushTraceFireFixture, weapon: "rocketlauncher", fireKind: "projectile" }, + lateBrushTraceHitFixture, collisionWorld, ), true, @@ -201,8 +201,8 @@ test("projectile direct player hit still rejects traces outside projectile targe assert.equal( deathmatch.quakeMultiplayerDeathmatchHitHasLineOfSight( - { ...liveE1m7FollowupFire, weapon: "rocketlauncher", fireKind: "projectile" }, - liveE1m7FollowupHit, + { ...lateBrushTraceFireFixture, weapon: "rocketlauncher", fireKind: "projectile" }, + lateBrushTraceHitFixture, collisionWorld, ), false, diff --git a/test/multiplayer/loopbackSession.test.mjs b/test/multiplayer/loopbackSession.test.mjs new file mode 100644 index 0000000..44403e1 --- /dev/null +++ b/test/multiplayer/loopbackSession.test.mjs @@ -0,0 +1,1511 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createLoopbackHarness, + createPlayer, + fireEnvelope, + helloEnvelope, + inputBatchEnvelope, + inputEnvelope, + latestMessage, + matchEnvelope, + pickupEnvelope, + presenceEnvelope, + waitForMessage, + worldEnvelope, + facts, + items, + DUEL_FORWARD_DIRECTION, + QUAD_ITEM_FLAG, + INVULNERABILITY_ITEM_FLAG, + weaponPickupDefinition, + quadPickupDefinition, + invulnerabilityPickupDefinition, +} from "./partyRoomHarness.mjs"; + +test("loopback session emits hello snapshot, presence event, and suppresses paused input", async () => { + const harness = await createLoopbackHarness({ color: "#00ffaa" }); + const { messages, session, status } = harness; + try { + assert.equal(status.state, "connected"); + assert.equal(status.mode, "loopback"); + assert.equal(messages.length, 0); + + session.send(helloEnvelope({ + color: "#00ffaa", + messageId: "hello-1", + sequence: 1, + sentAt: harness.now(), + })); + + const helloSnapshot = latestMessage(messages, "room.snapshot"); + assert.equal(helloSnapshot.payload.players.length, 1); + assert.equal(helloSnapshot.payload.players[0].playerId, "loopback:client-a"); + assert.equal(helloSnapshot.payload.players[0].displayName, "Alice"); + assert.equal(helloSnapshot.payload.players[0].lastInputSequence, 0); + + harness.advanceNow(120); + session.send(presenceEnvelope("input-paused", { + messageId: "presence-1", + sequence: 2, + sentAt: harness.now(), + })); + + const presenceEvent = latestMessage(messages, "room.event"); + assert.equal(presenceEvent.payload.event.eventType, "player.presence"); + assert.equal(presenceEvent.payload.event.playerId, "loopback:client-a"); + assert.equal(presenceEvent.payload.event.status, "input-paused"); + + const pausedSnapshot = latestMessage(messages, "room.snapshot"); + assert.equal(pausedSnapshot.payload.players[0].lastInputSequence, 0); + const messageCountBeforePausedInput = messages.length; + + harness.advanceNow(20); + session.send(inputEnvelope({ sequence: 3, inputSequence: 1, sentAt: harness.now() })); + assert.equal(messages.length, messageCountBeforePausedInput); + + harness.advanceNow(120); + session.send(presenceEnvelope("active", { + messageId: "presence-2", + sequence: 4, + sentAt: harness.now(), + })); + + const activeEvent = latestMessage(messages, "room.event"); + assert.equal(activeEvent.payload.event.status, "active"); + } finally { + harness.disconnect(); + } +}); + +test("loopback session rejects paused mutation intents", async () => { + const harness = await createLoopbackHarness({ now: 2000 }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-paused", sequence: 1, sentAt: harness.now() })); + + harness.advanceNow(120); + session.send(presenceEnvelope("backgrounded", { + messageId: "presence-backgrounded", + sequence: 2, + sentAt: harness.now(), + })); + assert.equal(latestMessage(messages, "room.event").payload.event.status, "backgrounded"); + + const mutationCases = [ + { + messageId: "paused-fire", + envelope: () => fireEnvelope({ sequence: 3, fireSequence: 1, sentAt: harness.now() }), + advanceMs: 30, + }, + { + messageId: "paused-pickup", + envelope: () => pickupEnvelope({ sequence: 4, pickupSequence: 1, sentAt: harness.now() }), + advanceMs: 160, + }, + { + messageId: "paused-world", + envelope: () => worldEnvelope({ sequence: 5, worldSequence: 1, sentAt: harness.now() }), + advanceMs: 1, + }, + { + messageId: "paused-match", + envelope: () => matchEnvelope({ sequence: 6, matchSequence: 1, sentAt: harness.now() }), + advanceMs: 250, + }, + ]; + + const firstMutationMessageCount = messages.length; + for (const testCase of mutationCases) { + harness.advanceNow(testCase.advanceMs); + session.send(testCase.envelope()); + const reject = latestMessage(messages, "room.reject"); + assert.equal(reject.payload.rejectedMessageId, testCase.messageId); + assert.equal(reject.payload.code, "unsupported"); + assert.equal(reject.payload.recoverable, true); + assert.match(reject.payload.message, /input is paused/); + } + assert.equal(messages.filter((message) => message.type === "room.reject").length, mutationCases.length); + assert.equal( + messages.slice(firstMutationMessageCount).filter((message) => message.type === "room.event").length, + 0, + ); + } finally { + harness.disconnect(); + } +}); + +test("loopback session rejects fire timestamps outside accepted input history", async () => { + const harness = await createLoopbackHarness({ now: 3000 }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-fire-history", sequence: 1, sentAt: harness.now() })); + session.send(inputBatchEnvelope({ + messageId: "loopback-fire-history-inputs", + sequence: 2, + sentAt: harness.now(), + inputSequences: [1, 2], + inputs: [ + { sampledAt: harness.now(), rotX: -78, rotY: 0 }, + { sampledAt: harness.now() + 50, rotX: -78, rotY: 0 }, + ], + })); + harness.advanceNow(80); + session.send(fireEnvelope({ + messageId: "loopback-fire-history-too-late", + sequence: 3, + sentAt: harness.now(), + fireSequence: 1, + fire: { + firedAt: harness.now() + 1_000, + }, + })); + + const reject = latestMessage(messages, "room.reject"); + assert.equal(reject.payload.code, "stale"); + assert.equal(reject.payload.recoverable, true); + assert.equal(reject.payload.rejectedMessageId, "loopback-fire-history-too-late"); + assert.match(reject.payload.message, /fire-after-input-history/); + } finally { + harness.disconnect(); + } +}); + +test("loopback session uses fire payload aim when the authoritative pose is one input behind", async () => { + const remotePlayer = createPlayer({ + playerId: "remote-player", + clientId: "remote-client", + displayName: "Remote", + origin: [4, 0, 0], + rotX: -78, + rotY: 180, + updatedAt: 5000, + }); + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-local", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 180, + }], + pickupDefinitions: [], + }); + const harness = await createLoopbackHarness({ + now: 5000, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + simulatedPlayers: () => [remotePlayer], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-fresh-aim", sequence: 1, sentAt: harness.now() })); + harness.advanceNow(120); + session.send(fireEnvelope({ + messageId: "fire-loopback-fresh-aim", + sequence: 2, + fireSequence: 1, + sentAt: harness.now(), + fire: { direction: DUEL_FORWARD_DIRECTION }, + })); + + const event = latestMessage(messages, "room.event").payload.event; + assert.equal(event.eventType, "player.damaged"); + assert.equal(event.victimPlayerId, "remote-player"); + assert.equal(event.damage, 24); + assert.equal(event.health, 76); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } +}); + +test("loopback session uses a bounded fire origin hint when the authoritative origin is one input behind", async () => { + const remotePlayer = createPlayer({ + playerId: "remote-player", + clientId: "remote-client", + displayName: "Remote", + origin: [4, 0.9, 0], + rotX: -78, + rotY: 180, + updatedAt: 5050, + }); + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-local", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [], + }); + const harness = await createLoopbackHarness({ + now: 5050, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + simulatedPlayers: () => [remotePlayer], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-fresh-origin", sequence: 1, sentAt: harness.now() })); + harness.advanceNow(120); + session.send(fireEnvelope({ + messageId: "fire-loopback-fresh-origin", + sequence: 2, + fireSequence: 1, + sentAt: harness.now(), + fire: { origin: [0, 0.4, 0] }, + })); + + const event = latestMessage(messages, "room.event").payload.event; + assert.equal(event.eventType, "player.damaged"); + assert.equal(event.victimPlayerId, "remote-player"); + assert.equal(event.damage, 24); + assert.equal(event.health, 76); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } +}); + +test("loopback session applies damage when LOS trace only clips the target skin", async () => { + const remotePlayer = createPlayer({ + playerId: "remote-player", + clientId: "remote-client", + displayName: "Remote", + origin: [4, 0, 0], + rotX: -78, + rotY: 180, + updatedAt: 5000, + }); + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-local", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [], + }); + const harness = await createLoopbackHarness({ + now: 5000, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + trustedSceneMovement: { + collisionWorld: { + traceUse: () => ({ + fraction: 0.985, + end: [3.92, 0, -0.82], + planeNormal: [0, 0, 1], + entityIndex: 84, + modelIndex: 3, + classname: "func_wall", + }), + }, + playerEyeHeight: 1.0, + }, + simulatedPlayers: () => [remotePlayer], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-late-los", sequence: 1, sentAt: harness.now() })); + harness.advanceNow(120); + session.send(fireEnvelope({ + messageId: "fire-loopback-late-los", + sequence: 2, + fireSequence: 1, + sentAt: harness.now(), + })); + + const event = latestMessage(messages, "room.event").payload.event; + assert.equal(event.eventType, "player.damaged"); + assert.equal(event.victimPlayerId, "remote-player"); + assert.equal(event.damage, 24); + assert.equal(event.health, 76); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } +}); + +test("loopback session applies source-order armor save but suppresses health damage while simulated victim is invulnerable", async () => { + const remoteInventory = { + ...items.createQuakeMultiplayerInitialInventory(), + health: 100, + armor: 50, + armorType: 0.8, + powerups: [{ + active: true, + activationField: "invincible_time", + finishedAt: 15_000, + finishedField: "invincible_finished", + itemFlag: INVULNERABILITY_ITEM_FLAG, + }], + }; + const remotePlayer = items.quakeMultiplayerPlayerWithInventory( + createPlayer({ + playerId: "remote-player", + clientId: "remote-client", + displayName: "Remote", + origin: [4, 0, 0], + rotX: -78, + rotY: 180, + updatedAt: 5_200, + }), + remoteInventory, + ); + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-local", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [], + }); + const harness = await createLoopbackHarness({ + now: 5_200, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + simulatedPlayers: () => [remotePlayer], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-invulnerable", sequence: 1, sentAt: harness.now() })); + harness.advanceNow(120); + session.send(fireEnvelope({ + messageId: "fire-loopback-invulnerable", + sequence: 2, + fireSequence: 1, + sentAt: harness.now(), + })); + + const events = messages + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event); + assert.equal(events.some((event) => + event.eventType === "player.damaged" && event.victimPlayerId === "remote-player" + ), false); + assert.equal(events.some((event) => + event.eventType === "player.killed" && event.victimPlayerId === "remote-player" + ), false); + const snapshot = latestMessage(messages, "room.snapshot"); + const remoteSnapshot = snapshot.payload.players.find((player) => player.playerId === "remote-player"); + assert.equal(remoteSnapshot?.health, 100); + assert.equal(remoteSnapshot?.armor, 30); + assert.equal(remoteSnapshot?.alive, true); + assert.ok( + (remoteSnapshot?.velocity?.some((value) => Math.abs(value) > 0) ?? false), + "expected invulnerable target to still receive source-style damage momentum", + ); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } +}); + +test("loopback session double-invulnerable telefrag clears protection and kills both players like Quake teledeath3", async () => { + const invulnerabilityPickup = invulnerabilityPickupDefinition(); + const remoteInventory = { + ...items.createQuakeMultiplayerInitialInventory(), + itemFlags: INVULNERABILITY_ITEM_FLAG, + powerups: [{ + active: true, + activationField: "invincible_time", + finishedAt: 15_000, + finishedField: "invincible_finished", + itemFlag: INVULNERABILITY_ITEM_FLAG, + }], + }; + const remotePlayer = items.quakeMultiplayerPlayerWithInventory( + createPlayer({ + playerId: "remote-player", + clientId: "remote-client", + displayName: "Remote", + origin: [4, 0, 0], + rotX: -78, + rotY: 180, + updatedAt: 5_200, + }), + remoteInventory, + ); + const teleportDefinition = { + kind: "teleport", + entityIndex: 700, + classname: "trigger_teleport", + destinationEntityIndex: 701, + destinationOrigin: [4, 0, 0], + destinationRotX: -78, + destinationRotY: 180, + }; + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-local", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [invulnerabilityPickup], + }); + const harness = await createLoopbackHarness({ + now: 5_200, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + trustedWorldDefinitions: [teleportDefinition], + simulatedPlayers: () => [remotePlayer], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-double-telefrag", sequence: 1, sentAt: harness.now() })); + harness.advanceNow(120); + session.send(pickupEnvelope({ + messageId: "pickup-loopback-invulnerability", + sequence: 2, + pickupSequence: 1, + sentAt: harness.now(), + pickup: { + entityIndex: invulnerabilityPickup.entityIndex, + origin: [0, 0, 0], + }, + })); + assert.equal( + messages.some((message) => + message.type === "room.event" && + message.payload.event.eventType === "pickup.taken" && + message.payload.event.entityIndex === invulnerabilityPickup.entityIndex + ), + true, + ); + + harness.advanceNow(120); + session.send(worldEnvelope({ + messageId: "world-loopback-double-telefrag", + sequence: 3, + worldSequence: 1, + sentAt: harness.now(), + intent: { + intentType: "teleport", + entityIndex: teleportDefinition.entityIndex, + destinationEntityIndex: teleportDefinition.destinationEntityIndex, + origin: [0, 0, 0], + velocity: [0, 0, 0], + }, + })); + + const kills = messages + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event) + .filter((event) => event.eventType === "player.killed" && event.damageSource === "teledeath3"); + assert.equal(kills.length, 2); + assert.equal(kills.some((event) => event.victimPlayerId === "loopback:client-a"), true); + assert.equal(kills.some((event) => event.victimPlayerId === "remote-player"), true); + + const snapshot = latestMessage(messages, "room.snapshot"); + const localSnapshot = snapshot.payload.players.find((player) => player.playerId === "loopback:client-a"); + const remoteSnapshot = snapshot.payload.players.find((player) => player.playerId === "remote-player"); + assert.equal(localSnapshot?.alive, false); + assert.equal(remoteSnapshot?.alive, false); + assert.equal(localSnapshot?.frags, -1); + assert.equal(remoteSnapshot?.frags, -1); + assert.equal(localSnapshot?.deaths, 1); + assert.equal(remoteSnapshot?.deaths, 1); + assert.equal( + localSnapshot?.inventory.powerups.some((powerup) => powerup.finishedField === "invincible_finished"), + false, + ); + assert.equal( + remoteSnapshot?.inventory.powerups.some((powerup) => powerup.finishedField === "invincible_finished"), + false, + ); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } +}); + +test("loopback session clears local active artifact powerups immediately on death", async () => { + const quadPickup = quadPickupDefinition({ durationMs: 30_000 }); + const hurtDefinition = { + kind: "hurt", + entityIndex: 6_001, + classname: "trigger_hurt", + damage: 150, + }; + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-loopback-local-death", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [quadPickup], + }); + const harness = await createLoopbackHarness({ + now: 5_400, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + trustedWorldDefinitions: [hurtDefinition], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-local-death-powerups", sequence: 1, sentAt: harness.now() })); + harness.advanceNow(120); + session.send(pickupEnvelope({ + messageId: "pickup-loopback-local-death-quad", + sequence: 2, + pickupSequence: 1, + sentAt: harness.now(), + pickup: { entityIndex: quadPickup.entityIndex, origin: [0, 0, 0] }, + })); + assert.equal( + messages.some((message) => + message.type === "room.event" && + message.payload.event.eventType === "pickup.taken" && + message.payload.event.entityIndex === quadPickup.entityIndex + ), + true, + ); + + harness.advanceNow(120); + session.send(worldEnvelope({ + messageId: "world-loopback-local-death-powerups", + sequence: 3, + worldSequence: 1, + sentAt: harness.now(), + intent: { + entityIndex: hurtDefinition.entityIndex, + origin: [0, 0, 0], + }, + })); + + const kill = messages + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event) + .find((event) => + event.eventType === "player.killed" && + event.victimPlayerId === "loopback:client-a" && + event.damageSource === "trigger_hurt" + ); + assert.ok(kill, "expected local trigger_hurt death"); + const snapshot = latestMessage(messages, "room.snapshot"); + const localSnapshot = snapshot.payload.players.find((player) => player.playerId === "loopback:client-a"); + assert.equal(localSnapshot?.alive, false); + assert.equal(localSnapshot?.inventory.itemFlags & QUAD_ITEM_FLAG, 0); + assert.equal( + localSnapshot?.inventory.powerups.some((powerup) => powerup.finishedField === "super_damage_finished"), + false, + ); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } +}); + +test("loopback session rewinds hit tests from authoritative snapshot history instead of current velocity", async () => { + let remotePlayer = createPlayer({ + playerId: "remote-player", + clientId: "remote-client", + displayName: "Remote", + origin: [4, 0, 0], + velocity: [0, 0, 0], + rotX: -78, + rotY: 180, + updatedAt: 7_000, + }); + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-local", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [], + }); + const harness = await createLoopbackHarness({ + now: 7_000, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + simulatedPlayers: () => [remotePlayer], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-history-hit", sequence: 1, sentAt: harness.now() })); + remotePlayer = { + ...remotePlayer, + origin: [4, 1.4, 0], + velocity: [0, 0, 0], + updatedAt: 7_100, + }; + harness.advanceNow(100); + session.send(fireEnvelope({ + messageId: "fire-loopback-history-hit", + sequence: 2, + fireSequence: 1, + sentAt: harness.now(), + fire: { + origin: [0, 0, -0.36], + direction: [1, 0, 0], + }, + })); + + const event = messages + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event) + .find((candidate) => + candidate.eventType === "player.damaged" && + candidate.victimPlayerId === "remote-player" + ); + assert.ok(event, "expected historical loopback target sample to receive damage"); + assert.equal(event.damage, 24); + assert.equal(event.health, 76); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } +}); + +test("loopback session blocks damage when LOS trace hits a real wall", async () => { + const remotePlayer = createPlayer({ + playerId: "remote-player", + clientId: "remote-client", + displayName: "Remote", + origin: [4, 0, 0], + rotX: -78, + rotY: 180, + updatedAt: 5500, + }); + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-local", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [], + }); + const harness = await createLoopbackHarness({ + now: 5500, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + trustedSceneMovement: { + collisionWorld: { + traceUse: () => ({ + fraction: 0.5, + end: [2, 0, -0.5], + planeNormal: [1, 0, 0], + entityIndex: 900, + modelIndex: 9, + classname: "func_wall", + }), + }, + playerEyeHeight: 1.0, + }, + simulatedPlayers: () => [remotePlayer], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-wall-los", sequence: 1, sentAt: harness.now() })); + harness.advanceNow(120); + const beforeCount = messages.length; + session.send(fireEnvelope({ + messageId: "fire-loopback-wall-los", + sequence: 2, + fireSequence: 1, + sentAt: harness.now(), + })); + + const newEvents = messages + .slice(beforeCount) + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event); + assert.equal(newEvents.some((event) => event.eventType === "player.damaged"), false); + const snapshot = latestMessage(messages, "room.snapshot"); + const remoteSnapshot = snapshot.payload.players.find((player) => player.playerId === "remote-player"); + assert.equal(remoteSnapshot?.health, 100); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } +}); + +test("loopback session damages a farther visible simulated player when a nearer candidate is blocked", async () => { + const nearPlayer = createPlayer({ + playerId: "near-player", + clientId: "near-client", + displayName: "Near", + origin: [2, 0, 0], + rotX: -78, + rotY: 180, + updatedAt: 6000, + }); + const farPlayer = createPlayer({ + playerId: "far-player", + clientId: "far-client", + displayName: "Far", + origin: [4, 0, 0], + rotX: -78, + rotY: 180, + updatedAt: 6000, + }); + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-local", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [], + }); + const harness = await createLoopbackHarness({ + now: 6000, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + trustedSceneMovement: { + collisionWorld: { + traceUse: (_origin, impact) => impact[0] < 3 + ? { + fraction: 0.5, + end: [1, 0, -0.5], + planeNormal: [1, 0, 0], + entityIndex: 44, + modelIndex: 2, + classname: "func_wall", + } + : null, + }, + playerEyeHeight: 1.0, + }, + simulatedPlayers: () => [nearPlayer, farPlayer], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-visible-far", sequence: 1, sentAt: harness.now() })); + harness.advanceNow(120); + session.send(fireEnvelope({ + messageId: "fire-loopback-visible-far", + sequence: 2, + fireSequence: 1, + sentAt: harness.now(), + })); + + const events = messages + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event); + assert.equal(events.some((event) => + event.eventType === "player.damaged" && event.victimPlayerId === "near-player" + ), false); + const farEvent = events.find((event) => + event.eventType === "player.damaged" && event.victimPlayerId === "far-player" + ); + assert.ok(farEvent, "expected farther visible simulated player to take damage"); + assert.equal(farEvent.damage, 24); + assert.equal(farEvent.health, 76); + const snapshot = latestMessage(messages, "room.snapshot"); + const nearSnapshot = snapshot.payload.players.find((player) => player.playerId === "near-player"); + const farSnapshot = snapshot.payload.players.find((player) => player.playerId === "far-player"); + assert.equal(nearSnapshot?.health, 100); + assert.equal(farSnapshot?.health, 76); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } +}); + +test("loopback session blocks indirect projectile splash through walls", async () => { + const rocketPickup = weaponPickupDefinition("rocketlauncher"); + const directPlayer = createPlayer({ + playerId: "direct-player", + clientId: "direct-client", + displayName: "Direct", + origin: [3, 0, 0], + rotX: -78, + rotY: 180, + updatedAt: 6500, + }); + const blockedPlayer = createPlayer({ + playerId: "blocked-player", + clientId: "blocked-client", + displayName: "Blocked", + origin: [3, 2, 0], + rotX: -78, + rotY: 180, + updatedAt: 6500, + }); + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-local", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [rocketPickup], + }); + const harness = await createLoopbackHarness({ + now: 6500, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + trustedSceneMovement: { + collisionWorld: { + traceUse: (_origin, point) => point[1] > 1 + ? { + fraction: 0.4, + end: [point[0], 1, point[2]], + planeNormal: [0, -1, 0], + entityIndex: 45, + modelIndex: 3, + classname: "func_wall", + } + : null, + }, + playerEyeHeight: 1.0, + }, + simulationTickMs: 1, + simulatedPlayers: () => [directPlayer, blockedPlayer], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-splash-wall", sequence: 1, sentAt: harness.now() })); + harness.advanceNow(120); + session.send(pickupEnvelope({ + messageId: "pickup-loopback-rocket", + sequence: 2, + pickupSequence: 1, + sentAt: harness.now(), + pickup: { entityIndex: rocketPickup.entityIndex, origin: [0, 0, 0] }, + })); + harness.advanceNow(200); + session.send(fireEnvelope({ + messageId: "fire-loopback-splash-wall", + sequence: 3, + fireSequence: 1, + sentAt: harness.now(), + })); + harness.advanceNow(400); + await waitForMessage(messages, (message) => + message.type === "room.event" && + message.payload.event.eventType === "projectile.impacted" + ); + + const events = messages + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event); + assert.ok(events.some((event) => + event.eventType === "player.killed" && event.victimPlayerId === "direct-player" + )); + assert.equal(events.some((event) => + (event.eventType === "player.damaged" || event.eventType === "player.killed") && + event.victimPlayerId === "blocked-player" + ), false); + const snapshot = latestMessage(messages, "room.snapshot"); + const blockedSnapshot = snapshot.payload.players.find((player) => player.playerId === "blocked-player"); + assert.equal(blockedSnapshot?.health, 100); + assert.equal(blockedSnapshot?.alive, true); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } +}); + +test("loopback session applies projectile wall-impact splash without a direct player hit", async () => { + const rocketPickup = weaponPickupDefinition("rocketlauncher"); + const nearMissPlayer = createPlayer({ + playerId: "near-miss-player", + clientId: "near-miss-client", + displayName: "Near Miss", + origin: [3, 2, 0], + rotX: -78, + rotY: 180, + updatedAt: 6900, + }); + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-local", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [rocketPickup], + }); + const harness = await createLoopbackHarness({ + now: 6900, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + trustedSceneMovement: { + collisionWorld: { + traceUse: (origin, point) => origin[0] === 0 && point[0] > 10 + ? { + fraction: 3 / 64, + end: [3, 0, 0], + planeNormal: [-1, 0, 0], + entityIndex: 44, + modelIndex: 3, + classname: "func_wall", + } + : null, + }, + playerEyeHeight: 1.0, + }, + simulationTickMs: 1, + simulatedPlayers: () => [nearMissPlayer], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-wall-splash", sequence: 1, sentAt: harness.now() })); + harness.advanceNow(120); + session.send(pickupEnvelope({ + messageId: "pickup-loopback-wall-splash-rocket", + sequence: 2, + pickupSequence: 1, + sentAt: harness.now(), + pickup: { entityIndex: rocketPickup.entityIndex, origin: [0, 0, 0] }, + })); + harness.advanceNow(200); + session.send(fireEnvelope({ + messageId: "fire-loopback-wall-splash", + sequence: 3, + fireSequence: 1, + sentAt: harness.now(), + fire: { + direction: [1, 0, 0], + }, + })); + harness.advanceNow(2_000); + await waitForMessage(messages, (message) => + message.type === "room.event" && + message.payload.event.eventType === "projectile.impacted" + ); + + const events = messages + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event); + const targetDamage = events.find((event) => + event.eventType === "player.damaged" && event.victimPlayerId === "near-miss-player" + ); + const selfDamage = events.find((event) => + event.eventType === "player.damaged" && event.victimPlayerId === "loopback:client-a" + ); + assert.ok(targetDamage, "expected wall splash to damage nearby simulated target"); + assert.equal(targetDamage.damage, 69); + assert.equal(targetDamage.health, 31); + assert.ok(selfDamage, "expected wall splash to apply half self damage"); + assert.equal(selfDamage.damage, 22); + assert.equal(selfDamage.health, 78); + const snapshot = latestMessage(messages, "room.snapshot"); + const targetSnapshot = snapshot.payload.players.find((player) => player.playerId === "near-miss-player"); + const selfSnapshot = snapshot.payload.players.find((player) => player.playerId === "loopback:client-a"); + assert.equal(targetSnapshot?.health, 31); + assert.equal(selfSnapshot?.health, 78); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } +}); + +test("loopback session applies projectile quad damage from impact-time attacker state", async () => { + const cases = [ + { + id: "loopback-quad-expired-before-impact", + quadDurationMs: 250, + pickupQuadBeforeFire: true, + expectedDamage: 9, + expectedHealth: 91, + }, + { + id: "loopback-quad-picked-up-before-impact", + quadDurationMs: 30_000, + pickupQuadBeforeFire: false, + expectedDamage: 36, + expectedHealth: 64, + }, + ]; + + for (const spec of cases) { + const nailgunPickup = weaponPickupDefinition("nailgun"); + const quadPickup = quadPickupDefinition({ durationMs: spec.quadDurationMs }); + const remotePlayer = createPlayer({ + playerId: `remote-${spec.id}`, + clientId: `remote-client-${spec.id}`, + displayName: "Remote", + origin: [8, 0, 0], + rotX: -78, + rotY: 180, + updatedAt: 8_000, + }); + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: `spawn-${spec.id}`, + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [nailgunPickup, quadPickup], + }); + const harness = await createLoopbackHarness({ + now: 8_000, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + simulationTickMs: 1, + simulatedPlayers: () => [remotePlayer], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ + messageId: `hello-${spec.id}`, + sequence: 1, + sentAt: harness.now(), + })); + harness.advanceNow(120); + session.send(pickupEnvelope({ + messageId: `pickup-nailgun-${spec.id}`, + sequence: 2, + pickupSequence: 1, + sentAt: harness.now(), + pickup: { entityIndex: nailgunPickup.entityIndex, origin: [0, 0, 0] }, + })); + harness.advanceNow(160); + if (spec.pickupQuadBeforeFire) { + session.send(pickupEnvelope({ + messageId: `pickup-quad-before-fire-${spec.id}`, + sequence: 3, + pickupSequence: 2, + sentAt: harness.now(), + pickup: { entityIndex: quadPickup.entityIndex, origin: [0, 0, 0] }, + })); + harness.advanceNow(160); + } + session.send(fireEnvelope({ + messageId: `fire-${spec.id}`, + sequence: 4, + fireSequence: 1, + sentAt: harness.now(), + fire: { + weapon: "nailgun", + fireKind: "projectile", + direction: [1, 0, 0], + }, + })); + if (!spec.pickupQuadBeforeFire) { + harness.advanceNow(160); + session.send(pickupEnvelope({ + messageId: `pickup-quad-before-impact-${spec.id}`, + sequence: 5, + pickupSequence: 2, + sentAt: harness.now(), + pickup: { entityIndex: quadPickup.entityIndex, origin: [0, 0, 0] }, + })); + } + assert.ok( + messages.some((message) => + message.type === "room.event" && + message.payload.event.eventType === "pickup.taken" && + message.payload.event.entityIndex === quadPickup.entityIndex + ), + `expected loopback quad pickup to be accepted for ${spec.id}`, + ); + const preImpactSnapshot = latestMessage(messages, "room.snapshot"); + const preImpactLocalPlayer = preImpactSnapshot.payload.players + .find((player) => player.playerId === "loopback:client-a"); + assert.equal( + items.quakeMultiplayerDamageMultiplierForInventory(preImpactLocalPlayer?.inventory, harness.now()), + 4, + `expected loopback local quad to be active before impact for ${spec.id}`, + ); + harness.advanceNow(700); + await waitForMessage(messages, (message) => + message.type === "room.event" && + message.payload.event.eventType === "projectile.impacted" && + message.payload.event.weapon === "nailgun" + ); + + const events = messages + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event); + const damage = events.find((event) => + event.eventType === "player.damaged" && + event.victimPlayerId === remotePlayer.playerId && + event.damageSource === "nailgun" + ); + assert.ok(damage, `expected loopback nailgun damage for ${spec.id}`); + assert.equal(damage.damage, spec.expectedDamage, spec.id); + assert.equal(damage.health, spec.expectedHealth, spec.id); + const snapshot = latestMessage(messages, "room.snapshot"); + const remoteSnapshot = snapshot.payload.players.find((player) => player.playerId === remotePlayer.playerId); + assert.equal(remoteSnapshot?.health, spec.expectedHealth, spec.id); + assert.deepEqual(messages.filter((message) => message.type === "room.reject"), [], spec.id); + } finally { + harness.disconnect(); + } + } +}); + +test("loopback session publishes a dynamic backpack when a simulated player dies", async () => { + const remotePlayer = createPlayer({ + playerId: "remote-drop-backpack", + clientId: "remote-client-drop-backpack", + displayName: "Remote", + origin: [4, 0, 0], + rotX: -78, + rotY: 180, + health: 10, + inventory: { + ...items.createQuakeMultiplayerInitialInventory(), + health: 10, + itemFlags: items.createQuakeMultiplayerInitialInventory().itemFlags | QUAD_ITEM_FLAG, + activeWeapon: "rocketlauncher", + weapons: ["axe", "shotgun", "rocketlauncher"], + shells: 2, + rockets: 5, + powerups: [{ + active: true, + activationField: "super_damage_time", + finishedAt: 15_000, + finishedField: "super_damage_finished", + itemFlag: QUAD_ITEM_FLAG, + itemFlagExpression: "IT_QUAD", + }], + }, + updatedAt: 5_000, + }); + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: [{ + spawnId: "spawn-loopback-drop-backpack", + classname: "info_player_deathmatch", + origin: [0, 0, 0], + rotX: -78, + rotY: 0, + }], + pickupDefinitions: [], + }); + const harness = await createLoopbackHarness({ + now: 5_000, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + simulatedPlayers: () => [remotePlayer], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-loopback-drop-backpack", sequence: 1, sentAt: harness.now() })); + harness.advanceNow(120); + session.send(fireEnvelope({ + messageId: "fire-loopback-drop-backpack", + sequence: 2, + fireSequence: 1, + sentAt: harness.now(), + })); + + const dropped = messages + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event) + .find((event) => event.eventType === "pickup.dropped"); + assert.ok(dropped, "expected loopback pickup.dropped event"); + assert.equal(dropped.definition.classname, "item_backpack"); + assert.equal(dropped.definition.runtime, true); + assert.equal(dropped.definition.effect.shells, 2); + assert.equal(dropped.definition.effect.rockets, 5); + assert.equal(dropped.definition.effect.weapon.id, "rocketlauncher"); + + const snapshot = latestMessage(messages, "room.snapshot"); + assert.equal( + snapshot.payload.dynamicPickups.some((definition) => + definition.entityIndex === dropped.definition.entityIndex + ), + true, + ); + const remoteSnapshot = snapshot.payload.players.find((player) => player.playerId === "remote-drop-backpack"); + assert.equal(remoteSnapshot?.alive, false); + assert.equal(remoteSnapshot?.inventory.itemFlags & QUAD_ITEM_FLAG, 0); + assert.equal( + remoteSnapshot?.inventory.powerups.some((powerup) => powerup.finishedField === "super_damage_finished"), + false, + ); + } finally { + 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(); + } +}); + +test("loopback target dispatch activates non-button movers", async () => { + const triggerDefinition = { + kind: "trigger", + entityIndex: 190, + classname: "trigger_multiple", + bounds: { + mins: [-1, -1, 0], + maxs: [1, 1, 2], + }, + touchActivates: true, + useActivates: false, + shootActivates: false, + oneShot: false, + delayMs: 0, + waitMs: 0, + targetEntityIndexes: [189], + }; + const moverDefinition = { + kind: "mover", + entityIndex: 189, + classname: "func_door_secret", + bounds: { + mins: [2, -1, 0], + maxs: [3, 1, 2], + }, + touchActivates: false, + useActivates: true, + shootActivates: false, + speed: 50, + moveMs: 200, + delayMs: 0, + fromOrigin: [0, 0, 0], + toOrigin: [1, 0, 0], + targetEntityIndexes: [], + }; + const deathmatchSpawns = [{ + spawnId: "spawn-trigger", + classname: "info_player_deathmatch", + origin: [0, 0, 1], + rotX: 0, + rotY: 0, + }]; + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns, + pickupDefinitions: [], + }); + const harness = await createLoopbackHarness({ + now: 4100, + sessionOptions: { + trustedGameplayDefinitions: gameplayDefinitions, + trustedWorldDefinitions: [triggerDefinition, moverDefinition], + }, + }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ + messageId: "hello-world-non-button-mover", + sequence: 1, + sentAt: harness.now(), + })); + + harness.advanceNow(120); + session.send(worldEnvelope({ + messageId: "world-non-button-mover", + sequence: 2, + worldSequence: 1, + sentAt: harness.now(), + intent: { + entityIndex: triggerDefinition.entityIndex, + origin: [0, 0, 1], + }, + })); + + const events = messages + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event); + const trigger = events.find((event) => + event.eventType === "world.trigger" && + event.entityIndex === triggerDefinition.entityIndex + ); + const targets = events.find((event) => + event.eventType === "world.targets" && + event.sourceEntityIndex === triggerDefinition.entityIndex + ); + const mover = events.find((event) => + event.eventType === "world.mover" && + event.entityIndex === moverDefinition.entityIndex + ); + + assert.ok(trigger, "expected trigger event"); + assert.ok(targets, "expected target dispatch event"); + assert.ok(mover, "expected target mover event"); + assert.equal(mover.classname, "func_door_secret"); + assert.equal(mover.activation, "target"); + assert.equal(mover.state, "moving-up"); + assert.equal(messages.some((message) => message.type === "room.reject"), false); + } finally { + harness.disconnect(); + } +}); diff --git a/test/multiplayer/partyRoomCombat.test.mjs b/test/multiplayer/partyRoomCombat.test.mjs new file mode 100644 index 0000000..20e24da --- /dev/null +++ b/test/multiplayer/partyRoomCombat.test.mjs @@ -0,0 +1,1155 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + fireEnvelope, + helloEnvelope, + partyRoomModule, + pickupEnvelope, + projectileAuthority, + facts, + items, + DUEL_FORWARD_DIRECTION, + QUAD_ITEM_FLAG, + INVULNERABILITY_ITEM_FLAG, + createFakePartyRoom, + latestConnectionMessage, + roomEvents, + latestSnapshotPlayerForClient, + weaponPickupDefinition, + connectDuelRoom, + connectTripleRoom, + cleanupDuelRoom, + cleanupPartyRoomConnections, + setPartyRoomPlayerWeapon, + setPartyRoomPlayerQuad, + setPartyRoomPlayerInvulnerable, + pickupWeapon, +} from "./partyRoomHarness.mjs"; + +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, { + random: () => 0.999999, + 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"); + const firedAtoB = roomEvents(alice, "player.fired").find((event) => event.eventId === "fire-fire-a"); + assert.equal(firedAtoB?.decision?.outcome, "hit-player"); + assert.equal(firedAtoB?.decision?.reason, "player-direct"); + assert.equal(firedAtoB?.decision?.targetPlayerId, "party:client-b"); + assert.equal(firedAtoB?.decision?.candidateCount, 1); + assert.equal(firedAtoB?.decision?.blockedCandidateCount, 0); + assert.equal(firedAtoB?.decision?.playerDamageCount, 1); + + 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"); + const firedBtoA = roomEvents(alice, "player.fired").find((event) => event.eventId === "fire-fire-b"); + assert.equal(firedBtoA?.decision?.outcome, "hit-player"); + assert.equal(firedBtoA?.decision?.reason, "player-direct"); + assert.equal(firedBtoA?.decision?.targetPlayerId, "party:client-a"); + assert.equal(firedBtoA?.decision?.candidateCount, 1); + assert.equal(firedBtoA?.decision?.blockedCandidateCount, 0); + assert.equal(firedBtoA?.decision?.playerDamageCount, 1); + 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 source-order armor save but suppresses health damage while the victim is invulnerable", () => { + const { alice, bob, partyRoom } = connectDuelRoom({ id: "invulnerable-victim" }); + try { + const bobPlayer = partyRoom.players.get("party:client-b"); + assert.ok(bobPlayer, "expected bob player"); + const inventory = items.quakeMultiplayerPlayerInventory(bobPlayer); + inventory.health = 100; + inventory.armor = 50; + inventory.armorType = 0.8; + inventory.powerups = [{ + active: true, + activationField: "invincible_time", + finishedAt: Date.now() + 10_000, + finishedField: "invincible_finished", + itemFlag: INVULNERABILITY_ITEM_FLAG, + }]; + partyRoom.players.set("party:client-b", items.quakeMultiplayerPlayerWithInventory(bobPlayer, inventory)); + + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-invulnerable-victim", + sequence: 2, + fireSequence: 1, + sentAt: Date.now(), + })), alice); + + assert.equal( + roomEvents(alice, "player.damaged").some((event) => event.victimPlayerId === "party:client-b"), + false, + ); + assert.equal( + roomEvents(alice, "player.killed").some((event) => event.victimPlayerId === "party:client-b"), + false, + ); + const victim = latestSnapshotPlayerForClient(alice, "client-b"); + assert.equal(victim.health, 100); + assert.equal(victim.armor, 30); + assert.equal(victim.alive, true); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room double-invulnerable telefrag clears protection and kills both players like Quake teledeath3", () => { + const { alice, bob, partyRoom } = connectDuelRoom({ id: "double-invulnerable-telefrag" }); + try { + const now = Date.now(); + const victim = partyRoom.players.get("party:client-b"); + assert.ok(victim, "expected victim"); + setPartyRoomPlayerInvulnerable(partyRoom, "client-a", now + 10_000); + setPartyRoomPlayerInvulnerable(partyRoom, "client-b", now + 10_000); + + partyRoom.applyTeleportDeath("party:client-a", victim.origin, "double-invulnerable-telefrag"); + + const kills = roomEvents(alice, "player.killed") + .filter((event) => event.damageSource === "teledeath3"); + assert.equal(kills.length, 2); + assert.equal(kills.some((event) => event.victimPlayerId === "party:client-a"), true); + assert.equal(kills.some((event) => event.victimPlayerId === "party:client-b"), true); + + const aliceSnapshot = latestSnapshotPlayerForClient(alice, "client-a"); + const bobSnapshot = latestSnapshotPlayerForClient(alice, "client-b"); + assert.equal(aliceSnapshot.alive, false); + assert.equal(bobSnapshot.alive, false); + assert.equal(aliceSnapshot.frags, -1); + assert.equal(bobSnapshot.frags, -1); + assert.equal(aliceSnapshot.deaths, 1); + assert.equal(bobSnapshot.deaths, 1); + assert.equal( + aliceSnapshot.inventory.powerups.some((powerup) => powerup.finishedField === "invincible_finished"), + false, + ); + assert.equal( + bobSnapshot.inventory.powerups.some((powerup) => powerup.finishedField === "invincible_finished"), + false, + ); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room subtracts a victim frag for world/environment kills", () => { + const { alice, bob, partyRoom } = connectDuelRoom({ id: "world-kill-frag-penalty" }); + try { + partyRoom.applyPlayerDamage({ + victimPlayerId: "party:client-b", + damage: 150, + source: "trigger_hurt", + eventId: "world-kill-frag-penalty", + now: Date.now(), + }); + + const kill = roomEvents(alice, "player.killed") + .find((event) => event.victimPlayerId === "party:client-b"); + assert.ok(kill, "expected environment kill event"); + assert.equal(kill.attackerPlayerId, undefined); + assert.equal(kill.damageSource, "trigger_hurt"); + const victim = latestSnapshotPlayerForClient(alice, "client-b"); + assert.equal(victim.alive, false); + assert.equal(victim.frags, -1); + assert.equal(victim.deaths, 1); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room clears active artifact powerups immediately on player death", () => { + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "death-clears-powerups", + matchSettings: { fragLimit: 99 }, + }); + try { + const now = Date.now(); + setPartyRoomPlayerQuad(partyRoom, "client-b", now + 10_000); + const bobPlayer = partyRoom.players.get("party:client-b"); + assert.ok(bobPlayer, "expected bob player"); + const inventory = items.quakeMultiplayerPlayerInventory(bobPlayer); + inventory.health = 10; + partyRoom.players.set("party:client-b", items.quakeMultiplayerPlayerWithInventory(bobPlayer, inventory)); + + partyRoom.applyPlayerDamage({ + attackerPlayerId: "party:client-a", + victimPlayerId: "party:client-b", + damage: 24, + source: "shotgun", + eventId: "death-clears-powerups", + now, + }); + + const victim = latestSnapshotPlayerForClient(alice, "client-b"); + assert.equal(victim.alive, false); + assert.equal(victim.inventory.itemFlags & QUAD_ITEM_FLAG, 0); + assert.equal( + victim.inventory.powerups.some((powerup) => powerup.finishedField === "super_damage_finished"), + false, + ); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room respawns at a clear deathmatch spawn instead of the occupied cursor spawn", () => { + 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: [8, 0, 0], rotX: -78, rotY: 180 }, + { spawnId: "spawn-c-occupied", classname: "info_player_deathmatch", origin: [0.5, 0, 0], rotX: -78, rotY: 90 }, + { spawnId: "spawn-d-clear", classname: "info_player_deathmatch", origin: [16, 0, 0], rotX: -78, rotY: 270 }, + ]; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "respawn-clear-spawn", + deathmatchSpawns, + matchSettings: { fragLimit: 99 }, + }); + try { + partyRoom.applyPlayerDamage({ + attackerPlayerId: "party:client-a", + victimPlayerId: "party:client-b", + damage: 150, + source: "shotgun", + eventId: "respawn-clear-spawn-kill", + now: Date.now(), + }); + partyRoom.respawnPlayer("party:client-b"); + + const respawn = roomEvents(alice, "player.respawned") + .find((event) => event.player?.playerId === "party:client-b"); + assert.ok(respawn, "expected respawn event"); + assert.equal(respawn.player.spawnId, "spawn-d-clear"); + assert.deepEqual(respawn.player.origin, [16, 0, 0]); + const bobSnapshot = latestSnapshotPlayerForClient(alice, "client-b"); + assert.equal(bobSnapshot.spawnId, "spawn-d-clear"); + assert.deepEqual(bobSnapshot.origin, [16, 0, 0]); + assert.equal(bobSnapshot.alive, true); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room applies authoritative weapon damage after weapon pickups", () => { + const cases = [ + { weapon: "axe", damage: 20, pickup: false, 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", damage: 87, pickup: true, spawnDistance: 4, eventType: "player.damaged", health: 13 }, + { weapon: "rocketlauncher", pickup: true, spawnDistance: 4, eventType: "player.killed", health: -5 }, + ]; + + 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 { + setPartyRoomPlayerWeapon(partyRoom, "client-a", spec.weapon); + } + + 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 serverProjectile = projectileAuthority.quakeMultiplayerServerProjectileWeaponSupported(spec.weapon); + if (serverProjectile) { + const fired = roomEvents(alice, "player.fired") + .find((candidate) => candidate.eventId === `fire-fire-${spec.weapon}`); + assert.equal(fired?.decision?.outcome, "projectile-spawned", `${spec.weapon} should spawn a server projectile`); + const spawned = roomEvents(alice, "projectile.spawned") + .find((candidate) => candidate.projectile.weapon === spec.weapon); + assert.ok(spawned, `expected projectile.spawned for ${spec.weapon}`); + assert.equal( + roomEvents(alice, spec.eventType) + .some((candidate) => + candidate.attackerPlayerId === "party:client-a" && + candidate.victimPlayerId === "party:client-b" && + candidate.damageSource === spec.weapon + ), + false, + `${spec.weapon} should not apply damage in the same tick as fire`, + ); + partyRoom.advanceRoomSimulation(Date.now() + 400); + const impact = roomEvents(alice, "projectile.impacted") + .find((candidate) => candidate.weapon === spec.weapon); + assert.ok(impact, `expected projectile.impacted for ${spec.weapon}`); + assert.equal(impact.impactKind, "player", `${spec.weapon} should impact the player`); + assert.equal(impact.targetPlayerId, "party:client-b", `${spec.weapon} impact target`); + } + + 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, spec.health, `${spec.weapon} death 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("party room weapon pickup keeps a better current weapon by Quake deathmatch rank", () => { + const nailgunPickup = weaponPickupDefinition("nailgun"); + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "weapon-pickup-rank-switch", + pickupDefinitions: [nailgunPickup], + }); + try { + const player = partyRoom.players.get("party:client-a"); + assert.ok(player, "expected player"); + const inventory = items.quakeMultiplayerPlayerInventory(player); + inventory.activeWeapon = "rocketlauncher"; + inventory.weapons = ["axe", "shotgun", "rocketlauncher"]; + inventory.rockets = 5; + inventory.nails = 0; + partyRoom.players.set("party:client-a", items.quakeMultiplayerPlayerWithInventory(player, inventory)); + + partyRoom.onMessage(JSON.stringify(pickupEnvelope({ + clientId: "client-a", + messageId: "pickup-nailgun-rank-switch", + sequence: 2, + pickupSequence: 1, + sentAt: Date.now(), + pickup: { + entityIndex: nailgunPickup.entityIndex, + origin: [0, 0, 0], + }, + })), alice); + + const pickup = roomEvents(alice, "pickup.taken") + .find((event) => event.entityIndex === nailgunPickup.entityIndex); + assert.ok(pickup, "expected nailgun pickup"); + const snapshot = latestSnapshotPlayerForClient(alice, "client-a"); + assert.equal(snapshot.inventory.weapons.includes("nailgun"), true); + assert.equal(snapshot.inventory.nails, 25); + assert.equal(snapshot.inventory.activeWeapon, "rocketlauncher"); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room accepts already-owned respawning weapon pickup at full ammo like Quake deathmatch", () => { + const nailgunPickup = { + ...weaponPickupDefinition("nailgun"), + lifecycle: { action: "respawn", condition: "deathmatch", delayMs: 30_000 }, + }; + const originalNow = Date.now; + let now = 4_500_000; + Date.now = () => now; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "weapon-pickup-full-ammo-respawn", + pickupDefinitions: [nailgunPickup], + }); + try { + const player = partyRoom.players.get("party:client-a"); + assert.ok(player, "expected player"); + const inventory = items.quakeMultiplayerPlayerInventory(player); + inventory.activeWeapon = "rocketlauncher"; + inventory.weapons = ["axe", "shotgun", "nailgun", "rocketlauncher"]; + inventory.nails = 200; + inventory.rockets = 5; + partyRoom.players.set("party:client-a", items.quakeMultiplayerPlayerWithInventory(player, inventory)); + + partyRoom.onMessage(JSON.stringify(pickupEnvelope({ + clientId: "client-a", + messageId: "pickup-owned-full-nailgun", + sequence: 2, + pickupSequence: 1, + sentAt: now, + pickup: { + entityIndex: nailgunPickup.entityIndex, + origin: [0, 0, 0], + }, + })), alice); + + const pickup = roomEvents(alice, "pickup.taken") + .find((event) => event.entityIndex === nailgunPickup.entityIndex); + assert.ok(pickup, "expected already-owned full-ammo weapon pickup to be taken"); + assert.equal(pickup.leaveInPlace, false); + const snapshot = latestConnectionMessage(alice, "room.snapshot"); + const pickupState = snapshot.payload.pickups.find((candidate) => + candidate.entityIndex === nailgunPickup.entityIndex + ); + assert.equal(pickupState?.available, false); + assert.equal(pickupState?.respawnAt, now + 30_000); + const playerSnapshot = latestSnapshotPlayerForClient(alice, "client-a"); + assert.equal(playerSnapshot.inventory.nails, 200); + assert.equal(playerSnapshot.inventory.activeWeapon, "rocketlauncher"); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + Date.now = originalNow; + } +}); + +test("party room ammo pickup selects a newly usable best weapon when the active weapon was best", () => { + const nailsPickup = { + pickupId: "item-spikes-auto-best", + entityIndex: 4010, + classname: "item_spikes", + origin: [0, 0, 0], + effect: { nails: 25 }, + }; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "ammo-pickup-auto-best-weapon", + pickupDefinitions: [nailsPickup], + }); + try { + const attacker = partyRoom.players.get("party:client-a"); + assert.ok(attacker, "expected attacker"); + const inventory = items.quakeMultiplayerPlayerInventory(attacker); + inventory.activeWeapon = "shotgun"; + inventory.weapons = ["axe", "shotgun", "supernailgun"]; + inventory.shells = 25; + inventory.nails = 0; + partyRoom.players.set("party:client-a", items.quakeMultiplayerPlayerWithInventory(attacker, inventory)); + + partyRoom.onMessage(JSON.stringify(pickupEnvelope({ + clientId: "client-a", + messageId: "pickup-nails-auto-best", + sequence: 2, + pickupSequence: 1, + sentAt: Date.now(), + pickup: { + entityIndex: nailsPickup.entityIndex, + origin: [0, 0, 0], + }, + })), alice); + + const pickup = roomEvents(alice, "pickup.taken") + .find((event) => event.entityIndex === nailsPickup.entityIndex); + assert.ok(pickup, "expected nails pickup"); + const player = latestSnapshotPlayerForClient(alice, "client-a"); + assert.equal(player.inventory.nails, 25); + assert.equal(player.inventory.activeWeapon, "supernailgun"); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room auto-selects the source best weapon when the active weapon has no ammo before fire", () => { + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "auto-best-weapon-before-fire", + spawnDistance: 4, + }); + try { + const attacker = partyRoom.players.get("party:client-a"); + assert.ok(attacker, "expected attacker"); + const inventory = items.quakeMultiplayerPlayerInventory(attacker); + inventory.activeWeapon = "nailgun"; + inventory.weapons = ["axe", "shotgun", "nailgun"]; + inventory.shells = 25; + inventory.nails = 0; + partyRoom.players.set("party:client-a", items.quakeMultiplayerPlayerWithInventory(attacker, inventory)); + + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-auto-best-before", + sequence: 2, + fireSequence: 1, + sentAt: Date.now(), + fire: { weapon: "nailgun" }, + })), alice); + + const fired = roomEvents(alice, "player.fired") + .find((event) => event.eventId === "fire-fire-auto-best-before"); + assert.equal(fired?.weapon, "shotgun"); + assert.equal(fired?.decision?.outcome, "hit-player"); + const damage = roomEvents(alice, "player.damaged") + .find((event) => event.victimPlayerId === "party:client-b"); + assert.ok(damage, "expected auto-selected shotgun to damage Bob"); + assert.equal(damage.damage, 24); + assert.equal(damage.health, 76); + const attackerSnapshot = latestSnapshotPlayerForClient(alice, "client-a"); + assert.equal(attackerSnapshot.inventory.activeWeapon, "shotgun"); + assert.equal(attackerSnapshot.inventory.nails, 0); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room switches to axe after consuming the last shell instead of getting stuck on an empty shotgun", () => { + const originalNow = Date.now; + let now = 3_000_000; + Date.now = () => now; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "auto-best-weapon-after-last-shell", + spawnDistance: 1.2, + }); + try { + const attacker = partyRoom.players.get("party:client-a"); + assert.ok(attacker, "expected attacker"); + const inventory = items.quakeMultiplayerPlayerInventory(attacker); + inventory.activeWeapon = "shotgun"; + inventory.weapons = ["axe", "shotgun"]; + inventory.shells = 1; + partyRoom.players.set("party:client-a", items.quakeMultiplayerPlayerWithInventory(attacker, inventory)); + + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-last-shell", + sequence: 2, + fireSequence: 1, + sentAt: now, + })), alice); + + const firstFired = roomEvents(alice, "player.fired") + .find((event) => event.eventId === "fire-fire-last-shell"); + assert.equal(firstFired?.weapon, "shotgun"); + const afterLastShell = latestSnapshotPlayerForClient(alice, "client-a"); + assert.equal(afterLastShell.inventory.shells, 0); + assert.equal(afterLastShell.inventory.activeWeapon, "axe"); + + now += 500; + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-after-last-shell", + sequence: 3, + fireSequence: 2, + sentAt: now, + })), alice); + + const secondFired = roomEvents(alice, "player.fired") + .find((event) => event.eventId === "fire-fire-after-last-shell"); + assert.equal(secondFired?.weapon, "axe"); + const axeDamage = roomEvents(alice, "player.damaged") + .find((event) => + event.eventId === "damage-fire-after-last-shell" && + event.damageSource === "axe" + ); + assert.ok(axeDamage, "expected axe fire after empty shotgun"); + assert.equal(axeDamage.damage, 20); + assert.equal(axeDamage.health, 56); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + Date.now = originalNow; + } +}); + +test("party room drops and removes a source-style backpack on player death", () => { + const originalNow = Date.now; + let now = 4_000_000; + Date.now = () => now; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "player-death-dropped-backpack", + matchSettings: { fragLimit: 99 }, + spawnDistance: 4, + }); + try { + const victim = partyRoom.players.get("party:client-b"); + assert.ok(victim, "expected victim"); + const victimInventory = items.quakeMultiplayerPlayerInventory(victim); + victimInventory.activeWeapon = "rocketlauncher"; + victimInventory.weapons = ["axe", "shotgun", "rocketlauncher"]; + victimInventory.shells = 4; + victimInventory.rockets = 7; + partyRoom.players.set("party:client-b", items.quakeMultiplayerPlayerWithInventory(victim, victimInventory)); + + partyRoom.applyPlayerDamage({ + attackerPlayerId: "party:client-a", + victimPlayerId: "party:client-b", + damage: 150, + source: "rocketlauncher", + eventId: "death-backpack", + now, + }); + + const dropped = roomEvents(alice, "pickup.dropped").find((event) => + event.sourcePlayerId === "party:client-b" + ); + assert.ok(dropped, "expected dropped backpack event"); + assert.equal(dropped.definition.classname, "item_backpack"); + assert.equal(dropped.definition.runtime, true); + assert.equal(dropped.definition.effect.shells, 4); + assert.equal(dropped.definition.effect.rockets, 7); + assert.equal(dropped.definition.effect.weapon.id, "rocketlauncher"); + assert.equal(dropped.pickup.available, true); + + const dropSnapshot = latestConnectionMessage(alice, "room.snapshot"); + assert.equal( + dropSnapshot.payload.dynamicPickups.some((definition) => + definition.entityIndex === dropped.definition.entityIndex + ), + true, + ); + assert.equal( + dropSnapshot.payload.pickups.some((pickup) => + pickup.entityIndex === dropped.definition.entityIndex && pickup.available + ), + true, + ); + + const taker = partyRoom.players.get("party:client-a"); + assert.ok(taker, "expected taker"); + const takerInventory = items.quakeMultiplayerPlayerInventory(taker); + takerInventory.shells = 0; + takerInventory.rockets = 0; + takerInventory.weapons = ["axe", "shotgun"]; + partyRoom.players.set("party:client-a", items.quakeMultiplayerPlayerWithInventory({ + ...taker, + origin: dropped.definition.origin, + }, takerInventory)); + + now += 100; + partyRoom.onMessage(JSON.stringify(pickupEnvelope({ + clientId: "client-a", + messageId: "pickup-dropped-backpack", + sequence: 2, + pickupSequence: 1, + sentAt: now, + pickup: { + entityIndex: dropped.definition.entityIndex, + origin: dropped.definition.origin, + }, + })), alice); + + const taken = roomEvents(alice, "pickup.taken").find((event) => + event.entityIndex === dropped.definition.entityIndex + ); + assert.ok(taken, "expected dynamic backpack pickup event"); + assert.equal(taken.leaveInPlace, false); + const afterPickup = latestSnapshotPlayerForClient(alice, "client-a"); + assert.equal(afterPickup.inventory.shells, 4); + assert.equal(afterPickup.inventory.rockets, 7); + assert.equal(afterPickup.inventory.weapons.includes("rocketlauncher"), true); + assert.equal(afterPickup.inventory.activeWeapon, "rocketlauncher"); + + const pickupSnapshot = latestConnectionMessage(alice, "room.snapshot"); + assert.equal( + pickupSnapshot.payload.dynamicPickups.some((definition) => + definition.entityIndex === dropped.definition.entityIndex + ), + false, + ); + assert.equal( + pickupSnapshot.payload.pickups.some((pickup) => + pickup.entityIndex === dropped.definition.entityIndex + ), + false, + ); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + Date.now = originalNow; + } +}); + +test("party room accepts grenade refire at the source 600ms cooldown", () => { + const originalNow = Date.now; + let now = 2_000_000; + Date.now = () => now; + const { alice, bob, partyRoom } = connectDuelRoom({ id: "grenade-source-cooldown" }); + try { + setPartyRoomPlayerWeapon(partyRoom, "client-a", "grenadelauncher"); + for (let index = 0; index < 2; index += 1) { + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: `fire-grenade-cooldown-${index}`, + sequence: 2 + index, + fireSequence: 1 + index, + sentAt: now, + fire: { direction: [-1, 0, 0] }, + })), alice); + now += 600; + } + + const fired = roomEvents(alice, "player.fired") + .filter((event) => + event.playerId === "party:client-a" && + event.weapon === "grenadelauncher" + ); + assert.equal(fired.length, 2); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + Date.now = originalNow; + } +}); + +test("party room applies repeated authoritative damage until death across sustained-fire weapons", () => { + const cases = [ + { weapon: "axe", spawnDistance: 1.2, stepMs: 500, damagedHealths: [80, 60, 40, 20], killHealth: 0 }, + { weapon: "shotgun", spawnDistance: 4, stepMs: 500, damagedHealths: [76, 52, 28, 4], killHealth: -20 }, + { weapon: "supershotgun", spawnDistance: 4, stepMs: 700, damagedHealths: [44], killHealth: -12 }, + { weapon: "nailgun", spawnDistance: 4, stepMs: 200, damagedHealths: [91, 82, 73, 64, 55, 46, 37, 28, 19, 10, 1], killHealth: -8 }, + { weapon: "supernailgun", spawnDistance: 4, stepMs: 200, damagedHealths: [82, 64, 46, 28, 10], killHealth: -8 }, + { weapon: "lightning", spawnDistance: 4, stepMs: 200, damagedHealths: [70, 40, 10], killHealth: -20 }, + ]; + const originalNow = Date.now; + let now = 1_000_000; + Date.now = () => now; + try { + for (const spec of cases) { + const { alice, bob, partyRoom } = connectDuelRoom({ + id: `repeated-${spec.weapon}`, + spawnDistance: spec.spawnDistance, + }); + try { + setPartyRoomPlayerWeapon(partyRoom, "client-a", spec.weapon); + for (let index = 0; index <= spec.damagedHealths.length; index += 1) { + now += spec.stepMs; + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: `fire-repeated-${spec.weapon}-${index}`, + sequence: 2 + index, + fireSequence: 1 + index, + sentAt: now, + fire: { weapon: spec.weapon }, + })), alice); + if (projectileAuthority.quakeMultiplayerServerProjectileWeaponSupported(spec.weapon)) { + now += 400; + partyRoom.advanceRoomSimulation(now); + } + const expectedHealth = spec.damagedHealths[index]; + if (expectedHealth !== undefined) { + const event = roomEvents(alice, "player.damaged") + .find((candidate) => + candidate.victimPlayerId === "party:client-b" && + candidate.damageSource === spec.weapon && + candidate.health === expectedHealth + ); + assert.ok(event, `expected repeated ${spec.weapon} damage ${index + 1}`); + assert.equal(event.health, expectedHealth, `${spec.weapon} health after shot ${index + 1}`); + assert.equal(latestSnapshotPlayerForClient(alice, "client-b").health, expectedHealth); + } else { + const event = roomEvents(alice, "player.killed") + .find((candidate) => + candidate.victimPlayerId === "party:client-b" && + candidate.damageSource === spec.weapon + ); + assert.ok(event, `expected repeated ${spec.weapon} kill`); + const victim = latestSnapshotPlayerForClient(alice, "client-b"); + assert.equal(victim.alive, false, `${spec.weapon} victim alive after kill`); + assert.equal(victim.health, spec.killHealth, `${spec.weapon} victim health after kill`); + } + } + 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); + now += 10_000; + } + } + } finally { + Date.now = originalNow; + } +}); + +test("party room applies damage when LOS trace only clips the target skin", () => { + const collisionWorld = { + traceUse: () => ({ + fraction: 0.9856583826296409, + end: [3.92, 0, -0.82], + planeNormal: [0, 0, 1], + entityIndex: 84, + modelIndex: 3, + classname: "func_wall", + }), + }; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "late-target-skin-los", + roomOptions: { + trustedSceneMovement: { + collisionWorld, + playerEyeHeight: 1.0, + }, + }, + spawnDistance: 4, + }); + try { + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-late-target-skin-los", + sequence: 2, + fireSequence: 1, + sentAt: Date.now(), + })), alice); + + const event = roomEvents(alice, "player.damaged") + .find((candidate) => + candidate.attackerPlayerId === "party:client-a" && + candidate.victimPlayerId === "party:client-b" && + candidate.damageSource === "shotgun" + ); + assert.ok(event, "expected late target-skin LOS trace to allow damage"); + assert.equal(event.damage, 24); + assert.equal(event.health, 76); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room uses fire payload aim when the authoritative pose is one input behind", () => { + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "fresh-fire-aim-stale-pose", + spawnDistance: 4, + }); + try { + const attacker = partyRoom.players.get("party:client-a"); + assert.ok(attacker, "expected attacker"); + partyRoom.players.set("party:client-a", { + ...attacker, + rotX: -78, + rotY: 180, + }); + + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-fresh-aim-stale-pose", + sequence: 2, + fireSequence: 1, + sentAt: Date.now(), + fire: { direction: DUEL_FORWARD_DIRECTION }, + })), alice); + + const event = roomEvents(alice, "player.damaged") + .find((candidate) => + candidate.attackerPlayerId === "party:client-a" && + candidate.victimPlayerId === "party:client-b" + ); + assert.ok(event, "expected fresh fire aim to damage despite stale authoritative yaw"); + assert.equal(event.damage, 24); + assert.equal(event.health, 76); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room uses a bounded fire origin hint when the authoritative origin is one input behind", () => { + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "fresh-fire-origin-stale-pose", + spawnDistance: 4, + }); + try { + const victim = partyRoom.players.get("party:client-b"); + assert.ok(victim, "expected victim"); + partyRoom.players.set("party:client-b", { + ...victim, + origin: [victim.origin[0], 0.9, victim.origin[2]], + }); + + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-fresh-origin-stale-pose", + sequence: 2, + fireSequence: 1, + sentAt: Date.now(), + fire: { origin: [0, 0.4, 0] }, + })), alice); + + const event = roomEvents(alice, "player.damaged") + .find((candidate) => + candidate.attackerPlayerId === "party:client-a" && + candidate.victimPlayerId === "party:client-b" + ); + assert.ok(event, "expected bounded fire origin hint to damage despite stale authoritative origin"); + assert.equal(event.damage, 24); + assert.equal(event.health, 76); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room rewinds hit tests from authoritative snapshot history instead of current velocity", () => { + const originalNow = Date.now; + let now = 10_000; + Date.now = () => now; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "historical-hit-stopped-target", + spawnDistance: 4, + }); + try { + const attacker = partyRoom.players.get("party:client-a"); + const target = partyRoom.players.get("party:client-b"); + assert.ok(attacker, "expected attacker"); + assert.ok(target, "expected target"); + partyRoom.players.set("party:client-a", { + ...attacker, + origin: [0, 0, 0], + velocity: [0, 0, 0], + updatedAt: now, + }); + partyRoom.players.set("party:client-b", { + ...target, + origin: [4, 0, 0], + velocity: [0, 0, 0], + updatedAt: now, + }); + partyRoom.broadcastSnapshot(); + + now += 100; + partyRoom.players.set("party:client-b", { + ...partyRoom.players.get("party:client-b"), + origin: [4, 1.4, 0], + velocity: [0, 0, 0], + updatedAt: now, + }); + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-historical-stopped-target", + sequence: 2, + fireSequence: 1, + sentAt: now, + fire: { + origin: [0, 0, -0.36], + direction: [1, 0, 0], + }, + })), alice); + + const event = roomEvents(alice, "player.damaged") + .find((candidate) => + candidate.attackerPlayerId === "party:client-a" && + candidate.victimPlayerId === "party:client-b" && + candidate.damageSource === "shotgun" + ); + assert.ok(event, "expected historical target sample to receive damage"); + assert.equal(event.damage, 24); + assert.equal(event.health, 76); + assert.equal(latestSnapshotPlayerForClient(alice, "client-b").health, 76); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + Date.now = originalNow; + } +}); + +test("party room still blocks damage when LOS trace hits a real wall", () => { + const collisionWorld = { + traceUse: () => ({ + fraction: 0.5, + end: [2, 0, -0.5], + planeNormal: [1, 0, 0], + entityIndex: 900, + modelIndex: 9, + classname: "func_wall", + }), + }; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "mid-wall-los", + roomOptions: { + trustedSceneMovement: { + collisionWorld, + playerEyeHeight: 1.0, + }, + }, + spawnDistance: 4, + }); + try { + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-mid-wall-los", + sequence: 2, + fireSequence: 1, + sentAt: Date.now(), + })), alice); + + const event = roomEvents(alice, "player.damaged") + .find((candidate) => + candidate.attackerPlayerId === "party:client-a" && + candidate.victimPlayerId === "party:client-b" + ); + assert.equal(event, undefined); + const bobPlayer = latestSnapshotPlayerForClient(alice, "client-b"); + assert.equal(bobPlayer.health, 100); + const fired = roomEvents(alice, "player.fired").find((candidate) => + candidate.eventId === "fire-fire-mid-wall-los" + ); + assert.equal(fired?.decision?.outcome, "miss"); + assert.equal(fired?.decision?.reason, "line-of-sight-blocked"); + assert.equal(fired?.decision?.candidateCount, 1); + assert.equal(fired?.decision?.blockedCandidateCount, 1); + assert.equal(fired?.decision?.playerDamageCount, 0); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room damages a farther visible player when a nearer candidate is blocked", () => { + const collisionWorld = { + traceUse: (_origin, impact) => impact[0] < 3 + ? { + fraction: 0.5, + end: [1, 0, -0.5], + planeNormal: [1, 0, 0], + entityIndex: 44, + modelIndex: 2, + classname: "func_wall", + } + : null, + }; + const { alice, bob, cara, partyRoom } = connectTripleRoom({ + id: "blocked-nearer-visible-farther", + roomOptions: { + trustedSceneMovement: { + collisionWorld, + playerEyeHeight: 1.0, + }, + }, + spawns: [ + { spawnId: "spawn-a", classname: "info_player_deathmatch", origin: [0, 0, 0], rotX: -78, rotY: 0 }, + { spawnId: "spawn-b", classname: "info_player_deathmatch", origin: [2, 0, 0], rotX: -78, rotY: 180 }, + { spawnId: "spawn-c", classname: "info_player_deathmatch", origin: [4, 0, 0], rotX: -78, rotY: 180 }, + ], + }); + try { + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-blocked-near-visible-far", + sequence: 2, + fireSequence: 1, + sentAt: Date.now(), + })), alice); + + const damagedEvents = roomEvents(alice, "player.damaged"); + assert.equal(damagedEvents.some((event) => event.victimPlayerId === "party:client-b"), false); + const farEvent = damagedEvents.find((event) => event.victimPlayerId === "party:client-c"); + assert.ok(farEvent, "expected farther visible player to take damage"); + assert.equal(farEvent.damage, 24); + assert.equal(farEvent.health, 76); + const fired = roomEvents(alice, "player.fired").find((candidate) => + candidate.eventId === "fire-fire-blocked-near-visible-far" + ); + assert.equal(fired?.decision?.outcome, "hit-player"); + assert.equal(fired?.decision?.reason, "player-direct"); + assert.equal(fired?.decision?.targetPlayerId, "party:client-c"); + assert.equal(fired?.decision?.candidateCount, 2); + assert.equal(fired?.decision?.blockedCandidateCount, 1); + assert.equal(fired?.decision?.playerDamageCount, 1); + assert.equal(latestSnapshotPlayerForClient(alice, "client-b").health, 100); + assert.equal(latestSnapshotPlayerForClient(alice, "client-c").health, 76); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupPartyRoomConnections(partyRoom, alice, bob, cara); + } +}); diff --git a/test/multiplayer/partyRoomHarness.mjs b/test/multiplayer/partyRoomHarness.mjs new file mode 100644 index 0000000..952d807 --- /dev/null +++ b/test/multiplayer/partyRoomHarness.mjs @@ -0,0 +1,366 @@ +import assert from "node:assert/strict"; +import { importTsModule } from "../importTsModule.mjs"; +import { + NORMALIZED_ROOM_KEY, + ROOM_KEY, + authority, + clientEnvelope, + createLoopbackHarness, + createPlayer, + fireEnvelope, + helloEnvelope, + inputBatchEnvelope, + inputEnvelope, + latestMessage, + matchEnvelope, + partyRoomModule, + pickupEnvelope, + presenceEnvelope, + protocol, + projectileAuthority, + validation, + waitForMessage, + worldEnvelope, +} from "./harness.mjs"; + +export { + NORMALIZED_ROOM_KEY, + ROOM_KEY, + authority, + clientEnvelope, + createLoopbackHarness, + createPlayer, + fireEnvelope, + helloEnvelope, + inputBatchEnvelope, + inputEnvelope, + latestMessage, + matchEnvelope, + partyRoomModule, + pickupEnvelope, + presenceEnvelope, + protocol, + projectileAuthority, + validation, + waitForMessage, + worldEnvelope, +}; + +export const facts = await importTsModule("src/runtime/multiplayer/facts.ts"); +export const items = await importTsModule("src/runtime/multiplayer/items.ts"); + +export const DUEL_FORWARD_DIRECTION = [0.9781476007338057, 0, -0.20791169081775934]; + +export class FakePartyConnection { + constructor(id) { + this.id = id; + this.messages = []; + this.closed = []; + this.state = null; + } + + send(message) { + this.messages.push(JSON.parse(message)); + } + + setState(state) { + this.state = state; + } + + close(code, reason) { + this.closed.push({ code, reason }); + } +} + +export function createFakePartyRoom(id = "test-room") { + const connections = []; + return { + room: { + id, + context: {}, + broadcast(message, without = []) { + const payload = JSON.parse(message); + for (const connection of connections) { + if (without.includes(connection.id)) continue; + connection.messages.push(payload); + } + }, + getConnections() { + return connections; + }, + }, + createConnection(connectionId) { + const connection = new FakePartyConnection(connectionId); + connections.push(connection); + return connection; + }, + }; +} + +export function latestConnectionMessage(connection, type) { + const message = connection.messages.findLast((candidate) => candidate.type === type); + assert.ok(message, `expected ${type} message on ${connection.id}`); + return message; +} + +export function roomEvents(connection, eventType) { + return connection.messages + .filter((message) => message.type === "room.event" && message.payload.event.eventType === eventType) + .map((message) => message.payload.event); +} + +export 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; +} + +export const weaponPickupFlags = { + axe: 4096, + supershotgun: 2, + nailgun: 4, + supernailgun: 8, + grenadelauncher: 16, + rocketlauncher: 32, + lightning: 64, +}; + +export const weaponPickupAmmo = { + axe: { shells: 0 }, + supershotgun: { shells: 10 }, + nailgun: { nails: 25 }, + supernailgun: { nails: 25 }, + grenadelauncher: { rockets: 5 }, + rocketlauncher: { rockets: 5 }, + lightning: { cells: 25 }, +}; + +export const QUAD_ITEM_FLAG = 4_194_304; +export const INVULNERABILITY_ITEM_FLAG = 1_048_576; + +export 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, + }, + }, + }; +} + +export function quadPickupDefinition({ entityIndex = 1999, durationMs = 30_000, origin = [0, 0, 0] } = {}) { + return { + pickupId: `powerup-quad-${entityIndex}`, + entityIndex, + classname: "item_artifact_super_damage", + origin, + effect: { + powerup: { + activationField: "super_damage_time", + durationMs, + finishedField: "super_damage_finished", + itemFlag: QUAD_ITEM_FLAG, + itemFlagExpression: "IT_QUAD", + }, + }, + }; +} + +export function invulnerabilityPickupDefinition({ entityIndex = 2999, durationMs = 30_000, origin = [0, 0, 0] } = {}) { + return { + pickupId: `powerup-invulnerability-${entityIndex}`, + entityIndex, + classname: "item_artifact_invulnerability", + origin, + effect: { + powerup: { + activationField: "invincible_time", + durationMs, + finishedField: "invincible_finished", + itemFlag: INVULNERABILITY_ITEM_FLAG, + }, + }, + }; +} + +export function connectDuelRoom({ + id, + deathmatchSpawns, + matchSettings = { fragLimit: 1 }, + pickupDefinitions = [], + roomOptions = {}, + spawnDistance = 4, +}) { + const spawns = 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: spawns, + pickupDefinitions, + }); + const { room, createConnection } = createFakePartyRoom(id); + const RoomClass = partyRoomModule.default; + const partyRoom = new RoomClass(room, { + random: () => 0.999999, + trustedGameplayDefinitions: gameplayDefinitions, + ...roomOptions, + }); + 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, + })), alice); + partyRoom.onMessage(JSON.stringify(helloEnvelope({ + clientId: "client-b", + displayName: "Bob", + messageId: `hello-b-${id}`, + sequence: 1, + sentAt: Date.now(), + matchSettings, + })), bob); + return { alice, bob, partyRoom }; +} + +export function connectTripleRoom({ id, roomOptions = {}, spawns }) { + const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ + deathmatchSpawns: spawns, + pickupDefinitions: [], + }); + const { room, createConnection } = createFakePartyRoom(id); + const RoomClass = partyRoomModule.default; + const partyRoom = new RoomClass(room, { + random: () => 0.999999, + trustedGameplayDefinitions: gameplayDefinitions, + ...roomOptions, + }); + const alice = createConnection("alice"); + const bob = createConnection("bob"); + const cara = createConnection("cara"); + partyRoom.onConnect(alice); + partyRoom.onConnect(bob); + partyRoom.onConnect(cara); + const clients = [ + { clientId: "client-a", connection: alice, displayName: "Alice" }, + { clientId: "client-b", connection: bob, displayName: "Bob" }, + { clientId: "client-c", connection: cara, displayName: "Cara" }, + ]; + for (const [index, client] of clients.entries()) { + partyRoom.onMessage(JSON.stringify(helloEnvelope({ + clientId: client.clientId, + displayName: client.displayName, + messageId: `hello-${client.clientId}-${id}`, + sequence: 1, + sentAt: Date.now(), + matchSettings: { fragLimit: 99, maxPlayers: 4 }, + })), client.connection); + } + return { alice, bob, cara, partyRoom }; +} + +export function cleanupDuelRoom(partyRoom, alice, bob) { + cleanupPartyRoomConnections(partyRoom, alice, bob); +} + +export function cleanupPartyRoomConnections(partyRoom, ...connections) { + for (const connection of connections) partyRoom.onClose(connection); +} + +export function setPartyRoomPlayerWeapon(partyRoom, clientId, weapon) { + const playerId = `party:${clientId}`; + const player = partyRoom.players.get(playerId); + assert.ok(player, `expected player ${playerId}`); + const inventory = items.quakeMultiplayerPlayerInventory(player); + inventory.weapons = [...new Set([...inventory.weapons, weapon])]; + inventory.activeWeapon = weapon; + inventory.shells = Math.max(inventory.shells, 50); + inventory.nails = Math.max(inventory.nails, 50); + inventory.rockets = Math.max(inventory.rockets, 50); + inventory.cells = Math.max(inventory.cells, 50); + partyRoom.players.set(playerId, items.quakeMultiplayerPlayerWithInventory(player, inventory)); +} + +export function setPartyRoomPlayerQuad(partyRoom, clientId, finishedAt) { + const playerId = `party:${clientId}`; + const player = partyRoom.players.get(playerId); + assert.ok(player, `expected player ${playerId}`); + const inventory = items.quakeMultiplayerPlayerInventory(player); + inventory.itemFlags |= QUAD_ITEM_FLAG; + inventory.powerups = [ + ...inventory.powerups.filter((powerup) => powerup.finishedField !== "super_damage_finished"), + { + active: true, + activationField: "super_damage_time", + finishedAt, + finishedField: "super_damage_finished", + itemFlag: QUAD_ITEM_FLAG, + itemFlagExpression: "IT_QUAD", + }, + ]; + partyRoom.players.set(playerId, items.quakeMultiplayerPlayerWithInventory(player, inventory)); +} + +export function setPartyRoomPlayerInvulnerable(partyRoom, clientId, finishedAt) { + const playerId = `party:${clientId}`; + const player = partyRoom.players.get(playerId); + assert.ok(player, `expected player ${playerId}`); + const inventory = items.quakeMultiplayerPlayerInventory(player); + inventory.itemFlags |= INVULNERABILITY_ITEM_FLAG; + inventory.powerups = [ + ...inventory.powerups.filter((powerup) => powerup.finishedField !== "invincible_finished"), + { + active: true, + activationField: "invincible_time", + finishedAt, + finishedField: "invincible_finished", + itemFlag: INVULNERABILITY_ITEM_FLAG, + }, + ]; + partyRoom.players.set(playerId, items.quakeMultiplayerPlayerWithInventory(player, inventory)); +} + +export 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; +} diff --git a/test/multiplayer/partyRoomLifecycle.test.mjs b/test/multiplayer/partyRoomLifecycle.test.mjs new file mode 100644 index 0000000..9be578a --- /dev/null +++ b/test/multiplayer/partyRoomLifecycle.test.mjs @@ -0,0 +1,275 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + authority, + fireEnvelope, + helloEnvelope, + inputBatchEnvelope, + inputEnvelope, + partyRoomModule, + presenceEnvelope, + createFakePartyRoom, + latestConnectionMessage, + roomEvents, + connectDuelRoom, + cleanupDuelRoom, + cleanupPartyRoomConnections, +} from "./partyRoomHarness.mjs"; + +test("party room accepts a fifth capped player as a spectator", () => { + const { room, createConnection } = createFakePartyRoom(); + const RoomClass = partyRoomModule.default; + const partyRoom = new RoomClass(room); + assert.equal(partyRoomModule.CSSQUAKE_PARTY_MAX_SPECTATORS_PER_ROOM, 8); + + for (let index = 1; index <= 4; index += 1) { + const connection = createConnection(`connection-${index}`); + partyRoom.onConnect(connection); + partyRoom.onMessage(JSON.stringify(helloEnvelope({ + clientId: `client-${index}`, + displayName: `Player ${index}`, + messageId: `hello-${index}`, + sequence: 1, + sentAt: Date.now(), + matchSettings: { maxPlayers: 8 }, + })), connection); + const snapshot = latestConnectionMessage(connection, "room.snapshot"); + assert.equal(snapshot.payload.match.maxPlayers, 4); + } + + const spectator = createConnection("connection-5"); + partyRoom.onConnect(spectator); + partyRoom.onMessage(JSON.stringify(helloEnvelope({ + clientId: "client-5", + displayName: "Player 5", + messageId: "hello-5", + sequence: 1, + sentAt: Date.now(), + matchSettings: { maxPlayers: 8 }, + })), spectator); + + const snapshot = latestConnectionMessage(spectator, "room.snapshot"); + assert.equal(snapshot.payload.players.length, 4); + assert.deepEqual(snapshot.payload.spectators, [{ + clientId: "client-5", + displayName: "Player 5", + }]); + assert.equal(spectator.state.role, "spectator"); + assert.equal(spectator.state.playerId, undefined); + assert.equal(spectator.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(spectator.closed.length, 0); + + for (let index = 6; index < 6 + partyRoomModule.CSSQUAKE_PARTY_MAX_SPECTATORS_PER_ROOM - 1; index += 1) { + const extraSpectator = createConnection(`connection-${index}`); + partyRoom.onConnect(extraSpectator); + partyRoom.onMessage(JSON.stringify(helloEnvelope({ + clientId: `client-${index}`, + displayName: `Player ${index}`, + messageId: `hello-${index}`, + sequence: 1, + sentAt: Date.now(), + matchSettings: { maxPlayers: 8 }, + })), extraSpectator); + assert.equal(extraSpectator.state.role, "spectator"); + assert.equal(extraSpectator.closed.length, 0); + } + + const overflow = createConnection("connection-overflow"); + partyRoom.onConnect(overflow); + partyRoom.onMessage(JSON.stringify(helloEnvelope({ + clientId: "client-overflow", + displayName: "Overflow", + messageId: "hello-overflow", + sequence: 1, + sentAt: Date.now(), + matchSettings: { maxPlayers: 8 }, + })), overflow); + const reject = latestConnectionMessage(overflow, "room.reject"); + assert.equal(reject.payload.code, "room-full"); + assert.equal(reject.payload.recoverable, false); + assert.deepEqual(overflow.closed.at(-1), { code: 1008, reason: "reject:room-full" }); +}); + +test("party room queues ordered input batches into the player simulation state", () => { + const { room, createConnection } = createFakePartyRoom("input-batch-room"); + const RoomClass = partyRoomModule.default; + const partyRoom = new RoomClass(room); + const connection = createConnection("connection-a"); + try { + partyRoom.onConnect(connection); + partyRoom.onMessage(JSON.stringify(helloEnvelope({ + messageId: "batch-hello", + sequence: 1, + sentAt: Date.now(), + })), connection); + + partyRoom.onMessage(JSON.stringify(inputBatchEnvelope({ + messageId: "batch-inputs", + sequence: 2, + inputSequences: [1, 2, 3], + sentAt: Date.now(), + })), connection); + + assert.equal(connection.state.authority.lastIntentSequences.input, 3); + const simulationState = partyRoom.playerSimulationStates.get("party:client-a"); + assert.ok(simulationState); + assert.deepEqual(simulationState.pendingInputs.map((input) => input.inputSequence), [1, 2, 3]); + assert.deepEqual(simulationState.acceptedInputHistory.map((input) => input.inputSequence), [1, 2, 3]); + } finally { + cleanupPartyRoomConnections(partyRoom, connection); + } +}); + +test("party room accepts fire timestamps near accepted input history", () => { + const { alice, bob, partyRoom } = connectDuelRoom({ id: "fire-input-history-accept" }); + try { + const base = Date.now(); + partyRoom.onMessage(JSON.stringify(inputBatchEnvelope({ + clientId: "client-a", + messageId: "fire-history-inputs", + sequence: 2, + sentAt: base, + inputSequences: [1, 2], + inputs: [ + { sampledAt: base, rotX: -78, rotY: 0 }, + { sampledAt: base + 50, rotX: -78, rotY: 0 }, + ], + })), alice); + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-history-valid", + sequence: 3, + sentAt: base + 60, + fireSequence: 1, + fire: { + firedAt: base + 55, + }, + })), alice); + + const damage = roomEvents(alice, "player.damaged") + .find((event) => event.attackerPlayerId === "party:client-a" && event.victimPlayerId === "party:client-b"); + assert.ok(damage, "expected accepted fire timestamp to damage the remote player"); + assert.equal(damage.damage, 24); + assert.equal(alice.messages.some((message) => message.type === "room.reject"), false); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room rejects fire timestamps outside accepted input history", () => { + const { alice, bob, partyRoom } = connectDuelRoom({ id: "fire-input-history-reject" }); + try { + const base = Date.now(); + partyRoom.onMessage(JSON.stringify(inputBatchEnvelope({ + clientId: "client-a", + messageId: "fire-history-reject-inputs", + sequence: 2, + sentAt: base, + inputSequences: [1, 2], + inputs: [ + { sampledAt: base, rotX: -78, rotY: 0 }, + { sampledAt: base + 50, rotX: -78, rotY: 0 }, + ], + })), alice); + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-history-too-late", + sequence: 3, + sentAt: base + 60, + fireSequence: 1, + fire: { + firedAt: base + 1_000, + }, + })), alice); + + const reject = latestConnectionMessage(alice, "room.reject"); + assert.equal(reject.payload.code, "stale"); + assert.equal(reject.payload.recoverable, true); + assert.equal(reject.payload.rejectedMessageId, "fire-history-too-late"); + assert.match(reject.payload.message, /fire-after-input-history/); + assert.equal( + roomEvents(alice, "player.damaged") + .some((event) => event.attackerPlayerId === "party:client-a" && event.victimPlayerId === "party:client-b"), + false, + ); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room closes a connection after repeated recoverable rejects", () => { + const { room, createConnection } = createFakePartyRoom(); + const RoomClass = partyRoomModule.default; + const partyRoom = new RoomClass(room); + const connection = createConnection("noisy-connection"); + + partyRoom.onConnect(connection); + partyRoom.onMessage(JSON.stringify(helloEnvelope({ + messageId: "hello-noisy", + sequence: 1, + sentAt: Date.now(), + })), connection); + + for (let index = 0; index < partyRoomModule.CSSQUAKE_PARTY_MAX_REJECTS_PER_CONNECTION; index += 1) { + partyRoom.onMessage(JSON.stringify(inputEnvelope({ + messageId: `stale-input-${index}`, + sequence: 1, + inputSequence: 1, + sentAt: Date.now(), + })), connection); + } + + const rejects = connection.messages.filter((message) => message.type === "room.reject"); + assert.equal(rejects.length, partyRoomModule.CSSQUAKE_PARTY_MAX_REJECTS_PER_CONNECTION); + assert.equal(rejects.at(-1).payload.code, "stale"); + assert.equal(rejects.at(-1).payload.recoverable, true); + assert.deepEqual(connection.closed.at(-1), { code: 1008, reason: "too-many-rejects" }); +}); + +test("party room keeps hello authority while trusted gameplay definitions are pending", async () => { + const { room, createConnection } = createFakePartyRoom(); + const RoomClass = partyRoomModule.default; + let resolveTrustedDefinitions; + const trustedDefinitions = new Promise((resolve) => { + resolveTrustedDefinitions = resolve; + }); + const partyRoom = new RoomClass(room, { + trustedGameplayDefinitionsFetcher: () => trustedDefinitions, + }); + const connection = createConnection("pending-hello-connection"); + + partyRoom.onConnect(connection); + const helloResult = partyRoom.onMessage(JSON.stringify(helloEnvelope({ + messageId: "pending-hello", + sequence: 1, + sentAt: Date.now(), + })), connection); + partyRoom.onMessage(JSON.stringify(presenceEnvelope("active", { + messageId: "presence-while-hello-pending", + sequence: 2, + sentAt: Date.now(), + })), connection); + + assert.equal(connection.closed.length, 0); + assert.equal(connection.messages.some((message) => + message.type === "room.reject" && + message.payload.code === "not-authorized" + ), false); + assert.equal(connection.state.authority.lastEnvelopeSequence, 2); + + resolveTrustedDefinitions({ + gameplayFacts: { + factsVersion: 1, + factsHash: "0000000000000000", + deathmatchSpawnCount: 0, + pickupCount: 0, + }, + deathmatchSpawns: [], + pickupDefinitions: [], + }); + await Promise.resolve(helloResult); + + assert.equal(connection.state.playerId, "party:client-a"); + assert.equal(connection.state.authority.lastEnvelopeSequence, 2); +}); diff --git a/test/multiplayer/partyRoomProjectiles.test.mjs b/test/multiplayer/partyRoomProjectiles.test.mjs new file mode 100644 index 0000000..695c335 --- /dev/null +++ b/test/multiplayer/partyRoomProjectiles.test.mjs @@ -0,0 +1,423 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createPlayer, + fireEnvelope, + projectileAuthority, + DUEL_FORWARD_DIRECTION, + latestConnectionMessage, + roomEvents, + latestSnapshotPlayerForClient, + connectDuelRoom, + connectTripleRoom, + cleanupDuelRoom, + cleanupPartyRoomConnections, + setPartyRoomPlayerWeapon, + setPartyRoomPlayerQuad, + setPartyRoomPlayerInvulnerable, +} from "./partyRoomHarness.mjs"; + +test("party room blocks indirect projectile splash through walls", () => { + const collisionWorld = { + traceUse: (_origin, point) => point[1] > 1 + ? { + fraction: 0.4, + end: [point[0], 1, point[2]], + planeNormal: [0, -1, 0], + entityIndex: 45, + modelIndex: 3, + classname: "func_wall", + } + : null, + }; + const { alice, bob, cara, partyRoom } = connectTripleRoom({ + id: "projectile-splash-wall", + roomOptions: { + trustedSceneMovement: { + collisionWorld, + playerEyeHeight: 1.0, + }, + }, + spawns: [ + { spawnId: "spawn-a", classname: "info_player_deathmatch", origin: [0, 0, 0], rotX: -78, rotY: 0 }, + { spawnId: "spawn-b", classname: "info_player_deathmatch", origin: [3, 0, 0], rotX: -78, rotY: 180 }, + { spawnId: "spawn-c", classname: "info_player_deathmatch", origin: [3, 2, 0], rotX: -78, rotY: 180 }, + ], + }); + try { + setPartyRoomPlayerWeapon(partyRoom, "client-a", "rocketlauncher"); + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-splash-wall", + sequence: 2, + fireSequence: 1, + sentAt: Date.now(), + })), alice); + partyRoom.advanceRoomSimulation(Date.now() + 400); + + const damagedEvents = roomEvents(alice, "player.damaged"); + const killedEvents = roomEvents(alice, "player.killed"); + assert.ok(killedEvents.some((event) => event.victimPlayerId === "party:client-b")); + assert.equal(damagedEvents.some((event) => event.victimPlayerId === "party:client-c"), false); + assert.equal(killedEvents.some((event) => event.victimPlayerId === "party:client-c"), false); + assert.equal(latestSnapshotPlayerForClient(alice, "client-c").health, 100); + assert.equal(latestSnapshotPlayerForClient(alice, "client-c").alive, true); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupPartyRoomConnections(partyRoom, alice, bob, cara); + } +}); + +test("party room applies projectile wall-impact splash without a direct player hit", () => { + const collisionWorld = { + traceUse: (origin, point) => origin[0] === 0 && point[0] > 10 + ? { + fraction: 3 / 64, + end: [3, 0, 0], + planeNormal: [-1, 0, 0], + entityIndex: 44, + modelIndex: 3, + classname: "func_wall", + } + : null, + }; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "projectile-wall-splash", + roomOptions: { + trustedSceneMovement: { + collisionWorld, + playerEyeHeight: 1.0, + }, + }, + }); + try { + setPartyRoomPlayerWeapon(partyRoom, "client-a", "rocketlauncher"); + const bobPlayer = partyRoom.players.get("party:client-b"); + assert.ok(bobPlayer, "expected bob player"); + partyRoom.players.set("party:client-b", { + ...bobPlayer, + origin: [3, 2, 0], + updatedAt: Date.now(), + }); + + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-wall-splash", + sequence: 2, + fireSequence: 1, + sentAt: Date.now(), + fire: { + direction: [1, 0, 0], + }, + })), alice); + partyRoom.advanceRoomSimulation(Date.now() + 2_000); + + const damagedEvents = roomEvents(alice, "player.damaged"); + const bobDamage = damagedEvents.find((event) => event.victimPlayerId === "party:client-b"); + const aliceDamage = damagedEvents.find((event) => event.victimPlayerId === "party:client-a"); + assert.ok(bobDamage, "expected wall splash to damage nearby non-direct target"); + assert.equal(bobDamage.damage, 69); + assert.equal(bobDamage.health, 31); + assert.ok(aliceDamage, "expected wall splash to apply half self damage"); + assert.equal(aliceDamage.damage, 22); + assert.equal(aliceDamage.health, 78); + assert.equal(latestSnapshotPlayerForClient(alice, "client-b").health, 31); + assert.equal(latestSnapshotPlayerForClient(alice, "client-a").health, 78); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } +}); + +test("party room applies projectile quad damage from impact-time attacker state", () => { + const originalNow = Date.now; + let now = 3_000_000; + Date.now = () => now; + const cases = [ + { + id: "quad-expired-before-impact", + setup: (partyRoom) => setPartyRoomPlayerQuad(partyRoom, "client-a", now + 50), + expectedDamage: 9, + expectedHealth: 91, + }, + { + id: "quad-picked-up-before-impact", + setup: () => {}, + beforeImpact: (partyRoom) => setPartyRoomPlayerQuad(partyRoom, "client-a", now + 10_000), + expectedDamage: 36, + expectedHealth: 64, + }, + ]; + + try { + for (const spec of cases) { + const { alice, bob, partyRoom } = connectDuelRoom({ + id: spec.id, + spawnDistance: 4, + }); + try { + setPartyRoomPlayerWeapon(partyRoom, "client-a", "nailgun"); + spec.setup(partyRoom); + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: `fire-${spec.id}`, + sequence: 2, + fireSequence: 1, + sentAt: now, + })), alice); + + assert.equal( + roomEvents(alice, "player.damaged") + .some((candidate) => candidate.damageSource === "nailgun"), + false, + "nail projectile should not damage on the fire tick", + ); + spec.beforeImpact?.(partyRoom); + now += 400; + partyRoom.advanceRoomSimulation(now); + + const event = roomEvents(alice, "player.damaged") + .find((candidate) => + candidate.attackerPlayerId === "party:client-a" && + candidate.victimPlayerId === "party:client-b" && + candidate.damageSource === "nailgun" + ); + assert.ok(event, `expected nailgun damage for ${spec.id}`); + assert.equal(event.damage, spec.expectedDamage, spec.id); + assert.equal(event.health, spec.expectedHealth, spec.id); + assert.equal(event.roomTime, 400, spec.id); + const impact = roomEvents(alice, "projectile.impacted") + .find((candidate) => candidate.weapon === "nailgun"); + assert.equal(impact?.roomTime, 400, spec.id); + assert.equal(latestSnapshotPlayerForClient(alice, "client-b").health, spec.expectedHealth); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + } + now += 1_000; + } + } finally { + Date.now = originalNow; + } +}); + +test("party room applies delayed projectile victim powerups at simulation impact time", () => { + const originalNow = Date.now; + const fireNow = 3_100_000; + Date.now = () => fireNow; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "projectile-victim-powerup-impact-time", + spawnDistance: 4, + }); + try { + setPartyRoomPlayerWeapon(partyRoom, "client-a", "nailgun"); + setPartyRoomPlayerInvulnerable(partyRoom, "client-b", fireNow + 50); + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-projectile-victim-powerup-impact-time", + sequence: 2, + fireSequence: 1, + sentAt: fireNow, + fire: { + weapon: "nailgun", + fireKind: "projectile", + }, + })), alice); + + partyRoom.advanceRoomSimulation(fireNow + 400); + + const event = roomEvents(alice, "player.damaged") + .find((candidate) => + candidate.attackerPlayerId === "party:client-a" && + candidate.victimPlayerId === "party:client-b" && + candidate.damageSource === "nailgun" + ); + assert.ok(event, "expected expired victim invulnerability not to block delayed projectile damage"); + assert.equal(event.damage, 9); + assert.equal(event.health, 91); + assert.equal(event.roomTime, 400); + const impact = roomEvents(alice, "projectile.impacted") + .find((candidate) => candidate.weapon === "nailgun"); + assert.equal(impact?.roomTime, 400); + assert.equal(latestSnapshotPlayerForClient(alice, "client-b").health, 91); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + cleanupDuelRoom(partyRoom, alice, bob); + Date.now = originalNow; + } +}); + +test("server grenade projectile advances through delayed arc impact damage", () => { + const projectile = projectileAuthority.createQuakeMultiplayerServerProjectile({ + fire: { + fireSequence: 1, + firedAt: 100, + fireKind: "projectile", + weapon: "grenadelauncher", + origin: [0, 0, 0], + direction: DUEL_FORWARD_DIRECTION, + range: 1024, + }, + now: 100, + ownerPlayerId: "party:client-a", + projectileId: "grenade-arc-1", + }); + assert.ok(projectile, "expected grenade launcher to create a server projectile"); + assert.equal(projectile.weapon, "grenadelauncher"); + assert.equal(projectileAuthority.quakeMultiplayerServerProjectileWeaponSupported("grenadelauncher"), true); + assert.ok(projectile.gravity > 0, "expected grenade projectile to carry gravity"); + assert.ok(projectile.velocity[2] > projectile.direction[2] * projectile.speed, "expected grenade launch kick"); + + const target = createPlayer({ + playerId: "party:client-b", + clientId: "client-b", + displayName: "Bob", + origin: [4, 0, 0], + rotX: -78, + rotY: 180, + updatedAt: 100, + }); + const immediate = projectileAuthority.advanceQuakeMultiplayerServerProjectile(projectile, { + collisionWorld: null, + now: 100, + players: [target], + }); + assert.equal(immediate.type, "active", "grenade should not damage on the fire tick"); + + const delayed = projectileAuthority.advanceQuakeMultiplayerServerProjectile(projectile, { + collisionWorld: null, + now: 500, + players: [target], + }); + assert.equal(delayed.type, "impact"); + assert.equal(delayed.impact.kind, "player"); + assert.equal(delayed.impact.targetPlayerId, "party:client-b"); + const hit = delayed.impact.damageHits.find((candidate) => candidate.target.playerId === "party:client-b"); + assert.ok(hit, "expected delayed grenade impact to damage target"); + assert.equal(hit.damage, 87); + assert.equal(hit.direct, false); +}); + +test("server grenade projectile bounces on world impact and explodes on fuse expiry", () => { + const projectile = projectileAuthority.createQuakeMultiplayerServerProjectile({ + fire: { + fireSequence: 1, + firedAt: 100, + fireKind: "projectile", + weapon: "grenadelauncher", + origin: [0, 0, 1], + direction: [1, 0, 0], + range: 1024, + }, + now: 100, + ownerPlayerId: "party:client-a", + projectileId: "grenade-bounce-1", + }); + assert.ok(projectile, "expected grenade launcher to create a server projectile"); + const fallingProjectile = { + ...projectile, + direction: [0.24253562503633297, 0, -0.9701425001453319], + gravity: 0, + speed: Math.hypot(2, 0, -8), + velocity: [2, 0, -8], + }; + const collisionWorld = { + traceUse: (origin, end) => { + if (origin[2] <= 0 || end[2] > 0) return null; + const fraction = origin[2] / (origin[2] - end[2]); + return { + fraction, + end: [ + origin[0] + (end[0] - origin[0]) * fraction, + origin[1] + (end[1] - origin[1]) * fraction, + 0, + ], + planeNormal: [0, 0, 1], + entityIndex: 44, + modelIndex: 3, + classname: "func_floor", + }; + }, + }; + + const bounced = projectileAuthority.advanceQuakeMultiplayerServerProjectile(fallingProjectile, { + collisionWorld, + now: 300, + players: [], + }); + assert.equal(bounced.type, "active"); + assert.ok(bounced.projectile.origin[2] > 0, "expected bounced grenade to be offset off the impact plane"); + assert.ok(bounced.projectile.velocity[2] > 0, "expected bounced grenade to reflect upward"); + + const target = createPlayer({ + playerId: "party:client-b", + clientId: "client-b", + displayName: "Bob", + origin: [bounced.projectile.origin[0], bounced.projectile.origin[1], 0], + updatedAt: 300, + }); + const expired = projectileAuthority.advanceQuakeMultiplayerServerProjectile(bounced.projectile, { + collisionWorld: null, + now: bounced.projectile.expiresAt + 1, + players: [target], + }); + assert.equal(expired.type, "impact"); + assert.equal(expired.impact.kind, "world"); + const hit = expired.impact.damageHits.find((candidate) => candidate.target.playerId === "party:client-b"); + assert.ok(hit, "expected grenade fuse explosion to apply splash damage"); + assert.equal(hit.direct, false); + assert.ok(hit.damage > 0); +}); + +test("party room snapshots active server projectile positions", () => { + const originalNow = Date.now; + let now = 2_500_000; + Date.now = () => now; + const { alice, bob, partyRoom } = connectDuelRoom({ + id: "projectile-snapshot-position", + spawnDistance: 20, + }); + try { + setPartyRoomPlayerWeapon(partyRoom, "client-a", "rocketlauncher"); + partyRoom.onMessage(JSON.stringify(fireEnvelope({ + clientId: "client-a", + messageId: "fire-projectile-snapshot-position", + sequence: 2, + fireSequence: 1, + sentAt: now, + fire: { + weapon: "rocketlauncher", + }, + })), alice); + + const spawned = roomEvents(alice, "projectile.spawned") + .find((candidate) => candidate.projectile.weapon === "rocketlauncher"); + assert.ok(spawned, "expected projectile.spawned event"); + const initialSnapshot = latestConnectionMessage(alice, "room.snapshot"); + const initialProjectile = initialSnapshot.payload.projectiles + ?.find((candidate) => candidate.projectileId === spawned.projectile.projectileId); + assert.ok(initialProjectile, "expected initial snapshot to carry active projectile"); + assert.deepEqual(initialProjectile.origin, spawned.projectile.origin); + + now += 100; + partyRoom.advanceRoomSimulation(now); + partyRoom.broadcastSnapshot(); + const movedSnapshot = latestConnectionMessage(alice, "room.snapshot"); + const movedProjectile = movedSnapshot.payload.projectiles + ?.find((candidate) => candidate.projectileId === spawned.projectile.projectileId); + assert.ok(movedProjectile, "expected later snapshot to keep active projectile"); + assert.equal(movedProjectile.updatedAt, now); + assert.ok( + movedProjectile.origin[0] > initialProjectile.origin[0], + "expected active projectile snapshot origin to advance", + ); + assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); + assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); + } finally { + Date.now = originalNow; + cleanupDuelRoom(partyRoom, alice, bob); + } +}); diff --git a/test/multiplayer/partyRoomWorld.test.mjs b/test/multiplayer/partyRoomWorld.test.mjs new file mode 100644 index 0000000..88d8ff4 --- /dev/null +++ b/test/multiplayer/partyRoomWorld.test.mjs @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + worldEnvelope, + connectDuelRoom, +} from "./partyRoomHarness.mjs"; + +test("party room target dispatch activates relay chains and target teleporters", () => { + const triggerDefinition = { + kind: "trigger", + entityIndex: 100, + classname: "trigger_multiple", + bounds: { + mins: [-1, -1, 0], + maxs: [1, 1, 2], + }, + touchActivates: true, + useActivates: false, + shootActivates: false, + oneShot: false, + delayMs: 0, + waitMs: 0, + targetEntityIndexes: [101, 102], + }; + const relayDefinition = { + kind: "trigger", + entityIndex: 101, + classname: "trigger_relay", + touchActivates: false, + useActivates: true, + shootActivates: false, + oneShot: false, + delayMs: 0, + waitMs: 0, + targetEntityIndexes: [103], + }; + const teleportDefinition = { + kind: "teleport", + entityIndex: 102, + classname: "trigger_teleport", + touchRequiresActivation: true, + activationWindowMs: 200, + destinationEntityIndex: 900, + destinationOrigin: [8, 0, 1], + destinationRotX: 90, + destinationRotY: 180, + }; + const moverDefinition = { + kind: "mover", + entityIndex: 103, + classname: "func_plat", + bounds: { + mins: [2, -1, 0], + maxs: [3, 1, 2], + }, + touchActivates: false, + useActivates: true, + shootActivates: false, + speed: 50, + moveMs: 200, + delayMs: 0, + fromOrigin: [0, 0, 0], + toOrigin: [0, 0, 1], + targetEntityIndexes: [], + }; + const { alice, partyRoom } = connectDuelRoom({ + id: "party-target-relay-teleport", + deathmatchSpawns: [ + { + spawnId: "spawn-target-a", + classname: "info_player_deathmatch", + origin: [0, 0, 1], + rotX: 90, + rotY: 0, + }, + { + spawnId: "spawn-target-b", + classname: "info_player_deathmatch", + origin: [4, 0, 1], + rotX: 90, + rotY: 180, + }, + ], + roomOptions: { + trustedWorldDefinitions: [ + triggerDefinition, + relayDefinition, + teleportDefinition, + moverDefinition, + ], + }, + }); + + partyRoom.onMessage(JSON.stringify(worldEnvelope({ + clientId: "client-a", + messageId: "world-party-target-relay-teleport", + sequence: 2, + worldSequence: 1, + sentAt: Date.now(), + intent: { + entityIndex: triggerDefinition.entityIndex, + origin: [0, 0, 1], + }, + })), alice); + + const events = alice.messages + .filter((message) => message.type === "room.event") + .map((message) => message.payload.event); + const sourceTrigger = events.find((event) => + event.eventType === "world.trigger" && + event.entityIndex === triggerDefinition.entityIndex && + event.activation === "touch" + ); + const sourceTargets = events.find((event) => + event.eventType === "world.targets" && + event.sourceEntityIndex === triggerDefinition.entityIndex + ); + const relayTrigger = events.find((event) => + event.eventType === "world.trigger" && + event.entityIndex === relayDefinition.entityIndex && + event.activation === "target" + ); + const relayTargets = events.find((event) => + event.eventType === "world.targets" && + event.sourceEntityIndex === relayDefinition.entityIndex + ); + const teleportUse = events.find((event) => + event.eventType === "world.use" && + event.entityIndex === teleportDefinition.entityIndex + ); + const mover = events.find((event) => + event.eventType === "world.mover" && + event.entityIndex === moverDefinition.entityIndex + ); + + assert.ok(sourceTrigger, "expected source trigger event"); + assert.ok(sourceTargets, "expected source target dispatch event"); + assert.deepEqual(sourceTargets.targetEntityIndexes, [101, 102]); + assert.ok(relayTrigger, "expected relay trigger event"); + assert.ok(relayTargets, "expected relay target dispatch event"); + assert.deepEqual(relayTargets.targetEntityIndexes, [103]); + assert.ok(teleportUse, "expected target teleporter activation event"); + assert.ok(mover, "expected chained target mover event"); + assert.equal(mover.classname, "func_plat"); + assert.equal(mover.activation, "target"); + assert.equal(mover.state, "moving-up"); + assert.equal(alice.messages.some((message) => message.type === "room.reject"), false); +}); diff --git a/test/multiplayer/protocol.test.mjs b/test/multiplayer/protocol.test.mjs index d39e381..7e79db7 100644 --- a/test/multiplayer/protocol.test.mjs +++ b/test/multiplayer/protocol.test.mjs @@ -5,344 +5,13 @@ import { NORMALIZED_ROOM_KEY, ROOM_KEY, authority, - clientEnvelope, - createLoopbackHarness, - createPlayer, - fireEnvelope, helloEnvelope, inputBatchEnvelope, inputEnvelope, - latestMessage, - matchEnvelope, - partyRoomModule, - pickupEnvelope, presenceEnvelope, protocol, - projectileAuthority, validation, - waitForMessage, - worldEnvelope, -} from "./harness.mjs"; -import { importTsModule } from "../importTsModule.mjs"; - -const facts = await importTsModule("src/runtime/multiplayer/facts.ts"); -const items = await importTsModule("src/runtime/multiplayer/items.ts"); - -const DUEL_FORWARD_DIRECTION = [0.9781476007338057, 0, -0.20791169081775934]; - -class FakePartyConnection { - constructor(id) { - this.id = id; - this.messages = []; - this.closed = []; - this.state = null; - } - - send(message) { - this.messages.push(JSON.parse(message)); - } - - setState(state) { - this.state = state; - } - - close(code, reason) { - this.closed.push({ code, reason }); - } -} - -function createFakePartyRoom(id = "test-room") { - const connections = []; - return { - room: { - id, - context: {}, - broadcast(message, without = []) { - const payload = JSON.parse(message); - for (const connection of connections) { - if (without.includes(connection.id)) continue; - connection.messages.push(payload); - } - }, - getConnections() { - return connections; - }, - }, - createConnection(connectionId) { - const connection = new FakePartyConnection(connectionId); - connections.push(connection); - return connection; - }, - }; -} - -function latestConnectionMessage(connection, type) { - const message = connection.messages.findLast((candidate) => candidate.type === type); - assert.ok(message, `expected ${type} message on ${connection.id}`); - 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 }, -}; - -const QUAD_ITEM_FLAG = 4_194_304; -const INVULNERABILITY_ITEM_FLAG = 1_048_576; - -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 quadPickupDefinition({ entityIndex = 1999, durationMs = 30_000, origin = [0, 0, 0] } = {}) { - return { - pickupId: `powerup-quad-${entityIndex}`, - entityIndex, - classname: "item_artifact_super_damage", - origin, - effect: { - powerup: { - activationField: "super_damage_time", - durationMs, - finishedField: "super_damage_finished", - itemFlag: QUAD_ITEM_FLAG, - itemFlagExpression: "IT_QUAD", - }, - }, - }; -} - -function invulnerabilityPickupDefinition({ entityIndex = 2999, durationMs = 30_000, origin = [0, 0, 0] } = {}) { - return { - pickupId: `powerup-invulnerability-${entityIndex}`, - entityIndex, - classname: "item_artifact_invulnerability", - origin, - effect: { - powerup: { - activationField: "invincible_time", - durationMs, - finishedField: "invincible_finished", - itemFlag: INVULNERABILITY_ITEM_FLAG, - }, - }, - }; -} - -function connectDuelRoom({ - id, - deathmatchSpawns, - matchSettings = { fragLimit: 1 }, - pickupDefinitions = [], - roomOptions = {}, - spawnDistance = 4, -}) { - const spawns = 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: spawns, - pickupDefinitions, - }); - const { room, createConnection } = createFakePartyRoom(id); - const RoomClass = partyRoomModule.default; - const partyRoom = new RoomClass(room, { - random: () => 0.999999, - trustedGameplayDefinitions: gameplayDefinitions, - ...roomOptions, - }); - 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, - })), alice); - partyRoom.onMessage(JSON.stringify(helloEnvelope({ - clientId: "client-b", - displayName: "Bob", - messageId: `hello-b-${id}`, - sequence: 1, - sentAt: Date.now(), - matchSettings, - })), bob); - return { alice, bob, partyRoom }; -} - -function connectTripleRoom({ id, roomOptions = {}, spawns }) { - const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ - deathmatchSpawns: spawns, - pickupDefinitions: [], - }); - const { room, createConnection } = createFakePartyRoom(id); - const RoomClass = partyRoomModule.default; - const partyRoom = new RoomClass(room, { - random: () => 0.999999, - trustedGameplayDefinitions: gameplayDefinitions, - ...roomOptions, - }); - const alice = createConnection("alice"); - const bob = createConnection("bob"); - const cara = createConnection("cara"); - partyRoom.onConnect(alice); - partyRoom.onConnect(bob); - partyRoom.onConnect(cara); - const clients = [ - { clientId: "client-a", connection: alice, displayName: "Alice" }, - { clientId: "client-b", connection: bob, displayName: "Bob" }, - { clientId: "client-c", connection: cara, displayName: "Cara" }, - ]; - for (const [index, client] of clients.entries()) { - partyRoom.onMessage(JSON.stringify(helloEnvelope({ - clientId: client.clientId, - displayName: client.displayName, - messageId: `hello-${client.clientId}-${id}`, - sequence: 1, - sentAt: Date.now(), - matchSettings: { fragLimit: 99, maxPlayers: 4 }, - })), client.connection); - } - return { alice, bob, cara, partyRoom }; -} - -function cleanupDuelRoom(partyRoom, alice, bob) { - cleanupPartyRoomConnections(partyRoom, alice, bob); -} - -function cleanupPartyRoomConnections(partyRoom, ...connections) { - for (const connection of connections) partyRoom.onClose(connection); -} - -function setPartyRoomPlayerWeapon(partyRoom, clientId, weapon) { - const playerId = `party:${clientId}`; - const player = partyRoom.players.get(playerId); - assert.ok(player, `expected player ${playerId}`); - const inventory = items.quakeMultiplayerPlayerInventory(player); - inventory.weapons = [...new Set([...inventory.weapons, weapon])]; - inventory.activeWeapon = weapon; - inventory.shells = Math.max(inventory.shells, 50); - inventory.nails = Math.max(inventory.nails, 50); - inventory.rockets = Math.max(inventory.rockets, 50); - inventory.cells = Math.max(inventory.cells, 50); - partyRoom.players.set(playerId, items.quakeMultiplayerPlayerWithInventory(player, inventory)); -} - -function setPartyRoomPlayerQuad(partyRoom, clientId, finishedAt) { - const playerId = `party:${clientId}`; - const player = partyRoom.players.get(playerId); - assert.ok(player, `expected player ${playerId}`); - const inventory = items.quakeMultiplayerPlayerInventory(player); - inventory.itemFlags |= QUAD_ITEM_FLAG; - inventory.powerups = [ - ...inventory.powerups.filter((powerup) => powerup.finishedField !== "super_damage_finished"), - { - active: true, - activationField: "super_damage_time", - finishedAt, - finishedField: "super_damage_finished", - itemFlag: QUAD_ITEM_FLAG, - itemFlagExpression: "IT_QUAD", - }, - ]; - partyRoom.players.set(playerId, items.quakeMultiplayerPlayerWithInventory(player, inventory)); -} - -function setPartyRoomPlayerInvulnerable(partyRoom, clientId, finishedAt) { - const playerId = `party:${clientId}`; - const player = partyRoom.players.get(playerId); - assert.ok(player, `expected player ${playerId}`); - const inventory = items.quakeMultiplayerPlayerInventory(player); - inventory.itemFlags |= INVULNERABILITY_ITEM_FLAG; - inventory.powerups = [ - ...inventory.powerups.filter((powerup) => powerup.finishedField !== "invincible_finished"), - { - active: true, - activationField: "invincible_time", - finishedAt, - finishedField: "invincible_finished", - itemFlag: INVULNERABILITY_ITEM_FLAG, - }, - ]; - partyRoom.players.set(playerId, items.quakeMultiplayerPlayerWithInventory(player, inventory)); -} - -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; -} +} from "./partyRoomHarness.mjs"; test("multiplayer room compatibility keys normalize map names and compare full asset identity", () => { const normalized = protocol.createQuakeMultiplayerRoomCompatibilityKey(ROOM_KEY); @@ -423,1747 +92,6 @@ test("multiplayer match settings clamp max players to launch cap", () => { ); }); -test("party room accepts a fifth capped player as a spectator", () => { - const { room, createConnection } = createFakePartyRoom(); - const RoomClass = partyRoomModule.default; - const partyRoom = new RoomClass(room); - assert.equal(partyRoomModule.CSSQUAKE_PARTY_MAX_SPECTATORS_PER_ROOM, 8); - - for (let index = 1; index <= 4; index += 1) { - const connection = createConnection(`connection-${index}`); - partyRoom.onConnect(connection); - partyRoom.onMessage(JSON.stringify(helloEnvelope({ - clientId: `client-${index}`, - displayName: `Player ${index}`, - messageId: `hello-${index}`, - sequence: 1, - sentAt: Date.now(), - matchSettings: { maxPlayers: 8 }, - })), connection); - const snapshot = latestConnectionMessage(connection, "room.snapshot"); - assert.equal(snapshot.payload.match.maxPlayers, 4); - } - - const spectator = createConnection("connection-5"); - partyRoom.onConnect(spectator); - partyRoom.onMessage(JSON.stringify(helloEnvelope({ - clientId: "client-5", - displayName: "Player 5", - messageId: "hello-5", - sequence: 1, - sentAt: Date.now(), - matchSettings: { maxPlayers: 8 }, - })), spectator); - - const snapshot = latestConnectionMessage(spectator, "room.snapshot"); - assert.equal(snapshot.payload.players.length, 4); - assert.deepEqual(snapshot.payload.spectators, [{ - clientId: "client-5", - displayName: "Player 5", - }]); - assert.equal(spectator.state.role, "spectator"); - assert.equal(spectator.state.playerId, undefined); - assert.equal(spectator.messages.filter((message) => message.type === "room.reject").length, 0); - assert.equal(spectator.closed.length, 0); - - for (let index = 6; index < 6 + partyRoomModule.CSSQUAKE_PARTY_MAX_SPECTATORS_PER_ROOM - 1; index += 1) { - const extraSpectator = createConnection(`connection-${index}`); - partyRoom.onConnect(extraSpectator); - partyRoom.onMessage(JSON.stringify(helloEnvelope({ - clientId: `client-${index}`, - displayName: `Player ${index}`, - messageId: `hello-${index}`, - sequence: 1, - sentAt: Date.now(), - matchSettings: { maxPlayers: 8 }, - })), extraSpectator); - assert.equal(extraSpectator.state.role, "spectator"); - assert.equal(extraSpectator.closed.length, 0); - } - - const overflow = createConnection("connection-overflow"); - partyRoom.onConnect(overflow); - partyRoom.onMessage(JSON.stringify(helloEnvelope({ - clientId: "client-overflow", - displayName: "Overflow", - messageId: "hello-overflow", - sequence: 1, - sentAt: Date.now(), - matchSettings: { maxPlayers: 8 }, - })), overflow); - const reject = latestConnectionMessage(overflow, "room.reject"); - assert.equal(reject.payload.code, "room-full"); - assert.equal(reject.payload.recoverable, false); - assert.deepEqual(overflow.closed.at(-1), { code: 1008, reason: "reject:room-full" }); -}); - -test("party room queues ordered input batches into the player simulation state", () => { - const { room, createConnection } = createFakePartyRoom("input-batch-room"); - const RoomClass = partyRoomModule.default; - const partyRoom = new RoomClass(room); - const connection = createConnection("connection-a"); - try { - partyRoom.onConnect(connection); - partyRoom.onMessage(JSON.stringify(helloEnvelope({ - messageId: "batch-hello", - sequence: 1, - sentAt: Date.now(), - })), connection); - - partyRoom.onMessage(JSON.stringify(inputBatchEnvelope({ - messageId: "batch-inputs", - sequence: 2, - inputSequences: [1, 2, 3], - sentAt: Date.now(), - })), connection); - - assert.equal(connection.state.authority.lastIntentSequences.input, 3); - const simulationState = partyRoom.playerSimulationStates.get("party:client-a"); - assert.ok(simulationState); - assert.deepEqual(simulationState.pendingInputs.map((input) => input.inputSequence), [1, 2, 3]); - assert.deepEqual(simulationState.acceptedInputHistory.map((input) => input.inputSequence), [1, 2, 3]); - } finally { - cleanupPartyRoomConnections(partyRoom, connection); - } -}); - -test("party room accepts fire timestamps near accepted input history", () => { - const { alice, bob, partyRoom } = connectDuelRoom({ id: "fire-input-history-accept" }); - try { - const base = Date.now(); - partyRoom.onMessage(JSON.stringify(inputBatchEnvelope({ - clientId: "client-a", - messageId: "fire-history-inputs", - sequence: 2, - sentAt: base, - inputSequences: [1, 2], - inputs: [ - { sampledAt: base, rotX: -78, rotY: 0 }, - { sampledAt: base + 50, rotX: -78, rotY: 0 }, - ], - })), alice); - partyRoom.onMessage(JSON.stringify(fireEnvelope({ - clientId: "client-a", - messageId: "fire-history-valid", - sequence: 3, - sentAt: base + 60, - fireSequence: 1, - fire: { - firedAt: base + 55, - }, - })), alice); - - const damage = roomEvents(alice, "player.damaged") - .find((event) => event.attackerPlayerId === "party:client-a" && event.victimPlayerId === "party:client-b"); - assert.ok(damage, "expected accepted fire timestamp to damage the remote player"); - assert.equal(damage.damage, 24); - assert.equal(alice.messages.some((message) => message.type === "room.reject"), false); - } finally { - cleanupDuelRoom(partyRoom, alice, bob); - } -}); - -test("party room rejects fire timestamps outside accepted input history", () => { - const { alice, bob, partyRoom } = connectDuelRoom({ id: "fire-input-history-reject" }); - try { - const base = Date.now(); - partyRoom.onMessage(JSON.stringify(inputBatchEnvelope({ - clientId: "client-a", - messageId: "fire-history-reject-inputs", - sequence: 2, - sentAt: base, - inputSequences: [1, 2], - inputs: [ - { sampledAt: base, rotX: -78, rotY: 0 }, - { sampledAt: base + 50, rotX: -78, rotY: 0 }, - ], - })), alice); - partyRoom.onMessage(JSON.stringify(fireEnvelope({ - clientId: "client-a", - messageId: "fire-history-too-late", - sequence: 3, - sentAt: base + 60, - fireSequence: 1, - fire: { - firedAt: base + 1_000, - }, - })), alice); - - const reject = latestConnectionMessage(alice, "room.reject"); - assert.equal(reject.payload.code, "stale"); - assert.equal(reject.payload.recoverable, true); - assert.equal(reject.payload.rejectedMessageId, "fire-history-too-late"); - assert.match(reject.payload.message, /fire-after-input-history/); - assert.equal( - roomEvents(alice, "player.damaged") - .some((event) => event.attackerPlayerId === "party:client-a" && event.victimPlayerId === "party:client-b"), - false, - ); - } finally { - cleanupDuelRoom(partyRoom, alice, bob); - } -}); - -test("party room closes a connection after repeated recoverable rejects", () => { - const { room, createConnection } = createFakePartyRoom(); - const RoomClass = partyRoomModule.default; - const partyRoom = new RoomClass(room); - const connection = createConnection("noisy-connection"); - - partyRoom.onConnect(connection); - partyRoom.onMessage(JSON.stringify(helloEnvelope({ - messageId: "hello-noisy", - sequence: 1, - sentAt: Date.now(), - })), connection); - - for (let index = 0; index < partyRoomModule.CSSQUAKE_PARTY_MAX_REJECTS_PER_CONNECTION; index += 1) { - partyRoom.onMessage(JSON.stringify(inputEnvelope({ - messageId: `stale-input-${index}`, - sequence: 1, - inputSequence: 1, - sentAt: Date.now(), - })), connection); - } - - const rejects = connection.messages.filter((message) => message.type === "room.reject"); - assert.equal(rejects.length, partyRoomModule.CSSQUAKE_PARTY_MAX_REJECTS_PER_CONNECTION); - assert.equal(rejects.at(-1).payload.code, "stale"); - assert.equal(rejects.at(-1).payload.recoverable, true); - 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, { - random: () => 0.999999, - 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"); - const firedAtoB = roomEvents(alice, "player.fired").find((event) => event.eventId === "fire-fire-a"); - assert.equal(firedAtoB?.decision?.outcome, "hit-player"); - assert.equal(firedAtoB?.decision?.reason, "player-direct"); - assert.equal(firedAtoB?.decision?.targetPlayerId, "party:client-b"); - assert.equal(firedAtoB?.decision?.candidateCount, 1); - assert.equal(firedAtoB?.decision?.blockedCandidateCount, 0); - assert.equal(firedAtoB?.decision?.playerDamageCount, 1); - - 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"); - const firedBtoA = roomEvents(alice, "player.fired").find((event) => event.eventId === "fire-fire-b"); - assert.equal(firedBtoA?.decision?.outcome, "hit-player"); - assert.equal(firedBtoA?.decision?.reason, "player-direct"); - assert.equal(firedBtoA?.decision?.targetPlayerId, "party:client-a"); - assert.equal(firedBtoA?.decision?.candidateCount, 1); - assert.equal(firedBtoA?.decision?.blockedCandidateCount, 0); - assert.equal(firedBtoA?.decision?.playerDamageCount, 1); - 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 source-order armor save but suppresses health damage while the victim is invulnerable", () => { - const { alice, bob, partyRoom } = connectDuelRoom({ id: "invulnerable-victim" }); - try { - const bobPlayer = partyRoom.players.get("party:client-b"); - assert.ok(bobPlayer, "expected bob player"); - const inventory = items.quakeMultiplayerPlayerInventory(bobPlayer); - inventory.health = 100; - inventory.armor = 50; - inventory.armorType = 0.8; - inventory.powerups = [{ - active: true, - activationField: "invincible_time", - finishedAt: Date.now() + 10_000, - finishedField: "invincible_finished", - itemFlag: INVULNERABILITY_ITEM_FLAG, - }]; - partyRoom.players.set("party:client-b", items.quakeMultiplayerPlayerWithInventory(bobPlayer, inventory)); - - partyRoom.onMessage(JSON.stringify(fireEnvelope({ - clientId: "client-a", - messageId: "fire-invulnerable-victim", - sequence: 2, - fireSequence: 1, - sentAt: Date.now(), - })), alice); - - assert.equal( - roomEvents(alice, "player.damaged").some((event) => event.victimPlayerId === "party:client-b"), - false, - ); - assert.equal( - roomEvents(alice, "player.killed").some((event) => event.victimPlayerId === "party:client-b"), - false, - ); - const victim = latestSnapshotPlayerForClient(alice, "client-b"); - assert.equal(victim.health, 100); - assert.equal(victim.armor, 30); - assert.equal(victim.alive, true); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - cleanupDuelRoom(partyRoom, alice, bob); - } -}); - -test("party room double-invulnerable telefrag clears protection and kills both players like Quake teledeath3", () => { - const { alice, bob, partyRoom } = connectDuelRoom({ id: "double-invulnerable-telefrag" }); - try { - const now = Date.now(); - const victim = partyRoom.players.get("party:client-b"); - assert.ok(victim, "expected victim"); - setPartyRoomPlayerInvulnerable(partyRoom, "client-a", now + 10_000); - setPartyRoomPlayerInvulnerable(partyRoom, "client-b", now + 10_000); - - partyRoom.applyTeleportDeath("party:client-a", victim.origin, "double-invulnerable-telefrag"); - - const kills = roomEvents(alice, "player.killed") - .filter((event) => event.damageSource === "teledeath3"); - assert.equal(kills.length, 2); - assert.equal(kills.some((event) => event.victimPlayerId === "party:client-a"), true); - assert.equal(kills.some((event) => event.victimPlayerId === "party:client-b"), true); - - const aliceSnapshot = latestSnapshotPlayerForClient(alice, "client-a"); - const bobSnapshot = latestSnapshotPlayerForClient(alice, "client-b"); - assert.equal(aliceSnapshot.alive, false); - assert.equal(bobSnapshot.alive, false); - assert.equal(aliceSnapshot.frags, -1); - assert.equal(bobSnapshot.frags, -1); - assert.equal(aliceSnapshot.deaths, 1); - assert.equal(bobSnapshot.deaths, 1); - assert.equal( - aliceSnapshot.inventory.powerups.some((powerup) => powerup.finishedField === "invincible_finished"), - false, - ); - assert.equal( - bobSnapshot.inventory.powerups.some((powerup) => powerup.finishedField === "invincible_finished"), - false, - ); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - cleanupDuelRoom(partyRoom, alice, bob); - } -}); - -test("party room subtracts a victim frag for world/environment kills", () => { - const { alice, bob, partyRoom } = connectDuelRoom({ id: "world-kill-frag-penalty" }); - try { - partyRoom.applyPlayerDamage({ - victimPlayerId: "party:client-b", - damage: 150, - source: "trigger_hurt", - eventId: "world-kill-frag-penalty", - now: Date.now(), - }); - - const kill = roomEvents(alice, "player.killed") - .find((event) => event.victimPlayerId === "party:client-b"); - assert.ok(kill, "expected environment kill event"); - assert.equal(kill.attackerPlayerId, undefined); - assert.equal(kill.damageSource, "trigger_hurt"); - const victim = latestSnapshotPlayerForClient(alice, "client-b"); - assert.equal(victim.alive, false); - assert.equal(victim.frags, -1); - assert.equal(victim.deaths, 1); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - cleanupDuelRoom(partyRoom, alice, bob); - } -}); - -test("party room clears active artifact powerups immediately on player death", () => { - const { alice, bob, partyRoom } = connectDuelRoom({ - id: "death-clears-powerups", - matchSettings: { fragLimit: 99 }, - }); - try { - const now = Date.now(); - setPartyRoomPlayerQuad(partyRoom, "client-b", now + 10_000); - const bobPlayer = partyRoom.players.get("party:client-b"); - assert.ok(bobPlayer, "expected bob player"); - const inventory = items.quakeMultiplayerPlayerInventory(bobPlayer); - inventory.health = 10; - partyRoom.players.set("party:client-b", items.quakeMultiplayerPlayerWithInventory(bobPlayer, inventory)); - - partyRoom.applyPlayerDamage({ - attackerPlayerId: "party:client-a", - victimPlayerId: "party:client-b", - damage: 24, - source: "shotgun", - eventId: "death-clears-powerups", - now, - }); - - const victim = latestSnapshotPlayerForClient(alice, "client-b"); - assert.equal(victim.alive, false); - assert.equal(victim.inventory.itemFlags & QUAD_ITEM_FLAG, 0); - assert.equal( - victim.inventory.powerups.some((powerup) => powerup.finishedField === "super_damage_finished"), - false, - ); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - cleanupDuelRoom(partyRoom, alice, bob); - } -}); - -test("party room respawns at a clear deathmatch spawn instead of the occupied cursor spawn", () => { - 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: [8, 0, 0], rotX: -78, rotY: 180 }, - { spawnId: "spawn-c-occupied", classname: "info_player_deathmatch", origin: [0.5, 0, 0], rotX: -78, rotY: 90 }, - { spawnId: "spawn-d-clear", classname: "info_player_deathmatch", origin: [16, 0, 0], rotX: -78, rotY: 270 }, - ]; - const { alice, bob, partyRoom } = connectDuelRoom({ - id: "respawn-clear-spawn", - deathmatchSpawns, - matchSettings: { fragLimit: 99 }, - }); - try { - partyRoom.applyPlayerDamage({ - attackerPlayerId: "party:client-a", - victimPlayerId: "party:client-b", - damage: 150, - source: "shotgun", - eventId: "respawn-clear-spawn-kill", - now: Date.now(), - }); - partyRoom.respawnPlayer("party:client-b"); - - const respawn = roomEvents(alice, "player.respawned") - .find((event) => event.player?.playerId === "party:client-b"); - assert.ok(respawn, "expected respawn event"); - assert.equal(respawn.player.spawnId, "spawn-d-clear"); - assert.deepEqual(respawn.player.origin, [16, 0, 0]); - const bobSnapshot = latestSnapshotPlayerForClient(alice, "client-b"); - assert.equal(bobSnapshot.spawnId, "spawn-d-clear"); - assert.deepEqual(bobSnapshot.origin, [16, 0, 0]); - assert.equal(bobSnapshot.alive, true); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - cleanupDuelRoom(partyRoom, alice, bob); - } -}); - -test("party room applies authoritative weapon damage after weapon pickups", () => { - const cases = [ - { weapon: "axe", damage: 20, pickup: false, 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", damage: 87, pickup: true, spawnDistance: 4, eventType: "player.damaged", health: 13 }, - { weapon: "rocketlauncher", pickup: true, spawnDistance: 4, eventType: "player.killed", health: -5 }, - ]; - - 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 { - setPartyRoomPlayerWeapon(partyRoom, "client-a", spec.weapon); - } - - 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 serverProjectile = projectileAuthority.quakeMultiplayerServerProjectileWeaponSupported(spec.weapon); - if (serverProjectile) { - const fired = roomEvents(alice, "player.fired") - .find((candidate) => candidate.eventId === `fire-fire-${spec.weapon}`); - assert.equal(fired?.decision?.outcome, "projectile-spawned", `${spec.weapon} should spawn a server projectile`); - const spawned = roomEvents(alice, "projectile.spawned") - .find((candidate) => candidate.projectile.weapon === spec.weapon); - assert.ok(spawned, `expected projectile.spawned for ${spec.weapon}`); - assert.equal( - roomEvents(alice, spec.eventType) - .some((candidate) => - candidate.attackerPlayerId === "party:client-a" && - candidate.victimPlayerId === "party:client-b" && - candidate.damageSource === spec.weapon - ), - false, - `${spec.weapon} should not apply damage in the same tick as fire`, - ); - partyRoom.advanceRoomSimulation(Date.now() + 400); - const impact = roomEvents(alice, "projectile.impacted") - .find((candidate) => candidate.weapon === spec.weapon); - assert.ok(impact, `expected projectile.impacted for ${spec.weapon}`); - assert.equal(impact.impactKind, "player", `${spec.weapon} should impact the player`); - assert.equal(impact.targetPlayerId, "party:client-b", `${spec.weapon} impact target`); - } - - 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, spec.health, `${spec.weapon} death 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("party room weapon pickup keeps a better current weapon by Quake deathmatch rank", () => { - const nailgunPickup = weaponPickupDefinition("nailgun"); - const { alice, bob, partyRoom } = connectDuelRoom({ - id: "weapon-pickup-rank-switch", - pickupDefinitions: [nailgunPickup], - }); - try { - const player = partyRoom.players.get("party:client-a"); - assert.ok(player, "expected player"); - const inventory = items.quakeMultiplayerPlayerInventory(player); - inventory.activeWeapon = "rocketlauncher"; - inventory.weapons = ["axe", "shotgun", "rocketlauncher"]; - inventory.rockets = 5; - inventory.nails = 0; - partyRoom.players.set("party:client-a", items.quakeMultiplayerPlayerWithInventory(player, inventory)); - - partyRoom.onMessage(JSON.stringify(pickupEnvelope({ - clientId: "client-a", - messageId: "pickup-nailgun-rank-switch", - sequence: 2, - pickupSequence: 1, - sentAt: Date.now(), - pickup: { - entityIndex: nailgunPickup.entityIndex, - origin: [0, 0, 0], - }, - })), alice); - - const pickup = roomEvents(alice, "pickup.taken") - .find((event) => event.entityIndex === nailgunPickup.entityIndex); - assert.ok(pickup, "expected nailgun pickup"); - const snapshot = latestSnapshotPlayerForClient(alice, "client-a"); - assert.equal(snapshot.inventory.weapons.includes("nailgun"), true); - assert.equal(snapshot.inventory.nails, 25); - assert.equal(snapshot.inventory.activeWeapon, "rocketlauncher"); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - cleanupDuelRoom(partyRoom, alice, bob); - } -}); - -test("party room accepts already-owned respawning weapon pickup at full ammo like Quake deathmatch", () => { - const nailgunPickup = { - ...weaponPickupDefinition("nailgun"), - lifecycle: { action: "respawn", condition: "deathmatch", delayMs: 30_000 }, - }; - const originalNow = Date.now; - let now = 4_500_000; - Date.now = () => now; - const { alice, bob, partyRoom } = connectDuelRoom({ - id: "weapon-pickup-full-ammo-respawn", - pickupDefinitions: [nailgunPickup], - }); - try { - const player = partyRoom.players.get("party:client-a"); - assert.ok(player, "expected player"); - const inventory = items.quakeMultiplayerPlayerInventory(player); - inventory.activeWeapon = "rocketlauncher"; - inventory.weapons = ["axe", "shotgun", "nailgun", "rocketlauncher"]; - inventory.nails = 200; - inventory.rockets = 5; - partyRoom.players.set("party:client-a", items.quakeMultiplayerPlayerWithInventory(player, inventory)); - - partyRoom.onMessage(JSON.stringify(pickupEnvelope({ - clientId: "client-a", - messageId: "pickup-owned-full-nailgun", - sequence: 2, - pickupSequence: 1, - sentAt: now, - pickup: { - entityIndex: nailgunPickup.entityIndex, - origin: [0, 0, 0], - }, - })), alice); - - const pickup = roomEvents(alice, "pickup.taken") - .find((event) => event.entityIndex === nailgunPickup.entityIndex); - assert.ok(pickup, "expected already-owned full-ammo weapon pickup to be taken"); - assert.equal(pickup.leaveInPlace, false); - const snapshot = latestConnectionMessage(alice, "room.snapshot"); - const pickupState = snapshot.payload.pickups.find((candidate) => - candidate.entityIndex === nailgunPickup.entityIndex - ); - assert.equal(pickupState?.available, false); - assert.equal(pickupState?.respawnAt, now + 30_000); - const playerSnapshot = latestSnapshotPlayerForClient(alice, "client-a"); - assert.equal(playerSnapshot.inventory.nails, 200); - assert.equal(playerSnapshot.inventory.activeWeapon, "rocketlauncher"); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - cleanupDuelRoom(partyRoom, alice, bob); - Date.now = originalNow; - } -}); - -test("party room ammo pickup selects a newly usable best weapon when the active weapon was best", () => { - const nailsPickup = { - pickupId: "item-spikes-auto-best", - entityIndex: 4010, - classname: "item_spikes", - origin: [0, 0, 0], - effect: { nails: 25 }, - }; - const { alice, bob, partyRoom } = connectDuelRoom({ - id: "ammo-pickup-auto-best-weapon", - pickupDefinitions: [nailsPickup], - }); - try { - const attacker = partyRoom.players.get("party:client-a"); - assert.ok(attacker, "expected attacker"); - const inventory = items.quakeMultiplayerPlayerInventory(attacker); - inventory.activeWeapon = "shotgun"; - inventory.weapons = ["axe", "shotgun", "supernailgun"]; - inventory.shells = 25; - inventory.nails = 0; - partyRoom.players.set("party:client-a", items.quakeMultiplayerPlayerWithInventory(attacker, inventory)); - - partyRoom.onMessage(JSON.stringify(pickupEnvelope({ - clientId: "client-a", - messageId: "pickup-nails-auto-best", - sequence: 2, - pickupSequence: 1, - sentAt: Date.now(), - pickup: { - entityIndex: nailsPickup.entityIndex, - origin: [0, 0, 0], - }, - })), alice); - - const pickup = roomEvents(alice, "pickup.taken") - .find((event) => event.entityIndex === nailsPickup.entityIndex); - assert.ok(pickup, "expected nails pickup"); - const player = latestSnapshotPlayerForClient(alice, "client-a"); - assert.equal(player.inventory.nails, 25); - assert.equal(player.inventory.activeWeapon, "supernailgun"); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - cleanupDuelRoom(partyRoom, alice, bob); - } -}); - -test("party room auto-selects the source best weapon when the active weapon has no ammo before fire", () => { - const { alice, bob, partyRoom } = connectDuelRoom({ - id: "auto-best-weapon-before-fire", - spawnDistance: 4, - }); - try { - const attacker = partyRoom.players.get("party:client-a"); - assert.ok(attacker, "expected attacker"); - const inventory = items.quakeMultiplayerPlayerInventory(attacker); - inventory.activeWeapon = "nailgun"; - inventory.weapons = ["axe", "shotgun", "nailgun"]; - inventory.shells = 25; - inventory.nails = 0; - partyRoom.players.set("party:client-a", items.quakeMultiplayerPlayerWithInventory(attacker, inventory)); - - partyRoom.onMessage(JSON.stringify(fireEnvelope({ - clientId: "client-a", - messageId: "fire-auto-best-before", - sequence: 2, - fireSequence: 1, - sentAt: Date.now(), - fire: { weapon: "nailgun" }, - })), alice); - - const fired = roomEvents(alice, "player.fired") - .find((event) => event.eventId === "fire-fire-auto-best-before"); - assert.equal(fired?.weapon, "shotgun"); - assert.equal(fired?.decision?.outcome, "hit-player"); - const damage = roomEvents(alice, "player.damaged") - .find((event) => event.victimPlayerId === "party:client-b"); - assert.ok(damage, "expected auto-selected shotgun to damage Bob"); - assert.equal(damage.damage, 24); - assert.equal(damage.health, 76); - const attackerSnapshot = latestSnapshotPlayerForClient(alice, "client-a"); - assert.equal(attackerSnapshot.inventory.activeWeapon, "shotgun"); - assert.equal(attackerSnapshot.inventory.nails, 0); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - cleanupDuelRoom(partyRoom, alice, bob); - } -}); - -test("party room switches to axe after consuming the last shell instead of getting stuck on an empty shotgun", () => { - const originalNow = Date.now; - let now = 3_000_000; - Date.now = () => now; - const { alice, bob, partyRoom } = connectDuelRoom({ - id: "auto-best-weapon-after-last-shell", - spawnDistance: 1.2, - }); - try { - const attacker = partyRoom.players.get("party:client-a"); - assert.ok(attacker, "expected attacker"); - const inventory = items.quakeMultiplayerPlayerInventory(attacker); - inventory.activeWeapon = "shotgun"; - inventory.weapons = ["axe", "shotgun"]; - inventory.shells = 1; - partyRoom.players.set("party:client-a", items.quakeMultiplayerPlayerWithInventory(attacker, inventory)); - - partyRoom.onMessage(JSON.stringify(fireEnvelope({ - clientId: "client-a", - messageId: "fire-last-shell", - sequence: 2, - fireSequence: 1, - sentAt: now, - })), alice); - - const firstFired = roomEvents(alice, "player.fired") - .find((event) => event.eventId === "fire-fire-last-shell"); - assert.equal(firstFired?.weapon, "shotgun"); - const afterLastShell = latestSnapshotPlayerForClient(alice, "client-a"); - assert.equal(afterLastShell.inventory.shells, 0); - assert.equal(afterLastShell.inventory.activeWeapon, "axe"); - - now += 500; - partyRoom.onMessage(JSON.stringify(fireEnvelope({ - clientId: "client-a", - messageId: "fire-after-last-shell", - sequence: 3, - fireSequence: 2, - sentAt: now, - })), alice); - - const secondFired = roomEvents(alice, "player.fired") - .find((event) => event.eventId === "fire-fire-after-last-shell"); - assert.equal(secondFired?.weapon, "axe"); - const axeDamage = roomEvents(alice, "player.damaged") - .find((event) => - event.eventId === "damage-fire-after-last-shell" && - event.damageSource === "axe" - ); - assert.ok(axeDamage, "expected axe fire after empty shotgun"); - assert.equal(axeDamage.damage, 20); - assert.equal(axeDamage.health, 56); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - cleanupDuelRoom(partyRoom, alice, bob); - Date.now = originalNow; - } -}); - -test("party room drops and removes a source-style backpack on player death", () => { - const originalNow = Date.now; - let now = 4_000_000; - Date.now = () => now; - const { alice, bob, partyRoom } = connectDuelRoom({ - id: "player-death-dropped-backpack", - matchSettings: { fragLimit: 99 }, - spawnDistance: 4, - }); - try { - const victim = partyRoom.players.get("party:client-b"); - assert.ok(victim, "expected victim"); - const victimInventory = items.quakeMultiplayerPlayerInventory(victim); - victimInventory.activeWeapon = "rocketlauncher"; - victimInventory.weapons = ["axe", "shotgun", "rocketlauncher"]; - victimInventory.shells = 4; - victimInventory.rockets = 7; - partyRoom.players.set("party:client-b", items.quakeMultiplayerPlayerWithInventory(victim, victimInventory)); - - partyRoom.applyPlayerDamage({ - attackerPlayerId: "party:client-a", - victimPlayerId: "party:client-b", - damage: 150, - source: "rocketlauncher", - eventId: "death-backpack", - now, - }); - - const dropped = roomEvents(alice, "pickup.dropped").find((event) => - event.sourcePlayerId === "party:client-b" - ); - assert.ok(dropped, "expected dropped backpack event"); - assert.equal(dropped.definition.classname, "item_backpack"); - assert.equal(dropped.definition.runtime, true); - assert.equal(dropped.definition.effect.shells, 4); - assert.equal(dropped.definition.effect.rockets, 7); - assert.equal(dropped.definition.effect.weapon.id, "rocketlauncher"); - assert.equal(dropped.pickup.available, true); - - const dropSnapshot = latestConnectionMessage(alice, "room.snapshot"); - assert.equal( - dropSnapshot.payload.dynamicPickups.some((definition) => - definition.entityIndex === dropped.definition.entityIndex - ), - true, - ); - assert.equal( - dropSnapshot.payload.pickups.some((pickup) => - pickup.entityIndex === dropped.definition.entityIndex && pickup.available - ), - true, - ); - - const taker = partyRoom.players.get("party:client-a"); - assert.ok(taker, "expected taker"); - const takerInventory = items.quakeMultiplayerPlayerInventory(taker); - takerInventory.shells = 0; - takerInventory.rockets = 0; - takerInventory.weapons = ["axe", "shotgun"]; - partyRoom.players.set("party:client-a", items.quakeMultiplayerPlayerWithInventory({ - ...taker, - origin: dropped.definition.origin, - }, takerInventory)); - - now += 100; - partyRoom.onMessage(JSON.stringify(pickupEnvelope({ - clientId: "client-a", - messageId: "pickup-dropped-backpack", - sequence: 2, - pickupSequence: 1, - sentAt: now, - pickup: { - entityIndex: dropped.definition.entityIndex, - origin: dropped.definition.origin, - }, - })), alice); - - const taken = roomEvents(alice, "pickup.taken").find((event) => - event.entityIndex === dropped.definition.entityIndex - ); - assert.ok(taken, "expected dynamic backpack pickup event"); - assert.equal(taken.leaveInPlace, false); - const afterPickup = latestSnapshotPlayerForClient(alice, "client-a"); - assert.equal(afterPickup.inventory.shells, 4); - assert.equal(afterPickup.inventory.rockets, 7); - assert.equal(afterPickup.inventory.weapons.includes("rocketlauncher"), true); - assert.equal(afterPickup.inventory.activeWeapon, "rocketlauncher"); - - const pickupSnapshot = latestConnectionMessage(alice, "room.snapshot"); - assert.equal( - pickupSnapshot.payload.dynamicPickups.some((definition) => - definition.entityIndex === dropped.definition.entityIndex - ), - false, - ); - assert.equal( - pickupSnapshot.payload.pickups.some((pickup) => - pickup.entityIndex === dropped.definition.entityIndex - ), - false, - ); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - cleanupDuelRoom(partyRoom, alice, bob); - Date.now = originalNow; - } -}); - -test("party room accepts grenade refire at the source 600ms cooldown", () => { - const originalNow = Date.now; - let now = 2_000_000; - Date.now = () => now; - const { alice, bob, partyRoom } = connectDuelRoom({ id: "grenade-source-cooldown" }); - try { - setPartyRoomPlayerWeapon(partyRoom, "client-a", "grenadelauncher"); - for (let index = 0; index < 2; index += 1) { - partyRoom.onMessage(JSON.stringify(fireEnvelope({ - clientId: "client-a", - messageId: `fire-grenade-cooldown-${index}`, - sequence: 2 + index, - fireSequence: 1 + index, - sentAt: now, - fire: { direction: [-1, 0, 0] }, - })), alice); - now += 600; - } - - const fired = roomEvents(alice, "player.fired") - .filter((event) => - event.playerId === "party:client-a" && - event.weapon === "grenadelauncher" - ); - assert.equal(fired.length, 2); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - cleanupDuelRoom(partyRoom, alice, bob); - Date.now = originalNow; - } -}); - -test("party room applies repeated authoritative damage until death across sustained-fire weapons", () => { - const cases = [ - { weapon: "axe", spawnDistance: 1.2, stepMs: 500, damagedHealths: [80, 60, 40, 20], killHealth: 0 }, - { weapon: "shotgun", spawnDistance: 4, stepMs: 500, damagedHealths: [76, 52, 28, 4], killHealth: -20 }, - { weapon: "supershotgun", spawnDistance: 4, stepMs: 700, damagedHealths: [44], killHealth: -12 }, - { weapon: "nailgun", spawnDistance: 4, stepMs: 200, damagedHealths: [91, 82, 73, 64, 55, 46, 37, 28, 19, 10, 1], killHealth: -8 }, - { weapon: "supernailgun", spawnDistance: 4, stepMs: 200, damagedHealths: [82, 64, 46, 28, 10], killHealth: -8 }, - { weapon: "lightning", spawnDistance: 4, stepMs: 200, damagedHealths: [70, 40, 10], killHealth: -20 }, - ]; - const originalNow = Date.now; - let now = 1_000_000; - Date.now = () => now; - try { - for (const spec of cases) { - const { alice, bob, partyRoom } = connectDuelRoom({ - id: `repeated-${spec.weapon}`, - spawnDistance: spec.spawnDistance, - }); - try { - setPartyRoomPlayerWeapon(partyRoom, "client-a", spec.weapon); - for (let index = 0; index <= spec.damagedHealths.length; index += 1) { - now += spec.stepMs; - partyRoom.onMessage(JSON.stringify(fireEnvelope({ - clientId: "client-a", - messageId: `fire-repeated-${spec.weapon}-${index}`, - sequence: 2 + index, - fireSequence: 1 + index, - sentAt: now, - fire: { weapon: spec.weapon }, - })), alice); - if (projectileAuthority.quakeMultiplayerServerProjectileWeaponSupported(spec.weapon)) { - now += 400; - partyRoom.advanceRoomSimulation(now); - } - const expectedHealth = spec.damagedHealths[index]; - if (expectedHealth !== undefined) { - const event = roomEvents(alice, "player.damaged") - .find((candidate) => - candidate.victimPlayerId === "party:client-b" && - candidate.damageSource === spec.weapon && - candidate.health === expectedHealth - ); - assert.ok(event, `expected repeated ${spec.weapon} damage ${index + 1}`); - assert.equal(event.health, expectedHealth, `${spec.weapon} health after shot ${index + 1}`); - assert.equal(latestSnapshotPlayerForClient(alice, "client-b").health, expectedHealth); - } else { - const event = roomEvents(alice, "player.killed") - .find((candidate) => - candidate.victimPlayerId === "party:client-b" && - candidate.damageSource === spec.weapon - ); - assert.ok(event, `expected repeated ${spec.weapon} kill`); - const victim = latestSnapshotPlayerForClient(alice, "client-b"); - assert.equal(victim.alive, false, `${spec.weapon} victim alive after kill`); - assert.equal(victim.health, spec.killHealth, `${spec.weapon} victim health after kill`); - } - } - 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); - now += 10_000; - } - } - } finally { - Date.now = originalNow; - } -}); - -test("party room applies damage when LOS trace only clips the target skin", () => { - const collisionWorld = { - traceUse: () => ({ - fraction: 0.9856583826296409, - end: [3.92, 0, -0.82], - planeNormal: [0, 0, 1], - entityIndex: 84, - modelIndex: 3, - classname: "func_wall", - }), - }; - const { alice, bob, partyRoom } = connectDuelRoom({ - id: "late-target-skin-los", - roomOptions: { - trustedSceneMovement: { - collisionWorld, - playerEyeHeight: 1.0, - }, - }, - spawnDistance: 4, - }); - try { - partyRoom.onMessage(JSON.stringify(fireEnvelope({ - clientId: "client-a", - messageId: "fire-late-target-skin-los", - sequence: 2, - fireSequence: 1, - sentAt: Date.now(), - })), alice); - - const event = roomEvents(alice, "player.damaged") - .find((candidate) => - candidate.attackerPlayerId === "party:client-a" && - candidate.victimPlayerId === "party:client-b" && - candidate.damageSource === "shotgun" - ); - assert.ok(event, "expected late target-skin LOS trace to allow damage"); - assert.equal(event.damage, 24); - assert.equal(event.health, 76); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - cleanupDuelRoom(partyRoom, alice, bob); - } -}); - -test("party room uses fire payload aim when the authoritative pose is one input behind", () => { - const { alice, bob, partyRoom } = connectDuelRoom({ - id: "fresh-fire-aim-stale-pose", - spawnDistance: 4, - }); - try { - const attacker = partyRoom.players.get("party:client-a"); - assert.ok(attacker, "expected attacker"); - partyRoom.players.set("party:client-a", { - ...attacker, - rotX: -78, - rotY: 180, - }); - - partyRoom.onMessage(JSON.stringify(fireEnvelope({ - clientId: "client-a", - messageId: "fire-fresh-aim-stale-pose", - sequence: 2, - fireSequence: 1, - sentAt: Date.now(), - fire: { direction: DUEL_FORWARD_DIRECTION }, - })), alice); - - const event = roomEvents(alice, "player.damaged") - .find((candidate) => - candidate.attackerPlayerId === "party:client-a" && - candidate.victimPlayerId === "party:client-b" - ); - assert.ok(event, "expected fresh fire aim to damage despite stale authoritative yaw"); - assert.equal(event.damage, 24); - assert.equal(event.health, 76); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - cleanupDuelRoom(partyRoom, alice, bob); - } -}); - -test("party room uses a bounded fire origin hint when the authoritative origin is one input behind", () => { - const { alice, bob, partyRoom } = connectDuelRoom({ - id: "fresh-fire-origin-stale-pose", - spawnDistance: 4, - }); - try { - const victim = partyRoom.players.get("party:client-b"); - assert.ok(victim, "expected victim"); - partyRoom.players.set("party:client-b", { - ...victim, - origin: [victim.origin[0], 0.9, victim.origin[2]], - }); - - partyRoom.onMessage(JSON.stringify(fireEnvelope({ - clientId: "client-a", - messageId: "fire-fresh-origin-stale-pose", - sequence: 2, - fireSequence: 1, - sentAt: Date.now(), - fire: { origin: [0, 0.4, 0] }, - })), alice); - - const event = roomEvents(alice, "player.damaged") - .find((candidate) => - candidate.attackerPlayerId === "party:client-a" && - candidate.victimPlayerId === "party:client-b" - ); - assert.ok(event, "expected bounded fire origin hint to damage despite stale authoritative origin"); - assert.equal(event.damage, 24); - assert.equal(event.health, 76); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - cleanupDuelRoom(partyRoom, alice, bob); - } -}); - -test("party room rewinds hit tests from authoritative snapshot history instead of current velocity", () => { - const originalNow = Date.now; - let now = 10_000; - Date.now = () => now; - const { alice, bob, partyRoom } = connectDuelRoom({ - id: "historical-hit-stopped-target", - spawnDistance: 4, - }); - try { - const attacker = partyRoom.players.get("party:client-a"); - const target = partyRoom.players.get("party:client-b"); - assert.ok(attacker, "expected attacker"); - assert.ok(target, "expected target"); - partyRoom.players.set("party:client-a", { - ...attacker, - origin: [0, 0, 0], - velocity: [0, 0, 0], - updatedAt: now, - }); - partyRoom.players.set("party:client-b", { - ...target, - origin: [4, 0, 0], - velocity: [0, 0, 0], - updatedAt: now, - }); - partyRoom.broadcastSnapshot(); - - now += 100; - partyRoom.players.set("party:client-b", { - ...partyRoom.players.get("party:client-b"), - origin: [4, 1.4, 0], - velocity: [0, 0, 0], - updatedAt: now, - }); - partyRoom.onMessage(JSON.stringify(fireEnvelope({ - clientId: "client-a", - messageId: "fire-historical-stopped-target", - sequence: 2, - fireSequence: 1, - sentAt: now, - fire: { - origin: [0, 0, -0.36], - direction: [1, 0, 0], - }, - })), alice); - - const event = roomEvents(alice, "player.damaged") - .find((candidate) => - candidate.attackerPlayerId === "party:client-a" && - candidate.victimPlayerId === "party:client-b" && - candidate.damageSource === "shotgun" - ); - assert.ok(event, "expected historical target sample to receive damage"); - assert.equal(event.damage, 24); - assert.equal(event.health, 76); - assert.equal(latestSnapshotPlayerForClient(alice, "client-b").health, 76); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - cleanupDuelRoom(partyRoom, alice, bob); - Date.now = originalNow; - } -}); - -test("party room still blocks damage when LOS trace hits a real wall", () => { - const collisionWorld = { - traceUse: () => ({ - fraction: 0.5, - end: [2, 0, -0.5], - planeNormal: [1, 0, 0], - entityIndex: 900, - modelIndex: 9, - classname: "func_wall", - }), - }; - const { alice, bob, partyRoom } = connectDuelRoom({ - id: "mid-wall-los", - roomOptions: { - trustedSceneMovement: { - collisionWorld, - playerEyeHeight: 1.0, - }, - }, - spawnDistance: 4, - }); - try { - partyRoom.onMessage(JSON.stringify(fireEnvelope({ - clientId: "client-a", - messageId: "fire-mid-wall-los", - sequence: 2, - fireSequence: 1, - sentAt: Date.now(), - })), alice); - - const event = roomEvents(alice, "player.damaged") - .find((candidate) => - candidate.attackerPlayerId === "party:client-a" && - candidate.victimPlayerId === "party:client-b" - ); - assert.equal(event, undefined); - const bobPlayer = latestSnapshotPlayerForClient(alice, "client-b"); - assert.equal(bobPlayer.health, 100); - const fired = roomEvents(alice, "player.fired").find((candidate) => - candidate.eventId === "fire-fire-mid-wall-los" - ); - assert.equal(fired?.decision?.outcome, "miss"); - assert.equal(fired?.decision?.reason, "line-of-sight-blocked"); - assert.equal(fired?.decision?.candidateCount, 1); - assert.equal(fired?.decision?.blockedCandidateCount, 1); - assert.equal(fired?.decision?.playerDamageCount, 0); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - cleanupDuelRoom(partyRoom, alice, bob); - } -}); - -test("party room damages a farther visible player when a nearer candidate is blocked", () => { - const collisionWorld = { - traceUse: (_origin, impact) => impact[0] < 3 - ? { - fraction: 0.5, - end: [1, 0, -0.5], - planeNormal: [1, 0, 0], - entityIndex: 44, - modelIndex: 2, - classname: "func_wall", - } - : null, - }; - const { alice, bob, cara, partyRoom } = connectTripleRoom({ - id: "blocked-nearer-visible-farther", - roomOptions: { - trustedSceneMovement: { - collisionWorld, - playerEyeHeight: 1.0, - }, - }, - spawns: [ - { spawnId: "spawn-a", classname: "info_player_deathmatch", origin: [0, 0, 0], rotX: -78, rotY: 0 }, - { spawnId: "spawn-b", classname: "info_player_deathmatch", origin: [2, 0, 0], rotX: -78, rotY: 180 }, - { spawnId: "spawn-c", classname: "info_player_deathmatch", origin: [4, 0, 0], rotX: -78, rotY: 180 }, - ], - }); - try { - partyRoom.onMessage(JSON.stringify(fireEnvelope({ - clientId: "client-a", - messageId: "fire-blocked-near-visible-far", - sequence: 2, - fireSequence: 1, - sentAt: Date.now(), - })), alice); - - const damagedEvents = roomEvents(alice, "player.damaged"); - assert.equal(damagedEvents.some((event) => event.victimPlayerId === "party:client-b"), false); - const farEvent = damagedEvents.find((event) => event.victimPlayerId === "party:client-c"); - assert.ok(farEvent, "expected farther visible player to take damage"); - assert.equal(farEvent.damage, 24); - assert.equal(farEvent.health, 76); - const fired = roomEvents(alice, "player.fired").find((candidate) => - candidate.eventId === "fire-fire-blocked-near-visible-far" - ); - assert.equal(fired?.decision?.outcome, "hit-player"); - assert.equal(fired?.decision?.reason, "player-direct"); - assert.equal(fired?.decision?.targetPlayerId, "party:client-c"); - assert.equal(fired?.decision?.candidateCount, 2); - assert.equal(fired?.decision?.blockedCandidateCount, 1); - assert.equal(fired?.decision?.playerDamageCount, 1); - assert.equal(latestSnapshotPlayerForClient(alice, "client-b").health, 100); - assert.equal(latestSnapshotPlayerForClient(alice, "client-c").health, 76); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - cleanupPartyRoomConnections(partyRoom, alice, bob, cara); - } -}); - -test("party room blocks indirect projectile splash through walls", () => { - const collisionWorld = { - traceUse: (_origin, point) => point[1] > 1 - ? { - fraction: 0.4, - end: [point[0], 1, point[2]], - planeNormal: [0, -1, 0], - entityIndex: 45, - modelIndex: 3, - classname: "func_wall", - } - : null, - }; - const { alice, bob, cara, partyRoom } = connectTripleRoom({ - id: "projectile-splash-wall", - roomOptions: { - trustedSceneMovement: { - collisionWorld, - playerEyeHeight: 1.0, - }, - }, - spawns: [ - { spawnId: "spawn-a", classname: "info_player_deathmatch", origin: [0, 0, 0], rotX: -78, rotY: 0 }, - { spawnId: "spawn-b", classname: "info_player_deathmatch", origin: [3, 0, 0], rotX: -78, rotY: 180 }, - { spawnId: "spawn-c", classname: "info_player_deathmatch", origin: [3, 2, 0], rotX: -78, rotY: 180 }, - ], - }); - try { - setPartyRoomPlayerWeapon(partyRoom, "client-a", "rocketlauncher"); - partyRoom.onMessage(JSON.stringify(fireEnvelope({ - clientId: "client-a", - messageId: "fire-splash-wall", - sequence: 2, - fireSequence: 1, - sentAt: Date.now(), - })), alice); - partyRoom.advanceRoomSimulation(Date.now() + 400); - - const damagedEvents = roomEvents(alice, "player.damaged"); - const killedEvents = roomEvents(alice, "player.killed"); - assert.ok(killedEvents.some((event) => event.victimPlayerId === "party:client-b")); - assert.equal(damagedEvents.some((event) => event.victimPlayerId === "party:client-c"), false); - assert.equal(killedEvents.some((event) => event.victimPlayerId === "party:client-c"), false); - assert.equal(latestSnapshotPlayerForClient(alice, "client-c").health, 100); - assert.equal(latestSnapshotPlayerForClient(alice, "client-c").alive, true); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - cleanupPartyRoomConnections(partyRoom, alice, bob, cara); - } -}); - -test("party room applies projectile wall-impact splash without a direct player hit", () => { - const collisionWorld = { - traceUse: (origin, point) => origin[0] === 0 && point[0] > 10 - ? { - fraction: 3 / 64, - end: [3, 0, 0], - planeNormal: [-1, 0, 0], - entityIndex: 44, - modelIndex: 3, - classname: "func_wall", - } - : null, - }; - const { alice, bob, partyRoom } = connectDuelRoom({ - id: "projectile-wall-splash", - roomOptions: { - trustedSceneMovement: { - collisionWorld, - playerEyeHeight: 1.0, - }, - }, - }); - try { - setPartyRoomPlayerWeapon(partyRoom, "client-a", "rocketlauncher"); - const bobPlayer = partyRoom.players.get("party:client-b"); - assert.ok(bobPlayer, "expected bob player"); - partyRoom.players.set("party:client-b", { - ...bobPlayer, - origin: [3, 2, 0], - updatedAt: Date.now(), - }); - - partyRoom.onMessage(JSON.stringify(fireEnvelope({ - clientId: "client-a", - messageId: "fire-wall-splash", - sequence: 2, - fireSequence: 1, - sentAt: Date.now(), - fire: { - direction: [1, 0, 0], - }, - })), alice); - partyRoom.advanceRoomSimulation(Date.now() + 2_000); - - const damagedEvents = roomEvents(alice, "player.damaged"); - const bobDamage = damagedEvents.find((event) => event.victimPlayerId === "party:client-b"); - const aliceDamage = damagedEvents.find((event) => event.victimPlayerId === "party:client-a"); - assert.ok(bobDamage, "expected wall splash to damage nearby non-direct target"); - assert.equal(bobDamage.damage, 69); - assert.equal(bobDamage.health, 31); - assert.ok(aliceDamage, "expected wall splash to apply half self damage"); - assert.equal(aliceDamage.damage, 22); - assert.equal(aliceDamage.health, 78); - assert.equal(latestSnapshotPlayerForClient(alice, "client-b").health, 31); - assert.equal(latestSnapshotPlayerForClient(alice, "client-a").health, 78); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - cleanupDuelRoom(partyRoom, alice, bob); - } -}); - -test("party room applies projectile quad damage from impact-time attacker state", () => { - const originalNow = Date.now; - let now = 3_000_000; - Date.now = () => now; - const cases = [ - { - id: "quad-expired-before-impact", - setup: (partyRoom) => setPartyRoomPlayerQuad(partyRoom, "client-a", now + 50), - expectedDamage: 9, - expectedHealth: 91, - }, - { - id: "quad-picked-up-before-impact", - setup: () => {}, - beforeImpact: (partyRoom) => setPartyRoomPlayerQuad(partyRoom, "client-a", now + 10_000), - expectedDamage: 36, - expectedHealth: 64, - }, - ]; - - try { - for (const spec of cases) { - const { alice, bob, partyRoom } = connectDuelRoom({ - id: spec.id, - spawnDistance: 4, - }); - try { - setPartyRoomPlayerWeapon(partyRoom, "client-a", "nailgun"); - spec.setup(partyRoom); - partyRoom.onMessage(JSON.stringify(fireEnvelope({ - clientId: "client-a", - messageId: `fire-${spec.id}`, - sequence: 2, - fireSequence: 1, - sentAt: now, - })), alice); - - assert.equal( - roomEvents(alice, "player.damaged") - .some((candidate) => candidate.damageSource === "nailgun"), - false, - "nail projectile should not damage on the fire tick", - ); - spec.beforeImpact?.(partyRoom); - now += 400; - partyRoom.advanceRoomSimulation(now); - - const event = roomEvents(alice, "player.damaged") - .find((candidate) => - candidate.attackerPlayerId === "party:client-a" && - candidate.victimPlayerId === "party:client-b" && - candidate.damageSource === "nailgun" - ); - assert.ok(event, `expected nailgun damage for ${spec.id}`); - assert.equal(event.damage, spec.expectedDamage, spec.id); - assert.equal(event.health, spec.expectedHealth, spec.id); - assert.equal(event.roomTime, 400, spec.id); - const impact = roomEvents(alice, "projectile.impacted") - .find((candidate) => candidate.weapon === "nailgun"); - assert.equal(impact?.roomTime, 400, spec.id); - assert.equal(latestSnapshotPlayerForClient(alice, "client-b").health, spec.expectedHealth); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - cleanupDuelRoom(partyRoom, alice, bob); - } - now += 1_000; - } - } finally { - Date.now = originalNow; - } -}); - -test("party room applies delayed projectile victim powerups at simulation impact time", () => { - const originalNow = Date.now; - const fireNow = 3_100_000; - Date.now = () => fireNow; - const { alice, bob, partyRoom } = connectDuelRoom({ - id: "projectile-victim-powerup-impact-time", - spawnDistance: 4, - }); - try { - setPartyRoomPlayerWeapon(partyRoom, "client-a", "nailgun"); - setPartyRoomPlayerInvulnerable(partyRoom, "client-b", fireNow + 50); - partyRoom.onMessage(JSON.stringify(fireEnvelope({ - clientId: "client-a", - messageId: "fire-projectile-victim-powerup-impact-time", - sequence: 2, - fireSequence: 1, - sentAt: fireNow, - fire: { - weapon: "nailgun", - fireKind: "projectile", - }, - })), alice); - - partyRoom.advanceRoomSimulation(fireNow + 400); - - const event = roomEvents(alice, "player.damaged") - .find((candidate) => - candidate.attackerPlayerId === "party:client-a" && - candidate.victimPlayerId === "party:client-b" && - candidate.damageSource === "nailgun" - ); - assert.ok(event, "expected expired victim invulnerability not to block delayed projectile damage"); - assert.equal(event.damage, 9); - assert.equal(event.health, 91); - assert.equal(event.roomTime, 400); - const impact = roomEvents(alice, "projectile.impacted") - .find((candidate) => candidate.weapon === "nailgun"); - assert.equal(impact?.roomTime, 400); - assert.equal(latestSnapshotPlayerForClient(alice, "client-b").health, 91); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - cleanupDuelRoom(partyRoom, alice, bob); - Date.now = originalNow; - } -}); - -test("server grenade projectile advances through delayed arc impact damage", () => { - const projectile = projectileAuthority.createQuakeMultiplayerServerProjectile({ - fire: { - fireSequence: 1, - firedAt: 100, - fireKind: "projectile", - weapon: "grenadelauncher", - origin: [0, 0, 0], - direction: DUEL_FORWARD_DIRECTION, - range: 1024, - }, - now: 100, - ownerPlayerId: "party:client-a", - projectileId: "grenade-arc-1", - }); - assert.ok(projectile, "expected grenade launcher to create a server projectile"); - assert.equal(projectile.weapon, "grenadelauncher"); - assert.equal(projectileAuthority.quakeMultiplayerServerProjectileWeaponSupported("grenadelauncher"), true); - assert.ok(projectile.gravity > 0, "expected grenade projectile to carry gravity"); - assert.ok(projectile.velocity[2] > projectile.direction[2] * projectile.speed, "expected grenade launch kick"); - - const target = createPlayer({ - playerId: "party:client-b", - clientId: "client-b", - displayName: "Bob", - origin: [4, 0, 0], - rotX: -78, - rotY: 180, - updatedAt: 100, - }); - const immediate = projectileAuthority.advanceQuakeMultiplayerServerProjectile(projectile, { - collisionWorld: null, - now: 100, - players: [target], - }); - assert.equal(immediate.type, "active", "grenade should not damage on the fire tick"); - - const delayed = projectileAuthority.advanceQuakeMultiplayerServerProjectile(projectile, { - collisionWorld: null, - now: 500, - players: [target], - }); - assert.equal(delayed.type, "impact"); - assert.equal(delayed.impact.kind, "player"); - assert.equal(delayed.impact.targetPlayerId, "party:client-b"); - const hit = delayed.impact.damageHits.find((candidate) => candidate.target.playerId === "party:client-b"); - assert.ok(hit, "expected delayed grenade impact to damage target"); - assert.equal(hit.damage, 87); - assert.equal(hit.direct, false); -}); - -test("server grenade projectile bounces on world impact and explodes on fuse expiry", () => { - const projectile = projectileAuthority.createQuakeMultiplayerServerProjectile({ - fire: { - fireSequence: 1, - firedAt: 100, - fireKind: "projectile", - weapon: "grenadelauncher", - origin: [0, 0, 1], - direction: [1, 0, 0], - range: 1024, - }, - now: 100, - ownerPlayerId: "party:client-a", - projectileId: "grenade-bounce-1", - }); - assert.ok(projectile, "expected grenade launcher to create a server projectile"); - const fallingProjectile = { - ...projectile, - direction: [0.24253562503633297, 0, -0.9701425001453319], - gravity: 0, - speed: Math.hypot(2, 0, -8), - velocity: [2, 0, -8], - }; - const collisionWorld = { - traceUse: (origin, end) => { - if (origin[2] <= 0 || end[2] > 0) return null; - const fraction = origin[2] / (origin[2] - end[2]); - return { - fraction, - end: [ - origin[0] + (end[0] - origin[0]) * fraction, - origin[1] + (end[1] - origin[1]) * fraction, - 0, - ], - planeNormal: [0, 0, 1], - entityIndex: 44, - modelIndex: 3, - classname: "func_floor", - }; - }, - }; - - const bounced = projectileAuthority.advanceQuakeMultiplayerServerProjectile(fallingProjectile, { - collisionWorld, - now: 300, - players: [], - }); - assert.equal(bounced.type, "active"); - assert.ok(bounced.projectile.origin[2] > 0, "expected bounced grenade to be offset off the impact plane"); - assert.ok(bounced.projectile.velocity[2] > 0, "expected bounced grenade to reflect upward"); - - const target = createPlayer({ - playerId: "party:client-b", - clientId: "client-b", - displayName: "Bob", - origin: [bounced.projectile.origin[0], bounced.projectile.origin[1], 0], - updatedAt: 300, - }); - const expired = projectileAuthority.advanceQuakeMultiplayerServerProjectile(bounced.projectile, { - collisionWorld: null, - now: bounced.projectile.expiresAt + 1, - players: [target], - }); - assert.equal(expired.type, "impact"); - assert.equal(expired.impact.kind, "world"); - const hit = expired.impact.damageHits.find((candidate) => candidate.target.playerId === "party:client-b"); - assert.ok(hit, "expected grenade fuse explosion to apply splash damage"); - assert.equal(hit.direct, false); - assert.ok(hit.damage > 0); -}); - -test("party room snapshots active server projectile positions", () => { - const originalNow = Date.now; - let now = 2_500_000; - Date.now = () => now; - const { alice, bob, partyRoom } = connectDuelRoom({ - id: "projectile-snapshot-position", - spawnDistance: 20, - }); - try { - setPartyRoomPlayerWeapon(partyRoom, "client-a", "rocketlauncher"); - partyRoom.onMessage(JSON.stringify(fireEnvelope({ - clientId: "client-a", - messageId: "fire-projectile-snapshot-position", - sequence: 2, - fireSequence: 1, - sentAt: now, - fire: { - weapon: "rocketlauncher", - }, - })), alice); - - const spawned = roomEvents(alice, "projectile.spawned") - .find((candidate) => candidate.projectile.weapon === "rocketlauncher"); - assert.ok(spawned, "expected projectile.spawned event"); - const initialSnapshot = latestConnectionMessage(alice, "room.snapshot"); - const initialProjectile = initialSnapshot.payload.projectiles - ?.find((candidate) => candidate.projectileId === spawned.projectile.projectileId); - assert.ok(initialProjectile, "expected initial snapshot to carry active projectile"); - assert.deepEqual(initialProjectile.origin, spawned.projectile.origin); - - now += 100; - partyRoom.advanceRoomSimulation(now); - partyRoom.broadcastSnapshot(); - const movedSnapshot = latestConnectionMessage(alice, "room.snapshot"); - const movedProjectile = movedSnapshot.payload.projectiles - ?.find((candidate) => candidate.projectileId === spawned.projectile.projectileId); - assert.ok(movedProjectile, "expected later snapshot to keep active projectile"); - assert.equal(movedProjectile.updatedAt, now); - assert.ok( - movedProjectile.origin[0] > initialProjectile.origin[0], - "expected active projectile snapshot origin to advance", - ); - assert.equal(alice.messages.filter((message) => message.type === "room.reject").length, 0); - assert.equal(bob.messages.filter((message) => message.type === "room.reject").length, 0); - } finally { - Date.now = originalNow; - 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 }); @@ -2284,64 +212,17 @@ test("client authority accepts immediate presence transitions", () => { const pausedResult = authority.validateQuakeMultiplayerClientAuthority( presenceEnvelope("input-paused", { sequence: 2, messageId: "presence-paused", sentAt: 120 }), - helloResult.state, - { now: 120 }, - ); - assert.equal(pausedResult.ok, true); - - const activeResult = authority.validateQuakeMultiplayerClientAuthority( - presenceEnvelope("active", { sequence: 3, messageId: "presence-active", sentAt: 121 }), - pausedResult.state, - { now: 121 }, - ); - assert.equal(activeResult.ok, true); -}); - -test("party room keeps hello authority while trusted gameplay definitions are pending", async () => { - const { room, createConnection } = createFakePartyRoom(); - const RoomClass = partyRoomModule.default; - let resolveTrustedDefinitions; - const trustedDefinitions = new Promise((resolve) => { - resolveTrustedDefinitions = resolve; - }); - const partyRoom = new RoomClass(room, { - trustedGameplayDefinitionsFetcher: () => trustedDefinitions, - }); - const connection = createConnection("pending-hello-connection"); - - partyRoom.onConnect(connection); - const helloResult = partyRoom.onMessage(JSON.stringify(helloEnvelope({ - messageId: "pending-hello", - sequence: 1, - sentAt: Date.now(), - })), connection); - partyRoom.onMessage(JSON.stringify(presenceEnvelope("active", { - messageId: "presence-while-hello-pending", - sequence: 2, - sentAt: Date.now(), - })), connection); - - assert.equal(connection.closed.length, 0); - assert.equal(connection.messages.some((message) => - message.type === "room.reject" && - message.payload.code === "not-authorized" - ), false); - assert.equal(connection.state.authority.lastEnvelopeSequence, 2); - - resolveTrustedDefinitions({ - gameplayFacts: { - factsVersion: 1, - factsHash: "0000000000000000", - deathmatchSpawnCount: 0, - pickupCount: 0, - }, - deathmatchSpawns: [], - pickupDefinitions: [], - }); - await Promise.resolve(helloResult); + helloResult.state, + { now: 120 }, + ); + assert.equal(pausedResult.ok, true); - assert.equal(connection.state.playerId, "party:client-a"); - assert.equal(connection.state.authority.lastEnvelopeSequence, 2); + const activeResult = authority.validateQuakeMultiplayerClientAuthority( + presenceEnvelope("active", { sequence: 3, messageId: "presence-active", sentAt: 121 }), + pausedResult.state, + { now: 121 }, + ); + assert.equal(activeResult.ok, true); }); test("room wrong-map rejects validate even when their room key differs", () => { @@ -2548,1631 +429,3 @@ test("room projectile lifecycle events validate authoritative projectile state", assert.equal(invalid.ok, false); assert.equal(invalid.code, "malformed"); }); - -test("loopback session emits hello snapshot, presence event, and suppresses paused input", async () => { - const harness = await createLoopbackHarness({ color: "#00ffaa" }); - const { messages, session, status } = harness; - try { - assert.equal(status.state, "connected"); - assert.equal(status.mode, "loopback"); - assert.equal(messages.length, 0); - - session.send(helloEnvelope({ - color: "#00ffaa", - messageId: "hello-1", - sequence: 1, - sentAt: harness.now(), - })); - - const helloSnapshot = latestMessage(messages, "room.snapshot"); - assert.equal(helloSnapshot.payload.players.length, 1); - assert.equal(helloSnapshot.payload.players[0].playerId, "loopback:client-a"); - assert.equal(helloSnapshot.payload.players[0].displayName, "Alice"); - assert.equal(helloSnapshot.payload.players[0].lastInputSequence, 0); - - harness.advanceNow(120); - session.send(presenceEnvelope("input-paused", { - messageId: "presence-1", - sequence: 2, - sentAt: harness.now(), - })); - - const presenceEvent = latestMessage(messages, "room.event"); - assert.equal(presenceEvent.payload.event.eventType, "player.presence"); - assert.equal(presenceEvent.payload.event.playerId, "loopback:client-a"); - assert.equal(presenceEvent.payload.event.status, "input-paused"); - - const pausedSnapshot = latestMessage(messages, "room.snapshot"); - assert.equal(pausedSnapshot.payload.players[0].lastInputSequence, 0); - const messageCountBeforePausedInput = messages.length; - - harness.advanceNow(20); - session.send(inputEnvelope({ sequence: 3, inputSequence: 1, sentAt: harness.now() })); - assert.equal(messages.length, messageCountBeforePausedInput); - - harness.advanceNow(120); - session.send(presenceEnvelope("active", { - messageId: "presence-2", - sequence: 4, - sentAt: harness.now(), - })); - - const activeEvent = latestMessage(messages, "room.event"); - assert.equal(activeEvent.payload.event.status, "active"); - } finally { - harness.disconnect(); - } -}); - -test("loopback session rejects paused mutation intents", async () => { - const harness = await createLoopbackHarness({ now: 2000 }); - const { messages, session } = harness; - try { - session.send(helloEnvelope({ messageId: "hello-paused", sequence: 1, sentAt: harness.now() })); - - harness.advanceNow(120); - session.send(presenceEnvelope("backgrounded", { - messageId: "presence-backgrounded", - sequence: 2, - sentAt: harness.now(), - })); - assert.equal(latestMessage(messages, "room.event").payload.event.status, "backgrounded"); - - const mutationCases = [ - { - messageId: "paused-fire", - envelope: () => fireEnvelope({ sequence: 3, fireSequence: 1, sentAt: harness.now() }), - advanceMs: 30, - }, - { - messageId: "paused-pickup", - envelope: () => pickupEnvelope({ sequence: 4, pickupSequence: 1, sentAt: harness.now() }), - advanceMs: 160, - }, - { - messageId: "paused-world", - envelope: () => worldEnvelope({ sequence: 5, worldSequence: 1, sentAt: harness.now() }), - advanceMs: 1, - }, - { - messageId: "paused-match", - envelope: () => matchEnvelope({ sequence: 6, matchSequence: 1, sentAt: harness.now() }), - advanceMs: 250, - }, - ]; - - const firstMutationMessageCount = messages.length; - for (const testCase of mutationCases) { - harness.advanceNow(testCase.advanceMs); - session.send(testCase.envelope()); - const reject = latestMessage(messages, "room.reject"); - assert.equal(reject.payload.rejectedMessageId, testCase.messageId); - assert.equal(reject.payload.code, "unsupported"); - assert.equal(reject.payload.recoverable, true); - assert.match(reject.payload.message, /input is paused/); - } - assert.equal(messages.filter((message) => message.type === "room.reject").length, mutationCases.length); - assert.equal( - messages.slice(firstMutationMessageCount).filter((message) => message.type === "room.event").length, - 0, - ); - } finally { - harness.disconnect(); - } -}); - -test("loopback session rejects fire timestamps outside accepted input history", async () => { - const harness = await createLoopbackHarness({ now: 3000 }); - const { messages, session } = harness; - try { - session.send(helloEnvelope({ messageId: "hello-loopback-fire-history", sequence: 1, sentAt: harness.now() })); - session.send(inputBatchEnvelope({ - messageId: "loopback-fire-history-inputs", - sequence: 2, - sentAt: harness.now(), - inputSequences: [1, 2], - inputs: [ - { sampledAt: harness.now(), rotX: -78, rotY: 0 }, - { sampledAt: harness.now() + 50, rotX: -78, rotY: 0 }, - ], - })); - harness.advanceNow(80); - session.send(fireEnvelope({ - messageId: "loopback-fire-history-too-late", - sequence: 3, - sentAt: harness.now(), - fireSequence: 1, - fire: { - firedAt: harness.now() + 1_000, - }, - })); - - const reject = latestMessage(messages, "room.reject"); - assert.equal(reject.payload.code, "stale"); - assert.equal(reject.payload.recoverable, true); - assert.equal(reject.payload.rejectedMessageId, "loopback-fire-history-too-late"); - assert.match(reject.payload.message, /fire-after-input-history/); - } finally { - harness.disconnect(); - } -}); - -test("loopback session uses fire payload aim when the authoritative pose is one input behind", async () => { - const remotePlayer = createPlayer({ - playerId: "remote-player", - clientId: "remote-client", - displayName: "Remote", - origin: [4, 0, 0], - rotX: -78, - rotY: 180, - updatedAt: 5000, - }); - const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ - deathmatchSpawns: [{ - spawnId: "spawn-local", - classname: "info_player_deathmatch", - origin: [0, 0, 0], - rotX: -78, - rotY: 180, - }], - pickupDefinitions: [], - }); - const harness = await createLoopbackHarness({ - now: 5000, - sessionOptions: { - trustedGameplayDefinitions: gameplayDefinitions, - simulatedPlayers: () => [remotePlayer], - }, - }); - const { messages, session } = harness; - try { - session.send(helloEnvelope({ messageId: "hello-loopback-fresh-aim", sequence: 1, sentAt: harness.now() })); - harness.advanceNow(120); - session.send(fireEnvelope({ - messageId: "fire-loopback-fresh-aim", - sequence: 2, - fireSequence: 1, - sentAt: harness.now(), - fire: { direction: DUEL_FORWARD_DIRECTION }, - })); - - const event = latestMessage(messages, "room.event").payload.event; - assert.equal(event.eventType, "player.damaged"); - assert.equal(event.victimPlayerId, "remote-player"); - assert.equal(event.damage, 24); - assert.equal(event.health, 76); - assert.equal(messages.some((message) => message.type === "room.reject"), false); - } finally { - harness.disconnect(); - } -}); - -test("loopback session uses a bounded fire origin hint when the authoritative origin is one input behind", async () => { - const remotePlayer = createPlayer({ - playerId: "remote-player", - clientId: "remote-client", - displayName: "Remote", - origin: [4, 0.9, 0], - rotX: -78, - rotY: 180, - updatedAt: 5050, - }); - const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ - deathmatchSpawns: [{ - spawnId: "spawn-local", - classname: "info_player_deathmatch", - origin: [0, 0, 0], - rotX: -78, - rotY: 0, - }], - pickupDefinitions: [], - }); - const harness = await createLoopbackHarness({ - now: 5050, - sessionOptions: { - trustedGameplayDefinitions: gameplayDefinitions, - simulatedPlayers: () => [remotePlayer], - }, - }); - const { messages, session } = harness; - try { - session.send(helloEnvelope({ messageId: "hello-loopback-fresh-origin", sequence: 1, sentAt: harness.now() })); - harness.advanceNow(120); - session.send(fireEnvelope({ - messageId: "fire-loopback-fresh-origin", - sequence: 2, - fireSequence: 1, - sentAt: harness.now(), - fire: { origin: [0, 0.4, 0] }, - })); - - const event = latestMessage(messages, "room.event").payload.event; - assert.equal(event.eventType, "player.damaged"); - assert.equal(event.victimPlayerId, "remote-player"); - assert.equal(event.damage, 24); - assert.equal(event.health, 76); - assert.equal(messages.some((message) => message.type === "room.reject"), false); - } finally { - harness.disconnect(); - } -}); - -test("loopback session applies damage when LOS trace only clips the target skin", async () => { - const remotePlayer = createPlayer({ - playerId: "remote-player", - clientId: "remote-client", - displayName: "Remote", - origin: [4, 0, 0], - rotX: -78, - rotY: 180, - updatedAt: 5000, - }); - const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ - deathmatchSpawns: [{ - spawnId: "spawn-local", - classname: "info_player_deathmatch", - origin: [0, 0, 0], - rotX: -78, - rotY: 0, - }], - pickupDefinitions: [], - }); - const harness = await createLoopbackHarness({ - now: 5000, - sessionOptions: { - trustedGameplayDefinitions: gameplayDefinitions, - trustedSceneMovement: { - collisionWorld: { - traceUse: () => ({ - fraction: 0.985, - end: [3.92, 0, -0.82], - planeNormal: [0, 0, 1], - entityIndex: 84, - modelIndex: 3, - classname: "func_wall", - }), - }, - playerEyeHeight: 1.0, - }, - simulatedPlayers: () => [remotePlayer], - }, - }); - const { messages, session } = harness; - try { - session.send(helloEnvelope({ messageId: "hello-loopback-late-los", sequence: 1, sentAt: harness.now() })); - harness.advanceNow(120); - session.send(fireEnvelope({ - messageId: "fire-loopback-late-los", - sequence: 2, - fireSequence: 1, - sentAt: harness.now(), - })); - - const event = latestMessage(messages, "room.event").payload.event; - assert.equal(event.eventType, "player.damaged"); - assert.equal(event.victimPlayerId, "remote-player"); - assert.equal(event.damage, 24); - assert.equal(event.health, 76); - assert.equal(messages.some((message) => message.type === "room.reject"), false); - } finally { - harness.disconnect(); - } -}); - -test("loopback session applies source-order armor save but suppresses health damage while simulated victim is invulnerable", async () => { - const remoteInventory = { - ...items.createQuakeMultiplayerInitialInventory(), - health: 100, - armor: 50, - armorType: 0.8, - powerups: [{ - active: true, - activationField: "invincible_time", - finishedAt: 15_000, - finishedField: "invincible_finished", - itemFlag: INVULNERABILITY_ITEM_FLAG, - }], - }; - const remotePlayer = items.quakeMultiplayerPlayerWithInventory( - createPlayer({ - playerId: "remote-player", - clientId: "remote-client", - displayName: "Remote", - origin: [4, 0, 0], - rotX: -78, - rotY: 180, - updatedAt: 5_200, - }), - remoteInventory, - ); - const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ - deathmatchSpawns: [{ - spawnId: "spawn-local", - classname: "info_player_deathmatch", - origin: [0, 0, 0], - rotX: -78, - rotY: 0, - }], - pickupDefinitions: [], - }); - const harness = await createLoopbackHarness({ - now: 5_200, - sessionOptions: { - trustedGameplayDefinitions: gameplayDefinitions, - simulatedPlayers: () => [remotePlayer], - }, - }); - const { messages, session } = harness; - try { - session.send(helloEnvelope({ messageId: "hello-loopback-invulnerable", sequence: 1, sentAt: harness.now() })); - harness.advanceNow(120); - session.send(fireEnvelope({ - messageId: "fire-loopback-invulnerable", - sequence: 2, - fireSequence: 1, - sentAt: harness.now(), - })); - - const events = messages - .filter((message) => message.type === "room.event") - .map((message) => message.payload.event); - assert.equal(events.some((event) => - event.eventType === "player.damaged" && event.victimPlayerId === "remote-player" - ), false); - assert.equal(events.some((event) => - event.eventType === "player.killed" && event.victimPlayerId === "remote-player" - ), false); - const snapshot = latestMessage(messages, "room.snapshot"); - const remoteSnapshot = snapshot.payload.players.find((player) => player.playerId === "remote-player"); - assert.equal(remoteSnapshot?.health, 100); - assert.equal(remoteSnapshot?.armor, 30); - assert.equal(remoteSnapshot?.alive, true); - assert.ok( - (remoteSnapshot?.velocity?.some((value) => Math.abs(value) > 0) ?? false), - "expected invulnerable target to still receive source-style damage momentum", - ); - assert.equal(messages.some((message) => message.type === "room.reject"), false); - } finally { - harness.disconnect(); - } -}); - -test("loopback session double-invulnerable telefrag clears protection and kills both players like Quake teledeath3", async () => { - const invulnerabilityPickup = invulnerabilityPickupDefinition(); - const remoteInventory = { - ...items.createQuakeMultiplayerInitialInventory(), - itemFlags: INVULNERABILITY_ITEM_FLAG, - powerups: [{ - active: true, - activationField: "invincible_time", - finishedAt: 15_000, - finishedField: "invincible_finished", - itemFlag: INVULNERABILITY_ITEM_FLAG, - }], - }; - const remotePlayer = items.quakeMultiplayerPlayerWithInventory( - createPlayer({ - playerId: "remote-player", - clientId: "remote-client", - displayName: "Remote", - origin: [4, 0, 0], - rotX: -78, - rotY: 180, - updatedAt: 5_200, - }), - remoteInventory, - ); - const teleportDefinition = { - kind: "teleport", - entityIndex: 700, - classname: "trigger_teleport", - destinationEntityIndex: 701, - destinationOrigin: [4, 0, 0], - destinationRotX: -78, - destinationRotY: 180, - }; - const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ - deathmatchSpawns: [{ - spawnId: "spawn-local", - classname: "info_player_deathmatch", - origin: [0, 0, 0], - rotX: -78, - rotY: 0, - }], - pickupDefinitions: [invulnerabilityPickup], - }); - const harness = await createLoopbackHarness({ - now: 5_200, - sessionOptions: { - trustedGameplayDefinitions: gameplayDefinitions, - trustedWorldDefinitions: [teleportDefinition], - simulatedPlayers: () => [remotePlayer], - }, - }); - const { messages, session } = harness; - try { - session.send(helloEnvelope({ messageId: "hello-loopback-double-telefrag", sequence: 1, sentAt: harness.now() })); - harness.advanceNow(120); - session.send(pickupEnvelope({ - messageId: "pickup-loopback-invulnerability", - sequence: 2, - pickupSequence: 1, - sentAt: harness.now(), - pickup: { - entityIndex: invulnerabilityPickup.entityIndex, - origin: [0, 0, 0], - }, - })); - assert.equal( - messages.some((message) => - message.type === "room.event" && - message.payload.event.eventType === "pickup.taken" && - message.payload.event.entityIndex === invulnerabilityPickup.entityIndex - ), - true, - ); - - harness.advanceNow(120); - session.send(worldEnvelope({ - messageId: "world-loopback-double-telefrag", - sequence: 3, - worldSequence: 1, - sentAt: harness.now(), - intent: { - intentType: "teleport", - entityIndex: teleportDefinition.entityIndex, - destinationEntityIndex: teleportDefinition.destinationEntityIndex, - origin: [0, 0, 0], - velocity: [0, 0, 0], - }, - })); - - const kills = messages - .filter((message) => message.type === "room.event") - .map((message) => message.payload.event) - .filter((event) => event.eventType === "player.killed" && event.damageSource === "teledeath3"); - assert.equal(kills.length, 2); - assert.equal(kills.some((event) => event.victimPlayerId === "loopback:client-a"), true); - assert.equal(kills.some((event) => event.victimPlayerId === "remote-player"), true); - - const snapshot = latestMessage(messages, "room.snapshot"); - const localSnapshot = snapshot.payload.players.find((player) => player.playerId === "loopback:client-a"); - const remoteSnapshot = snapshot.payload.players.find((player) => player.playerId === "remote-player"); - assert.equal(localSnapshot?.alive, false); - assert.equal(remoteSnapshot?.alive, false); - assert.equal(localSnapshot?.frags, -1); - assert.equal(remoteSnapshot?.frags, -1); - assert.equal(localSnapshot?.deaths, 1); - assert.equal(remoteSnapshot?.deaths, 1); - assert.equal( - localSnapshot?.inventory.powerups.some((powerup) => powerup.finishedField === "invincible_finished"), - false, - ); - assert.equal( - remoteSnapshot?.inventory.powerups.some((powerup) => powerup.finishedField === "invincible_finished"), - false, - ); - assert.equal(messages.some((message) => message.type === "room.reject"), false); - } finally { - harness.disconnect(); - } -}); - -test("loopback session clears local active artifact powerups immediately on death", async () => { - const quadPickup = quadPickupDefinition({ durationMs: 30_000 }); - const hurtDefinition = { - kind: "hurt", - entityIndex: 6_001, - classname: "trigger_hurt", - damage: 150, - }; - const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ - deathmatchSpawns: [{ - spawnId: "spawn-loopback-local-death", - classname: "info_player_deathmatch", - origin: [0, 0, 0], - rotX: -78, - rotY: 0, - }], - pickupDefinitions: [quadPickup], - }); - const harness = await createLoopbackHarness({ - now: 5_400, - sessionOptions: { - trustedGameplayDefinitions: gameplayDefinitions, - trustedWorldDefinitions: [hurtDefinition], - }, - }); - const { messages, session } = harness; - try { - session.send(helloEnvelope({ messageId: "hello-loopback-local-death-powerups", sequence: 1, sentAt: harness.now() })); - harness.advanceNow(120); - session.send(pickupEnvelope({ - messageId: "pickup-loopback-local-death-quad", - sequence: 2, - pickupSequence: 1, - sentAt: harness.now(), - pickup: { entityIndex: quadPickup.entityIndex, origin: [0, 0, 0] }, - })); - assert.equal( - messages.some((message) => - message.type === "room.event" && - message.payload.event.eventType === "pickup.taken" && - message.payload.event.entityIndex === quadPickup.entityIndex - ), - true, - ); - - harness.advanceNow(120); - session.send(worldEnvelope({ - messageId: "world-loopback-local-death-powerups", - sequence: 3, - worldSequence: 1, - sentAt: harness.now(), - intent: { - entityIndex: hurtDefinition.entityIndex, - origin: [0, 0, 0], - }, - })); - - const kill = messages - .filter((message) => message.type === "room.event") - .map((message) => message.payload.event) - .find((event) => - event.eventType === "player.killed" && - event.victimPlayerId === "loopback:client-a" && - event.damageSource === "trigger_hurt" - ); - assert.ok(kill, "expected local trigger_hurt death"); - const snapshot = latestMessage(messages, "room.snapshot"); - const localSnapshot = snapshot.payload.players.find((player) => player.playerId === "loopback:client-a"); - assert.equal(localSnapshot?.alive, false); - assert.equal(localSnapshot?.inventory.itemFlags & QUAD_ITEM_FLAG, 0); - assert.equal( - localSnapshot?.inventory.powerups.some((powerup) => powerup.finishedField === "super_damage_finished"), - false, - ); - assert.equal(messages.some((message) => message.type === "room.reject"), false); - } finally { - harness.disconnect(); - } -}); - -test("loopback session rewinds hit tests from authoritative snapshot history instead of current velocity", async () => { - let remotePlayer = createPlayer({ - playerId: "remote-player", - clientId: "remote-client", - displayName: "Remote", - origin: [4, 0, 0], - velocity: [0, 0, 0], - rotX: -78, - rotY: 180, - updatedAt: 7_000, - }); - const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ - deathmatchSpawns: [{ - spawnId: "spawn-local", - classname: "info_player_deathmatch", - origin: [0, 0, 0], - rotX: -78, - rotY: 0, - }], - pickupDefinitions: [], - }); - const harness = await createLoopbackHarness({ - now: 7_000, - sessionOptions: { - trustedGameplayDefinitions: gameplayDefinitions, - simulatedPlayers: () => [remotePlayer], - }, - }); - const { messages, session } = harness; - try { - session.send(helloEnvelope({ messageId: "hello-loopback-history-hit", sequence: 1, sentAt: harness.now() })); - remotePlayer = { - ...remotePlayer, - origin: [4, 1.4, 0], - velocity: [0, 0, 0], - updatedAt: 7_100, - }; - harness.advanceNow(100); - session.send(fireEnvelope({ - messageId: "fire-loopback-history-hit", - sequence: 2, - fireSequence: 1, - sentAt: harness.now(), - fire: { - origin: [0, 0, -0.36], - direction: [1, 0, 0], - }, - })); - - const event = messages - .filter((message) => message.type === "room.event") - .map((message) => message.payload.event) - .find((candidate) => - candidate.eventType === "player.damaged" && - candidate.victimPlayerId === "remote-player" - ); - assert.ok(event, "expected historical loopback target sample to receive damage"); - assert.equal(event.damage, 24); - assert.equal(event.health, 76); - assert.equal(messages.some((message) => message.type === "room.reject"), false); - } finally { - harness.disconnect(); - } -}); - -test("loopback session blocks damage when LOS trace hits a real wall", async () => { - const remotePlayer = createPlayer({ - playerId: "remote-player", - clientId: "remote-client", - displayName: "Remote", - origin: [4, 0, 0], - rotX: -78, - rotY: 180, - updatedAt: 5500, - }); - const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ - deathmatchSpawns: [{ - spawnId: "spawn-local", - classname: "info_player_deathmatch", - origin: [0, 0, 0], - rotX: -78, - rotY: 0, - }], - pickupDefinitions: [], - }); - const harness = await createLoopbackHarness({ - now: 5500, - sessionOptions: { - trustedGameplayDefinitions: gameplayDefinitions, - trustedSceneMovement: { - collisionWorld: { - traceUse: () => ({ - fraction: 0.5, - end: [2, 0, -0.5], - planeNormal: [1, 0, 0], - entityIndex: 900, - modelIndex: 9, - classname: "func_wall", - }), - }, - playerEyeHeight: 1.0, - }, - simulatedPlayers: () => [remotePlayer], - }, - }); - const { messages, session } = harness; - try { - session.send(helloEnvelope({ messageId: "hello-loopback-wall-los", sequence: 1, sentAt: harness.now() })); - harness.advanceNow(120); - const beforeCount = messages.length; - session.send(fireEnvelope({ - messageId: "fire-loopback-wall-los", - sequence: 2, - fireSequence: 1, - sentAt: harness.now(), - })); - - const newEvents = messages - .slice(beforeCount) - .filter((message) => message.type === "room.event") - .map((message) => message.payload.event); - assert.equal(newEvents.some((event) => event.eventType === "player.damaged"), false); - const snapshot = latestMessage(messages, "room.snapshot"); - const remoteSnapshot = snapshot.payload.players.find((player) => player.playerId === "remote-player"); - assert.equal(remoteSnapshot?.health, 100); - assert.equal(messages.some((message) => message.type === "room.reject"), false); - } finally { - harness.disconnect(); - } -}); - -test("loopback session damages a farther visible simulated player when a nearer candidate is blocked", async () => { - const nearPlayer = createPlayer({ - playerId: "near-player", - clientId: "near-client", - displayName: "Near", - origin: [2, 0, 0], - rotX: -78, - rotY: 180, - updatedAt: 6000, - }); - const farPlayer = createPlayer({ - playerId: "far-player", - clientId: "far-client", - displayName: "Far", - origin: [4, 0, 0], - rotX: -78, - rotY: 180, - updatedAt: 6000, - }); - const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ - deathmatchSpawns: [{ - spawnId: "spawn-local", - classname: "info_player_deathmatch", - origin: [0, 0, 0], - rotX: -78, - rotY: 0, - }], - pickupDefinitions: [], - }); - const harness = await createLoopbackHarness({ - now: 6000, - sessionOptions: { - trustedGameplayDefinitions: gameplayDefinitions, - trustedSceneMovement: { - collisionWorld: { - traceUse: (_origin, impact) => impact[0] < 3 - ? { - fraction: 0.5, - end: [1, 0, -0.5], - planeNormal: [1, 0, 0], - entityIndex: 44, - modelIndex: 2, - classname: "func_wall", - } - : null, - }, - playerEyeHeight: 1.0, - }, - simulatedPlayers: () => [nearPlayer, farPlayer], - }, - }); - const { messages, session } = harness; - try { - session.send(helloEnvelope({ messageId: "hello-loopback-visible-far", sequence: 1, sentAt: harness.now() })); - harness.advanceNow(120); - session.send(fireEnvelope({ - messageId: "fire-loopback-visible-far", - sequence: 2, - fireSequence: 1, - sentAt: harness.now(), - })); - - const events = messages - .filter((message) => message.type === "room.event") - .map((message) => message.payload.event); - assert.equal(events.some((event) => - event.eventType === "player.damaged" && event.victimPlayerId === "near-player" - ), false); - const farEvent = events.find((event) => - event.eventType === "player.damaged" && event.victimPlayerId === "far-player" - ); - assert.ok(farEvent, "expected farther visible simulated player to take damage"); - assert.equal(farEvent.damage, 24); - assert.equal(farEvent.health, 76); - const snapshot = latestMessage(messages, "room.snapshot"); - const nearSnapshot = snapshot.payload.players.find((player) => player.playerId === "near-player"); - const farSnapshot = snapshot.payload.players.find((player) => player.playerId === "far-player"); - assert.equal(nearSnapshot?.health, 100); - assert.equal(farSnapshot?.health, 76); - assert.equal(messages.some((message) => message.type === "room.reject"), false); - } finally { - harness.disconnect(); - } -}); - -test("loopback session blocks indirect projectile splash through walls", async () => { - const rocketPickup = weaponPickupDefinition("rocketlauncher"); - const directPlayer = createPlayer({ - playerId: "direct-player", - clientId: "direct-client", - displayName: "Direct", - origin: [3, 0, 0], - rotX: -78, - rotY: 180, - updatedAt: 6500, - }); - const blockedPlayer = createPlayer({ - playerId: "blocked-player", - clientId: "blocked-client", - displayName: "Blocked", - origin: [3, 2, 0], - rotX: -78, - rotY: 180, - updatedAt: 6500, - }); - const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ - deathmatchSpawns: [{ - spawnId: "spawn-local", - classname: "info_player_deathmatch", - origin: [0, 0, 0], - rotX: -78, - rotY: 0, - }], - pickupDefinitions: [rocketPickup], - }); - const harness = await createLoopbackHarness({ - now: 6500, - sessionOptions: { - trustedGameplayDefinitions: gameplayDefinitions, - trustedSceneMovement: { - collisionWorld: { - traceUse: (_origin, point) => point[1] > 1 - ? { - fraction: 0.4, - end: [point[0], 1, point[2]], - planeNormal: [0, -1, 0], - entityIndex: 45, - modelIndex: 3, - classname: "func_wall", - } - : null, - }, - playerEyeHeight: 1.0, - }, - simulationTickMs: 1, - simulatedPlayers: () => [directPlayer, blockedPlayer], - }, - }); - const { messages, session } = harness; - try { - session.send(helloEnvelope({ messageId: "hello-loopback-splash-wall", sequence: 1, sentAt: harness.now() })); - harness.advanceNow(120); - session.send(pickupEnvelope({ - messageId: "pickup-loopback-rocket", - sequence: 2, - pickupSequence: 1, - sentAt: harness.now(), - pickup: { entityIndex: rocketPickup.entityIndex, origin: [0, 0, 0] }, - })); - harness.advanceNow(200); - session.send(fireEnvelope({ - messageId: "fire-loopback-splash-wall", - sequence: 3, - fireSequence: 1, - sentAt: harness.now(), - })); - harness.advanceNow(400); - await waitForMessage(messages, (message) => - message.type === "room.event" && - message.payload.event.eventType === "projectile.impacted" - ); - - const events = messages - .filter((message) => message.type === "room.event") - .map((message) => message.payload.event); - assert.ok(events.some((event) => - event.eventType === "player.killed" && event.victimPlayerId === "direct-player" - )); - assert.equal(events.some((event) => - (event.eventType === "player.damaged" || event.eventType === "player.killed") && - event.victimPlayerId === "blocked-player" - ), false); - const snapshot = latestMessage(messages, "room.snapshot"); - const blockedSnapshot = snapshot.payload.players.find((player) => player.playerId === "blocked-player"); - assert.equal(blockedSnapshot?.health, 100); - assert.equal(blockedSnapshot?.alive, true); - assert.equal(messages.some((message) => message.type === "room.reject"), false); - } finally { - harness.disconnect(); - } -}); - -test("loopback session applies projectile wall-impact splash without a direct player hit", async () => { - const rocketPickup = weaponPickupDefinition("rocketlauncher"); - const nearMissPlayer = createPlayer({ - playerId: "near-miss-player", - clientId: "near-miss-client", - displayName: "Near Miss", - origin: [3, 2, 0], - rotX: -78, - rotY: 180, - updatedAt: 6900, - }); - const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ - deathmatchSpawns: [{ - spawnId: "spawn-local", - classname: "info_player_deathmatch", - origin: [0, 0, 0], - rotX: -78, - rotY: 0, - }], - pickupDefinitions: [rocketPickup], - }); - const harness = await createLoopbackHarness({ - now: 6900, - sessionOptions: { - trustedGameplayDefinitions: gameplayDefinitions, - trustedSceneMovement: { - collisionWorld: { - traceUse: (origin, point) => origin[0] === 0 && point[0] > 10 - ? { - fraction: 3 / 64, - end: [3, 0, 0], - planeNormal: [-1, 0, 0], - entityIndex: 44, - modelIndex: 3, - classname: "func_wall", - } - : null, - }, - playerEyeHeight: 1.0, - }, - simulationTickMs: 1, - simulatedPlayers: () => [nearMissPlayer], - }, - }); - const { messages, session } = harness; - try { - session.send(helloEnvelope({ messageId: "hello-loopback-wall-splash", sequence: 1, sentAt: harness.now() })); - harness.advanceNow(120); - session.send(pickupEnvelope({ - messageId: "pickup-loopback-wall-splash-rocket", - sequence: 2, - pickupSequence: 1, - sentAt: harness.now(), - pickup: { entityIndex: rocketPickup.entityIndex, origin: [0, 0, 0] }, - })); - harness.advanceNow(200); - session.send(fireEnvelope({ - messageId: "fire-loopback-wall-splash", - sequence: 3, - fireSequence: 1, - sentAt: harness.now(), - fire: { - direction: [1, 0, 0], - }, - })); - harness.advanceNow(2_000); - await waitForMessage(messages, (message) => - message.type === "room.event" && - message.payload.event.eventType === "projectile.impacted" - ); - - const events = messages - .filter((message) => message.type === "room.event") - .map((message) => message.payload.event); - const targetDamage = events.find((event) => - event.eventType === "player.damaged" && event.victimPlayerId === "near-miss-player" - ); - const selfDamage = events.find((event) => - event.eventType === "player.damaged" && event.victimPlayerId === "loopback:client-a" - ); - assert.ok(targetDamage, "expected wall splash to damage nearby simulated target"); - assert.equal(targetDamage.damage, 69); - assert.equal(targetDamage.health, 31); - assert.ok(selfDamage, "expected wall splash to apply half self damage"); - assert.equal(selfDamage.damage, 22); - assert.equal(selfDamage.health, 78); - const snapshot = latestMessage(messages, "room.snapshot"); - const targetSnapshot = snapshot.payload.players.find((player) => player.playerId === "near-miss-player"); - const selfSnapshot = snapshot.payload.players.find((player) => player.playerId === "loopback:client-a"); - assert.equal(targetSnapshot?.health, 31); - assert.equal(selfSnapshot?.health, 78); - assert.equal(messages.some((message) => message.type === "room.reject"), false); - } finally { - harness.disconnect(); - } -}); - -test("loopback session applies projectile quad damage from impact-time attacker state", async () => { - const cases = [ - { - id: "loopback-quad-expired-before-impact", - quadDurationMs: 250, - pickupQuadBeforeFire: true, - expectedDamage: 9, - expectedHealth: 91, - }, - { - id: "loopback-quad-picked-up-before-impact", - quadDurationMs: 30_000, - pickupQuadBeforeFire: false, - expectedDamage: 36, - expectedHealth: 64, - }, - ]; - - for (const spec of cases) { - const nailgunPickup = weaponPickupDefinition("nailgun"); - const quadPickup = quadPickupDefinition({ durationMs: spec.quadDurationMs }); - const remotePlayer = createPlayer({ - playerId: `remote-${spec.id}`, - clientId: `remote-client-${spec.id}`, - displayName: "Remote", - origin: [8, 0, 0], - rotX: -78, - rotY: 180, - updatedAt: 8_000, - }); - const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ - deathmatchSpawns: [{ - spawnId: `spawn-${spec.id}`, - classname: "info_player_deathmatch", - origin: [0, 0, 0], - rotX: -78, - rotY: 0, - }], - pickupDefinitions: [nailgunPickup, quadPickup], - }); - const harness = await createLoopbackHarness({ - now: 8_000, - sessionOptions: { - trustedGameplayDefinitions: gameplayDefinitions, - simulationTickMs: 1, - simulatedPlayers: () => [remotePlayer], - }, - }); - const { messages, session } = harness; - try { - session.send(helloEnvelope({ - messageId: `hello-${spec.id}`, - sequence: 1, - sentAt: harness.now(), - })); - harness.advanceNow(120); - session.send(pickupEnvelope({ - messageId: `pickup-nailgun-${spec.id}`, - sequence: 2, - pickupSequence: 1, - sentAt: harness.now(), - pickup: { entityIndex: nailgunPickup.entityIndex, origin: [0, 0, 0] }, - })); - harness.advanceNow(160); - if (spec.pickupQuadBeforeFire) { - session.send(pickupEnvelope({ - messageId: `pickup-quad-before-fire-${spec.id}`, - sequence: 3, - pickupSequence: 2, - sentAt: harness.now(), - pickup: { entityIndex: quadPickup.entityIndex, origin: [0, 0, 0] }, - })); - harness.advanceNow(160); - } - session.send(fireEnvelope({ - messageId: `fire-${spec.id}`, - sequence: 4, - fireSequence: 1, - sentAt: harness.now(), - fire: { - weapon: "nailgun", - fireKind: "projectile", - direction: [1, 0, 0], - }, - })); - if (!spec.pickupQuadBeforeFire) { - harness.advanceNow(160); - session.send(pickupEnvelope({ - messageId: `pickup-quad-before-impact-${spec.id}`, - sequence: 5, - pickupSequence: 2, - sentAt: harness.now(), - pickup: { entityIndex: quadPickup.entityIndex, origin: [0, 0, 0] }, - })); - } - assert.ok( - messages.some((message) => - message.type === "room.event" && - message.payload.event.eventType === "pickup.taken" && - message.payload.event.entityIndex === quadPickup.entityIndex - ), - `expected loopback quad pickup to be accepted for ${spec.id}`, - ); - const preImpactSnapshot = latestMessage(messages, "room.snapshot"); - const preImpactLocalPlayer = preImpactSnapshot.payload.players - .find((player) => player.playerId === "loopback:client-a"); - assert.equal( - items.quakeMultiplayerDamageMultiplierForInventory(preImpactLocalPlayer?.inventory, harness.now()), - 4, - `expected loopback local quad to be active before impact for ${spec.id}`, - ); - harness.advanceNow(700); - await waitForMessage(messages, (message) => - message.type === "room.event" && - message.payload.event.eventType === "projectile.impacted" && - message.payload.event.weapon === "nailgun" - ); - - const events = messages - .filter((message) => message.type === "room.event") - .map((message) => message.payload.event); - const damage = events.find((event) => - event.eventType === "player.damaged" && - event.victimPlayerId === remotePlayer.playerId && - event.damageSource === "nailgun" - ); - assert.ok(damage, `expected loopback nailgun damage for ${spec.id}`); - assert.equal(damage.damage, spec.expectedDamage, spec.id); - assert.equal(damage.health, spec.expectedHealth, spec.id); - const snapshot = latestMessage(messages, "room.snapshot"); - const remoteSnapshot = snapshot.payload.players.find((player) => player.playerId === remotePlayer.playerId); - assert.equal(remoteSnapshot?.health, spec.expectedHealth, spec.id); - assert.deepEqual(messages.filter((message) => message.type === "room.reject"), [], spec.id); - } finally { - harness.disconnect(); - } - } -}); - -test("loopback session publishes a dynamic backpack when a simulated player dies", async () => { - const remotePlayer = createPlayer({ - playerId: "remote-drop-backpack", - clientId: "remote-client-drop-backpack", - displayName: "Remote", - origin: [4, 0, 0], - rotX: -78, - rotY: 180, - health: 10, - inventory: { - ...items.createQuakeMultiplayerInitialInventory(), - health: 10, - itemFlags: items.createQuakeMultiplayerInitialInventory().itemFlags | QUAD_ITEM_FLAG, - activeWeapon: "rocketlauncher", - weapons: ["axe", "shotgun", "rocketlauncher"], - shells: 2, - rockets: 5, - powerups: [{ - active: true, - activationField: "super_damage_time", - finishedAt: 15_000, - finishedField: "super_damage_finished", - itemFlag: QUAD_ITEM_FLAG, - itemFlagExpression: "IT_QUAD", - }], - }, - updatedAt: 5_000, - }); - const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ - deathmatchSpawns: [{ - spawnId: "spawn-loopback-drop-backpack", - classname: "info_player_deathmatch", - origin: [0, 0, 0], - rotX: -78, - rotY: 0, - }], - pickupDefinitions: [], - }); - const harness = await createLoopbackHarness({ - now: 5_000, - sessionOptions: { - trustedGameplayDefinitions: gameplayDefinitions, - simulatedPlayers: () => [remotePlayer], - }, - }); - const { messages, session } = harness; - try { - session.send(helloEnvelope({ messageId: "hello-loopback-drop-backpack", sequence: 1, sentAt: harness.now() })); - harness.advanceNow(120); - session.send(fireEnvelope({ - messageId: "fire-loopback-drop-backpack", - sequence: 2, - fireSequence: 1, - sentAt: harness.now(), - })); - - const dropped = messages - .filter((message) => message.type === "room.event") - .map((message) => message.payload.event) - .find((event) => event.eventType === "pickup.dropped"); - assert.ok(dropped, "expected loopback pickup.dropped event"); - assert.equal(dropped.definition.classname, "item_backpack"); - assert.equal(dropped.definition.runtime, true); - assert.equal(dropped.definition.effect.shells, 2); - assert.equal(dropped.definition.effect.rockets, 5); - assert.equal(dropped.definition.effect.weapon.id, "rocketlauncher"); - - const snapshot = latestMessage(messages, "room.snapshot"); - assert.equal( - snapshot.payload.dynamicPickups.some((definition) => - definition.entityIndex === dropped.definition.entityIndex - ), - true, - ); - const remoteSnapshot = snapshot.payload.players.find((player) => player.playerId === "remote-drop-backpack"); - assert.equal(remoteSnapshot?.alive, false); - assert.equal(remoteSnapshot?.inventory.itemFlags & QUAD_ITEM_FLAG, 0); - assert.equal( - remoteSnapshot?.inventory.powerups.some((powerup) => powerup.finishedField === "super_damage_finished"), - false, - ); - } finally { - 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(); - } -}); - -test("loopback target dispatch activates non-button movers", async () => { - const triggerDefinition = { - kind: "trigger", - entityIndex: 190, - classname: "trigger_multiple", - bounds: { - mins: [-1, -1, 0], - maxs: [1, 1, 2], - }, - touchActivates: true, - useActivates: false, - shootActivates: false, - oneShot: false, - delayMs: 0, - waitMs: 0, - targetEntityIndexes: [189], - }; - const moverDefinition = { - kind: "mover", - entityIndex: 189, - classname: "func_door_secret", - bounds: { - mins: [2, -1, 0], - maxs: [3, 1, 2], - }, - touchActivates: false, - useActivates: true, - shootActivates: false, - speed: 50, - moveMs: 200, - delayMs: 0, - fromOrigin: [0, 0, 0], - toOrigin: [1, 0, 0], - targetEntityIndexes: [], - }; - const deathmatchSpawns = [{ - spawnId: "spawn-trigger", - classname: "info_player_deathmatch", - origin: [0, 0, 1], - rotX: 0, - rotY: 0, - }]; - const gameplayDefinitions = facts.createQuakeMultiplayerGameplayDefinitions({ - deathmatchSpawns, - pickupDefinitions: [], - }); - const harness = await createLoopbackHarness({ - now: 4100, - sessionOptions: { - trustedGameplayDefinitions: gameplayDefinitions, - trustedWorldDefinitions: [triggerDefinition, moverDefinition], - }, - }); - const { messages, session } = harness; - try { - session.send(helloEnvelope({ - messageId: "hello-world-non-button-mover", - sequence: 1, - sentAt: harness.now(), - })); - - harness.advanceNow(120); - session.send(worldEnvelope({ - messageId: "world-non-button-mover", - sequence: 2, - worldSequence: 1, - sentAt: harness.now(), - intent: { - entityIndex: triggerDefinition.entityIndex, - origin: [0, 0, 1], - }, - })); - - const events = messages - .filter((message) => message.type === "room.event") - .map((message) => message.payload.event); - const trigger = events.find((event) => - event.eventType === "world.trigger" && - event.entityIndex === triggerDefinition.entityIndex - ); - const targets = events.find((event) => - event.eventType === "world.targets" && - event.sourceEntityIndex === triggerDefinition.entityIndex - ); - const mover = events.find((event) => - event.eventType === "world.mover" && - event.entityIndex === moverDefinition.entityIndex - ); - - assert.ok(trigger, "expected trigger event"); - assert.ok(targets, "expected target dispatch event"); - assert.ok(mover, "expected target mover event"); - assert.equal(mover.classname, "func_door_secret"); - assert.equal(mover.activation, "target"); - assert.equal(mover.state, "moving-up"); - assert.equal(messages.some((message) => message.type === "room.reject"), false); - } finally { - harness.disconnect(); - } -}); - -test("party room target dispatch activates relay chains and target teleporters", () => { - const triggerDefinition = { - kind: "trigger", - entityIndex: 100, - classname: "trigger_multiple", - bounds: { - mins: [-1, -1, 0], - maxs: [1, 1, 2], - }, - touchActivates: true, - useActivates: false, - shootActivates: false, - oneShot: false, - delayMs: 0, - waitMs: 0, - targetEntityIndexes: [101, 102], - }; - const relayDefinition = { - kind: "trigger", - entityIndex: 101, - classname: "trigger_relay", - touchActivates: false, - useActivates: true, - shootActivates: false, - oneShot: false, - delayMs: 0, - waitMs: 0, - targetEntityIndexes: [103], - }; - const teleportDefinition = { - kind: "teleport", - entityIndex: 102, - classname: "trigger_teleport", - touchRequiresActivation: true, - activationWindowMs: 200, - destinationEntityIndex: 900, - destinationOrigin: [8, 0, 1], - destinationRotX: 90, - destinationRotY: 180, - }; - const moverDefinition = { - kind: "mover", - entityIndex: 103, - classname: "func_plat", - bounds: { - mins: [2, -1, 0], - maxs: [3, 1, 2], - }, - touchActivates: false, - useActivates: true, - shootActivates: false, - speed: 50, - moveMs: 200, - delayMs: 0, - fromOrigin: [0, 0, 0], - toOrigin: [0, 0, 1], - targetEntityIndexes: [], - }; - const { alice, partyRoom } = connectDuelRoom({ - id: "party-target-relay-teleport", - deathmatchSpawns: [ - { - spawnId: "spawn-target-a", - classname: "info_player_deathmatch", - origin: [0, 0, 1], - rotX: 90, - rotY: 0, - }, - { - spawnId: "spawn-target-b", - classname: "info_player_deathmatch", - origin: [4, 0, 1], - rotX: 90, - rotY: 180, - }, - ], - roomOptions: { - trustedWorldDefinitions: [ - triggerDefinition, - relayDefinition, - teleportDefinition, - moverDefinition, - ], - }, - }); - - partyRoom.onMessage(JSON.stringify(worldEnvelope({ - clientId: "client-a", - messageId: "world-party-target-relay-teleport", - sequence: 2, - worldSequence: 1, - sentAt: Date.now(), - intent: { - entityIndex: triggerDefinition.entityIndex, - origin: [0, 0, 1], - }, - })), alice); - - const events = alice.messages - .filter((message) => message.type === "room.event") - .map((message) => message.payload.event); - const sourceTrigger = events.find((event) => - event.eventType === "world.trigger" && - event.entityIndex === triggerDefinition.entityIndex && - event.activation === "touch" - ); - const sourceTargets = events.find((event) => - event.eventType === "world.targets" && - event.sourceEntityIndex === triggerDefinition.entityIndex - ); - const relayTrigger = events.find((event) => - event.eventType === "world.trigger" && - event.entityIndex === relayDefinition.entityIndex && - event.activation === "target" - ); - const relayTargets = events.find((event) => - event.eventType === "world.targets" && - event.sourceEntityIndex === relayDefinition.entityIndex - ); - const teleportUse = events.find((event) => - event.eventType === "world.use" && - event.entityIndex === teleportDefinition.entityIndex - ); - const mover = events.find((event) => - event.eventType === "world.mover" && - event.entityIndex === moverDefinition.entityIndex - ); - - assert.ok(sourceTrigger, "expected source trigger event"); - assert.ok(sourceTargets, "expected source target dispatch event"); - assert.deepEqual(sourceTargets.targetEntityIndexes, [101, 102]); - assert.ok(relayTrigger, "expected relay trigger event"); - assert.ok(relayTargets, "expected relay target dispatch event"); - assert.deepEqual(relayTargets.targetEntityIndexes, [103]); - assert.ok(teleportUse, "expected target teleporter activation event"); - assert.ok(mover, "expected chained target mover event"); - assert.equal(mover.classname, "func_plat"); - assert.equal(mover.activation, "target"); - assert.equal(mover.state, "moving-up"); - assert.equal(alice.messages.some((message) => message.type === "room.reject"), false); -});