diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b29ec83..489e83e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,5 +19,6 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile + - run: pnpm typecheck - run: pnpm test - run: pnpm build diff --git a/package.json b/package.json index 41530a7..30194eb 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,10 @@ "test:browser": "node test/browser/runBrowserFixtures.mjs", "test:harness": "node test/runHarnessPlan.mjs", "test:perf": "node test/perf/runPerfPreflight.mjs", - "test:dev": "pnpm test && pnpm test:perf", - "test:all": "pnpm test && pnpm test:assets && pnpm test:browser:smoke && pnpm test:browser && pnpm test:perf" + "test:dev": "pnpm typecheck && pnpm test && pnpm test:perf", + "test:all": "pnpm typecheck && pnpm test && pnpm test:assets && pnpm test:browser:smoke && pnpm test:browser && pnpm test:perf", + "typecheck": "node test/typescript/check.mjs", + "typecheck:all": "tsc --noEmit" }, "dependencies": { "@layoutit/polycss": "^0.2.6", @@ -42,6 +44,7 @@ "partykit": "0.0.115", "playwright": "^1.60.0", "sharp": "^0.34.5", + "typescript": "5.9.3", "vite": "^7.3.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 576d737..4967baa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: sharp: specifier: ^0.34.5 version: 0.34.5 + typescript: + specifier: 5.9.3 + version: 5.9.3 vite: specifier: ^7.3.1 version: 7.3.5(@types/node@25.9.3) @@ -1232,6 +1235,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} @@ -2219,6 +2227,8 @@ snapshots: tslib@2.8.1: {} + typescript@5.9.3: {} + ufo@1.6.4: {} undici-types@7.24.6: {} diff --git a/src/App.ts b/src/App.ts index f942fae..f64d69a 100644 --- a/src/App.ts +++ b/src/App.ts @@ -1,3 +1,5 @@ +import { createQuakeSceneState } from "./runtime/app/sceneState"; +import { quakeMapLoadFailureCause, quakeMapLoadFailureIsCurrent, type QuakeMapLoadResult } from "./runtime/app/mapLoadOwnership"; import { createPolyPerspectiveCamera, createPolyFirstPersonControls, @@ -644,9 +646,9 @@ function quakeDebugMonsterCameraStandoffFallbackAway(rotX: number, rotY: number) } function quakeDebugMonsterCameraStandoffCandidateValid(origin: Vec3, candidate: Vec3): boolean { - if (!currentCollisionWorld) return false; - if (currentCollisionWorld.contentsAt?.(candidate) === QUAKE_CONTENTS_SOLID) return false; - const trace = currentCollisionWorld.traceUse?.(origin, candidate); + if (!quakeSceneState.view.collisionWorld) return false; + if (quakeSceneState.view.collisionWorld.contentsAt?.(candidate) === QUAKE_CONTENTS_SOLID) return false; + const trace = quakeSceneState.view.collisionWorld.traceUse?.(origin, candidate); if (trace && trace.fraction < 0.999) return false; const originLeaf = world.leafIndexAt(origin); const candidateLeaf = world.leafIndexAt(candidate); @@ -1288,7 +1290,7 @@ const quakeCameraView = createQuakeCameraViewFlow({ cameraFeedback: () => quakeCameraFeedback, getPlayerOrigin: () => getPlayer().currentOrigin(), host, - modelPivot: () => quakeModelPivot, + modelPivot: () => quakeSceneState.view.modelPivot, playerEyeHeight: () => getPlayer().eyeHeight(), playerSpawn: (spawn) => getPlayer().spawn(spawn), renderSupersample: QUAKE_RENDER_SUPERSAMPLE, @@ -1325,9 +1327,9 @@ interface QuakeRemoteMultiplayerProjectileVisual { const quakeRemoteMultiplayerProjectiles = new Map(); const quakeLoopbackTrustedSceneMovement = { collisionWorld: { - contentsAt: (point: Vec3) => currentCollisionWorld?.contentsAt?.(point) ?? null, + contentsAt: (point: Vec3) => quakeSceneState.view.collisionWorld?.contentsAt?.(point) ?? null, floorAt: (x: number, y: number, maxZ?: number, minZ?: number) => - currentCollisionWorld?.floorAt(x, y, maxZ, minZ) ?? null, + quakeSceneState.view.collisionWorld?.floorAt(x, y, maxZ, minZ) ?? null, resolve: ( origin: [number, number, number], previous: [number, number, number], @@ -1335,13 +1337,13 @@ const quakeLoopbackTrustedSceneMovement = { currentGroundZ: number, forceAir?: boolean, ) => - currentCollisionWorld?.resolve(origin, previous, eyeHeight, currentGroundZ, forceAir) ?? { + quakeSceneState.view.collisionWorld?.resolve(origin, previous, eyeHeight, currentGroundZ, forceAir) ?? { origin, groundZ: currentGroundZ, grounded: false, touches: [], }, - traceUse: (start: Vec3, end: Vec3) => currentCollisionWorld?.traceUse?.(start, end) ?? null, + traceUse: (start: Vec3, end: Vec3) => quakeSceneState.view.collisionWorld?.traceUse?.(start, end) ?? null, }, playerEyeHeight: QUAKE_PLAYER_VIEW_Z, }; @@ -1464,7 +1466,7 @@ const quakeEntityMeshes = createQuakeEntityMeshMountFlow({ }); function quakeShootablePrewarmLeavesAt(origin: [number, number, number]): Set | null { - const visibility = currentResult?.visibility; + const visibility = quakeSceneState.view.scene?.visibility; const visibleLeaves = visibility?.visibleLeavesAt(origin) ?? null; const metadata = visibility?.metadata; if (!visibility || !metadata || !visibleLeaves) return visibleLeaves; @@ -1482,7 +1484,7 @@ function quakeShootablePrewarmLeavesAt(origin: [number, number, number]): Set({ applyView: applyQuakeUrlView, + canLoadMap: () => quakeMapLoadingReady, clearStartupState: clearQuakeMainMenuStartupState, currentMapName: () => currentMapName, currentView: currentQuakeCssView, - hasCurrentScene: () => currentResult !== null, + hasCurrentScene: () => quakeSceneState.view.scene !== null, hideMainMenu: () => menu.hideMainMenu(), isDisposed: () => quakeAppDisposed, isLoading: () => quakeAppLoading, @@ -1619,15 +1623,15 @@ const quakeDebugRecordingSnapshot = createQuakeDebugRecordingSnapshotFlow({ }), gameplay: () => ({ appDisposed: quakeAppDisposed, - collisionReady: currentCollisionWorld !== null, - currentScene: currentResult !== null, + collisionReady: quakeSceneState.view.collisionWorld !== null, + currentScene: quakeSceneState.view.scene !== null, loading: quakeAppLoading, mapName: currentMapName, multiplayerEnabled: QUAKE_MULTIPLAYER_ENABLED, multiplayerInputPaused: quakeMultiplayerInputPaused, paused: isQuakeGamePaused(), playerDead: quakePlayerDead, - transitionSerial: quakeTransitionSerial, + transitionSerial: quakeSceneState.view.transitionSerial, }), hazards: () => { const origin = getPlayer().currentOrigin(); @@ -1637,8 +1641,8 @@ const quakeDebugRecordingSnapshot = createQuakeDebugRecordingSnapshotFlow({ origin[1], origin[2] - eyeHeight + 2 * QUAKE_COLLISION_UNIT_SCALE, ]; - const playerContents = currentCollisionWorld?.contentsAt?.(contentsPoint) ?? null; - const playerWaterLevel = quakePlayerWaterLevel(currentCollisionWorld?.contentsAt, origin, eyeHeight); + const playerContents = quakeSceneState.view.collisionWorld?.contentsAt?.(contentsPoint) ?? null; + const playerWaterLevel = quakePlayerWaterLevel(quakeSceneState.view.collisionWorld?.contentsAt, origin, eyeHeight); const contentsHazard = quakeContentsDamageForWaterLevel(playerContents, playerWaterLevel); return { ...quakePointHazards.counts(), @@ -1678,7 +1682,7 @@ const quakeDebugRecordingSnapshot = createQuakeDebugRecordingSnapshotFlow({ const quakeDebugRecorder = createQuakeDebugRecorder({ appVersion: __CSSQUAKE_VERSION__, currentMapName: () => currentMapName, - entityManifest: () => currentResult?.entityManifest ?? null, + entityManifest: () => quakeSceneState.view.scene?.entityManifest ?? null, onStateChange: quakeDebugRecordingPanelEnabled ? syncQuakeDebugRecordingButton : undefined, snapshot: () => quakeDebugRecordingSnapshot.capture(), statusElement: quakeDebugRecordingPanelEnabled ? debugStatElements.get("recording") ?? null : null, @@ -1710,7 +1714,7 @@ quakeCameraFeedback = createQuakeCameraFeedbackFlow({ cameraPerspectiveStyle: () => quakeCameraView.cameraPerspectiveStyle(), canUseGameplayInput: canUseQuakeGameplayInput, controls, - hasCurrentScene: () => currentResult !== null, + hasCurrentScene: () => quakeSceneState.view.scene !== null, isDisposed: () => quakeAppDisposed, queueCrosshairTargetSync: queueQuakeCrosshairTargetSync, renderOriginPolicy: quakeDebugMonsterCameraStandoff, @@ -1810,7 +1814,7 @@ const quakeHudFlow = createQuakeHudFlow({ }); const quakePowerups = createQuakePowerupFlow({ getInventory: () => player?.inventory() ?? null, - hasCurrentScene: () => currentResult !== null, + hasCurrentScene: () => quakeSceneState.view.scene !== null, isDisposed: () => quakeAppDisposed, isPaused: isQuakeGamePaused, isPlayerDead: () => quakePlayerDead, @@ -1854,16 +1858,16 @@ const shootables = createQuakeShootablesController({ visibilityOrigin: controls.getOrigin(), }); }, - contentsAt: (point) => currentCollisionWorld?.contentsAt?.(point) ?? null, + contentsAt: (point) => quakeSceneState.view.collisionWorld?.contentsAt?.(point) ?? null, floorAt: (x, y, maxZ, minZ) => - currentCollisionWorld?.floorAt(x, y, maxZ, minZ) ?? - currentCollisionWorld?.staticFloorAt(x, y, maxZ, minZ) ?? + quakeSceneState.view.collisionWorld?.floorAt(x, y, maxZ, minZ) ?? + quakeSceneState.view.collisionWorld?.staticFloorAt(x, y, maxZ, minZ) ?? null, getPlayerEyeHeight: () => getPlayer().eyeHeight(), getPlayerForward: () => forwardDirection(scene.camera.state.rotX ?? 90, scene.camera.state.rotY ?? 270), getPlayerOrigin: () => getPlayer().currentOrigin(), hasLineOfSight: (start, end) => quakeSceneMount.lineOfSight(start, end), - traceLine: (start, end) => currentCollisionWorld?.traceUse?.(start, end) ?? null, + traceLine: (start, end) => quakeSceneState.view.collisionWorld?.traceUse?.(start, end) ?? null, isPlayerInvisible: () => quakePowerups.isInvisible(), isGameplayPaused: isQuakeGamePaused, isInPlayerView: (point) => quakeSceneMount.isPointInPlayerView(point, QUAKE_MONSTER_MOUNT_VIEW_DOT_MIN), @@ -1897,7 +1901,7 @@ const quakeDamageableBrushes = createQuakeDamageableBrushFlow({ activateEntity: activateQuakeEntity, activateSecretTrigger: activateQuakeSecretTrigger, disableEntity: (entityIndex) => targetSystem.disableEntity(entityIndex), - getEntity: (entityIndex) => entityByIndex.get(entityIndex), + getEntity: (entityIndex) => quakeSceneState.view.entities.get(entityIndex), isEntityDisabled: (entityIndex) => targetSystem.isDisabled(entityIndex), isPaused: isQuakeGamePaused, pausedTimerPollMs: QUAKE_PAUSED_TIMER_POLL_MS, @@ -1905,9 +1909,9 @@ const quakeDamageableBrushes = createQuakeDamageableBrushFlow({ useTargets: (entity) => targetSystem.useTargets(entity), }); const quakePointHazards = createQuakePointHazardFlow({ - getEntity: (entityIndex) => entityByIndex.get(entityIndex), + getEntity: (entityIndex) => quakeSceneState.view.entities.get(entityIndex), gravity: QUAKE_GRAVITY, - hasCurrentScene: () => currentResult !== null, + hasCurrentScene: () => quakeSceneState.view.scene !== null, isEntityDisabled: (entityIndex) => targetSystem.isDisabled(entityIndex), isPaused: isQuakeGamePaused, onHazardsChanged: () => syncQuakeHazards(getPlayer().currentOrigin()), @@ -1926,7 +1930,7 @@ const triggerSystem = createQuakeTriggersController({ activateTeleport: activateQuakeTeleport, completeLevel: completeQuakeLevel, disableEntity: targetSystem.disableEntity, - getEntity: (entityIndex) => entityByIndex.get(entityIndex), + getEntity: (entityIndex) => quakeSceneState.view.entities.get(entityIndex), getOrigin: () => controls.getOrigin(), getTouchedTriggers: (origin) => quakeSceneMount.currentTouchedTriggers(origin), isEntityDisabled: targetSystem.isDisabled, @@ -1935,7 +1939,7 @@ const triggerSystem = createQuakeTriggersController({ requestTouch: requestQuakeMultiplayerTriggerTouch, triggerSpecial: activateQuakeSpecialTrigger, triggerWait: quakeRuntimeTriggerWait, - transitionSerial: () => quakeTransitionSerial, + transitionSerial: () => quakeSceneState.view.transitionSerial, useTargets: targetSystem.useTargets, }); pickups = createQuakePickupController({ @@ -1944,7 +1948,7 @@ pickups = createQuakePickupController({ applyQuakeInventoryDelta(getPlayer().inventory(), effect); syncQuakeHud(); flashQuakeBonusOverlay(); - const gameLogic = currentResult?.gameLogic ?? null; + const gameLogic = quakeSceneState.view.scene?.gameLogic ?? null; const pickupMessage = feedback?.message ?? quakePickupMessageForEntity(entity, gameLogic); if (pickupMessage) quakeTextPresentation.notify(pickupMessage); if (feedback?.soundPath) { @@ -1954,7 +1958,7 @@ pickups = createQuakePickupController({ } }, canPickup: (effect, entity) => { - const canPickup = quakeCanPickupForInventory(entity, getPlayer().inventory(), currentResult?.gameLogic ?? null, effect); + const canPickup = quakeCanPickupForInventory(entity, getPlayer().inventory(), quakeSceneState.view.scene?.gameLogic ?? null, effect); if (!canPickup) return false; if (QUAKE_MULTIPLAYER_ENABLED && quakeMultiplayerSession.status().state === "connected") { requestQuakeMultiplayerPickup(entity.index); @@ -1966,7 +1970,7 @@ pickups = createQuakePickupController({ playerForward: () => forwardDirection(scene.camera.state.rotX ?? 90, scene.camera.state.rotY ?? 270), playerViewDot: (point) => quakeSceneMount.playerViewDot(point), pointToPoly: quakeCameraView.pointToPoly, - gameLogic: () => currentResult?.gameLogic ?? null, + gameLogic: () => quakeSceneState.view.scene?.gameLogic ?? null, isGameplayPaused: isQuakeGamePaused, programMetadata: () => currentProgramMetadata, shouldSpawn: shouldSpawnQuakePickupForCurrentMode, @@ -1981,13 +1985,13 @@ const weapons = createQuakeWeaponsController({ addProjectileMesh: (modelPath, weapon) => quakeWeaponPresentation.addProjectileMesh(modelPath, weapon), canUseGameplayInput: canUseQuakeGameplayInput, hasViewmodel: viewmodel.hasWeapon, - getCollisionWorld: () => currentCollisionWorld, - getEntities: () => entityByIndex, + getCollisionWorld: () => quakeSceneState.view.collisionWorld, + getEntities: () => quakeSceneState.view.entities, getDamageableBrushTargets: quakeDamageableBrushWeaponTargets, getShootables: shootables.weaponTargets, getPlayerEyeHeight: () => getPlayer().eyeHeight(), getPlayerWaterLevel: () => - quakePlayerWaterLevel(currentCollisionWorld?.contentsAt, getPlayer().currentOrigin(), getPlayer().eyeHeight()), + quakePlayerWaterLevel(quakeSceneState.view.collisionWorld?.contentsAt, getPlayer().currentOrigin(), getPlayer().eyeHeight()), getActiveWeapon: () => getPlayer().inventory().activeWeapon, getAmmo: (field) => getPlayer().inventory()[field], consumeAmmo: (field, amount) => { @@ -2125,8 +2129,8 @@ player = createQuakePlayerController({ canTakeDamage: () => !quakeDamageDisabled && !quakePlayerDead, controls, getYaw: () => scene.camera.state.rotY ?? 270, - getCollisionWorld: () => currentCollisionWorld, - getCurrentScene: () => currentResult, + getCollisionWorld: () => quakeSceneState.view.collisionWorld, + getCurrentScene: () => quakeSceneState.view.scene, gravity: QUAKE_GRAVITY, alwaysRun: () => quakeAlwaysRun, isGameplayPaused: isQuakeGamePaused, @@ -2150,7 +2154,7 @@ player = createQuakePlayerController({ world.syncVisibility(force); shootables.syncVisibility(controls.getOrigin(), force); }, - transitionSerial: () => quakeTransitionSerial, + transitionSerial: () => quakeSceneState.view.transitionSerial, quakecRandom: (label) => shootables.nextPlayerQuakecRandom({ functionName: label, reason: "player-death", @@ -2159,8 +2163,7 @@ player = createQuakePlayerController({ let currentPickupModelLibrary: QuakePickupModelLibrary | null = null; let currentProgramMetadata: QuakeProgramMetadata | null = null; -let currentCollisionWorld: QuakeCollisionWorld | null = null; -let currentResult: QuakeScene | null = null; +const quakeSceneState = createQuakeSceneState(); let quakeMultiplayerPickupDefinitionsScene: QuakeScene | null = null; let quakeMultiplayerPickupDefinitions: readonly QuakeMultiplayerPickupDefinition[] = []; let quakeMultiplayerDynamicPickupDefinitions = new Map(); @@ -2168,9 +2171,6 @@ let quakeMultiplayerWorldIntentDefinitionsScene: QuakeScene | null = null; let quakeMultiplayerWorldIntentDefinitions: readonly QuakeMultiplayerWorldDefinition[] = []; let quakeGameplayStarted = false; let quakeClickToPlayCenterPrintVisible = false; -let entityByIndex = new Map(); -let quakeModelPivot = { x: 0, y: 0, z: 0 }; -let quakeTransitionSerial = 0; let quakeMultiplayerSceneSerial = 0; let quakeMultiplayerClientSequence = 0; let quakeMultiplayerFireSequence = 0; @@ -2192,23 +2192,23 @@ let quakeMultiplayerApplyingWorldEvent = false; const quakeMultiplayerPickupRequestAt = new Map(); function* quakeDamageableBrushWeaponTargets(): Iterable { - const sceneResult = currentResult; + const sceneResult = quakeSceneState.view.scene; if (!sceneResult) return; for (const entry of quakeDamageableBrushes.snapshot().brushes) { if (entry.health <= 0) continue; - const entity = entityByIndex.get(entry.entityIndex); + const entity = quakeSceneState.view.entities.get(entry.entityIndex); if (!entity || !quakeDamageableBrushCanBeWeaponTarget(entity) || entity.modelIndex === undefined) continue; const model = sceneResult.models.find((item) => item.index === entity.modelIndex); if (!model) continue; const min: Vec3 = [ - (model.mins.x - quakeModelPivot.x) * QUAKE_COLLISION_UNIT_SCALE, - (model.mins.y - quakeModelPivot.y) * QUAKE_COLLISION_UNIT_SCALE, - (model.mins.z - quakeModelPivot.z) * QUAKE_COLLISION_UNIT_SCALE, + (model.mins.x - quakeSceneState.view.modelPivot.x) * QUAKE_COLLISION_UNIT_SCALE, + (model.mins.y - quakeSceneState.view.modelPivot.y) * QUAKE_COLLISION_UNIT_SCALE, + (model.mins.z - quakeSceneState.view.modelPivot.z) * QUAKE_COLLISION_UNIT_SCALE, ]; const max: Vec3 = [ - (model.maxs.x - quakeModelPivot.x) * QUAKE_COLLISION_UNIT_SCALE, - (model.maxs.y - quakeModelPivot.y) * QUAKE_COLLISION_UNIT_SCALE, - (model.maxs.z - quakeModelPivot.z) * QUAKE_COLLISION_UNIT_SCALE, + (model.maxs.x - quakeSceneState.view.modelPivot.x) * QUAKE_COLLISION_UNIT_SCALE, + (model.maxs.y - quakeSceneState.view.modelPivot.y) * QUAKE_COLLISION_UNIT_SCALE, + (model.maxs.z - quakeSceneState.view.modelPivot.z) * QUAKE_COLLISION_UNIT_SCALE, ]; yield { entity, @@ -2254,7 +2254,7 @@ quakeWeaponPresentation = createQuakeWeaponPresentationFlow({ sceneElement, }); quakeTextPresentation = createQuakeTextPresentationFlow({ - currentGameLogic: () => currentResult?.gameLogic ?? null, + currentGameLogic: () => quakeSceneState.view.scene?.gameLogic ?? null, hudAvailable: () => Boolean(quakeHud), isPlayerDead: () => quakePlayerDead, text: quakeText, @@ -2263,7 +2263,7 @@ quakeMoverInteractions = createQuakeMoverInteractionFlow({ audio, applyButtonLeafVisual: applyQuakeButtonLeafVisual, compactInlineStyle: quakeCameraView.compactInlineStyle, - currentCollisionWorld: () => currentCollisionWorld, + currentCollisionWorld: () => quakeSceneState.view.collisionWorld, currentGroundEntity: () => getPlayer().currentGroundEntity(), doorMessageCooldownMs: QUAKE_DOOR_MESSAGE_COOLDOWN_MS, getMover: (entityIndex) => movers.get(entityIndex), @@ -2308,7 +2308,7 @@ const quakeLoading = createQuakeLoadingFlow({ clearWeaponViewPunch: quakeCameraView.clearWeaponViewPunch, currentMapName: () => currentMapName, dom: quakeDom, - hasCurrentResult: () => currentResult !== null, + hasCurrentResult: () => quakeSceneState.view.scene !== null, hideStatsOverlay: quakeStatsOverlay.hide, initialLoading: quakeAppLoading, isDisposed: () => quakeAppDisposed, @@ -2367,20 +2367,11 @@ const quakeSceneMount = createQuakeSceneMountFlow({ powerupActive: (finishedField) => quakePowerups.powerupActive(finishedField), setCamera: quakeCameraView.setCamera, shootables, - state: { - setCollisionWorld: (world) => { currentCollisionWorld = world; }, - setCurrentScene: (nextScene) => { currentResult = nextScene; }, - setEntityIndex: (index) => { entityByIndex = index; }, - setModelPivot: (pivot) => { - quakeModelPivot = pivot; - quakeMoverInteractions.setModelPivot(pivot); - }, - setTransitionSerial: (value) => { quakeTransitionSerial = value; }, - }, + state: quakeSceneState, + onModelPivotChange: quakeMoverInteractions.setModelPivot, syncCrosshairTarget: syncQuakeCrosshairTarget, targets: targetSystem, trace: markQuakeTrace, - transitionSerial: () => quakeTransitionSerial, triggers: triggerSystem, viewmodel, weapons, @@ -2390,9 +2381,9 @@ quakeEntityActivation = createQuakeEntityActivationFlow({ addBodyClasses: addQuakeBodyClasses, audio, clearAttackInput: quakePointerGameplay.clearAttackInput, - currentCollisionWorld: () => currentCollisionWorld, - currentGameLogic: () => currentResult?.gameLogic, - entities: () => entityByIndex, + currentCollisionWorld: () => quakeSceneState.view.collisionWorld, + currentGameLogic: () => quakeSceneState.view.scene?.gameLogic, + entities: () => quakeSceneState.view.entities, getOrigin: () => controls.getOrigin(), intermission: { show: () => { @@ -2421,12 +2412,13 @@ quakeEntityActivation = createQuakeEntityActivationFlow({ setCenterPrint: (message) => quakeTextPresentation.setCenterPrint(message), showDirectCenterPrintMessageText: (entity) => quakeTextPresentation.showDirectCenterPrintMessageText(entity), }, - transitionSerialIncrement: () => { quakeTransitionSerial++; }, + transitionSerialIncrement: quakeSceneState.advanceTransition, triggers: triggerSystem, viewmodel, world, }); quakePlayerLifecycle = createQuakePlayerLifecycleFlow({ + currentLoad: () => quakeMapLoader.currentLoad(), addBodyClasses: addQuakeBodyClasses, appLoading: () => quakeAppLoading, clearAttackInput: quakePointerGameplay.clearAttackInput, @@ -2447,9 +2439,9 @@ quakePlayerLifecycle = createQuakePlayerLifecycleFlow({ clearTextCenterPrint: () => quakeTextPresentation.clearCenterPrint(), clearWeaponViewPunch: quakeCameraView.clearWeaponViewPunch, controls, - currentCollisionWorld: () => currentCollisionWorld, + currentCollisionWorld: () => quakeSceneState.view.collisionWorld, currentMapName: () => currentMapName, - currentResult: () => currentResult, + currentResult: () => quakeSceneState.view.scene, exitPointerLockIfHost: () => { if (document.pointerLockElement === host) document.exitPointerLock(); }, @@ -2586,7 +2578,7 @@ function setQuakeMultiplayerInputPaused(paused: boolean): void { quakeCameraView.clearWeaponViewPunch(); controls.update({ moveEnabled: false, jumpEnabled: false, crouchEnabled: false, gravity: 0 }); clearQuakeCrosshairTarget(); - } else if (!quakeAppLoading && currentCollisionWorld !== null) { + } else if (!quakeAppLoading && quakeSceneState.view.collisionWorld !== null) { controls.update({ moveEnabled: true }); syncQuakeCrosshairTarget(); } @@ -2631,7 +2623,7 @@ function applyQuakeGamePaused(paused: boolean): void { const pausedForMs = quakeGamePausedAt ? Math.max(0, now - quakeGamePausedAt) : 0; quakeGamePausedAt = 0; resumeQuakeGameplayTimers(pausedForMs); - if (currentResult && !quakeAppLoading && !quakePlayerDead) { + if (quakeSceneState.view.scene && !quakeAppLoading && !quakePlayerDead) { const origin = getPlayer().currentOrigin(); syncQuakeHazards(origin); getPickups().syncCollision(origin, getPlayer().eyeHeight(), STEP_HEIGHT); @@ -2727,7 +2719,7 @@ function syncQuakeViewmodelVisibility(): void { function canShowQuakeImpactParticles(): boolean { return ( !quakeAppLoading && - currentResult !== null && + quakeSceneState.view.scene !== null && !quakePlayerDead && !hasQuakeBodyClass("quake-level-complete") && !hasQuakeBodyClass("quake-menu-unlocked") && @@ -2798,7 +2790,7 @@ function handleQuakeDebugRecordingButtonClick(event: Event): void { quakeDebugRecorder.stop("stop"); return; } - if (quakeAppLoading || currentResult === null) { + if (quakeAppLoading || quakeSceneState.view.scene === null) { const recordingStatus = debugStatElements.get("recording"); if (recordingStatus) recordingStatus.textContent = "load first"; return; @@ -2930,7 +2922,7 @@ function respawnQuakePlayerFromDeath(): boolean { return quakePlayerLifecycle.respawnFromDeath(); } -async function startQuakeNewGame(): Promise { +async function startQuakeNewGame(): Promise { requestQuakeLandscapeOnMobile(quakeApp).then((result) => { markQuakeTrace("landscape-lock-request", result); }).catch((error: unknown) => { @@ -2939,7 +2931,7 @@ async function startQuakeNewGame(): Promise { message: error instanceof Error ? error.message : String(error), }); }); - await quakePlayerLifecycle.startNewGame(); + return quakePlayerLifecycle.startNewGame(); } function resumeQuakeGameplayAfterMapLoad(): void { @@ -3527,7 +3519,7 @@ function quakeMapLoadView(options: QuakeMapLoadOptions): QuakeCssView | null { } function currentQuakeMultiplayerRoomKey(): QuakeMultiplayerRoomCompatibilityKey | null { - if (!currentResult) return null; + if (!quakeSceneState.view.scene) return null; const sceneUrl = quakeSceneUrlForCurrentMode(currentMapName); if (!sceneUrl) return null; return { @@ -3539,8 +3531,8 @@ function currentQuakeMultiplayerRoomKey(): QuakeMultiplayerRoomCompatibilityKey } function applyQuakeMultiplayerInitialSpawnHint(): void { - if (!QUAKE_MULTIPLAYER_ENABLED || !currentResult || quakeMultiplayerLocalSpawnId) return; - const gameplayDefinitions = quakeMultiplayerGameplayDefinitionsFromScene(currentResult, { + if (!QUAKE_MULTIPLAYER_ENABLED || !quakeSceneState.view.scene || quakeMultiplayerLocalSpawnId) return; + const gameplayDefinitions = quakeMultiplayerGameplayDefinitionsFromScene(quakeSceneState.view.scene, { pointToRoom: quakeCameraView.pointToPoly, playerEyeHeight: getPlayer().eyeHeight(), playerMinsZ: QUAKE_PLAYER_MINS_Z, @@ -4045,7 +4037,7 @@ function chooseQuakeMultiplayerSpectatorPlayer( function applyQuakeMultiplayerAuthoritativePlayerState( playerState: QuakeMultiplayerAuthoritativePlayerState, ): void { - if (!player || !currentResult) return; + if (!player || !quakeSceneState.view.scene) return; const inventory = getPlayer().inventory(); const inventoryFingerprint = quakeMultiplayerAuthoritativeInventoryFingerprint(playerState); const inventoryChanged = inventoryFingerprint !== quakeMultiplayerLastInventoryFingerprint; @@ -4510,7 +4502,7 @@ function handleQuakeMultiplayerWorldChanged( ): void { if (event.data?.clientId === QUAKE_MULTIPLAYER_LOCAL_CLIENT_ID) return; if (event.entityIndex === undefined) return; - const entity = entityByIndex.get(event.entityIndex); + const entity = quakeSceneState.view.entities.get(event.entityIndex); if (!entity) return; quakeMultiplayerApplyingWorldEvent = true; try { @@ -4639,10 +4631,10 @@ function quakeMultiplayerTouchIntentFacesTrustedDefinition(entityIndex: number): } function currentQuakeMultiplayerWorldIntentDefinitions(): readonly QuakeMultiplayerWorldDefinition[] { - if (!currentResult) return []; - if (quakeMultiplayerWorldIntentDefinitionsScene !== currentResult) { - quakeMultiplayerWorldIntentDefinitionsScene = currentResult; - quakeMultiplayerWorldIntentDefinitions = quakeMultiplayerWorldDefinitionsFromScene(currentResult, { + if (!quakeSceneState.view.scene) return []; + if (quakeMultiplayerWorldIntentDefinitionsScene !== quakeSceneState.view.scene) { + quakeMultiplayerWorldIntentDefinitionsScene = quakeSceneState.view.scene; + quakeMultiplayerWorldIntentDefinitions = quakeMultiplayerWorldDefinitionsFromScene(quakeSceneState.view.scene, { pointToRoom: quakeCameraView.pointToPoly, playerEyeHeight: getPlayer().eyeHeight(), }); @@ -4747,8 +4739,8 @@ function applyQuakeMultiplayerView(originValue: readonly [number, number, number } function sendQuakeMultiplayerHello(roomKey: QuakeMultiplayerRoomCompatibilityKey): void { - const gameplayDefinitions = currentResult - ? quakeMultiplayerGameplayDefinitionsFromScene(currentResult, { + const gameplayDefinitions = quakeSceneState.view.scene + ? quakeMultiplayerGameplayDefinitionsFromScene(quakeSceneState.view.scene, { pointToRoom: quakeCameraView.pointToPoly, playerEyeHeight: getPlayer().eyeHeight(), playerMinsZ: QUAKE_PLAYER_MINS_Z, @@ -4813,10 +4805,10 @@ function quakeMultiplayerPickupDefinitionForEntity( } function currentQuakeMultiplayerPickupDefinitions(): readonly QuakeMultiplayerPickupDefinition[] { - if (!currentResult) return []; - if (quakeMultiplayerPickupDefinitionsScene !== currentResult) { - quakeMultiplayerPickupDefinitionsScene = currentResult; - quakeMultiplayerPickupDefinitions = quakeMultiplayerGameplayDefinitionsFromScene(currentResult, { + if (!quakeSceneState.view.scene) return []; + if (quakeMultiplayerPickupDefinitionsScene !== quakeSceneState.view.scene) { + quakeMultiplayerPickupDefinitionsScene = quakeSceneState.view.scene; + quakeMultiplayerPickupDefinitions = quakeMultiplayerGameplayDefinitionsFromScene(quakeSceneState.view.scene, { pointToRoom: quakeCameraView.pointToPoly, playerEyeHeight: getPlayer().eyeHeight(), playerMinsZ: QUAKE_PLAYER_MINS_Z, @@ -4975,7 +4967,7 @@ function tickQuakeMultiplayerPose(now: number): void { !QUAKE_MULTIPLAYER_ENABLED || quakeAppDisposed || quakeMultiplayerSession.status().state !== "connected" || - !currentResult + !quakeSceneState.view.scene ) { return; } @@ -5083,8 +5075,8 @@ function quakeLoopbackSimulatedPlayers(): readonly QuakeMultiplayerAuthoritative } function quakeLoopbackTrustedWorldDefinitions(roomKey: QuakeMultiplayerRoomCompatibilityKey) { - if (!currentResult || currentMapName !== roomKey.mapName || !player) return null; - return quakeMultiplayerWorldDefinitionsFromScene(currentResult, { + if (!quakeSceneState.view.scene || currentMapName !== roomKey.mapName || !player) return null; + return quakeMultiplayerWorldDefinitionsFromScene(quakeSceneState.view.scene, { pointToRoom: quakeCameraView.pointToPoly, playerEyeHeight: getPlayer().eyeHeight(), }); @@ -5105,7 +5097,7 @@ function completeQuakeLevel(entity: QuakeEntity): void { } function resetQuakeLevelStatsForCurrentScene(): void { - quakeLevelStats.reset(currentMapName, quakeLevelStatsTotalsForEntities(currentResult?.entities ?? [])); + quakeLevelStats.reset(currentMapName, quakeLevelStatsTotalsForEntities(quakeSceneState.view.scene?.entities ?? [])); } function syncQuakeIntermissionCamera(): void { @@ -5120,9 +5112,9 @@ function syncQuakeIntermissionCamera(): void { } function quakeIntermissionPointForCurrentScene(): QuakeEntityManifestPoint | null { - const manifestPoint = currentResult?.entityManifest.intermissions?.[0]; + const manifestPoint = quakeSceneState.view.scene?.entityManifest.intermissions?.[0]; if (manifestPoint) return manifestPoint; - const entity = currentResult?.entities + const entity = quakeSceneState.view.scene?.entities .filter((candidate) => candidate.classname.startsWith("info_intermission") && candidate.origin) .sort((a, b) => a.index - b.index)[0]; if (!entity?.origin) return null; @@ -5163,13 +5155,13 @@ function quakeParseEntityVector(value: string | undefined): QuakeVertex | null { } function activateSolidTouch(touch: QuakeTouchedTrigger): void { - const entity = entityByIndex.get(touch.entityIndex); + const entity = quakeSceneState.view.entities.get(touch.entityIndex); if (entity?.classname === "func_button" && requestQuakeMultiplayerTouchIntent(entity.index, "touch")) return; quakeEntityActivation.activateSolidTouch(touch); } function touchQuakeEntity(entityIndex: number): boolean { - const entity = entityByIndex.get(entityIndex); + const entity = quakeSceneState.view.entities.get(entityIndex); if (!entity || entity.modelIndex === undefined) return false; activateSolidTouch({ entityIndex, @@ -5304,13 +5296,14 @@ function applyQuakeUrlView(view: QuakeCssView): void { syncQuakeCrosshairTarget(); } -async function loadQuakeMap(mapName: string, options: QuakeMapLoadOptions = {}): Promise { - await quakeMapLoader.loadMap(mapName, options); +async function loadQuakeMap(mapName: string, options: QuakeMapLoadOptions = {}): Promise { + return quakeMapLoader.loadMap(mapName, options); } async function completeQuakeSceneReadiness( modelPromise = quakeViewmodelAssets.preload(), progress?: QuakeLoadingProgressTracker, + isCurrent: () => boolean = () => !quakeAppDisposed, ): Promise { const completeEffectSpritesTask = progress?.startTask("Effect sprites"); try { @@ -5318,13 +5311,15 @@ async function completeQuakeSceneReadiness( } finally { completeEffectSpritesTask?.(); } + if (!isCurrent()) return; const completeWorldTexturesTask = progress?.startTask("World textures"); try { await world.waitForVisibleTextures(); } finally { completeWorldTexturesTask?.(); } - await quakeLoading.completeSceneReadiness(modelPromise, quakeViewmodelAssets.mount, progress); + if (!isCurrent()) return; + await quakeLoading.completeSceneReadiness(modelPromise, quakeViewmodelAssets.mount, progress, isCurrent); } function installQuakeAppDebugHooks(): void { @@ -5381,6 +5376,7 @@ async function loadQuake(): Promise { await quakeLoading.loadStartup({ fetchManifest: fetchQuakeAssetManifest, initializedLine: QUAKE_LOADING_CONSOLE_INITIALIZED_LINE, + onReady: () => { quakeMapLoadingReady = true; }, loadMap: loadQuakeMap, loadPickupModels, loadProgramMetadata, @@ -5473,13 +5469,14 @@ function disposeQuakeApp(): void { } const quakeSaveSession = createCssQuakeSaveSession({ + currentLoad: () => quakeMapLoader.currentLoad(), activeWeaponView: () => ({ rotX: scene.camera.state.rotX, rotY: scene.camera.state.rotY, }), canSaveNow: () => Boolean( - currentResult && - currentCollisionWorld && + quakeSceneState.view.scene && + quakeSceneState.view.collisionWorld && quakeGameplayStarted && !quakeAppLoading && !quakePlayerDead && @@ -5500,7 +5497,7 @@ const quakeSaveSession = createCssQuakeSaveSession({ clearWeaponViewPunch: () => quakeCameraView.clearWeaponViewPunch(false), currentMapName: () => currentMapName, currentOrigin: () => getPlayer().currentOrigin(), - hasCurrentScene: (mapName) => Boolean(currentResult && (!mapName || currentMapName === mapName)), + hasCurrentScene: (mapName) => Boolean(quakeSceneState.view.scene && (!mapName || currentMapName === mapName)), loadMap: loadQuakeMap, mapExists: quakeAssetCatalog.mapExists, notify: (message) => quakeTextPresentation.notify(message), @@ -5535,7 +5532,7 @@ const quakeMapLoader = createQuakeAppMapLoader quakeAppDisposed, mapLoadView: quakeMapLoadView, - mountScene: quakeSceneMount.mountScene, + prepareScene: quakeSceneMount.prepareScene, onCurrentMapChange: (mapName) => { currentMapName = mapName; menu.setCurrentLevel(mapName); @@ -5607,12 +5604,12 @@ const quakeAppRuntime = createQuakeAppRuntimeContext({ }, session: { currentMapName: () => currentMapName, - currentScene: () => currentResult, - collisionWorld: () => currentCollisionWorld, - entities: () => entityByIndex, + currentScene: () => quakeSceneState.view.scene, + collisionWorld: () => quakeSceneState.view.collisionWorld, + entities: () => quakeSceneState.view.entities, isDisposed: () => quakeAppDisposed, isLoading: () => quakeAppLoading, - transitionSerial: () => quakeTransitionSerial, + transitionSerial: () => quakeSceneState.view.transitionSerial, }, gameplay: { isPaused: isQuakeGamePaused, @@ -5664,6 +5661,8 @@ installQuakeAppDebugHooks(); }; void loadQuake().catch((error) => { + if (!quakeMapLoadFailureIsCurrent(error)) return; + error = quakeMapLoadFailureCause(error); console.error(error); if (!quakeAppDisposed) { if (error instanceof QuakeAssetsRegeneratingError) { diff --git a/src/runtime/app/debugApi.ts b/src/runtime/app/debugApi.ts index 217575b..0a628e1 100644 --- a/src/runtime/app/debugApi.ts +++ b/src/runtime/app/debugApi.ts @@ -1,3 +1,4 @@ +import type { QuakeMapLoadResult } from "./mapLoadOwnership"; import type { Vec3 } from "@layoutit/polycss"; import type { QuakeDebugRecorder } from "../debug/recording"; @@ -25,7 +26,7 @@ export interface QuakeAppDebugApiOptions { fireballEmittersCount(): number; fireballsCount(): number; forwardDirection(rotX: number, rotY: number): Vec3; - loadMap(mapName: string): Promise; + loadMap(mapName: string): Promise; mapExists(mapName: string): boolean; pointToPoly(point: { x: number; y: number; z: number }): Vec3; renderOrigin(): Vec3; diff --git a/src/runtime/app/entityActivationFlow.ts b/src/runtime/app/entityActivationFlow.ts index a91123a..c7445ad 100644 --- a/src/runtime/app/entityActivationFlow.ts +++ b/src/runtime/app/entityActivationFlow.ts @@ -1,3 +1,4 @@ +import { quakeMapLoadFailureIsCurrent, type QuakeMapLoadResult } from "./mapLoadOwnership"; import type { Vec3 } from "@layoutit/polycss"; import type { QuakeGameLogicFacts } from "../../prepare/gameLogicFacts"; @@ -49,7 +50,7 @@ export interface QuakeEntityActivationFlowOptions { currentGameLogic(): QuakeGameLogicFacts | null | undefined; entities(): ReadonlyMap; getOrigin(): [number, number, number]; - loadMap(mapName: string, options: { loadingStatus: string; resumeGameplay: boolean }): Promise; + loadMap(mapName: string, options: { loadingStatus: string; resumeGameplay: boolean }): Promise; mapExists(mapName: string): boolean; intermission: QuakeEntityActivationIntermission; movers: Pick; @@ -444,6 +445,7 @@ export function createQuakeEntityActivationFlow( try { await options.loadMap(nextMap, { loadingStatus: "Loading", resumeGameplay: true }); } catch (error) { + if (!quakeMapLoadFailureIsCurrent(error)) return true; console.error(error); options.text.setCenterPrint(`COULD NOT LOAD ${nextMap.toUpperCase()}`); } diff --git a/src/runtime/app/loadingFlow.ts b/src/runtime/app/loadingFlow.ts index e7e910d..e017634 100644 --- a/src/runtime/app/loadingFlow.ts +++ b/src/runtime/app/loadingFlow.ts @@ -1,3 +1,4 @@ +import type { QuakeMapLoadResult } from "./mapLoadOwnership"; import { QUAKE_ASSETS_REGENERATING_ACTION, QUAKE_ASSETS_REGENERATING_STATUS, @@ -60,8 +61,9 @@ export interface QuakeLoadingFlow { clearDeathOverlay(): void; completeSceneReadiness( modelPromise: Promise, - mountModel: (modelPromise: Promise) => Promise, + mountModel: (modelPromise: Promise, isCurrent: () => boolean) => Promise, progress?: QuakeLoadingProgressTracker, + isCurrent?: () => boolean, ): Promise; createProgressTracker(status?: string): QuakeLoadingProgressTracker; handleGameplayStarted(started: boolean): void; @@ -79,7 +81,8 @@ export interface QuakeLoadingFlow { export interface QuakeLoadingStartupOptions { fetchManifest(): Promise; initializedLine: string; - loadMap(mapName: string, options?: QuakeMapLoadOptions): Promise; + onReady(): void; + loadMap(mapName: string, options?: QuakeMapLoadOptions): Promise; loadPickupModels(progress?: QuakeLoadingProgressTracker): Promise; loadProgramMetadata(progress?: QuakeLoadingProgressTracker): Promise; pakLine: string; @@ -133,30 +136,35 @@ export function createQuakeLoadingFlow(options: QuakeLoadingFlowOptions): QuakeL } finally { completeManifestTask(); } - const startupRoute = startup.routeFromLocation(); - const startMap = startupRoute.mapName; - if (!startup.sceneUrl(startMap)) throw new Error(`No prepared Quake start map registered for ${startMap}.`); const programMetadataPromise = startup.loadProgramMetadata(progress); const pickupModelsPromise = startup.loadPickupModels(progress); const weaponPromise = startup.preloadWeapon(progress); if (hasPakAssets) loadingConsole.queueLine(startup.pakLine); await Promise.all([programMetadataPromise, pickupModelsPromise, weaponPromise]); if (options.isDisposed()) return; + // Finish bootstrap before accepting map requests; read the latest browser route. + const preparedRoute = startup.routeFromLocation(); + if (!startup.routeIsDirect(preparedRoute) && !(preparedRoute.mapParamPresent && !preparedRoute.mapParamValid)) { + loadingConsole.queueLine(startup.initializedLine); + await loadingConsole.waitForQueue(); + if (options.isDisposed()) return; + } + startup.onReady(); + const startupRoute = startup.routeFromLocation(); + const startMap = startupRoute.mapName; + if (!startup.sceneUrl(startMap)) throw new Error(`No prepared Quake start map registered for ${startMap}.`); startup.setCurrentMapName(startMap); startup.setMenuCurrentLevel(startMap); const shouldPrimeInvalidMapFallback = startupRoute.mapParamPresent && !startupRoute.mapParamValid; if (startup.routeIsDirect(startupRoute) || shouldPrimeInvalidMapFallback) { - await startup.loadMap(startMap, { + const loaded = await startup.loadMap(startMap, { urlMode: startup.routeIsDirect(startupRoute) && startup.routeShouldNormalize(startupRoute) ? "replace" : "none", view: startup.routeIsDirect(startupRoute) ? startupRoute.view : null, }); - if (options.isDisposed()) return; + if (!loaded || !loaded.isCurrent() || options.isDisposed()) return; startup.syncRoutePresentation(startupRoute, { preferMenu: shouldPrimeInvalidMapFallback }); return; } - loadingConsole.queueLine(startup.initializedLine); - await loadingConsole.waitForQueue(); - if (options.isDisposed()) return; setLoading(false); if (options.isDisposed()) return; startup.syncRoutePresentation(startupRoute); @@ -316,13 +324,16 @@ export function createQuakeLoadingFlow(options: QuakeLoadingFlowOptions): QuakeL async function completeSceneReadiness( modelPromise: Promise, - mountModel: (modelPromise: Promise) => Promise, + mountModel: (modelPromise: Promise, isCurrent: () => boolean) => Promise, progress?: QuakeLoadingProgressTracker, + isCurrent: () => boolean = () => true, ): Promise { - await mountModel(modelPromise); - if (options.isDisposed()) return; + if (options.isDisposed() || !isCurrent()) return; + await mountModel(modelPromise, isCurrent); + if (options.isDisposed() || !isCurrent()) return; const completeReadinessTask = progress?.startTask("Rendered first frame"); const readiness = await waitForReadiness(); + if (options.isDisposed() || !isCurrent()) return; completeReadinessTask?.(); const completeFunReminderTask = progress?.startTask("Don't forget to have fun!"); completeFunReminderTask?.(); diff --git a/src/runtime/app/mapLoadOwnership.ts b/src/runtime/app/mapLoadOwnership.ts new file mode 100644 index 0000000..8c07e0d --- /dev/null +++ b/src/runtime/app/mapLoadOwnership.ts @@ -0,0 +1,22 @@ +/** A completed load can lose ownership before an awaiting caller resumes. */ +export interface QuakeMapLoadCompletion { + isCurrent(): boolean; +} + +export type QuakeMapLoadResult = QuakeMapLoadCompletion | false; + +/** Keep failure ownership alive through async wrappers and catch callbacks too. */ +export class QuakeMapLoadFailure extends Error { + constructor(cause: unknown, readonly isCurrent: () => boolean) { + super(cause instanceof Error ? cause.message : String(cause), { cause }); + this.name = "QuakeMapLoadFailure"; + } +} + +export function quakeMapLoadFailureIsCurrent(error: unknown): boolean { + return !(error instanceof QuakeMapLoadFailure) || error.isCurrent(); +} + +export function quakeMapLoadFailureCause(error: unknown): unknown { + return error instanceof QuakeMapLoadFailure ? error.cause : error; +} diff --git a/src/runtime/app/playerLifecycleFlow.ts b/src/runtime/app/playerLifecycleFlow.ts index 1e7851d..5472dae 100644 --- a/src/runtime/app/playerLifecycleFlow.ts +++ b/src/runtime/app/playerLifecycleFlow.ts @@ -1,3 +1,4 @@ +import type { QuakeMapLoadResult } from "./mapLoadOwnership"; import type { QuakeScene } from "../../types/quake"; import type { QuakePlayerController, QuakePlayerDeathDetails, QuakePlayerDeathResult } from "../player"; import type { QuakeMapLoadOptions } from "./session"; @@ -29,6 +30,7 @@ export interface QuakePlayerLifecycleFlowOptions { controls: QuakePlayerLifecycleControls; currentCollisionWorld(): unknown | null; currentMapName(): string; + currentLoad(): QuakeMapLoadResult; currentResult(): QuakeScene | null; exitPointerLockIfHost(): void; focusHost(): void; @@ -39,7 +41,7 @@ export interface QuakePlayerLifecycleFlowOptions { isMainMenuOpen(): boolean; isMenuPanelOpen(): boolean; jumpVelocity: number; - loadMap(mapName: string, options?: QuakeMapLoadOptions): Promise; + loadMap(mapName: string, options?: QuakeMapLoadOptions): Promise; player(): Pick; playDeathSound?: (soundPath: string) => boolean; pointerTrace(kind: string, details: Record): void; @@ -82,7 +84,7 @@ export interface QuakePlayerLifecycleFlow { shouldOpenMainMenuOnControlsEnd(): boolean; shouldResumeMainMenuOnEscape(): boolean; showPlayerDeath(details?: QuakePlayerDeathDetails): QuakePlayerDeathResult | void; - startNewGame(): Promise; + startNewGame(): Promise; suppressMainMenuOnResumeControlsEnd(): void; } @@ -265,16 +267,17 @@ export function createQuakePlayerLifecycleFlow( return true; } - async function startNewGame(): Promise { + async function startNewGame(): Promise { const mapName = options.currentResult() ? options.currentMapName() : options.startMap(); - if (!options.currentResult()) { - await options.loadMap(mapName, { + const loaded = options.currentLoad(); + if (!options.currentResult() || !loaded) { + return options.loadMap(mapName, { loadingStatus: `World ${mapName}.bsp`, preserveLoadingConsole: true, urlMode: "push", }); - return; } + if (!loaded.isCurrent()) return false; options.clearMegahealthRot(); options.clearPowerups(); options.clearMoveInput(); @@ -283,6 +286,7 @@ export function createQuakePlayerLifecycleFlow( clearLevelComplete(); options.player().respawn(); options.setGameplayStarted(true); + return loaded; } function resumeGameplayAfterMapLoad(): void { diff --git a/src/runtime/app/routeFlow.ts b/src/runtime/app/routeFlow.ts index 8784e43..da168fa 100644 --- a/src/runtime/app/routeFlow.ts +++ b/src/runtime/app/routeFlow.ts @@ -1,3 +1,4 @@ +import { quakeMapLoadFailureCause, quakeMapLoadFailureIsCurrent, type QuakeMapLoadResult } from "./mapLoadOwnership"; import { parseQuakeUrlRouteFromLocation, quakeUrlForMapView, @@ -17,6 +18,7 @@ export interface QuakeCssView { export interface QuakeRouteFlowOptions { applyView(view: TView): void; + canLoadMap(): boolean; clearStartupState(): void; currentMapName(): string; currentView(): TView; @@ -24,7 +26,7 @@ export interface QuakeRouteFlowOptions { hideMainMenu(): void; isDisposed(): boolean; isLoading(): boolean; - loadMap(mapName: string, options: QuakeMapLoadOptions): Promise; + loadMap(mapName: string, options: QuakeMapLoadOptions): Promise; mapExists(mapName: string): boolean; menuEnabled: boolean; compactMultiplayerInviteMapName?: (inviteId: string) => string | null; @@ -53,6 +55,8 @@ export interface QuakeRouteFlow { export function createQuakeRouteFlow( options: QuakeRouteFlowOptions, ): QuakeRouteFlow { + let navigationGeneration = 0; + function routeFromLocation(): QuakeUrlRoute { return parseQuakeUrlRouteFromLocation(window.location, { compactMultiplayerInviteMapName: options.compactMultiplayerInviteMapName, @@ -125,9 +129,10 @@ export function createQuakeRouteFlow( } function handlePopState(): void { - if (options.isDisposed() || options.isLoading()) return; + if (options.isDisposed() || !options.canLoadMap()) return; + ++navigationGeneration; const route = routeFromLocation(); - if (options.currentMapName() === route.mapName && options.hasCurrentScene()) { + if (!options.isLoading() && options.currentMapName() === route.mapName && options.hasCurrentScene()) { const currentRouteView = routeView(route); if (currentRouteView) { options.applyView(currentRouteView); @@ -145,13 +150,18 @@ export function createQuakeRouteFlow( } function navigateToRoute(route: QuakeUrlRoute): void { + const generation = navigationGeneration; + const isCurrent = () => !options.isDisposed() && generation === navigationGeneration; void options.loadMap(route.mapName, { urlMode: "none", view: route.view }) - .then(() => { - if (!options.isDisposed()) syncPresentation(route); + .then((loaded) => { + if (loaded && loaded.isCurrent() && isCurrent() && !options.isLoading() && options.currentMapName() === route.mapName) { + syncPresentation(route); + } }) .catch((error) => { + if (!isCurrent() || !quakeMapLoadFailureIsCurrent(error)) return; + error = quakeMapLoadFailureCause(error); console.error(error); - if (options.isDisposed()) return; if (error instanceof QuakeAssetsRegeneratingError) { options.setAssetsRegenerating(error.message); } else { diff --git a/src/runtime/app/saveSession.ts b/src/runtime/app/saveSession.ts index 1139b8e..6e3b799 100644 --- a/src/runtime/app/saveSession.ts +++ b/src/runtime/app/saveSession.ts @@ -1,3 +1,4 @@ +import type { QuakeMapLoadResult } from "./mapLoadOwnership"; import { createCssQuakeSaveSlot as createCssQuakeSaveSlotV1, readCssQuakeSaveSlot, @@ -11,7 +12,7 @@ type CssQuakeSaveSlotInput = Parameters[0]; export interface CssQuakeSaveSessionController { canLoad(): boolean; canSave(): boolean; - load(): Promise; + load(): Promise; save(): void; } @@ -31,9 +32,10 @@ export interface CssQuakeSaveSessionOptions { clearPowerupTimers(): void; clearWeaponViewPunch(): void; currentMapName(): string; + currentLoad(): QuakeMapLoadResult; currentOrigin(): [number, number, number]; hasCurrentScene(mapName?: string): boolean; - loadMap(mapName: string, options?: QuakeMapLoadOptions): Promise; + loadMap(mapName: string, options?: QuakeMapLoadOptions): Promise; mapExists(mapName: string): boolean; notify(message: string): void; resetActiveTriggers(): void; @@ -107,26 +109,28 @@ export function createCssQuakeSaveSession(options: CssQuakeSaveSessionOptions): } } - async function load(): Promise { + async function load(): Promise { const slot = readCssQuakeSaveSlot(); if (!slot || !options.mapExists(slot.mapName)) { options.notify("No saved game"); - return; + return false; } options.clearAttackInput(); options.clearMoveInput(); options.clearMobileMoveInput(); - if (!options.hasCurrentScene(slot.mapName)) { - await options.loadMap(slot.mapName, { + let loaded = options.currentLoad(); + if (!loaded || !options.hasCurrentScene(slot.mapName)) { + loaded = await options.loadMap(slot.mapName, { loadingStatus: "Loading save", resumeGameplay: false, urlMode: "push", }); } - if (!options.hasCurrentScene(slot.mapName)) return; + if (!loaded || !loaded.isCurrent() || !options.hasCurrentScene(slot.mapName)) return false; applySaveSlot(slot); options.trace("progress-load", { mapName: slot.mapName, savedAt: slot.savedAt }); options.notify("Game loaded"); + return loaded; } function applySaveSlot(slot: CssQuakeSaveSlotV1): void { diff --git a/src/runtime/app/sceneMountFlow.ts b/src/runtime/app/sceneMountFlow.ts index dbe092d..fcec929 100644 --- a/src/runtime/app/sceneMountFlow.ts +++ b/src/runtime/app/sceneMountFlow.ts @@ -1,3 +1,4 @@ +import type { QuakeSceneStateView, QuakeSceneStateWriter } from "./sceneState"; import type { Vec3 } from "@layoutit/polycss"; import type { QuakeEntity, QuakeScene } from "../../types/quake"; @@ -24,14 +25,6 @@ import type { QuakeWeaponsController } from "../weapons"; import type { QuakeDamageableBrushFlow } from "./damageableBrushFlow"; import type { QuakePointHazardFlow } from "./pointHazardFlow"; -interface QuakeSceneMountStateHooks { - setCollisionWorld(world: QuakeCollisionWorld | null): void; - setCurrentScene(scene: QuakeScene | null): void; - setEntityIndex(index: Map): void; - setModelPivot(pivot: { x: number; y: number; z: number }): void; - setTransitionSerial(value: number): void; -} - export interface QuakeSceneMountFlowOptions { audio: QuakeSoundController; damageableBrushes: QuakeDamageableBrushFlow; @@ -41,7 +34,8 @@ export interface QuakeSceneMountFlowOptions { player: QuakePlayerController; pointHazards: QuakePointHazardFlow; shootables: QuakeShootablesController; - state: QuakeSceneMountStateHooks; + state: { view: QuakeSceneStateView; writer: QuakeSceneStateWriter }; + onModelPivotChange(pivot: { x: number; y: number; z: number }): void; targets: QuakeTargetsController; triggers: QuakeTriggersController; viewmodel: QuakeViewmodelController; @@ -59,7 +53,6 @@ export interface QuakeSceneMountFlowOptions { setCamera(spawn: QuakeScene["spawn"]): void; syncCrosshairTarget(): void; trace(kind: string, details?: Record): void; - transitionSerial(): number; focusCurrentMenu(): void; } @@ -70,6 +63,7 @@ export interface QuakeSceneMountFlow { isPointInPlayerView(point: Vec3, minDot: number): boolean; lineOfSight(start: Vec3, end: Vec3): boolean; mountScene(scene: QuakeScene): void; + prepareScene(scene: QuakeScene): () => void; playerViewDot(point: Vec3): number; respawnScene(scene: QuakeScene, previousOrigin: [number, number, number]): void; setupMonsterJumpTriggers(scene: QuakeScene): void; @@ -80,10 +74,7 @@ export interface QuakeSceneMountFlow { } export function createQuakeSceneMountFlow(options: QuakeSceneMountFlowOptions): QuakeSceneMountFlow { - let entityByIndex = new Map(); - let currentScene: QuakeScene | null = null; - let currentCollisionWorld: QuakeCollisionWorld | null = null; - let modelPivot = { x: 0, y: 0, z: 0 }; + const state = options.state.view; function disposeCurrentScene(): void { options.beforeDisposeScene(); @@ -104,16 +95,20 @@ export function createQuakeSceneMountFlow(options: QuakeSceneMountFlowOptions): setModelPivot({ x: 0, y: 0, z: 0 }); options.audio.syncAmbientEntities([]); options.weapons.reset(); - options.state.setTransitionSerial(0); + options.state.writer.setTransitionSerial(0); + } + + function prepareScene(scene: QuakeScene): () => void { + const collisionWorld = scene.collision ? buildQuakeClipCollisionWorld(scene.collision) : null; + if (!collisionWorld) throw new Error(`Prepared Quake scene ${scene.label} is missing collision data.`); + return () => mountPreparedScene(scene, collisionWorld); } - function mountScene(scene: QuakeScene): void { + function mountPreparedScene(scene: QuakeScene, collisionWorld: QuakeCollisionWorld): void { disposeCurrentScene(); setCurrentScene(scene); clearSkyBackground(); - const collisionWorld = scene.collision ? buildQuakeClipCollisionWorld(scene.collision) : null; setCollisionWorld(collisionWorld); - if (!collisionWorld) throw new Error(`Prepared Quake scene ${scene.label} is missing collision data.`); options.world.mount(scene); setupEntityActions(scene); const runtime = scene.entityManifest.runtime; @@ -164,7 +159,7 @@ export function createQuakeSceneMountFlow(options: QuakeSceneMountFlowOptions): options.movers.setup( entitiesForIndexes([...runtime.moverEntityIndexes, ...runtime.moverSupportEntityIndexes]), scene.models, - modelPivot, + state.modelPivot, scene.gameLogic, ); } @@ -175,14 +170,14 @@ export function createQuakeSceneMountFlow(options: QuakeSceneMountFlowOptions): for (const index of indexes) { if (seen.has(index)) continue; seen.add(index); - const entity = entityByIndex.get(index); + const entity = state.entities.get(index); if (entity) out.push(entity); } return out; } function lineOfSight(start: Vec3, end: Vec3): boolean { - const trace = currentCollisionWorld?.traceUse?.(start, end); + const trace = state.collisionWorld?.traceUse?.(start, end); return !trace || trace.fraction >= 0.96; } @@ -218,9 +213,9 @@ export function createQuakeSceneMountFlow(options: QuakeSceneMountFlowOptions): } function syncDebugGameplay(origin: [number, number, number]): void { - const transitionSerial = options.transitionSerial(); + const transitionSerial = state.transitionSerial; const triggers = syncTouchedTriggers(origin); - if (options.transitionSerial() !== transitionSerial) return; + if (state.transitionSerial !== transitionSerial) return; const currentOrigin = options.player.currentOrigin(); if (syncHazards(currentOrigin, triggers)) return; @@ -233,7 +228,7 @@ export function createQuakeSceneMountFlow(options: QuakeSceneMountFlowOptions): function currentTouchedTriggers(origin: [number, number, number]): QuakeTouchedTrigger[] { return [ - ...(currentCollisionWorld?.touchingTriggers?.(origin, options.player.eyeHeight()) ?? []), + ...(state.collisionWorld?.touchingTriggers?.(origin, options.player.eyeHeight()) ?? []), ...options.movers.touchingDoorTriggerFields(origin, options.player.eyeHeight()), ].filter((trigger) => !options.targets.isDisabled(trigger.entityIndex)); } @@ -244,17 +239,17 @@ export function createQuakeSceneMountFlow(options: QuakeSceneMountFlowOptions): ): QuakeHazardDamage | null { let hazard: QuakeHazardDamage | null = null; for (const trigger of triggers) { - const entity = entityByIndex.get(trigger.entityIndex); + const entity = state.entities.get(trigger.entityIndex); if (!entity) continue; - const triggerHazard = quakeTriggerHurtDamage(entity, currentScene?.gameLogic); + const triggerHazard = quakeTriggerHurtDamage(entity, state.scene?.gameLogic); hazard = strongerHazard( hazard, triggerHazard ? { ...triggerHazard, entityIndex: trigger.entityIndex } : null, ); } hazard = strongerHazard(hazard, options.pointHazards.hazardAt(origin)); - const contents = currentCollisionWorld?.contentsAt?.(playerContentsPoint(origin)); - const waterLevel = quakePlayerWaterLevel(currentCollisionWorld?.contentsAt, origin, options.player.eyeHeight()); + const contents = state.collisionWorld?.contentsAt?.(playerContentsPoint(origin)); + const waterLevel = quakePlayerWaterLevel(state.collisionWorld?.contentsAt, origin, options.player.eyeHeight()); const contentsHazard = quakeContentsDamageForWaterLevel(contents, waterLevel); const radsuitActive = ( contentsHazard?.kind === "slime" || @@ -282,29 +277,26 @@ export function createQuakeSceneMountFlow(options: QuakeSceneMountFlowOptions): options.shootables.setupMonsterJumpTriggers( scene.entities.filter((entity) => entity.classname === "trigger_monsterjump"), scene.models, - scene.collision?.pivot ?? modelPivot, + scene.collision?.pivot ?? state.modelPivot, scene.gameLogic, ); } function setEntityIndex(index: Map): void { - entityByIndex = index; - options.state.setEntityIndex(index); + options.state.writer.setEntityIndex(index); } function setCollisionWorld(world: QuakeCollisionWorld | null): void { - currentCollisionWorld = world; - options.state.setCollisionWorld(world); + options.state.writer.setCollisionWorld(world); } function setCurrentScene(scene: QuakeScene | null): void { - currentScene = scene; - options.state.setCurrentScene(scene); + options.state.writer.setCurrentScene(scene); } function setModelPivot(pivot: { x: number; y: number; z: number }): void { - modelPivot = pivot; - options.state.setModelPivot(pivot); + options.state.writer.setModelPivot(pivot); + options.onModelPivotChange(pivot); } return { @@ -313,7 +305,8 @@ export function createQuakeSceneMountFlow(options: QuakeSceneMountFlowOptions): entitiesForIndexes, isPointInPlayerView, lineOfSight, - mountScene, + mountScene: scene => prepareScene(scene)(), + prepareScene, playerViewDot, respawnScene, setupMonsterJumpTriggers, diff --git a/src/runtime/app/sceneState.ts b/src/runtime/app/sceneState.ts new file mode 100644 index 0000000..186b289 --- /dev/null +++ b/src/runtime/app/sceneState.ts @@ -0,0 +1,49 @@ +import type { QuakeEntity, QuakeScene } from "../../types/quake"; +import type { QuakeCollisionWorld } from "../collision"; + +/** Live reads shared by gameplay, presentation and debug; consumers cannot publish state. */ +export interface QuakeSceneStateView { + readonly scene: QuakeScene | null; + readonly collisionWorld: QuakeCollisionWorld | null; + readonly entities: ReadonlyMap; + readonly modelPivot: Readonly<{ x: number; y: number; z: number }>; + readonly transitionSerial: number; +} + +/** Publication belongs to the scene lifecycle, not to application callbacks. */ +export interface QuakeSceneStateWriter { + setCollisionWorld(world: QuakeCollisionWorld | null): void; + setCurrentScene(scene: QuakeScene | null): void; + setEntityIndex(index: Map): void; + setModelPivot(pivot: { x: number; y: number; z: number }): void; + setTransitionSerial(value: number): void; +} + +export function createQuakeSceneState(): { + view: QuakeSceneStateView; + writer: QuakeSceneStateWriter; + advanceTransition(): void; +} { + let scene: QuakeScene | null = null; + let collisionWorld: QuakeCollisionWorld | null = null; + let entities = new Map(); + let modelPivot = { x: 0, y: 0, z: 0 }; + let transitionSerial = 0; + return { + view: { + get scene() { return scene; }, + get collisionWorld() { return collisionWorld; }, + get entities() { return entities; }, + get modelPivot() { return modelPivot; }, + get transitionSerial() { return transitionSerial; }, + }, + writer: { + setCollisionWorld: value => { collisionWorld = value; }, + setCurrentScene: value => { scene = value; }, + setEntityIndex: value => { entities = value; }, + setModelPivot: value => { modelPivot = value; }, + setTransitionSerial: value => { transitionSerial = value; }, + }, + advanceTransition: () => { transitionSerial++; }, + }; +} diff --git a/src/runtime/app/session.ts b/src/runtime/app/session.ts index 67ec06b..6e79d04 100644 --- a/src/runtime/app/session.ts +++ b/src/runtime/app/session.ts @@ -7,6 +7,7 @@ import { } from "../loadingConsole"; import { preloadQuakeRenderBundleAssets, preloadQuakeRenderBundleFloorAssets } from "../renderBundleMesh"; import type { QuakeUrlUpdateMode, QuakeUrlView } from "../routeState"; +import { QuakeMapLoadFailure, type QuakeMapLoadResult } from "./mapLoadOwnership"; export const QUAKE_ASSET_ROOT = "/q"; export const QUAKE_MANIFEST_URL = `${QUAKE_ASSET_ROOT}/manifest.json`; @@ -47,16 +48,19 @@ export interface QuakeMapLoadOptions { } export interface QuakeAppMapLoader { - loadMap(mapName: string, options?: QuakeMapLoadOptions): Promise; + /** Check result.isCurrent() at the point of use, including after each await. */ + loadMap(mapName: string, options?: QuakeMapLoadOptions): Promise; + /** False until the latest request has completed successfully. */ + currentLoad(): QuakeMapLoadResult; } export interface QuakeAppMapLoaderOptions { - completeSceneReadiness(weaponPromise: Promise, progress: QuakeLoadingProgressTracker): Promise; + completeSceneReadiness(weaponPromise: Promise, progress: QuakeLoadingProgressTracker, isCurrent: () => boolean): Promise; createProgressTracker(status: string): QuakeLoadingProgressTracker; fetchScene(url: string, mapName: string, progress: QuakeLoadingProgressTracker): Promise; isDisposed(): boolean; mapLoadView(options: QuakeMapLoadOptions): TView | null; - mountScene(scene: QuakeScene): void; + prepareScene(scene: QuakeScene): () => void; onCurrentMapChange(mapName: string): void; preloadMapAssets(mapName: string, progress: QuakeLoadingProgressTracker): Promise; preloadSceneAssets(scene: QuakeScene, progress: QuakeLoadingProgressTracker): Promise; @@ -232,35 +236,59 @@ export async function fetchQuakeScene( export function createQuakeAppMapLoader( options: QuakeAppMapLoaderOptions, ): QuakeAppMapLoader { + let generation = 0; + let completed: QuakeMapLoadResult = false; return { - async loadMap(mapName: string, loadOptions: QuakeMapLoadOptions = {}): Promise { + currentLoad: () => completed && completed.isCurrent() ? completed : false, + async loadMap(mapName: string, loadOptions: QuakeMapLoadOptions = {}): Promise { const nextMapName = mapName.trim().toLowerCase(); const url = options.sceneUrl(nextMapName); if (!url) throw new Error(`No prepared Quake map registered for ${nextMapName}.`); + const requestGeneration = ++generation; + let finished = false; + const completion = { isCurrent: () => requestGeneration === generation && !options.isDisposed() }; + const isCurrent = () => !finished && completion.isCurrent(); const loadingStatus = loadOptions.loadingStatus ?? `World ${nextMapName}.bsp`; - const progress = options.createProgressTracker(loadingStatus); + const tracker = options.createProgressTracker(loadingStatus); + const progress: QuakeLoadingProgressTracker = { + setStatus: (status) => { if (isCurrent()) tracker.setStatus(status); }, + startTask: (status) => { + if (!isCurrent()) return () => {}; + const complete = tracker.startTask(status); + return () => { if (isCurrent()) complete(); }; + }, + }; options.setLoading(true, loadingStatus, { preserveConsole: loadOptions.preserveLoadingConsole }); try { - const scenePromise = options.fetchScene(url, nextMapName, progress); - const weaponPromise = options.preloadWeapon(progress); - const scene = await scenePromise; - if (options.isDisposed()) return; + // Observe both promises immediately, including weapon failures while fetch is pending. + const [scene, weapon] = await Promise.all([ + options.fetchScene(url, nextMapName, progress), + options.preloadWeapon(progress), + ]); + if (!isCurrent()) return false; await options.preloadSceneAssets(scene, progress); + if (!isCurrent()) return false; await options.preloadMapAssets(nextMapName, progress); - if (options.isDisposed()) return; + if (!isCurrent()) return false; + const mountPreparedScene = options.prepareScene(scene); options.onCurrentMapChange(nextMapName); - options.mountScene(scene); + mountPreparedScene(); const routeView = options.mapLoadView(loadOptions); if (routeView) options.syncUrlView(routeView); options.updateUrl(nextMapName, loadOptions.urlMode ?? "push", routeView); - if (options.isDisposed()) return; - await options.completeSceneReadiness(weaponPromise, progress); - if (options.isDisposed()) return; + if (!isCurrent()) return false; + await options.completeSceneReadiness(Promise.resolve(weapon), progress, isCurrent); + if (!isCurrent()) return false; if (loadOptions.resumeGameplay) options.resumeGameplayAfterMapLoad(); options.setGameplayStarted(true); + completed = completion; + return completion; } catch (error) { - if (!options.isDisposed()) options.setLoading(false); - throw error; + if (!isCurrent()) return false; + options.setLoading(false); + throw new QuakeMapLoadFailure(error, completion.isCurrent); + } finally { + finished = true; } }, }; diff --git a/src/runtime/app/viewmodelAssetFlow.ts b/src/runtime/app/viewmodelAssetFlow.ts index 166f436..071c493 100644 --- a/src/runtime/app/viewmodelAssetFlow.ts +++ b/src/runtime/app/viewmodelAssetFlow.ts @@ -16,7 +16,7 @@ export interface QuakeViewmodelAssetFlowOptions { export interface QuakeViewmodelAssetFlow { clearMountedState(): void; - mount(modelPromise?: Promise): Promise; + mount(modelPromise?: Promise, isCurrent?: () => boolean): Promise; preload(progress?: QuakeLoadingProgressTracker, modelPath?: string): Promise; syncActiveWeaponViewModel(): void; } @@ -65,9 +65,9 @@ export function createQuakeViewmodelAssetFlow(options: QuakeViewmodelAssetFlowOp return model; } - async function mount(modelPromise = preload()): Promise { + async function mount(modelPromise = preload(), isCurrent: () => boolean = () => true): Promise { const model = await modelPromise; - if (options.isDisposed()) return; + if (options.isDisposed() || !isCurrent()) return; mountModel(model); syncActiveWeaponViewModel(); } diff --git a/src/runtime/debug/quakeDebug.ts b/src/runtime/debug/quakeDebug.ts index f10966e..e8a7429 100644 --- a/src/runtime/debug/quakeDebug.ts +++ b/src/runtime/debug/quakeDebug.ts @@ -1,3 +1,4 @@ +import { quakeMapLoadFailureIsCurrent, type QuakeMapLoadResult } from "../app/mapLoadOwnership"; import type { Vec3 } from "@layoutit/polycss"; import type { QuakeEntity } from "../../types/quake"; @@ -185,7 +186,7 @@ export interface QuakeDebugRuntime { hideMainMenu(): void; inventory(): QuakePlayerInventory; isLoading(): boolean; - loadMap(mapName: string): Promise; + loadMap(mapName: string): Promise; mapExists(mapName: string): boolean; getWeaponTuning(): QuakeResolvedViewmodelTuning; resetWeaponTuning(): QuakeResolvedViewmodelTuning; @@ -532,7 +533,14 @@ async function loadQuakeDebugMap(runtime: QuakeDebugRuntime, mapName: string): P } if (runtime.isLoading()) return false; runtime.hideMainMenu(); - await runtime.loadMap(nextMapName); + let loaded: QuakeMapLoadResult; + try { + loaded = await runtime.loadMap(nextMapName); + } catch (error) { + if (!quakeMapLoadFailureIsCurrent(error)) return false; + throw error; + } + if (!loaded || !loaded.isCurrent()) return false; runtime.hideMainMenu(); return true; } diff --git a/src/runtime/menu.ts b/src/runtime/menu.ts index b347a46..f9781d4 100644 --- a/src/runtime/menu.ts +++ b/src/runtime/menu.ts @@ -1,3 +1,4 @@ +import { quakeMapLoadFailureIsCurrent, type QuakeMapLoadResult } from "./app/mapLoadOwnership"; import { mountQuakeBitmapText } from "./bitmapText"; interface QuakeMenuControls { @@ -30,11 +31,11 @@ export interface QuakeMenuControllerOptions { levelPanel: HTMLElement | null; aboutPanel: HTMLElement | null; optionsPanel: HTMLElement | null; - onSelectNewGame?(): void | Promise; + onSelectNewGame?(): void | Promise; onShowMultiplayer?(): void; - onLoadGame?(): void | Promise; + onLoadGame?(): void | Promise; onSaveGame?(): void | Promise; - onSelectLevel?(mapName: string): void | Promise; + onSelectLevel?(mapName: string): void | Promise; onSelectQuit?(): void; canLoadGame?(): boolean; canSaveGame?(): boolean; @@ -174,17 +175,19 @@ export function createQuakeMenuController({ syncSinglePlayerItemAvailability(); hideMainMenu(); Promise.resolve(onSelectNewGame()) - .then(() => { + .then((loaded) => { startingNewGame = false; - clearPendingMainMenu(); syncSinglePlayerItemAvailability(); + if (loaded === false || (loaded && !loaded.isCurrent())) return; + clearPendingMainMenu(); controls.lock(); }) .catch((error: unknown) => { - console.error(error); startingNewGame = false; - clearPendingMainMenu(); syncSinglePlayerItemAvailability(); + if (!quakeMapLoadFailureIsCurrent(error)) return; + console.error(error); + clearPendingMainMenu(); showSinglePlayerPanel(); }); } @@ -195,15 +198,17 @@ export function createQuakeMenuController({ syncSinglePlayerItemAvailability(); hideMainMenu(); Promise.resolve(onLoadGame()) - .then(() => { + .then((loaded) => { loadingGame = false; syncSinglePlayerItemAvailability(); + if (loaded === false || (loaded && !loaded.isCurrent())) return; controls.lock(); }) .catch((error: unknown) => { - console.error(error); loadingGame = false; syncSinglePlayerItemAvailability(); + if (!quakeMapLoadFailureIsCurrent(error)) return; + console.error(error); showSinglePlayerPanel(); }); } @@ -655,14 +660,16 @@ export function createQuakeMenuController({ setLoadingLevel(mapName); hideMainMenu(); Promise.resolve(onSelectLevel(mapName)) - .then(() => { - setCurrentLevel(mapName); + .then((loaded) => { setLoadingLevel(null); + if (loaded === false || (loaded && !loaded.isCurrent())) return; + setCurrentLevel(mapName); controls.lock(); }) .catch((error: unknown) => { - console.error(error); setLoadingLevel(null); + if (!quakeMapLoadFailureIsCurrent(error)) return; + console.error(error); showLevelPanel(); }); } diff --git a/src/runtime/renderBundleMesh.ts b/src/runtime/renderBundleMesh.ts index 1c76919..c78868a 100644 --- a/src/runtime/renderBundleMesh.ts +++ b/src/runtime/renderBundleMesh.ts @@ -51,7 +51,6 @@ const renderBundleRootVarsCache = new WeakMap>(); const renderBundleStyleCache = new Map(); const renderBundleStyleLoadPromises = new Map>(); -const renderBundleLoadedStyles = new WeakSet(); const renderBundleLeafFrameStylesLoadPromises = new Map>(); const renderBundleDebugOutlinePreloads = new WeakMap>(); const renderBundleDebugTransparentOutlinePreloads = new WeakMap>(); @@ -690,8 +689,9 @@ function ensureQuakeRenderBundleStyles( const link = document.createElement("link"); link.rel = "stylesheet"; link.href = resolveQuakeAssetUrl(renderBundle.styleUrl); - document.head.append(link); renderBundleStyleCache.set(key, link); + trackQuakeRenderBundleStyle(key, link); + document.head.append(link); return link; } if (!renderBundle.meshCss) return null; @@ -1350,26 +1350,33 @@ function quakeRenderBundleUrlBasename(url: string): string { function preloadQuakeRenderBundleStyle(renderBundle: QuakePreparedRenderBundle): Promise { const key = quakeRenderBundleStyleKey(renderBundle); if (!key) return Promise.resolve(); - const existing = renderBundleStyleLoadPromises.get(key); - if (existing) return existing; - const element = ensureQuakeRenderBundleStyles(renderBundle, document); - if (!(element instanceof HTMLLinkElement)) return Promise.resolve(); - const promise = new Promise((resolve) => { - if (renderBundleLoadedStyles.has(element) || element.sheet) { - resolve(); - return; - } - const done = () => { - renderBundleLoadedStyles.add(element); - element.removeEventListener("load", done); - element.removeEventListener("error", done); - resolve(); + ensureQuakeRenderBundleStyles(renderBundle, document); + return renderBundleStyleLoadPromises.get(key) ?? Promise.resolve(); +} + +function trackQuakeRenderBundleStyle(key: string, element: HTMLLinkElement): void { + // Register before insertion so even an immediate cached load/error is observed. + const promise = new Promise((resolve, reject) => { + const cleanup = () => { + element.removeEventListener("load", loaded); + element.removeEventListener("error", failed); + }; + const loaded = () => { cleanup(); resolve(); }; + const failed = () => { + cleanup(); + if (renderBundleStyleCache.get(key) === element) { + renderBundleStyleCache.delete(key); + renderBundleStyleLoadPromises.delete(key); + } + element.remove(); + reject(new Error(`Could not load Quake render bundle stylesheet ${element.href}.`)); }; - element.addEventListener("load", done); - element.addEventListener("error", done); + element.addEventListener("load", loaded); + element.addEventListener("error", failed); }); renderBundleStyleLoadPromises.set(key, promise); - return promise; + // Mount can initiate a load before readiness awaits it. Preserve the rejection for readiness. + void promise.catch(() => {}); } async function loadQuakeRenderBundleLeafFrameStyles(renderBundle: QuakePreparedRenderBundle): Promise { @@ -1384,17 +1391,24 @@ async function loadQuakeRenderBundleLeafFrameStyles(renderBundle: QuakePreparedR }); renderBundleLeafFrameStylesLoadPromises.set(url, promise); } - const file = await promise; - if (file.version !== 3) { - throw new Error(`Unsupported Quake render bundle frame styles version ${String(file.version)} in ${url}.`); - } - const frameIndex = renderBundle.leafFrameStylesIndex ?? 0; - const frameStyles = file.frames[frameIndex]; - if (!frameStyles) { - throw new Error(`Quake render bundle frame styles missing frame ${frameIndex} in ${url}.`); + try { + const file = await promise; + if (file.version !== 3) { + throw new Error(`Unsupported Quake render bundle frame styles version ${String(file.version)} in ${url}.`); + } + const frameIndex = renderBundle.leafFrameStylesIndex ?? 0; + const frameStyles = file.frames[frameIndex]; + if (!frameStyles) { + throw new Error(`Quake render bundle frame styles missing frame ${frameIndex} in ${url}.`); + } + renderBundle.leafFrameStyles = hydrateQuakePackedRenderBundleLeafFrameStyles(file.frames, frameIndex); + quakeRenderBundleCompiledLeafFrameStyles(renderBundle); + } catch (error) { + if (renderBundleLeafFrameStylesLoadPromises.get(url) === promise) { + renderBundleLeafFrameStylesLoadPromises.delete(url); + } + throw error; } - renderBundle.leafFrameStyles = hydrateQuakePackedRenderBundleLeafFrameStyles(file.frames, frameIndex); - quakeRenderBundleCompiledLeafFrameStyles(renderBundle); } function hydrateQuakePackedRenderBundleLeafFrameStyles( @@ -1856,11 +1870,16 @@ function preloadQuakeRenderBundleAsset(url: string): Promise { image.decoding = "async"; image.loading = "eager"; (image as HTMLImageElement & { fetchPriority?: "low" | "high" | "auto" }).fetchPriority = "high"; - const promise = new Promise((resolve) => { + const promise = new Promise((resolve, reject) => { image.onload = () => { void image.decode().catch(() => undefined).finally(resolve); }; - image.onerror = () => resolve(); + image.onerror = () => reject(new Error(`Could not load Quake render bundle image ${resolvedUrl}.`)); + }).catch((error) => { + if (renderBundleAssetPreloads.get(resolvedUrl)?.image === image) { + renderBundleAssetPreloads.delete(resolvedUrl); + } + throw error; }); renderBundleAssetPreloads.set(resolvedUrl, { image, promise }); image.src = resolvedUrl; diff --git a/src/runtime/shootables.ts b/src/runtime/shootables.ts index b0a3692..07540b0 100644 --- a/src/runtime/shootables.ts +++ b/src/runtime/shootables.ts @@ -36,9 +36,6 @@ import { } from "./pickups"; import type { QuakePlayerDamageContext } from "./player"; import { - isQuakeRenderBundleFrameSetHandle, - markQuakeRenderBundleFrameSetHandleMotionMaterial, - setQuakeRenderBundleFrameSetHandleFrame, stripPolyMeshMetadata, type QuakeRenderBundleFrameSetMotionMaterialOptions, type QuakeRenderBundleFrameSetMountOptions, @@ -164,15 +161,7 @@ import { import type { QuakeShootablesProgressSnapshot } from "./shootables/progress"; import { createQuakeShootablesProgressRuntime } from "./shootables/progressRuntime"; import { createQuakeShootablePrewarmQueues } from "./shootables/prewarm"; -import { - countQuakeShootableHandles, - flashQuakeShootable, - forEachQuakeShootableHandle, - removeQuakeShootableHandles, - setQuakeShootableHandleTransformIfChanged, - syncQuakeShootableHandleVisibility, - syncQuakeShootableLifecycleClassesForShootable, -} from "./shootables/presentation"; +import { createQuakeShootablePresentation } from "./shootables/presentation"; import { createQuakeShootableStateMap, type QuakeDamageActorReference, @@ -386,7 +375,6 @@ const QUAKE_SHOOTABLE_OVERSIZED_RENDER_HEIGHT = 3; const QUAKE_SHOOTABLE_PREWARM_TIMEOUT_MS = 250; const QUAKE_SHOOTABLE_VISIBILITY_GRACE_MS = 300; const QUAKE_SHOOTABLE_ENEMY_PREWARM_VIEW_DOT_MIN = -0.35; -const QUAKE_SHOOTABLE_ANIMATION_FRAME_POOL_SIZE = 3; const QUAKE_EXPLOBOX_BECOME_EXPLOSION_Z_OFFSET = 32 * QUAKE_COLLISION_UNIT_SCALE; const QUAKE_ENEMY_TICK_MS = 1000 / 60; const QUAKE_ENEMY_DT_CLAMP = 0.05; @@ -413,24 +401,6 @@ interface QuakeShootableVisibilityCandidate { index: number; } -function markShootableTrace( - kind: string, - shootable: QuakeShootableState, - details: QuakeShootableTraceDetails = {}, -): void { - if (kind.startsWith("enemy-move") && isQuakeDebugDomMetadataEnabled()) { - recordMoveGoalDecisionTrace(kind, shootable, details); - } - markQuakeTrace(kind, { - entity: shootable.entity.index, - class: shootable.entity.classname, - leaf: shootable.leafIndex ?? null, - frame: shootable.enemy?.animationFrameIndex ?? null, - mode: shootable.enemy?.animationMode ?? null, - visible: shootable.visible, - ...details, - }); -} function recordMoveGoalDecisionTrace( kind: string, @@ -531,6 +501,42 @@ export function createQuakeShootablesController({ let lastVisibilitySync: QuakeShootablesDebugVisibilitySyncSnapshot | null = null; let lastMotionMaterialForward: Vec3 | null = null; let lastMotionMaterialOrigin: Vec3 | null = null; + function markShootableTrace( + kind: string, + shootable: QuakeShootableState, + details: QuakeShootableTraceDetails = {}, + ): void { + if (kind.startsWith("enemy-move") && isQuakeDebugDomMetadataEnabled()) { + recordMoveGoalDecisionTrace(kind, shootable, details); + } + markQuakeTrace(kind, { + entity: shootable.entity.index, + class: shootable.entity.classname, + leaf: shootable.leafIndex ?? null, + frame: shootable.enemy?.animationFrameIndex ?? null, + mode: shootable.enemy?.animationMode ?? null, + visible: presentation.isVisible(shootable), + ...details, + }); + } + + const presentation = createQuakeShootablePresentation({ + addMesh, pointToPoly, pixelate, schedulePresentationResync, enemyMotionMaterial, + lifecycle: shootableLifecycleClassState, + nextFrameIndex: nextShootableAnimationFrameIndex, + markTrace: markShootableTrace, + onHandlesChanged(changes) { + for (const key of Object.keys(changes) as Array) { + visibilityChurn[key] += changes[key] ?? 0; + } + }, + }); + const countShootableHandles = presentation.handleCount; + const removeShootableHandles = presentation.remove; + const syncShootableEnemyDatasets = presentation.syncDatasets; + const canUseShootableAnimationFrameSet = presentation.supportsFrameSet; + const ensureShootableAnimationFrameHandle = presentation.ensureFrame; + const trimShootableAnimationFrameHandles = presentation.trimFrames; const prewarmQueues = createQuakeShootablePrewarmQueues({ canPoolAnimationFrame: canPoolShootableAnimationFrames, canPrewarmShootable: canPrewarmShootableHandle, @@ -538,6 +544,9 @@ export function createQuakeShootablesController({ ensureShootableAnimationFrameHandle(shootable, frameIndex); }, getShootable: (entityIndex) => shootables.get(entityIndex), + hasHandle: presentation.hasHandle, + isVisible: presentation.isVisible, + hasFrame: presentation.hasFrame, mountShootable: mountShootableHandle, setShootableVisible, timeoutMs: QUAKE_SHOOTABLE_PREWARM_TIMEOUT_MS, @@ -677,6 +686,7 @@ export function createQuakeShootablesController({ getPlayerOrigin, hasLineOfSight, isGameplayPaused, + isVisible: presentation.isVisible, markTrace: markShootableTrace, nextRandom: nextQuakecRandom, playerDamageBounds: quakecPlayerDamageBounds, @@ -694,9 +704,10 @@ export function createQuakeShootablesController({ chainDurationMs: quakeMonsterChainDurationMs, clearAttackState: clearEnemyAttackState, countHandles: countShootableHandles, + hasHandle: presentation.hasHandle, destroyZombieGib: (shootable, context) => destroy(shootable.entity.index, context), dropBackpack, - flashShootable: flashQuakeShootable, + flashShootable: presentation.flash, isScriptedBoss: (classname) => Boolean(quakeBossScriptedLifecycle(classname)), markTrace: markShootableTrace, nextRandom: nextQuakecRandom, @@ -814,9 +825,6 @@ export function createQuakeShootablesController({ model, collisionBounds, bounds, - handle: null, - frameHandles: new Map(), - visible: false, lastMountCandidateAt: Number.NEGATIVE_INFINITY, yaw, health: shootableHealth(entity), @@ -1005,7 +1013,7 @@ export function createQuakeShootablesController({ if (shootable.health > 0) { applyShootableDamageRetarget(shootable, damageContext, now); playEnemyPainAnimation(shootable, now, damageAmount); - flashQuakeShootable(shootable); + presentation.flash(shootable); return true; } applyShootableKilledTarget(shootable, damageContext); @@ -1188,7 +1196,7 @@ export function createQuakeShootablesController({ if (shootable.entity.properties.target) fireTarget(shootable.entity.properties.target, shootable.entity.index); return true; } - if (!shootable.handle) { + if (!presentation.hasHandle(shootable)) { shootables.delete(entityIndex); if (shootable.entity.properties.target) fireTarget(shootable.entity.properties.target, shootable.entity.index); return true; @@ -1430,7 +1438,7 @@ export function createQuakeShootablesController({ for (const shootable of shootables.values()) { combatBudget.recordWeaponTargetCandidate(); if (!shouldYieldWeaponTarget(shootable, playerOrigin, logicalWeaponTargetsYielded)) continue; - if (!shootable.handle || !shootable.visible) logicalWeaponTargetsYielded++; + if (!presentation.hasHandle(shootable) || !presentation.isVisible(shootable)) logicalWeaponTargetsYielded++; combatBudget.recordWeaponTargetYield(); yield { entity: shootable.entity, @@ -1446,7 +1454,7 @@ export function createQuakeShootablesController({ playerOrigin: [number, number, number] | null, logicalWeaponTargetsYielded: number, ): boolean { - if (shootable.handle && shootable.visible) return true; + if (presentation.hasHandle(shootable) && presentation.isVisible(shootable)) return true; if (!playerOrigin || !isLiveLogicalWeaponTarget(shootable)) return false; if (logicalWeaponTargetsYielded >= QUAKE_COMBAT_BUDGET_LIMITS.combatInterestSet) return false; if (!isEventBoundLogicalWeaponTarget(shootable, playerOrigin)) return false; @@ -1693,7 +1701,7 @@ export function createQuakeShootablesController({ const inPrewarmPvs = prewarmLeaves ? prewarmLeaf : null; const distanceSq = distanceSq3(origin, shootable.origin); const distance = Math.sqrt(distanceSq); - const usingUnmountDistance = shootable.visible; + const usingUnmountDistance = presentation.isVisible(shootable); const maxDistanceSq = usingUnmountDistance ? QUAKE_SHOOTABLE_UNMOUNT_DISTANCE_SQ : QUAKE_SHOOTABLE_MOUNT_DISTANCE_SQ; const mountDecision = debugShootableMountDecision(shootable, origin, oversizedRenderVolume); const canPrewarm = canPrewarmShootableHandle(shootable); @@ -1780,7 +1788,7 @@ export function createQuakeShootablesController({ const handleCount = countShootableHandles(shootable); const desiredMounted = desiredMountedIndexes.has(shootable.entity.index); const desiredPrewarmed = desiredPrewarmIndexes.has(shootable.entity.index); - const usingUnmountDistance = shootable.visible; + const usingUnmountDistance = presentation.isVisible(shootable); const blockReasons = input ? debugShootableBlockReasons(shootable, input, desiredMounted, desiredPrewarmed) : []; @@ -1797,9 +1805,9 @@ export function createQuakeShootablesController({ enemy: Boolean(shootable.enemy), dead: shootable.dead, health: shootable.health, - visible: shootable.visible, + visible: presentation.isVisible(shootable), mounted: handleCount > 0, - prewarmed: handleCount > 0 && !shootable.visible, + prewarmed: handleCount > 0 && !presentation.isVisible(shootable), inPvs: input?.inPvs ?? null, inPrewarmPvs: input?.inPrewarmPvs ?? null, pvsSource: debugShootablePvsSource( @@ -1830,7 +1838,7 @@ export function createQuakeShootablesController({ budgetBlocked: Boolean(input?.mountCandidate && !desiredMounted), blockReasons, handleCount, - frameHandles: shootable.frameHandles.size, + frameHandles: presentation.frameHandleCount(shootable), yaw: shootable.yaw, animationFrame: enemy?.animationFrameIndex ?? null, animationMode: enemy?.animationMode ?? null, @@ -1960,14 +1968,14 @@ export function createQuakeShootablesController({ if (shootable.dead && !isPersistentShootableCorpse(shootable)) reasons.push("dead"); if (input.visibilityGrace) reasons.push("visibility-grace"); if (input.inPvs === false) reasons.push("not-in-pvs"); - if (!input.withinMountDistance && !shootable.visible) reasons.push("beyond-mount-distance"); - if (!input.withinUnmountDistance && shootable.visible) reasons.push("beyond-unmount-distance"); + if (!input.withinMountDistance && !presentation.isVisible(shootable)) reasons.push("beyond-mount-distance"); + if (!input.withinUnmountDistance && presentation.isVisible(shootable)) reasons.push("beyond-unmount-distance"); if (input.inFrontOfCamera === false) reasons.push("behind-camera"); if (input.visibleTargetCount === 0) reasons.push("out-of-view"); if (input.lineOfSightTargetCount === 0 && !input.oversizedRenderVolume) reasons.push("no-line-of-sight"); if (input.mountCandidate && !desiredMounted) reasons.push("mount-budget"); if (input.prewarmCandidate && !desiredMounted && !desiredPrewarmed && input.canPrewarm) reasons.push("prewarm-budget"); - if (!input.withinPrewarmDistance && !desiredMounted && !shootable.visible) reasons.push("beyond-prewarm-distance"); + if (!input.withinPrewarmDistance && !desiredMounted && !presentation.isVisible(shootable)) reasons.push("beyond-prewarm-distance"); return reasons; } @@ -2045,11 +2053,11 @@ export function createQuakeShootablesController({ function debugMountEntity(entityIndex: number): boolean { const shootable = shootables.get(entityIndex); if (!shootable || shootable.dead) return false; - if (!shootable.handle) mountShootableHandle(shootable); - if (!shootable.handle) return false; + if (!presentation.hasHandle(shootable)) mountShootableHandle(shootable); + if (!presentation.hasHandle(shootable)) return false; setShootableVisible(shootable, true); syncShootableTransform(shootable); - return shootable.visible; + return presentation.isVisible(shootable); } function debugForceEnemyAttack(entityIndex: number, targetOrigin?: Vec3): boolean { @@ -2175,17 +2183,17 @@ export function createQuakeShootablesController({ const hasHandle = handleCount > 0; const isEnemy = Boolean(shootable.enemy); meshHandles += handleCount; - frameHandles += shootable.frameHandles.size; - if (isEnemy) enemyFrameHandles += shootable.frameHandles.size; + frameHandles += presentation.frameHandleCount(shootable); + if (isEnemy) enemyFrameHandles += presentation.frameHandleCount(shootable); if (hasHandle) { mountedIndexes.add(shootable.entity.index); if (isEnemy) mountedEnemies++; } - if (shootable.handle && shootable.visible) { + if (presentation.hasHandle(shootable) && presentation.isVisible(shootable)) { visibleIndexes.add(shootable.entity.index); if (isEnemy) visibleEnemies++; } - if (hasHandle && !shootable.visible) { + if (hasHandle && !presentation.isVisible(shootable)) { prewarmedIndexes.add(shootable.entity.index); if (isEnemy) prewarmedEnemies++; } @@ -2229,7 +2237,7 @@ export function createQuakeShootablesController({ prewarmLeaves.has(shootable.leafIndex) || isOversizedShootableRenderVolume(shootable); const distanceSq = distanceSq3(origin, shootable.origin); - const maxDistanceSq = shootable.visible ? QUAKE_SHOOTABLE_UNMOUNT_DISTANCE_SQ : QUAKE_SHOOTABLE_MOUNT_DISTANCE_SQ; + const maxDistanceSq = presentation.isVisible(shootable) ? QUAKE_SHOOTABLE_UNMOUNT_DISTANCE_SQ : QUAKE_SHOOTABLE_MOUNT_DISTANCE_SQ; if (isPersistentShootableCorpse(shootable)) { if (visibleLeaf && distanceSq <= maxDistanceSq) { corpseCandidates.push({ index: shootable.entity.index, distanceSq }); @@ -2373,17 +2381,17 @@ export function createQuakeShootablesController({ ): boolean { for (const index of mountedIndexes) { const shootable = shootables.get(index); - if (!shootable || !shootable.handle || !shootable.visible) return true; + if (!shootable || !presentation.hasHandle(shootable) || !presentation.isVisible(shootable)) return true; } for (const index of prewarmedIndexes) { const shootable = shootables.get(index); if (!shootable || !canPrewarmShootableHandle(shootable)) continue; - if (!shootable.handle && !prewarmQueues.hasQueuedPrewarm(index)) return true; - if (shootable.handle && shootable.visible) return true; + if (!presentation.hasHandle(shootable) && !prewarmQueues.hasQueuedPrewarm(index)) return true; + if (presentation.hasHandle(shootable) && presentation.isVisible(shootable)) return true; } for (const shootable of shootables.values()) { const index = shootable.entity.index; - if (!shootable.handle || mountedIndexes.has(index) || prewarmedIndexes.has(index)) continue; + if (!presentation.hasHandle(shootable) || mountedIndexes.has(index) || prewarmedIndexes.has(index)) continue; if (isShootableDeathAnimating(shootable)) continue; return true; } @@ -2395,13 +2403,13 @@ export function createQuakeShootablesController({ const deathAnimating = isShootableDeathAnimating(shootable); const shouldKeepHandle = mounted || (prewarmed && canPrewarmHandle) || deathAnimating; - if (shootable.handle && !shouldKeepHandle) { + if (presentation.hasHandle(shootable) && !shouldKeepHandle) { clearEnemyAttackState(shootable); removeShootableHandles(shootable); } if (!shouldKeepHandle) return; if (shootable.dead && !isPersistentShootableCorpse(shootable) && !deathAnimating) return; - if (!shootable.handle) { + if (!presentation.hasHandle(shootable)) { if (!mounted) { if (!canPrewarmHandle) return; if (!shouldMountShootablePrewarmImmediately(shootable)) { @@ -2422,43 +2430,15 @@ export function createQuakeShootablesController({ function mountShootableHandle(shootable: QuakeShootableState): void { initializeEnemyAnimation(shootable, performance.now()); - if (canUseShootableAnimationFrameSet(shootable)) { - shootable.handle = addShootableMesh(shootable.entity, shootable.model, enemyAnimationFrameIndex(shootable)); - markShootableTrace("shootable-mount", shootable, { - backend: "frameset", - handles: countShootableHandles(shootable), - }); - syncShootableTransform(shootable); - syncShootableHandleVisibility(shootable); - syncShootableEnemyDatasets(shootable); - return; - } - if (canPoolShootableAnimationFrames(shootable)) { - const frameIndex = enemyAnimationFrameIndex(shootable); - const handle = ensureShootableAnimationFrameHandle(shootable, frameIndex); - if (!handle) return; - setActiveShootableAnimationFrameHandle(shootable, frameIndex, handle); - markShootableTrace("shootable-mount", shootable, { - backend: "pool", - handles: countShootableHandles(shootable), - }); + if (presentation.mount(shootable, canPoolShootableAnimationFrames(shootable)) === "pool") { scheduleNextShootableAnimationFramePrewarm(shootable); - return; } - shootable.handle = addShootableMesh(shootable.entity, shootable.model, enemyAnimationFrameIndex(shootable)); - markShootableTrace("shootable-mount", shootable, { - backend: "replace", - handles: countShootableHandles(shootable), - }); - syncShootableTransform(shootable); - syncShootableHandleVisibility(shootable); - syncShootableEnemyDatasets(shootable); } function scheduleNextShootableAnimationFramePrewarm(shootable: QuakeShootableState): void { - if (!shootable.visible || !canPoolShootableAnimationFrames(shootable)) return; + if (!presentation.isVisible(shootable) || !canPoolShootableAnimationFrames(shootable)) return; const frameIndex = nextShootableAnimationFrameIndex(shootable); - if (frameIndex === undefined || shootable.frameHandles.has(frameIndex)) return; + if (frameIndex === undefined || presentation.hasFrame(shootable, frameIndex)) return; prewarmQueues.scheduleAnimationFrame(shootable, frameIndex); } @@ -2474,7 +2454,7 @@ export function createQuakeShootablesController({ if (!canPrewarmShootableHandle(shootable)) return false; if (!shootable.enemy) return true; if (canKeepEngagedEnemyPrewarmed(shootable, playerOrigin)) return true; - if (shootable.visible || shootable.dead || !visibleLeaf) return false; + if (presentation.isVisible(shootable) || shootable.dead || !visibleLeaf) return false; return true; } @@ -2497,7 +2477,7 @@ export function createQuakeShootablesController({ if (!canCoarselyMountShootableHandle(shootable, playerOrigin)) return false; const visibleTargets = shootableMountVisibilityTargets(shootable).filter((target) => isInPlayerView(target)); if (isOversizedShootableRenderVolume(shootable)) return true; - const lineOfSight = shootable.handle && !shootable.visible + const lineOfSight = presentation.hasHandle(shootable) && !presentation.isVisible(shootable) ? unbudgetedLineOfSight : budgetedLineOfSight; let lineOfSightDeferred = false; @@ -2506,7 +2486,7 @@ export function createQuakeShootablesController({ if (result === "clear") return true; if (result === "deferred") lineOfSightDeferred = true; } - return lineOfSightDeferred && shootable.visible; + return lineOfSightDeferred && presentation.isVisible(shootable); } function canCoarselyMountShootableHandle(shootable: QuakeShootableState, playerOrigin: Vec3): boolean { @@ -2520,7 +2500,7 @@ export function createQuakeShootablesController({ distanceSq: number, now: number, ): boolean { - if (!shootable.enemy || !shootable.visible || shootable.dead) return false; + if (!shootable.enemy || !presentation.isVisible(shootable) || shootable.dead) return false; if (distanceSq > QUAKE_SHOOTABLE_UNMOUNT_DISTANCE_SQ) return false; return visibilityGraceRemainingMs(shootable, now) > 0; } @@ -2564,24 +2544,22 @@ export function createQuakeShootablesController({ } function setShootableVisible(shootable: QuakeShootableState, visible: boolean): void { - if (!shootable.handle) { - shootable.visible = false; + if (!presentation.hasHandle(shootable)) { + presentation.setVisible(shootable, false); return; } - const wasVisible = shootable.visible; + const wasVisible = presentation.isVisible(shootable); if (visible === wasVisible) return; - shootable.visible = visible; - if (!visible && wasVisible) { - clearEnemyAttackState(shootable); - } - syncShootableHandleVisibility(shootable); + // Losing render eligibility cancels attacks in the existing gameplay rules. + if (!visible && wasVisible) clearEnemyAttackState(shootable); + presentation.setVisible(shootable, visible); syncShootableEnemyDatasets(shootable); markShootableTrace("shootable-visible", shootable, { active: visible, handles: countShootableHandles(shootable), }); if (visible) { - markEnemyMotionMaterial(shootable, shootable.handle, "visible"); + presentation.markMotionMaterial(shootable, "visible"); scheduleNextShootableAnimationFramePrewarm(shootable); } } @@ -2590,119 +2568,17 @@ export function createQuakeShootablesController({ return false; } - function canUseShootableAnimationFrameSet(shootable: QuakeShootableState): boolean { - return Boolean(shootable.enemy && shootable.model?.animationFrames?.length && shootable.model.animationFrameSet); - } - function ensureShootableAnimationFrameHandle( - shootable: QuakeShootableState, - frameIndex: number, - ): PolyMeshHandle | null { - const existing = shootable.frameHandles.get(frameIndex); - if (existing) return existing; - const handle = addShootableMesh(shootable.entity, shootable.model, frameIndex); - if (!handle) return null; - shootable.frameHandles.set(frameIndex, handle); - visibilityChurn.totalFrameHandlesCreated++; - markShootableTrace("shootable-frame-handle-create", shootable, { - requestedFrame: frameIndex, - handles: countShootableHandles(shootable), - }); - syncShootableTransformForHandle(shootable, handle); - syncShootableHandleVisibility(shootable); - syncShootableEnemyDataset(shootable, handle, frameIndex); - return handle; - } - function setActiveShootableAnimationFrameHandle( - shootable: QuakeShootableState, - frameIndex: number, - handle: PolyMeshHandle, - ): void { - shootable.frameHandles.delete(frameIndex); - shootable.frameHandles.set(frameIndex, handle); - shootable.handle = handle; - syncShootableTransform(shootable); - syncShootableHandleVisibility(shootable); - syncShootableEnemyDatasets(shootable); - trimShootableAnimationFrameHandles(shootable); - } - function syncShootableHandleVisibility(shootable: QuakeShootableState): void { - syncQuakeShootableHandleVisibility(shootable, shootableLifecycleClassState(shootable)); - } - function trimShootableAnimationFrameHandles(shootable: QuakeShootableState): void { - if (shootable.frameHandles.size <= QUAKE_SHOOTABLE_ANIMATION_FRAME_POOL_SIZE) return; - const keepFrameIndex = enemyAnimationFrameIndex(shootable); - const nextFrameIndex = nextShootableAnimationFrameIndex(shootable); - for (const [frameIndex, handle] of shootable.frameHandles) { - if (shootable.frameHandles.size <= QUAKE_SHOOTABLE_ANIMATION_FRAME_POOL_SIZE) return; - if (handle === shootable.handle || frameIndex === keepFrameIndex || frameIndex === nextFrameIndex) continue; - handle.remove(); - visibilityChurn.totalMeshHandlesRemoved++; - visibilityChurn.totalFrameHandlesRemoved++; - shootable.frameHandles.delete(frameIndex); - } - } - function forEachShootableHandle(shootable: QuakeShootableState, callback: (handle: PolyMeshHandle) => void): void { - forEachQuakeShootableHandle(shootable, callback); - } - function countShootableHandles(shootable: QuakeShootableState): number { - return countQuakeShootableHandles(shootable); - } - function removeShootableHandles(shootable: QuakeShootableState): void { - const removed = removeQuakeShootableHandles(shootable); - visibilityChurn.totalMeshHandlesRemoved += removed.handles; - visibilityChurn.totalFrameHandlesRemoved += removed.frameHandles; - } - function addShootableMesh(entity: QuakeEntity, model?: QuakePickupModel, frameIndex = 0): PolyMeshHandle | null { - if (!entity.origin) return null; - const usesEnemyRuntime = quakeMonsterUsesEnemyRuntime(entity); - const handle = addMesh( - entity, - model, - frameIndex, - usesEnemyRuntime && enemyMotionMaterial - ? { frameSetMountOptions: { motionMaterial: enemyMotionMaterial } } - : undefined, - ); - if (!handle) return null; - visibilityChurn.totalMeshHandlesCreated++; - handle.element.classList.add("shootable"); - if (usesEnemyRuntime) handle.element.classList.add("enemy"); - stripPolyMeshMetadata(handle.element); - if (isQuakeDebugDomMetadataEnabled()) { - handle.element.dataset.entityIndex = String(entity.index); - handle.element.dataset.classname = entity.classname; - } - markQuakeTrace("shootable-mesh-create", { - entity: entity.index, - class: entity.classname, - enemy: usesEnemyRuntime, - frame: frameIndex, - leaves: handle.element.querySelectorAll("b,i,s,u").length, - model: Boolean(model), - }); - handle.setTransform({ - position: pointToPoly(entity.origin), - rotation: [ - 0, - 0, - normalizeShootableYaw(entity.angle ?? quakeEntityNumber(entity, "angle", 0), Boolean(model)), - ], - scale: model?.renderScale ? 1 / model.renderScale : 1, - }); - if (!model) { - pixelate(handle); - schedulePresentationResync(handle); - } - return handle; - } + + + function startEnemyLoop(): void { enemyLoop.start(); @@ -2761,7 +2637,7 @@ export function createQuakeShootablesController({ const enemy = shootable.enemy; if (!enemy) return; if (debugEnemyTickFilter && !debugEnemyTickFilter.has(shootable.entity.index)) return; - if (!shootable.handle || !shootable.visible) { + if (!presentation.hasHandle(shootable) || !presentation.isVisible(shootable)) { combatBudget.recordEnemyUpdate("skipped-unmounted"); updateUnmountedEnemy(shootable, playerOrigin, dt, now); return; @@ -3092,7 +2968,7 @@ export function createQuakeShootablesController({ acquisitionDecision: QuakeEnemyAcquisitionDecision | null, ): boolean { if (ambientMonsterPathingEnabled()) return false; - if (!shootable.visible || shootable.dead || shootable.health <= 0) return false; + if (!presentation.isVisible(shootable) || shootable.dead || shootable.health <= 0) return false; if (isPlayerInvisible?.() === true) return false; if ( acquisitionDecision?.reason !== "behind-mid" && @@ -3521,7 +3397,7 @@ export function createQuakeShootablesController({ const enemy = shootable.enemy; const profile = quakeMonsterAnimationProfile(shootable); const model = shootable.model; - if (!enemy || !profile || !model?.animationFrames?.length || !shootable.handle || !shootable.visible) return; + if (!enemy || !profile || !model?.animationFrames?.length || !presentation.hasHandle(shootable) || !presentation.isVisible(shootable)) return; const range = boundedAnimationRange(enemyAnimationRange(profile, mode), model); if (enemy.animationMode !== mode || enemy.animationFrameIndex < range.start || @@ -3660,7 +3536,7 @@ export function createQuakeShootablesController({ function syncAnimationPresentation(): void { if (!enemyAnimationPresentationEnabled()) return; for (const shootable of shootables.values()) { - if (!shootable.enemy || !shootable.handle || !shootable.visible) continue; + if (!shootable.enemy || !presentation.hasHandle(shootable) || !presentation.isVisible(shootable)) continue; activateShootableAnimationFrame(shootable, enemyAnimationFrameIndex(shootable)); } } @@ -3693,46 +3569,20 @@ export function createQuakeShootablesController({ }); return; } - if (!shootable.handle || !shootable.visible) { + if (!presentation.hasHandle(shootable) || !presentation.isVisible(shootable)) { markShootableTrace("enemy-animation-frame-logical", shootable, { requestedFrame: frameIndex, handles: countShootableHandles(shootable), }); return; } - if (isQuakeRenderBundleFrameSetHandle(shootable.handle)) { - if (setQuakeRenderBundleFrameSetHandleFrame(shootable.handle, frameIndex)) { - syncShootableEnemyDatasets(shootable); - markShootableTrace("enemy-animation-frame", shootable, { - backend: "frameset", - requestedFrame: frameIndex, - handles: countShootableHandles(shootable), - }); - } - return; - } - if (!canPoolShootableAnimationFrames(shootable)) { - replaceShootableAnimationFrame(shootable, frameIndex); - markShootableTrace("enemy-animation-frame", shootable, { - backend: "replace", - requestedFrame: frameIndex, - handles: countShootableHandles(shootable), - }); - return; + if (presentation.activateFrame(shootable, frameIndex, canPoolShootableAnimationFrames(shootable)) === "pool") { + scheduleNextShootableAnimationFramePrewarm(shootable); } - const handle = ensureShootableAnimationFrameHandle(shootable, frameIndex); - if (!handle) return; - setActiveShootableAnimationFrameHandle(shootable, frameIndex, handle); - markShootableTrace("enemy-animation-frame", shootable, { - backend: "pool", - requestedFrame: frameIndex, - handles: countShootableHandles(shootable), - }); - scheduleNextShootableAnimationFramePrewarm(shootable); } function shouldThrottleShootableAnimationFrame(shootable: QuakeShootableState): boolean { - if (!shootable.enemy || !shootable.handle || !shootable.visible) return false; + if (!shootable.enemy || !presentation.hasHandle(shootable) || !presentation.isVisible(shootable)) return false; if (shootable.dead || shootable.enemy.animationMode === "death") return false; const depth = shootableCameraDepth(shootable, getPlayerOrigin()); return depth > 0 && depth < shootableFrameSwapSafeDepth(shootable); @@ -3759,80 +3609,11 @@ export function createQuakeShootablesController({ return dotVec3(toShootable, forwardHorizontal); } - function replaceShootableAnimationFrame(shootable: QuakeShootableState, frameIndex: number): void { - const previousHandle = shootable.handle; - if (!previousHandle) return; - const nextHandle = addShootableMesh(shootable.entity, shootable.model, frameIndex); - if (!nextHandle) return; - previousHandle.remove(); - visibilityChurn.totalMeshHandlesRemoved++; - shootable.handle = nextHandle; - syncShootableTransform(shootable); - syncShootableHandleVisibility(shootable); - syncShootableEnemyDatasets(shootable); - } - function syncShootableEnemyDatasets(shootable: QuakeShootableState): void { - if (!isQuakeDebugDomMetadataEnabled()) return; - for (const [frameIndex, handle] of shootable.frameHandles) { - syncShootableEnemyDataset(shootable, handle, frameIndex); - } - if (shootable.handle && ![...shootable.frameHandles.values()].includes(shootable.handle)) { - syncShootableEnemyDataset(shootable, shootable.handle, enemyAnimationFrameIndex(shootable)); - } - } - function syncShootableEnemyDataset( - shootable: QuakeShootableState, - handle: PolyMeshHandle, - frameIndex: number, - ): void { - if (!isQuakeDebugDomMetadataEnabled()) return; - const enemy = shootable.enemy; - if (!enemy) return; - if (enemy.awake) { - setElementDatasetValue(handle.element, "awake", "true"); - } else { - removeElementDatasetValue(handle.element, "awake"); - } - if (enemy.attackVisual) { - setElementDatasetValue(handle.element, "attack", enemy.attackVisual); - } else { - removeElementDatasetValue(handle.element, "attack"); - } - setElementDatasetValue(handle.element, "originX", shootable.origin[0].toFixed(4)); - setElementDatasetValue(handle.element, "originY", shootable.origin[1].toFixed(4)); - setElementDatasetValue(handle.element, "originZ", shootable.origin[2].toFixed(4)); - setElementDatasetValue(handle.element, "yaw", shootable.yaw.toFixed(3)); - if (enemy.currentTarget) { - setElementDatasetValue(handle.element, "target", enemyTargetTraceLabel(enemy.currentTarget) ?? ""); - } else { - removeElementDatasetValue(handle.element, "target"); - } - setElementDatasetValue(handle.element, "animationMode", enemy.animationMode); - setElementDatasetValue(handle.element, "animationFrame", String(frameIndex)); - if (enemy.quakecLastState) { - setElementDatasetValue(handle.element, "quakecChain", enemy.quakecLastState.chain); - setElementDatasetValue(handle.element, "quakecState", enemy.quakecLastState.stateName); - setElementDatasetValue(handle.element, "quakecFrame", enemy.quakecLastState.frame); - setElementDatasetValue(handle.element, "quakecCalls", enemy.quakecLastState.calls.join(",")); - } else { - removeElementDatasetValue(handle.element, "quakecChain"); - removeElementDatasetValue(handle.element, "quakecState"); - removeElementDatasetValue(handle.element, "quakecFrame"); - removeElementDatasetValue(handle.element, "quakecCalls"); - } - } - function setElementDatasetValue(element: HTMLElement, key: string, value: string): void { - if (element.dataset[key] === value) return; - element.dataset[key] = value; - } - function removeElementDatasetValue(element: HTMLElement, key: string): void { - if (element.dataset[key] === undefined) return; - delete element.dataset[key]; - } + function enemyAnimationFrameIndex(shootable: QuakeShootableState): number { return shootable.enemy?.animationFrameIndex ?? 0; @@ -3882,7 +3663,7 @@ export function createQuakeShootablesController({ const enemy = shootable.enemy; const runner = enemy?.quakecRunner; const model = shootable.model; - if (!enemy || !runner || !model?.animationFrames?.length || !shootable.handle || !shootable.visible) { + if (!enemy || !runner || !model?.animationFrames?.length || !presentation.hasHandle(shootable) || !presentation.isVisible(shootable)) { return null; } if (!runner.hasChain(chain)) return null; @@ -3905,7 +3686,7 @@ export function createQuakeShootablesController({ const profile = quakeMonsterAnimationProfile(shootable); const model = shootable.model; const range = profile ? enemyOptionalAnimationRange(profile, mode) : undefined; - if (!enemy || !profile || !range || !model?.animationFrames?.length || !shootable.handle || !shootable.visible) { + if (!enemy || !profile || !range || !model?.animationFrames?.length || !presentation.hasHandle(shootable) || !presentation.isVisible(shootable)) { return null; } const boundedRange = boundedAnimationRange(range, model); @@ -3927,7 +3708,7 @@ export function createQuakeShootablesController({ shootable: QuakeShootableState, gib: QuakeMonsterDeathGibOutput, ): void { - if (!shootable.visible || !shootable.handle || !currentModelLibrary) return; + if (!presentation.isVisible(shootable) || !presentation.hasHandle(shootable) || !currentModelLibrary) return; const pieces = gib.pieces?.length ? gib.pieces.map((piece) => ({ kind: piece.call === "ThrowHead" ? "head" : "gib", @@ -4073,7 +3854,7 @@ export function createQuakeShootablesController({ } function syncShootableLifecycleClassesForShootable(shootable: QuakeShootableState): void { - syncQuakeShootableLifecycleClassesForShootable(shootable, shootableLifecycleClassState(shootable)); + presentation.syncLifecycle(shootable); } function shootableLifecycleClassState(shootable: QuakeShootableState): { @@ -4086,42 +3867,11 @@ export function createQuakeShootablesController({ }; } - function syncShootableTransform( - shootable: QuakeShootableState, - yaw = shootable.yaw, - ): void { + function syncShootableTransform(shootable: QuakeShootableState, yaw = shootable.yaw): void { shootable.yaw = yaw; - forEachShootableHandle(shootable, (handle) => syncShootableTransformForHandle(shootable, handle, yaw)); + presentation.syncTransform(shootable, yaw); } - function syncShootableTransformForHandle( - shootable: QuakeShootableState, - handle: PolyMeshHandle, - yaw = shootable.yaw, - ): void { - const renderPosition = shootable.origin; - const scale = shootable.model?.renderScale ? 1 / shootable.model.renderScale : 1; - const renderYaw = normalizeShootableYaw(yaw, Boolean(shootable.model)); - if (isQuakeDebugDomMetadataEnabled() && shootable.enemy) { - setElementDatasetValue(handle.element, "yaw", yaw.toFixed(3)); - } - if (!setQuakeShootableHandleTransformIfChanged( - handle, - renderPosition, - renderYaw, - scale, - QUAKE_SHOOTABLE_TRANSFORM_EPSILON, - )) return; - if (shootable.enemy && shootable.visible && handle === shootable.handle) { - markShootableTrace("enemy-transform", shootable, { - renderYaw, - yaw, - x: renderPosition[0], - y: renderPosition[1], - z: renderPosition[2], - }); - } - } function syncEnemyMotionMaterialsForView(origin: [number, number, number], reason: string): void { if (!enemyMotionMaterial) return; @@ -4134,26 +3884,10 @@ export function createQuakeShootablesController({ lastMotionMaterialForward = [...forward] as Vec3; if (!originChanged && !forwardChanged) return; for (const shootable of shootables.values()) { - markEnemyMotionMaterial(shootable, shootable.handle, reason); + presentation.markMotionMaterial(shootable, reason); } } - function markEnemyMotionMaterial( - shootable: QuakeShootableState, - handle: PolyMeshHandle | null, - reason: string, - ): boolean { - if ( - !enemyMotionMaterial || - !shootable.enemy || - shootable.dead || - !shootable.visible || - handle !== shootable.handle - ) { - return false; - } - return markQuakeRenderBundleFrameSetHandleMotionMaterial(handle, reason); - } function normalizeShootableYaw(yaw: number, hasAliasModel = false): number { return hasAliasModel ? quakeAliasModelRenderYaw(yaw) : normalizeQuakeRenderYaw(yaw); diff --git a/src/runtime/shootables/deathState.ts b/src/runtime/shootables/deathState.ts index 5580a51..92553ef 100644 --- a/src/runtime/shootables/deathState.ts +++ b/src/runtime/shootables/deathState.ts @@ -56,6 +56,7 @@ export interface QuakeShootableDeathStateRuntimeOptions { chainDurationMs(classname: string, chain: string, runner: QuakeMonsterStateRunner): number; clearAttackState(shootable: QuakeShootableState): void; countHandles(shootable: QuakeShootableState): number; + hasHandle(shootable: QuakeShootableState): boolean; destroyZombieGib(shootable: QuakeShootableState, context: QuakeShootableDamageContext): boolean; dropBackpack?: (drop: QuakeMonsterBackpackDropRuntime) => boolean | void; flashShootable(shootable: QuakeShootableState): void; @@ -211,7 +212,7 @@ export function createQuakeShootableDeathStateRuntime( enemy.nextAnimationFrameAt = Infinity; if (corpseFrameIndex === undefined) return; enemy.animationFrameIndex = corpseFrameIndex; - if (shootable.handle) options.activateAnimationFrame(shootable, corpseFrameIndex); + if (options.hasHandle(shootable)) options.activateAnimationFrame(shootable, corpseFrameIndex); } function syncZombiePainDownStep( diff --git a/src/runtime/shootables/enemyCombat.ts b/src/runtime/shootables/enemyCombat.ts index 8337917..1f7c796 100644 --- a/src/runtime/shootables/enemyCombat.ts +++ b/src/runtime/shootables/enemyCombat.ts @@ -69,6 +69,7 @@ export interface QuakeEnemyCombatRuntimeOptions extends QuakeEnemyCombatContext damagePlayer(amount: number, context?: QuakePlayerDamageContext): boolean; getPlayerOrigin(): [number, number, number]; isGameplayPaused?: () => boolean; + isVisible(shootable: QuakeShootableState): boolean; markTrace(kind: string, shootable: QuakeShootableState, details?: QuakeEnemyCombatTraceDetails): void; playSound?(soundPath: string, options?: QuakeEnemyCombatSoundOptions): boolean; shootableBoundsForDamage(shootable: QuakeShootableState): QuakeBounds; @@ -408,7 +409,7 @@ export function createQuakeEnemyCombatRuntime(options: QuakeEnemyCombatRuntimeOp event.originOffsetUnits, ); const fireProjectile = (fireNow: number, target: Vec3): void => { - if (shootable.dead || !shootable.enemy || !shootable.visible) return; + if (shootable.dead || !shootable.enemy || !options.isVisible(shootable)) return; options.spawnProjectile(shootable, enemy, start, target, profile, fireNow); options.markTrace("enemy-quakec-event", shootable, { call: event.call, diff --git a/src/runtime/shootables/presentation.ts b/src/runtime/shootables/presentation.ts index bce91f5..164957b 100644 --- a/src/runtime/shootables/presentation.ts +++ b/src/runtime/shootables/presentation.ts @@ -1,164 +1,578 @@ import type { PolyMeshHandle, Vec3 } from "@layoutit/polycss"; +import type { QuakeEntity } from "../../types/quake"; +import { quakeAliasModelRenderYaw, normalizeQuakeRenderYaw } from "../aliasModelOrientation"; +import { COLLISION_EPSILON } from "../constants"; +import { isQuakeDebugDomMetadataEnabled, markQuakeTrace } from "../debug/traceMarks"; +import { quakeEntityNumber } from "../entities"; +import type { QuakePickupModel } from "../pickups"; +import { + isQuakeRenderBundleFrameSetHandle, + markQuakeRenderBundleFrameSetHandleMotionMaterial, + setQuakeRenderBundleFrameSetHandleFrame, + stripPolyMeshMetadata, + type QuakeRenderBundleFrameSetMotionMaterialOptions, + type QuakeRenderBundleFrameSetMountOptions, +} from "../renderBundleMesh"; +import { quakeMonsterUsesEnemyRuntime } from "./bounds"; +import type { QuakeShootableState, QuakeEnemyTargetReference } from "./state"; -import type { QuakeShootableState, QuakeShootableTransformSnapshot } from "./state"; - -export interface QuakeShootableLifecycleClassState { - deathAnimating: boolean; - persistentCorpse: boolean; +type Shootable = Readonly; +type TraceDetails = Record; +interface RenderRecord { + handle: PolyMeshHandle | null; + frameHandles: Map; + visible: boolean; } - -export interface QuakeShootableHandleRemovalStats { - frameHandles: number; - handles: number; +type FrameBackend = "frameset" | "pool" | "replace"; +interface TransformSnapshot { x: number; y: number; z: number; yaw: number; scale: number } +interface Lifecycle { deathAnimating: boolean; persistentCorpse: boolean } +interface HandleChanges { + totalMeshHandlesCreated?: number; + totalMeshHandlesRemoved?: number; + totalFrameHandlesCreated?: number; + totalFrameHandlesRemoved?: number; } - -const QUAKE_SHOOTABLE_PREWARMED_CLASS = "quake-shootable-prewarmed"; -const QUAKE_SHOOTABLE_FRAME_HIDDEN_CLASS = "quake-frame-hidden"; -const QUAKE_SHOOTABLE_DYING_CLASS = "quake-shootable-dying"; -const QUAKE_SHOOTABLE_CORPSE_CLASS = "quake-shootable-corpse"; -const QUAKE_SHOOTABLE_DEAD_CLASS = "quake-shootable-dead"; -const QUAKE_SHOOTABLE_HURT_CLASS = "quake-shootable-hurt"; -const QUAKE_SHOOTABLE_HURT_FLASH_MS = 120; -const quakeShootableTransformSnapshots = new WeakMap(); -const quakeShootableHurtFlashTimers = new WeakMap(); - -export function forEachQuakeShootableHandle( - shootable: QuakeShootableState, - callback: (handle: PolyMeshHandle) => void, -): void { - const handles = new Set(shootable.frameHandles.values()); - if (shootable.handle) handles.add(shootable.handle); - for (const handle of handles) callback(handle); +export interface QuakeShootablePresentationOptions { + addMesh(entity: QuakeEntity, model?: QuakePickupModel, frameIndex?: number, + options?: { frameSetMountOptions?: QuakeRenderBundleFrameSetMountOptions }): PolyMeshHandle | null; + pointToPoly(point: { x: number; y: number; z: number }): Vec3; + pixelate(handle: PolyMeshHandle): void; + schedulePresentationResync(handle: PolyMeshHandle): void; + enemyMotionMaterial?: QuakeRenderBundleFrameSetMotionMaterialOptions | null; + lifecycle(shootable: Shootable): Lifecycle; + nextFrameIndex(shootable: Shootable): number | undefined; + markTrace(kind: string, shootable: Shootable, details?: TraceDetails): void; + onHandlesChanged(changes: HandleChanges): void; } -export function countQuakeShootableHandles(shootable: QuakeShootableState): number { - const handles = new Set(shootable.frameHandles.values()); - if (shootable.handle) handles.add(shootable.handle); - return handles.size; -} +const QUAKE_SHOOTABLE_ANIMATION_FRAME_POOL_SIZE = 3; +const QUAKE_SHOOTABLE_TRANSFORM_EPSILON = COLLISION_EPSILON; -export function removeQuakeShootableHandles(shootable: QuakeShootableState): QuakeShootableHandleRemovalStats { - const handles = countQuakeShootableHandles(shootable); - const frameHandles = shootable.frameHandles.size; - forEachQuakeShootableHandle(shootable, (handle) => handle.remove()); - shootable.handle = null; - shootable.frameHandles.clear(); - shootable.visible = false; - return { handles, frameHandles }; -} +/** Owns mesh lifetime and publication. Simulation state never contains a mesh or frame pool. */ +export function createQuakeShootablePresentation(options: QuakeShootablePresentationOptions) { + const { addMesh, pointToPoly, pixelate, schedulePresentationResync, enemyMotionMaterial, + markTrace: markShootableTrace, lifecycle: shootableLifecycleClassState, + nextFrameIndex: nextShootableAnimationFrameIndex } = options; + const records = new WeakMap(); + function stateFor(shootable: Shootable): RenderRecord { + let state = records.get(shootable); + if (!state) { + state = { handle: null, frameHandles: new Map(), visible: false }; + records.set(shootable, state); + } + return state; + } + function hasHandle(shootable: Shootable): boolean { return records.get(shootable)?.handle != null; } + function isVisible(shootable: Shootable): boolean { return records.get(shootable)?.visible ?? false; } + function frameHandleCount(shootable: Shootable): number { return records.get(shootable)?.frameHandles.size ?? 0; } + function hasFrame(shootable: Shootable, frame: number): boolean { return records.get(shootable)?.frameHandles.has(frame) ?? false; } + function setVisible(shootable: Shootable, visible: boolean): void { + const state = stateFor(shootable); + state.visible = state.handle !== null && visible; + syncShootableHandleVisibility(shootable); + } + function canUseShootableAnimationFrameSet(shootable: Shootable): boolean { + return Boolean(shootable.enemy && shootable.model?.animationFrames?.length && shootable.model.animationFrameSet); + } -export function syncQuakeShootableHandleVisibility( - shootable: QuakeShootableState, - lifecycle: QuakeShootableLifecycleClassState, -): void { - forEachQuakeShootableHandle(shootable, (handle) => { - syncQuakeShootableLifecycleClasses(shootable, handle, lifecycle); - const active = handle === shootable.handle; - if (!shootable.visible) { - handle.element.classList.add(QUAKE_SHOOTABLE_PREWARMED_CLASS); - if (active) handle.element.classList.remove(QUAKE_SHOOTABLE_FRAME_HIDDEN_CLASS); - handle.element.setAttribute("aria-hidden", "true"); - return; + function ensureShootableAnimationFrameHandle( + shootable: Shootable, + frameIndex: number, + ): PolyMeshHandle | null { + const state = stateFor(shootable); + const existing = state.frameHandles.get(frameIndex); + if (existing) return existing; + const handle = addShootableMesh(shootable.entity, shootable.model, frameIndex); + if (!handle) return null; + state.frameHandles.set(frameIndex, handle); + options.onHandlesChanged({ totalFrameHandlesCreated: 1 }); + markShootableTrace("shootable-frame-handle-create", shootable, { + requestedFrame: frameIndex, + handles: countShootableHandles(shootable), + }); + syncShootableTransformForHandle(shootable, handle); + syncShootableHandleVisibility(shootable); + syncShootableEnemyDataset(shootable, handle, frameIndex); + return handle; + } + + function setActiveShootableAnimationFrameHandle( + shootable: Shootable, + frameIndex: number, + handle: PolyMeshHandle, + ): void { + const state = stateFor(shootable); + state.frameHandles.delete(frameIndex); + state.frameHandles.set(frameIndex, handle); + state.handle = handle; + syncShootableTransform(shootable); + syncShootableHandleVisibility(shootable); + syncShootableEnemyDatasets(shootable); + trimShootableAnimationFrameHandles(shootable); + } + + function syncShootableHandleVisibility(shootable: Shootable): void { + syncQuakeShootableHandleVisibility(shootable, shootableLifecycleClassState(shootable)); + } + + function trimShootableAnimationFrameHandles(shootable: Shootable): void { + const state = stateFor(shootable); + if (state.frameHandles.size <= QUAKE_SHOOTABLE_ANIMATION_FRAME_POOL_SIZE) return; + const keepFrameIndex = enemyAnimationFrameIndex(shootable); + const nextFrameIndex = nextShootableAnimationFrameIndex(shootable); + for (const [frameIndex, handle] of state.frameHandles) { + if (state.frameHandles.size <= QUAKE_SHOOTABLE_ANIMATION_FRAME_POOL_SIZE) return; + if (handle === state.handle || frameIndex === keepFrameIndex || frameIndex === nextFrameIndex) continue; + handle.remove(); + options.onHandlesChanged({ totalMeshHandlesRemoved: 1 }); + options.onHandlesChanged({ totalFrameHandlesRemoved: 1 }); + state.frameHandles.delete(frameIndex); + } + } + + function forEachShootableHandle(shootable: Shootable, callback: (handle: PolyMeshHandle) => void): void { + forEachQuakeShootableHandle(shootable, callback); + } + + function countShootableHandles(shootable: Shootable): number { + return countQuakeShootableHandles(shootable); + } + + function removeShootableHandles(shootable: Shootable): void { + const removed = removeQuakeShootableHandles(shootable); + options.onHandlesChanged({ totalMeshHandlesRemoved: removed.handles }); + options.onHandlesChanged({ totalFrameHandlesRemoved: removed.frameHandles }); + } + + function addShootableMesh(entity: QuakeEntity, model?: QuakePickupModel, frameIndex = 0): PolyMeshHandle | null { + if (!entity.origin) return null; + const usesEnemyRuntime = quakeMonsterUsesEnemyRuntime(entity); + const handle = addMesh( + entity, + model, + frameIndex, + usesEnemyRuntime && enemyMotionMaterial + ? { frameSetMountOptions: { motionMaterial: enemyMotionMaterial } } + : undefined, + ); + if (!handle) return null; + options.onHandlesChanged({ totalMeshHandlesCreated: 1 }); + handle.element.classList.add("shootable"); + if (usesEnemyRuntime) handle.element.classList.add("enemy"); + stripPolyMeshMetadata(handle.element); + if (isQuakeDebugDomMetadataEnabled()) { + handle.element.dataset.entityIndex = String(entity.index); + handle.element.dataset.classname = entity.classname; } - handle.element.classList.remove(QUAKE_SHOOTABLE_PREWARMED_CLASS); - if (active) { - handle.element.classList.remove(QUAKE_SHOOTABLE_FRAME_HIDDEN_CLASS); - handle.element.removeAttribute("aria-hidden"); + markQuakeTrace("shootable-mesh-create", { + entity: entity.index, + class: entity.classname, + enemy: usesEnemyRuntime, + frame: frameIndex, + leaves: handle.element.querySelectorAll("b,i,s,u").length, + model: Boolean(model), + }); + handle.setTransform({ + position: pointToPoly(entity.origin), + rotation: [ + 0, + 0, + normalizeShootableYaw(entity.angle ?? quakeEntityNumber(entity, "angle", 0), Boolean(model)), + ], + scale: model?.renderScale ? 1 / model.renderScale : 1, + }); + if (!model) { + pixelate(handle); + schedulePresentationResync(handle); + } + return handle; + } + + function replaceShootableAnimationFrame(shootable: Shootable, frameIndex: number): void { + const state = stateFor(shootable); + const previousHandle = state.handle; + if (!previousHandle) return; + const nextHandle = addShootableMesh(shootable.entity, shootable.model, frameIndex); + if (!nextHandle) return; + previousHandle.remove(); + options.onHandlesChanged({ totalMeshHandlesRemoved: 1 }); + state.handle = nextHandle; + syncShootableTransform(shootable); + syncShootableHandleVisibility(shootable); + syncShootableEnemyDatasets(shootable); + } + + function syncShootableEnemyDatasets(shootable: Shootable): void { + const state = stateFor(shootable); + if (!isQuakeDebugDomMetadataEnabled()) return; + for (const [frameIndex, handle] of state.frameHandles) { + syncShootableEnemyDataset(shootable, handle, frameIndex); + } + if (state.handle && ![...state.frameHandles.values()].includes(state.handle)) { + syncShootableEnemyDataset(shootable, state.handle, enemyAnimationFrameIndex(shootable)); + } + } + + function syncShootableEnemyDataset( + shootable: Shootable, + handle: PolyMeshHandle, + frameIndex: number, + ): void { + if (!isQuakeDebugDomMetadataEnabled()) return; + const enemy = shootable.enemy; + if (!enemy) return; + if (enemy.awake) { + setElementDatasetValue(handle.element, "awake", "true"); } else { - handle.element.classList.add(QUAKE_SHOOTABLE_FRAME_HIDDEN_CLASS); - handle.element.setAttribute("aria-hidden", "true"); + removeElementDatasetValue(handle.element, "awake"); } - }); -} + if (enemy.attackVisual) { + setElementDatasetValue(handle.element, "attack", enemy.attackVisual); + } else { + removeElementDatasetValue(handle.element, "attack"); + } + setElementDatasetValue(handle.element, "originX", shootable.origin[0].toFixed(4)); + setElementDatasetValue(handle.element, "originY", shootable.origin[1].toFixed(4)); + setElementDatasetValue(handle.element, "originZ", shootable.origin[2].toFixed(4)); + setElementDatasetValue(handle.element, "yaw", shootable.yaw.toFixed(3)); + if (enemy.currentTarget) { + setElementDatasetValue(handle.element, "target", enemyTargetTraceLabel(enemy.currentTarget) ?? ""); + } else { + removeElementDatasetValue(handle.element, "target"); + } + setElementDatasetValue(handle.element, "animationMode", enemy.animationMode); + setElementDatasetValue(handle.element, "animationFrame", String(frameIndex)); + if (enemy.quakecLastState) { + setElementDatasetValue(handle.element, "quakecChain", enemy.quakecLastState.chain); + setElementDatasetValue(handle.element, "quakecState", enemy.quakecLastState.stateName); + setElementDatasetValue(handle.element, "quakecFrame", enemy.quakecLastState.frame); + setElementDatasetValue(handle.element, "quakecCalls", enemy.quakecLastState.calls.join(",")); + } else { + removeElementDatasetValue(handle.element, "quakecChain"); + removeElementDatasetValue(handle.element, "quakecState"); + removeElementDatasetValue(handle.element, "quakecFrame"); + removeElementDatasetValue(handle.element, "quakecCalls"); + } + } -export function syncQuakeShootableLifecycleClassesForShootable( - shootable: QuakeShootableState, - lifecycle: QuakeShootableLifecycleClassState, -): void { - forEachQuakeShootableHandle(shootable, (handle) => syncQuakeShootableLifecycleClasses(shootable, handle, lifecycle)); -} + function setElementDatasetValue(element: HTMLElement, key: string, value: string): void { + if (element.dataset[key] === value) return; + element.dataset[key] = value; + } -export function flashQuakeShootable(shootable: QuakeShootableState): void { - const element = shootable.handle?.element; - if (!element) return; - const previousTimer = quakeShootableHurtFlashTimers.get(element); - if (previousTimer !== undefined) window.clearTimeout(previousTimer); - element.classList.remove(QUAKE_SHOOTABLE_HURT_CLASS); - void element.offsetWidth; - element.classList.add(QUAKE_SHOOTABLE_HURT_CLASS); - const timer = window.setTimeout(() => { - quakeShootableHurtFlashTimers.delete(element); - if (element.isConnected) element.classList.remove(QUAKE_SHOOTABLE_HURT_CLASS); - }, QUAKE_SHOOTABLE_HURT_FLASH_MS); - quakeShootableHurtFlashTimers.set(element, timer); -} + function removeElementDatasetValue(element: HTMLElement, key: string): void { + if (element.dataset[key] === undefined) return; + delete element.dataset[key]; + } -export function setQuakeShootableHandleTransformIfChanged( - handle: PolyMeshHandle, - renderPosition: Vec3, - yaw: number, - scale: number, - epsilon: number, -): boolean { - const next = { - x: renderPosition[0], - y: renderPosition[1], - z: renderPosition[2], - yaw, - scale, - }; - const previous = quakeShootableTransformSnapshots.get(handle); - if (previous && quakeShootableTransformSnapshotEquals(previous, next, epsilon)) return false; - quakeShootableTransformSnapshots.set(handle, next); - handle.setTransform({ - position: renderPosition, - rotation: [0, 0, yaw], - scale, - }); - return true; -} + function syncShootableTransformForHandle( + shootable: Shootable, + handle: PolyMeshHandle, + yaw = shootable.yaw, + ): void { + const state = stateFor(shootable); + const renderPosition = shootable.origin; + const scale = shootable.model?.renderScale ? 1 / shootable.model.renderScale : 1; + const renderYaw = normalizeShootableYaw(yaw, Boolean(shootable.model)); + if (isQuakeDebugDomMetadataEnabled() && shootable.enemy) { + setElementDatasetValue(handle.element, "yaw", yaw.toFixed(3)); + } + if (!setQuakeShootableHandleTransformIfChanged( + handle, + renderPosition, + renderYaw, + scale, + QUAKE_SHOOTABLE_TRANSFORM_EPSILON, + )) return; + if (shootable.enemy && state.visible && handle === state.handle) { + markShootableTrace("enemy-transform", shootable, { + renderYaw, + yaw, + x: renderPosition[0], + y: renderPosition[1], + z: renderPosition[2], + }); + } + } -function syncQuakeShootableLifecycleClasses( - shootable: QuakeShootableState, - handle: PolyMeshHandle, - lifecycle: QuakeShootableLifecycleClassState, -): void { - if (!shootable.dead) { - handle.element.classList.remove( - QUAKE_SHOOTABLE_CORPSE_CLASS, - QUAKE_SHOOTABLE_DEAD_CLASS, - QUAKE_SHOOTABLE_DYING_CLASS, - ); - return; - } - handle.element.classList.remove(QUAKE_SHOOTABLE_HURT_CLASS); - if (lifecycle.deathAnimating) { - handle.element.classList.add(QUAKE_SHOOTABLE_DYING_CLASS); - handle.element.classList.remove(QUAKE_SHOOTABLE_CORPSE_CLASS, QUAKE_SHOOTABLE_DEAD_CLASS); - return; - } - handle.element.classList.remove(QUAKE_SHOOTABLE_DYING_CLASS); - if (lifecycle.persistentCorpse) { - handle.element.classList.add(QUAKE_SHOOTABLE_CORPSE_CLASS); - handle.element.classList.remove(QUAKE_SHOOTABLE_DEAD_CLASS); - return; - } - handle.element.classList.add(QUAKE_SHOOTABLE_DEAD_CLASS); - handle.element.classList.remove(QUAKE_SHOOTABLE_CORPSE_CLASS); -} + function markEnemyMotionMaterial( + shootable: Shootable, + handle: PolyMeshHandle | null, + reason: string, + ): boolean { + const state = stateFor(shootable); + if ( + !enemyMotionMaterial || + !shootable.enemy || + shootable.dead || + !state.visible || + handle !== state.handle + ) { + return false; + } + return markQuakeRenderBundleFrameSetHandleMotionMaterial(handle, reason); + } -function quakeShootableTransformSnapshotEquals( - previous: QuakeShootableTransformSnapshot, - next: QuakeShootableTransformSnapshot, - epsilon: number, -): boolean { - return quakeTransformNumberEquals(previous.x, next.x, epsilon) && - quakeTransformNumberEquals(previous.y, next.y, epsilon) && - quakeTransformNumberEquals(previous.z, next.z, epsilon) && - quakeTransformNumberEquals(previous.yaw, next.yaw, epsilon) && - quakeTransformNumberEquals(previous.scale, next.scale, epsilon); -} + function enemyTargetTraceLabel(target: QuakeEnemyTargetReference | null): string | null { + if (!target) return null; + return target.kind === "shootable" ? `${target.classname}:${target.entityIndex}` : target.kind; + } + + function enemyAnimationFrameIndex(shootable: Shootable): number { + return shootable.enemy?.animationFrameIndex ?? 0; + } + + function normalizeShootableYaw(yaw: number, hasAliasModel = false): number { + return hasAliasModel ? quakeAliasModelRenderYaw(yaw) : normalizeQuakeRenderYaw(yaw); + } + + function mount(shootable: Shootable, poolFrames: boolean): FrameBackend | null { + const state = stateFor(shootable); + if (state.handle) { + return isQuakeRenderBundleFrameSetHandle(state.handle) ? "frameset" : state.frameHandles.size ? "pool" : "replace"; + } + if (canUseShootableAnimationFrameSet(shootable)) { + state.handle = addShootableMesh(shootable.entity, shootable.model, enemyAnimationFrameIndex(shootable)); + markShootableTrace("shootable-mount", shootable, { + backend: "frameset", + handles: countShootableHandles(shootable), + }); + syncShootableTransform(shootable); + syncShootableHandleVisibility(shootable); + syncShootableEnemyDatasets(shootable); + return "frameset"; + } + if (poolFrames) { + const frameIndex = enemyAnimationFrameIndex(shootable); + const handle = ensureShootableAnimationFrameHandle(shootable, frameIndex); + if (!handle) return null; + setActiveShootableAnimationFrameHandle(shootable, frameIndex, handle); + markShootableTrace("shootable-mount", shootable, { + backend: "pool", + handles: countShootableHandles(shootable), + }); + + return "pool"; + } + state.handle = addShootableMesh(shootable.entity, shootable.model, enemyAnimationFrameIndex(shootable)); + markShootableTrace("shootable-mount", shootable, { + backend: "replace", + handles: countShootableHandles(shootable), + }); + syncShootableTransform(shootable); + syncShootableHandleVisibility(shootable); + syncShootableEnemyDatasets(shootable); + return "replace"; + } + + function activateFrame(shootable: Shootable, frameIndex: number, poolFrames: boolean): FrameBackend | null { + const state = stateFor(shootable); + if (!state.handle || !state.visible) return null; + if (isQuakeRenderBundleFrameSetHandle(state.handle)) { + if (setQuakeRenderBundleFrameSetHandleFrame(state.handle, frameIndex)) { + syncShootableEnemyDatasets(shootable); + markShootableTrace("enemy-animation-frame", shootable, { + backend: "frameset", + requestedFrame: frameIndex, + handles: countShootableHandles(shootable), + }); + } + return "frameset"; + } + if (!poolFrames) { + replaceShootableAnimationFrame(shootable, frameIndex); + markShootableTrace("enemy-animation-frame", shootable, { + backend: "replace", + requestedFrame: frameIndex, + handles: countShootableHandles(shootable), + }); + return "replace"; + } + const handle = ensureShootableAnimationFrameHandle(shootable, frameIndex); + if (!handle) return null; + setActiveShootableAnimationFrameHandle(shootable, frameIndex, handle); + markShootableTrace("enemy-animation-frame", shootable, { + backend: "pool", + requestedFrame: frameIndex, + handles: countShootableHandles(shootable), + }); + return "pool"; + } + + function syncShootableTransform( + shootable: Shootable, + yaw = shootable.yaw, + ): void { + forEachShootableHandle(shootable, (handle) => syncShootableTransformForHandle(shootable, handle, yaw)); + } + + const QUAKE_SHOOTABLE_PREWARMED_CLASS = "quake-shootable-prewarmed"; + const QUAKE_SHOOTABLE_FRAME_HIDDEN_CLASS = "quake-frame-hidden"; + const QUAKE_SHOOTABLE_DYING_CLASS = "quake-shootable-dying"; + const QUAKE_SHOOTABLE_CORPSE_CLASS = "quake-shootable-corpse"; + const QUAKE_SHOOTABLE_DEAD_CLASS = "quake-shootable-dead"; + const QUAKE_SHOOTABLE_HURT_CLASS = "quake-shootable-hurt"; + const QUAKE_SHOOTABLE_HURT_FLASH_MS = 120; + const quakeShootableTransformSnapshots = new WeakMap(); + const quakeShootableHurtFlashTimers = new WeakMap(); + + function forEachQuakeShootableHandle( + shootable: Shootable, + callback: (handle: PolyMeshHandle) => void, + ): void { + const state = stateFor(shootable); + const handles = new Set(state.frameHandles.values()); + if (state.handle) handles.add(state.handle); + for (const handle of handles) callback(handle); + } -function quakeTransformNumberEquals(previous: number, next: number, epsilon: number): boolean { - return Math.abs(previous - next) <= epsilon; + function countQuakeShootableHandles(shootable: Shootable): number { + const state = stateFor(shootable); + const handles = new Set(state.frameHandles.values()); + if (state.handle) handles.add(state.handle); + return handles.size; + } + + function removeQuakeShootableHandles(shootable: Shootable): { frameHandles: number; handles: number } { + const state = stateFor(shootable); + const handles = countQuakeShootableHandles(shootable); + const frameHandles = state.frameHandles.size; + forEachQuakeShootableHandle(shootable, (handle) => handle.remove()); + state.handle = null; + state.frameHandles.clear(); + state.visible = false; + return { handles, frameHandles }; + } + + function syncQuakeShootableHandleVisibility( + shootable: Shootable, + lifecycle: Lifecycle, + ): void { + const state = stateFor(shootable); + forEachQuakeShootableHandle(shootable, (handle) => { + syncQuakeShootableLifecycleClasses(shootable, handle, lifecycle); + const active = handle === state.handle; + if (!state.visible) { + handle.element.classList.add(QUAKE_SHOOTABLE_PREWARMED_CLASS); + if (active) handle.element.classList.remove(QUAKE_SHOOTABLE_FRAME_HIDDEN_CLASS); + handle.element.setAttribute("aria-hidden", "true"); + return; + } + handle.element.classList.remove(QUAKE_SHOOTABLE_PREWARMED_CLASS); + if (active) { + handle.element.classList.remove(QUAKE_SHOOTABLE_FRAME_HIDDEN_CLASS); + handle.element.removeAttribute("aria-hidden"); + } else { + handle.element.classList.add(QUAKE_SHOOTABLE_FRAME_HIDDEN_CLASS); + handle.element.setAttribute("aria-hidden", "true"); + } + }); + } + + function syncQuakeShootableLifecycleClassesForShootable( + shootable: Shootable, + lifecycle: Lifecycle, + ): void { + forEachQuakeShootableHandle(shootable, (handle) => syncQuakeShootableLifecycleClasses(shootable, handle, lifecycle)); + } + + function flashQuakeShootable(shootable: Shootable): void { + const state = stateFor(shootable); + const element = state.handle?.element; + if (!element) return; + const previousTimer = quakeShootableHurtFlashTimers.get(element); + if (previousTimer !== undefined) window.clearTimeout(previousTimer); + element.classList.remove(QUAKE_SHOOTABLE_HURT_CLASS); + void element.offsetWidth; + element.classList.add(QUAKE_SHOOTABLE_HURT_CLASS); + const timer = window.setTimeout(() => { + quakeShootableHurtFlashTimers.delete(element); + if (element.isConnected) element.classList.remove(QUAKE_SHOOTABLE_HURT_CLASS); + }, QUAKE_SHOOTABLE_HURT_FLASH_MS); + quakeShootableHurtFlashTimers.set(element, timer); + } + + function setQuakeShootableHandleTransformIfChanged( + handle: PolyMeshHandle, + renderPosition: Vec3, + yaw: number, + scale: number, + epsilon: number, + ): boolean { + const next = { + x: renderPosition[0], + y: renderPosition[1], + z: renderPosition[2], + yaw, + scale, + }; + const previous = quakeShootableTransformSnapshots.get(handle); + if (previous && quakeShootableTransformSnapshotEquals(previous, next, epsilon)) return false; + quakeShootableTransformSnapshots.set(handle, next); + handle.setTransform({ + position: renderPosition, + rotation: [0, 0, yaw], + scale, + }); + return true; + } + + function syncQuakeShootableLifecycleClasses( + shootable: Shootable, + handle: PolyMeshHandle, + lifecycle: Lifecycle, + ): void { + if (!shootable.dead) { + handle.element.classList.remove( + QUAKE_SHOOTABLE_CORPSE_CLASS, + QUAKE_SHOOTABLE_DEAD_CLASS, + QUAKE_SHOOTABLE_DYING_CLASS, + ); + return; + } + handle.element.classList.remove(QUAKE_SHOOTABLE_HURT_CLASS); + if (lifecycle.deathAnimating) { + handle.element.classList.add(QUAKE_SHOOTABLE_DYING_CLASS); + handle.element.classList.remove(QUAKE_SHOOTABLE_CORPSE_CLASS, QUAKE_SHOOTABLE_DEAD_CLASS); + return; + } + handle.element.classList.remove(QUAKE_SHOOTABLE_DYING_CLASS); + if (lifecycle.persistentCorpse) { + handle.element.classList.add(QUAKE_SHOOTABLE_CORPSE_CLASS); + handle.element.classList.remove(QUAKE_SHOOTABLE_DEAD_CLASS); + return; + } + handle.element.classList.add(QUAKE_SHOOTABLE_DEAD_CLASS); + handle.element.classList.remove(QUAKE_SHOOTABLE_CORPSE_CLASS); + } + + function quakeShootableTransformSnapshotEquals( + previous: TransformSnapshot, + next: TransformSnapshot, + epsilon: number, + ): boolean { + return quakeTransformNumberEquals(previous.x, next.x, epsilon) && + quakeTransformNumberEquals(previous.y, next.y, epsilon) && + quakeTransformNumberEquals(previous.z, next.z, epsilon) && + quakeTransformNumberEquals(previous.yaw, next.yaw, epsilon) && + quakeTransformNumberEquals(previous.scale, next.scale, epsilon); + } + + function quakeTransformNumberEquals(previous: number, next: number, epsilon: number): boolean { + return Math.abs(previous - next) <= epsilon; + } + + return { + hasHandle, isVisible, frameHandleCount, hasFrame, setVisible, mount, activateFrame, + supportsFrameSet: canUseShootableAnimationFrameSet, + ensureFrame: (shootable: Shootable, frame: number) => { ensureShootableAnimationFrameHandle(shootable, frame); }, + trimFrames: trimShootableAnimationFrameHandles, + handleCount: countShootableHandles, + remove: removeShootableHandles, + syncTransform: syncShootableTransform, + syncDatasets: syncShootableEnemyDatasets, + syncLifecycle: (shootable: Shootable) => syncQuakeShootableLifecycleClassesForShootable(shootable, shootableLifecycleClassState(shootable)), + flash: flashQuakeShootable, + markMotionMaterial: (shootable: Shootable, reason: string) => markEnemyMotionMaterial(shootable, stateFor(shootable).handle, reason), + }; } diff --git a/src/runtime/shootables/prewarm.ts b/src/runtime/shootables/prewarm.ts index f7a0deb..936f732 100644 --- a/src/runtime/shootables/prewarm.ts +++ b/src/runtime/shootables/prewarm.ts @@ -3,9 +3,6 @@ import type { QuakeIdleDeadline, QuakeWindowWithIdle } from "./state"; interface QuakePrewarmShootableState { dead: boolean; entity: { index: number }; - frameHandles: Map; - handle: unknown | null; - visible: boolean; } const QUAKE_SHOOTABLE_PREWARM_MIN_IDLE_MS = 4; @@ -24,6 +21,9 @@ export interface QuakeShootablePrewarmQueues { + hasHandle(shootable: TShootable): boolean; + isVisible(shootable: TShootable): boolean; + hasFrame(shootable: TShootable, frameIndex: number): boolean; canPoolAnimationFrame(shootable: TShootable): boolean; canPrewarmShootable(shootable: TShootable): boolean; ensureAnimationFrame(shootable: TShootable, frameIndex: number): void; @@ -103,7 +103,7 @@ export function createQuakeShootablePrewarmQueues; - visible: boolean; lastMountCandidateAt: number; yaw: number; health: number; @@ -37,14 +34,6 @@ export function createQuakeShootableStateMap(): QuakeShootableStateMap { return new Map(); } -export interface QuakeShootableTransformSnapshot { - scale: number; - x: number; - y: number; - yaw: number; - z: number; -} - export interface QuakeEnemyState { animationFrameIndex: number; animationLockUntil: number; diff --git a/src/runtime/world.ts b/src/runtime/world.ts index 0f9be46..a2da4e3 100644 --- a/src/runtime/world.ts +++ b/src/runtime/world.ts @@ -1128,6 +1128,13 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) } }; + function observeTextureReadiness(promise: Promise): Promise { + // Visibility also preloads during play, when no readiness caller is waiting. + // Keep the original rejected promise so a readiness check still fails. + void promise.catch(() => {}); + return promise; + } + const waitForVisibleAtlasPages = (): Promise => visibleAtlasPageReadyPromise; const waitForVisibleTextures = (): Promise => Promise.all([ @@ -1149,7 +1156,7 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) const exposedPages = currentPages.length ? currentPages : allPages; const warmPages = prewarmPages.length ? prewarmPages : exposedPages; setVisibleAtlasResidencyPages(exposedPages); - visibleAtlasPrewarmReadyPromise = preloadQuakeRenderBundleAtlasPages(currentRenderBundle, warmPages); + visibleAtlasPrewarmReadyPromise = observeTextureReadiness(preloadQuakeRenderBundleAtlasPages(currentRenderBundle, warmPages)); }; const setVisibleAtlasResidencyPages = (pageIndexes: readonly number[]): void => { @@ -1160,7 +1167,7 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) const pageKey = nextPages.join(","); if (pageKey !== visibleAtlasPageKey) { visibleAtlasPageKey = pageKey; - visibleAtlasPageReadyPromise = preloadQuakeRenderBundleAtlasPages(currentRenderBundle, nextPages); + visibleAtlasPageReadyPromise = observeTextureReadiness(preloadQuakeRenderBundleAtlasPages(currentRenderBundle, nextPages)); } }; @@ -1173,7 +1180,7 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) exposeQuakeRenderBundleAtlasPages(currentHandle.element, currentRenderBundle, nextPages); visibleAtlasPageSet = new Set(nextPages); visibleAtlasPageKey = pageKey; - visibleAtlasPageReadyPromise = preloadQuakeRenderBundleAtlasPages(currentRenderBundle, nextPages); + visibleAtlasPageReadyPromise = observeTextureReadiness(preloadQuakeRenderBundleAtlasPages(currentRenderBundle, nextPages)); } } syncMountedWorldTextureReadiness(); @@ -1184,10 +1191,10 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) visibleWorldTextureReadyPromise = Promise.resolve(); return; } - visibleWorldTextureReadyPromise = preloadQuakeRenderBundleElementAssets( + visibleWorldTextureReadyPromise = observeTextureReadiness(preloadQuakeRenderBundleElementAssets( currentHandle.element, mountedWorldTextureElements(), - ); + )); }; const mountedWorldTextureElements = (): HTMLElement[] => { @@ -1224,7 +1231,7 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) visibleAtlasPageSet.add(pageIndex); visibleAtlasPageKey = [...visibleAtlasPageSet].sort((a, b) => a - b).join(","); const pagePromise = preloadQuakeRenderBundleAtlasPages(currentRenderBundle, [pageIndex]); - visibleAtlasPageReadyPromise = Promise.all([visibleAtlasPageReadyPromise, pagePromise]).then(() => undefined); + visibleAtlasPageReadyPromise = observeTextureReadiness(Promise.all([visibleAtlasPageReadyPromise, pagePromise]).then(() => undefined)); }; const addQuakeLightstyleRenderBundleMesh = (renderBundle: QuakePreparedRenderBundle): PolyMeshHandle => { diff --git a/test/HARNESS.md b/test/HARNESS.md index ca43141..16a260c 100644 --- a/test/HARNESS.md +++ b/test/HARNESS.md @@ -77,3 +77,11 @@ Enemy projectile chain fixtures use debug-only hooks exposed through `window.__c Debug poses with `{ gameplay: true }` synchronize the player controller origin and bypass the click-to-play pause gate only for the explicit debug gameplay sync. Use that shape for browser fixtures that need pickup, hazard, or trigger collision to run headlessly. Mover/pusher browser coverage is intentionally deferred. The existing ignored local pusher fixture fails on the E1M4 train/knight crush watchpoint, so it should not become a committed acceptance gate until either the fixture expectation or product behavior is repaired. + +## Ownership regression checks + +`pnpm test` includes scene disposal/preflight checks and 36 shootable controller scenarios with 648 checkpoints. The scenarios compare health, origin, targets, animation events, mesh publication and pending callbacks against commit `7d796145b9a972f9da5e399a6802e86f8450ea83`. They use a controlled clock and recording mesh handles; browser fixtures cover actual prepared rendering. + +`test/runtime/captureShootableOwnership.mjs ` verifies the reference source dependencies against their pinned Git blobs before capturing results. Review any reference change independently of the implementation under test. + +The `loading` browser family covers held-request history navigation, asset retry, collision preflight failure/retry, and a touch-capable New Game/movement/combat/death/save/load lifecycle. It requires complete prepared assets and runs headlessly. diff --git a/test/browser/browserFixtureDefinitions.mjs b/test/browser/browserFixtureDefinitions.mjs index 28eabd8..16bf8fb 100644 --- a/test/browser/browserFixtureDefinitions.mjs +++ b/test/browser/browserFixtureDefinitions.mjs @@ -1,6 +1,7 @@ import { combatBudgetFixture, logicalTargetabilityFixture } from "./browserFixtureCombat.mjs"; import { liquidDamageFixture, mapLogicFixture, pickupFixture } from "./browserFixtureMapLogic.mjs"; import { monsterDomFixture } from "./browserFixtureMonster.mjs"; +import { loadingAssetRetryFixture, loadingGameplayFixture, loadingHistoryFixture, loadingCollisionPreflightFixture } from "./browserFixtureLoading.mjs"; import { ogreGrenadeChainFixture, ogreGrenadeBounceFixture, @@ -27,6 +28,10 @@ export const browserFixtures = [ mapLogicFixture, liquidDamageFixture, pickupFixture, + loadingHistoryFixture, + loadingCollisionPreflightFixture, + loadingAssetRetryFixture, + loadingGameplayFixture, ]; export function browserFixtureById(id) { diff --git a/test/browser/browserFixtureLoading.mjs b/test/browser/browserFixtureLoading.mjs new file mode 100644 index 0000000..98466bb --- /dev/null +++ b/test/browser/browserFixtureLoading.mjs @@ -0,0 +1,201 @@ +import assert from "node:assert/strict"; +import { readPreparedScene } from "../assets/preparedAssets.mjs"; +import { collectPageErrors, debugMapUrl, waitForDebugMapReady } from "./browserHarnessSupport.mjs"; +import { assertNoPageErrors, defineBrowserFixture, runDebugMapFixture } from "./fixtureHarness.mjs"; + +export const loadingHistoryFixture = defineBrowserFixture({ + id: "loading-history", label: "History navigation during map loading", family: "loading", + artifact: "bench/results/quake/loading-history.json", maps: ["e1m1", "e1m2"], + run: async ({ browser, baseUrl, options }) => runDebugMapFixture({ + browser, baseUrl, options, mapName: "e1m1", + run: async ({ page, pageErrors }) => { + let release, requested; + const held = new Promise(resolve => { release = resolve; }); + const requestStarted = new Promise(resolve => { requested = resolve; }); + await page.route("**/e1m2.json", async route => { + requested(); + await held; + await route.continue(); + }); + try { + await page.evaluate(() => { + history.pushState({}, "", "?debug=1&map=e1m2"); + history.pushState({}, "", "?debug=1&map=e1m3"); + history.back(); + }); + await Promise.race([requestStarted, page.waitForTimeout(options.timeoutMs).then(() => { throw new Error("History did not request e1m2"); })]); + await page.evaluate(() => history.back()); + await waitForDebugMapReady(page, { ...options, mapName: "e1m1" }); + release(); + await page.waitForLoadState("networkidle"); + const state = await page.evaluate(() => ({ map: window.__cssQuakeDebug.stats().mapName, urlMap: new URL(location.href).searchParams.get("map"), loading: window.__cssQuakeDebug.stats().loading })); + assert.deepEqual(state, { map: "e1m1", urlMap: "e1m1", loading: false }); + assertNoPageErrors(pageErrors); + return { generatedAt: new Date().toISOString(), browser: browser.version(), state }; + } finally { release(); } + }, + }), +}); + +export const loadingAssetRetryFixture = defineBrowserFixture({ + id: "loading-asset-retry", label: "Prepared texture retry without page reload", family: "loading", + artifact: "bench/results/quake/loading-asset-retry.json", mapName: "e1m1", + run: async ({ browser, baseUrl, options }) => { + const scene = readPreparedScene("e1m1"); + const assetUrl = scene.renderBundle.assetUrls.find(url => url.includes("-floor-")); + assert.ok(assetUrl, "The real prepared world must have a floor texture"); + const page = await browser.newPage({ viewport: options.viewport }); + const errors = []; + let attempts = 0; + page.on("pageerror", error => errors.push(error.message)); + await page.route(`**${assetUrl}`, async route => { + if (++attempts === 1) await route.abort("failed"); + else await route.continue(); + }); + try { + await page.goto(debugMapUrl(baseUrl, "e1m1"), { waitUntil: "domcontentloaded", timeout: options.timeoutMs }); + await page.waitForFunction(() => document.querySelector('.quake-loading-console-persisted[aria-busy="false"]'), null, { timeout: options.timeoutMs }); + assert.equal(attempts, 1); + // Real browser history is also available while the error overlay is displayed. + await page.evaluate(() => { history.pushState({}, "", "?debug=1&map=e1m2"); history.back(); }); + await waitForDebugMapReady(page, { ...options, mapName: "e1m1" }); + assert.equal(attempts, 2); + assertNoPageErrors(errors); + const weapon = await page.evaluate(() => window.__cssQuakeDebug.viewmodel()); + return { generatedAt: new Date().toISOString(), browser: browser.version(), attempts, weapon }; + } finally { await page.close(); } + }, +}); + +export const loadingGameplayFixture = defineBrowserFixture({ + id: "loading-gameplay", label: "New game, movement, combat, respawn and save/load with touch controls", family: "loading", + artifact: "bench/results/quake/loading-gameplay.json", mapName: "e1m1", + run: async ({ browser, baseUrl, options }) => { + // Touch availability uses the product input path without headless pointer-lock support. + const page = await browser.newPage({ viewport: options.viewport, hasTouch: true }); + const errors = collectPageErrors(page); + const snapshot = () => page.evaluate(() => { + const stats = window.__cssQuakeDebug.stats(); + return { origin: stats.origin, health: stats.playerHealth, shells: stats.playerShells, weapon: stats.activeWeapon, map: stats.mapName, loading: stats.loading, bodyClasses: document.body.className, move: stats.playerMove, focused: document.activeElement?.id, locked: document.pointerLockElement?.id }; + }); + try { + await page.goto(debugMapUrl(baseUrl), { waitUntil: "domcontentloaded", timeout: options.timeoutMs }); + await page.waitForFunction(() => window.__cssQuakeDebug && !window.__cssQuakeDebug.stats().loading, null, { timeout: options.timeoutMs }); + await page.locator('[data-quake-main-menu-action="single-player"]').click(); + await page.locator('[data-quake-single-player-action="new-game"]').click(); + await waitForDebugMapReady(page, { ...options, mapName: "e1m1" }); + const spawned = await snapshot(); + assert.equal(spawned.health, 100); + await page.keyboard.down("w"); + await page.waitForTimeout(250); + await page.keyboard.up("w"); + const moved = await snapshot(); + assert.ok(Math.hypot(moved.origin[0] - spawned.origin[0], moved.origin[1] - spawned.origin[1]) > 0.1, `Keyboard movement must work after map readiness: ${JSON.stringify({ spawned, moved })}`); + await page.keyboard.down("Space"); + await page.waitForTimeout(120); + const jumped = await snapshot(); + await page.keyboard.up("Space"); + assert.ok(jumped.origin[2] > moved.origin[2] + 0.05, "Jump must work after map readiness"); + await page.waitForTimeout(650); + const fired = await page.evaluate(() => window.__cssQuakeDebug.fire()); + assert.equal(fired, true); + const shot = await snapshot(); + assert.equal(shot.shells, moved.shells - 1); + await page.keyboard.press("1"); + await page.waitForFunction(() => window.__cssQuakeDebug.stats().activeWeapon === "axe"); + await page.keyboard.press("2"); + await page.waitForFunction(() => window.__cssQuakeDebug.stats().activeWeapon === "shotgun"); + await page.evaluate(() => window.__cssQuakeDebug.damage(1000)); + await page.waitForFunction(() => window.__cssQuakeDebug.stats().playerHealth <= 0); + await page.touchscreen.tap(Math.round(options.viewport.width / 2), Math.round(options.viewport.height / 2)); + await page.waitForFunction(() => window.__cssQuakeDebug.stats().playerHealth === 100); + const respawned = await snapshot(); + assert.equal(respawned.map, "e1m1"); + assert.equal(respawned.loading, false); + await page.waitForTimeout(650); // The preceding shot still owns its source weapon cooldown. + assert.equal(await page.evaluate(() => window.__cssQuakeDebug.fire()), true); + const openSinglePlayer = async () => { + await page.waitForTimeout(1100); // Let the existing death/resume menu suppression expire. + await page.keyboard.press("Escape"); + await page.locator('[data-quake-main-menu-action="single-player"]').click(); + }; + await page.evaluate(() => window.__cssQuakeDebug.damage(17)); + await openSinglePlayer(); + await page.locator('[data-quake-single-player-action="save"]').click(); + const saved = await page.evaluate(() => JSON.parse(localStorage.getItem("cssquake.save.v1"))); + assert.equal(saved.mapName, "e1m1"); + const savedState = await snapshot(); + assert.equal(savedState.health, 83); + await page.waitForTimeout(650); + assert.equal(await page.evaluate(() => window.__cssQuakeDebug.fire()), true); + assert.equal((await snapshot()).shells, savedState.shells - 1); + await openSinglePlayer(); + await page.locator('[data-quake-single-player-action="load"]').click(); + await page.waitForFunction(health => window.__cssQuakeDebug.stats().playerHealth === health && !document.body.classList.contains("quake-menu-open"), savedState.health); + const restoredSameMap = await snapshot(); + assert.equal(restoredSameMap.shells, savedState.shells); + assert.equal(restoredSameMap.weapon, savedState.weapon); + assert.equal(await page.evaluate(() => window.__cssQuakeDebug.loadMap("e1m2")), true); + await waitForDebugMapReady(page, { ...options, mapName: "e1m2" }); + await openSinglePlayer(); + await page.locator('[data-quake-single-player-action="load"]').click(); + await waitForDebugMapReady(page, { ...options, mapName: "e1m1" }); + await page.waitForFunction(health => window.__cssQuakeDebug.stats().playerHealth === health, savedState.health); + const restoredOtherMap = await snapshot(); + assert.equal(restoredOtherMap.shells, savedState.shells); + assert.equal(restoredOtherMap.weapon, savedState.weapon); + assert.ok(Math.hypot(...restoredOtherMap.origin.map((value, axis) => value - saved.view.origin[axis])) < 0.1, "Load Game must restore the saved camera/player location"); + await openSinglePlayer(); + await page.locator('[data-quake-single-player-action="new-game"]').click(); + await page.waitForFunction(() => window.__cssQuakeDebug.stats().playerHealth === 100); + const restarted = await snapshot(); + assert.equal(restarted.map, "e1m1"); + assert.equal(restarted.shells, spawned.shells); + // Existing menu/respawn lock calls are unsupported in headless Chromium. Keep that + // limitation visible, and fail on every other console or page error. + const unsupportedPointerLock = errors.filter(error => error === "The root document of this element is not valid for pointer lock."); + assertNoPageErrors(errors.filter(error => !unsupportedPointerLock.includes(error))); + return { generatedAt: new Date().toISOString(), browser: browser.version(), inputMode: "touch-capable browser with keyboard movement", unsupportedPointerLock, spawned, moved, jumped, shot, respawned, savedState, restoredSameMap, restoredOtherMap, restarted }; + } finally { await page.close(); } + }, +}); + +export const loadingCollisionPreflightFixture = defineBrowserFixture({ + id: "loading-collision-preflight", label: "Invalid collision preserves the mounted map", family: "loading", + artifact: "bench/results/quake/loading-collision-preflight.json", maps: ["e1m1", "e1m2"], + run: async ({ browser, baseUrl, options }) => runDebugMapFixture({ + browser, baseUrl, options, mapName: "e1m1", + run: async ({ page, pageErrors }) => { + // Keep the old player and its actual world DOM; corrupt only the next map response. + const before = await page.evaluate(() => { + const stats = window.__cssQuakeDebug.stats(); + window.__retainedWorldProbe = document.querySelector(".quake-world-mesh"); + return { map: stats.mapName, origin: stats.origin, health: stats.playerHealth }; + }); + let intercepted = false; + await page.route("**/e1m2.json", async route => { + const response = await route.fetch(); + const scene = await response.json(); + scene.collision = null; + intercepted = true; + await route.fulfill({ response, json: scene }); + }); + const result = await page.evaluate(async () => { + let error; + try { await window.__cssQuakeDebug.loadMap("e1m2"); } catch (cause) { error = cause.message; } + const stats = window.__cssQuakeDebug.stats(); + return { error, map: stats.mapName, origin: stats.origin, health: stats.playerHealth, + retainedWorld: window.__retainedWorldProbe?.isConnected === true, + loading: stats.loading }; + }); + assert.equal(intercepted, true); + assert.match(result.error, /missing collision data/); + assert.deepEqual(result, { error: result.error, ...before, retainedWorld: true, loading: false }); + await page.unroute("**/e1m2.json"); + assert.equal(await page.evaluate(() => window.__cssQuakeDebug.loadMap("e1m2")), true); + await waitForDebugMapReady(page, { ...options, mapName: "e1m2" }); + assertNoPageErrors(pageErrors); + return { generatedAt: new Date().toISOString(), browser: browser.version(), before, result, retry: "e1m2" }; + }, + }), +}); diff --git a/test/gameplay/enemyCombat.test.mjs b/test/gameplay/enemyCombat.test.mjs index 14933e6..da5a05a 100644 --- a/test/gameplay/enemyCombat.test.mjs +++ b/test/gameplay/enemyCombat.test.mjs @@ -27,6 +27,7 @@ function createCombatRuntime(options = {}) { const sounds = []; const traces = []; const runtime = createQuakeEnemyCombatRuntime({ + isVisible: (shootable) => shootable.visible, damagePlayer: options.damagePlayer ?? (() => true), getPlayerOrigin: () => [0, 0, 0], hasLineOfSight: () => true, diff --git a/test/runtime/captureShootableOwnership.mjs b/test/runtime/captureShootableOwnership.mjs new file mode 100644 index 0000000..85532fd --- /dev/null +++ b/test/runtime/captureShootableOwnership.mjs @@ -0,0 +1,32 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { build } from 'esbuild'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { ownershipCases, runShootableOwnershipScenario } from './shootableOwnershipScenario.mjs'; +// Run from the repository root. The reference directory must contain exact Git source bytes. +const root=process.argv[2]; +const destination=process.argv[3]; +if(!root || !destination) throw new Error('Usage: node test/runtime/captureShootableOwnership.mjs '); +const rev='7d796145b9a972f9da5e399a6802e86f8450ea83'; +const entry=fs.readFileSync('test/runtime/shootableOwnershipModules.ts','utf8').replaceAll('../../src/',root+'/src/'); +const built=await build({stdin:{contents:entry,resolveDir:process.cwd(),loader:'ts'},bundle:true,platform:'node',format:'esm',write:false,metafile:true,logLevel:'silent'}); +const inputs=[]; +for(const file of Object.keys(built.metafile.inputs)) { + const absolute=path.resolve(file); + if(!absolute.startsWith(root+'/src/')) continue; + const relative=path.relative(root,absolute); + const bytes=fs.readFileSync(absolute); + const expected=execFileSync('git',['show',`${rev}:${relative}`],{maxBuffer:16*1024*1024}); + if(!bytes.equals(expected))throw new Error(`Reference source drift: ${relative}`); + inputs.push({file:relative,sha256:createHash('sha256').update(bytes).digest('hex')}); +} +const api=await import('data:text/javascript;base64,'+Buffer.from(built.outputFiles[0].text).toString('base64')); +const cases=[]; +for(const scenario of ownershipCases) { const result=runShootableOwnershipScenario(api,scenario); cases.push(result); console.log(scenario, result.checkpoints.length); } +const output={scenarioSha256:createHash('sha256').update(fs.readFileSync('test/runtime/shootableOwnershipScenario.mjs')).digest('hex'),node:process.version,kind:'cssquake-shootable-main-characterization',reference:rev,scope:'Deterministic controller and mesh publication; mock mesh handles, no native or visual parity claim.',inputs:inputs.sort((a,b)=>a.file.localeCompare(b.file)),cases}; +// One checkpoint per line keeps the bound input manifest and scenarios reviewable. +const {cases:capturedCases,...metadata}=output; +const prefix=JSON.stringify(metadata,null,2).slice(0,-2); +const caseJson=capturedCases.map(({checkpoints,...header})=>` {${JSON.stringify(header).slice(1,-1)},\n \"checkpoints\": [\n${checkpoints.map(value=>' '+JSON.stringify(value)).join(',\n')}\n ]}`).join(',\n'); +fs.writeFileSync(destination,prefix+',\n \"cases\": [\n'+caseJson+'\n ]\n}\n'); diff --git a/test/runtime/fixtures/shootableOwnershipMain.json b/test/runtime/fixtures/shootableOwnershipMain.json new file mode 100644 index 0000000..73c29fc --- /dev/null +++ b/test/runtime/fixtures/shootableOwnershipMain.json @@ -0,0 +1,766 @@ +{ + "scenarioSha256": "598cb8b13c6146896b8c3967326f8b08d42e7d6fdaf4719e8488e70dbad5473b", + "node": "v22.12.0", + "kind": "cssquake-shootable-main-characterization", + "reference": "7d796145b9a972f9da5e399a6802e86f8450ea83", + "scope": "Deterministic controller and mesh publication; mock mesh handles, no native or visual parity claim.", + "inputs": [], + "cases": [ + {"scenario":{"classname":"monster_army","backend":"frameset","quakec":false},"frameCount":114, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"f966718b70494ba904a74f8c6cba8a31d5638d05b29bc583552c89652b41e0d6","stateDigest":"674b1778a5d196df25ea4f9120041747675798d0d0ab136033d950e69be8b584","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"03627909e200754a55c6feeff6b47fc7de089a2be66f1bc04d758eb4e333c259","stateDigest":"d10c2409eefa795f4254aff5325884e2a0241b3941d0acc2a34e3db359795429","publicationDigest":"b756f351ac994d8dae0738773129a46697c3a7495d0ee349660151244ecb7cc4","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":10,"eventDigest":"9c0486a373c207ca6e34408548839e76c661daf7bf7c1f32e2c1faf61d9bc926","stateDigest":"b976b48bd227f8a93707463bbca97279253da8ad2eb73763b6458d77eb88215a","publicationDigest":"02569bfcfe85222d6864fc8c1dbdf91543be2437a1f07773bc0ceb19ec8ca32f","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":10,"eventDigest":"9c0486a373c207ca6e34408548839e76c661daf7bf7c1f32e2c1faf61d9bc926","stateDigest":"b976b48bd227f8a93707463bbca97279253da8ad2eb73763b6458d77eb88215a","publicationDigest":"02569bfcfe85222d6864fc8c1dbdf91543be2437a1f07773bc0ceb19ec8ca32f","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":12,"eventDigest":"7b82d4d56d314103f7356a21f2fc847fe775917d9ef52c0a7896412fc91ac0ae","stateDigest":"81f11da283525d268e4034367b84099091cf1064dff54a8a1d0a67014dd92cce","publicationDigest":"ab098771b2ae5fcd6fc7fb944a008080d159b2ffb6920a4d61614765e323f236","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":12,"eventDigest":"7b82d4d56d314103f7356a21f2fc847fe775917d9ef52c0a7896412fc91ac0ae","stateDigest":"81f11da283525d268e4034367b84099091cf1064dff54a8a1d0a67014dd92cce","publicationDigest":"ab098771b2ae5fcd6fc7fb944a008080d159b2ffb6920a4d61614765e323f236","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":16,"eventDigest":"68c75223d6d21b96898062887367be5af7221e6e881dbf3d7e8871f273479ded","stateDigest":"87c7506b7134819ff2d01443f7ae6b565adaae0598b9cc54773c304a136e1bb8","publicationDigest":"78fdee6d548aa1e9dcd0da4b6fa01ab8ced98a6fd9c145977e84dfdbb102a010","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":23,"eventDigest":"bdae4567d1a6f5af01982b23514a76aa151e3fe1b71ca0d19c7658cf7f01694f","stateDigest":"109401a7593c21480964b3640d64deb2f33d51f52b4b8f7c7f1c0cc0432d380d","publicationDigest":"3399e75ffe6695c7079ea5921ce64b3d3bf5d333cb4c9bee214e37dcaf7e219f","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":26,"eventDigest":"c28f01f060c74b25d0420253542eb5963e7b50cb5d99d11b79debeb384413de9","stateDigest":"8a3f3cb3e4354c58d12fae8b688573261f4fd1334cd320f4e15b5d6a05e62989","publicationDigest":"641e9ba6b35f77ceb9393966d29f1fe72d459fd272e32608d4bf0b6191a0e8e7","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":36,"eventDigest":"a63b19982a9be2834a4d1fed130200c4da9f36087173dde26fc8e77c75ce9c3f","stateDigest":"93f378d13e4fa0c71d159c49429923331641a2613ceb84b96f2127736daa9dd0","publicationDigest":"a161a353b30cfc031f465bb076bd85c3e84226d182895984e5915b49045d5985","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":44,"eventDigest":"73c3a8c6fa39f3812740cd2a11bf7fd8cbfcb08e435c4dd4ee77e3ce6fc612e5","stateDigest":"a66720fc600c721fc4f5975ee10063cff146f955d5d9d5489a5bf1bb208b91da","publicationDigest":"934a7256a189f952689fd05964b998679fc586bda39b3593340a3721b9d37691","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":55,"eventDigest":"44272ba329e6afa6929d0d857c1437f9f5e70aa73304be043e792af394b15420","stateDigest":"7e414f455bfa3dca7ab6f99af2e277e34c8bfbf176edf869fee5de2791fb5537","publicationDigest":"22d0db89106db53fccf968790147d448f900ab802f9a47d41904960ce7f17f41","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":57,"eventDigest":"3f4110e67babe2663158e0f3e959714cacee32addcaa28a9fe8bcd52d11c21ee","stateDigest":"9f2065c5247cb5f16d32361b3f33900c2730ede5cd420ca54e43ae5b32264a47","publicationDigest":"769611ac8050222925025766a565fa8f3d8485ef48c40d185b34f7d24cb43dfd","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":58,"eventDigest":"5cd37baddb172672093c68a8200f0101a49576361afad4d823c47d2c0b2221bd","stateDigest":"850ed02e7a54b824e47f4d7f1605edaf40cc882f0ed36ce1e6e802578f49e3b8","publicationDigest":"99b0fd5c6687570a8cf1a7aacd269c081a72213ffc4e72ebf8ed06fdfbe403b5","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":58,"eventDigest":"5cd37baddb172672093c68a8200f0101a49576361afad4d823c47d2c0b2221bd","stateDigest":"850ed02e7a54b824e47f4d7f1605edaf40cc882f0ed36ce1e6e802578f49e3b8","publicationDigest":"99b0fd5c6687570a8cf1a7aacd269c081a72213ffc4e72ebf8ed06fdfbe403b5","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":68,"eventDigest":"517736f4828f729392cf6c0bece1f4bd799aa805b99cb288b9a679352a264a8b","stateDigest":"ce409302189a04fb2a6ea9495402198cd5a5732f3d3b26fb4c608d08f3ccc149","publicationDigest":"2612ae4a4a4b3b72261044dd25e4a535f1c52e6ffdd7eb6e57c8def18f1b1af0","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":76,"eventDigest":"98d4e7571bdd330ecd5848bf511816e932f664cf105caa764b8ecbdc873f316f","stateDigest":"cb0a17c292d1f1e836e451fb3c57072bf76ab72974398b76d6d8a8f5b4a451de","publicationDigest":"d5f5eba197b86b733f67d00c2ba8b406cbfb36156fda64349783608da64ecbb2","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":76,"eventDigest":"98d4e7571bdd330ecd5848bf511816e932f664cf105caa764b8ecbdc873f316f","stateDigest":"cb0a17c292d1f1e836e451fb3c57072bf76ab72974398b76d6d8a8f5b4a451de","publicationDigest":"d5f5eba197b86b733f67d00c2ba8b406cbfb36156fda64349783608da64ecbb2","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_army","backend":"frameset","quakec":true},"frameCount":114, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"f966718b70494ba904a74f8c6cba8a31d5638d05b29bc583552c89652b41e0d6","stateDigest":"674b1778a5d196df25ea4f9120041747675798d0d0ab136033d950e69be8b584","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"03627909e200754a55c6feeff6b47fc7de089a2be66f1bc04d758eb4e333c259","stateDigest":"d10c2409eefa795f4254aff5325884e2a0241b3941d0acc2a34e3db359795429","publicationDigest":"b756f351ac994d8dae0738773129a46697c3a7495d0ee349660151244ecb7cc4","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[4.629999999999999,4.531193156845207e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":32,"eventDigest":"4cbce4b2aa702e2d6fee326bd32f314993dad8e74058903a223c8e4a83f96fb2","stateDigest":"9025ee66991ee001e9fb239b4da700304b80d9ec8c9c52cba0a034747680e15e","publicationDigest":"351a301fb345f8b3e442569c71b93d8e528f17221eef399f992cfbc6b923dabc","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[4.629999999999999,4.531193156845207e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":32,"eventDigest":"4cbce4b2aa702e2d6fee326bd32f314993dad8e74058903a223c8e4a83f96fb2","stateDigest":"9025ee66991ee001e9fb239b4da700304b80d9ec8c9c52cba0a034747680e15e","publicationDigest":"351a301fb345f8b3e442569c71b93d8e528f17221eef399f992cfbc6b923dabc","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[4.43,6.980486755139913e-17,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":52,"eventDigest":"27b0d2c11278d8acd0ec2727ceb8b45acfd4a7de40fd2f2de5eaf22bb9242f4a","stateDigest":"bbfacfa0e8398b3af95d8d3d6f9d4bf1d6c7dc47eeb51c2bf07ef139d09e5041","publicationDigest":"252f88b321f1e80c307a031167a2be0487a4550f3f4c91c497ba4b8474306d6e","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[4.43,6.980486755139913e-17,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":52,"eventDigest":"27b0d2c11278d8acd0ec2727ceb8b45acfd4a7de40fd2f2de5eaf22bb9242f4a","stateDigest":"bbfacfa0e8398b3af95d8d3d6f9d4bf1d6c7dc47eeb51c2bf07ef139d09e5041","publicationDigest":"252f88b321f1e80c307a031167a2be0487a4550f3f4c91c497ba4b8474306d6e","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[4.43,6.980486755139913e-17,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":58,"eventDigest":"776f1ee5d5e7879b80f56f160ee5e8e4747d2e2a94d8ffb5d3d6d7c78838b4e4","stateDigest":"cb348cb7c98083651f38f5a55014a12cf00c36ac01671d466dc3e44e2af8f548","publicationDigest":"d6e0efb709dfda08e01b0e87e2030e4d383f55001a22508aae70925920a7215e","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[4.43,6.980486755139913e-17,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":69,"eventDigest":"df2361a26c5e29da8ab3b126b744986beaaad5d665f0900460463abbeff1c9e6","stateDigest":"e0d8276823b043cee43d554feea2630a29db2c915708bc904e74bf3493ea68dc","publicationDigest":"02de8053305f573b9e2c40afacf48712eda343250a63829e144f6686ecce75ed","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[4.43,6.980486755139913e-17,0.48],"yaw":-180}]},"visible":[],"mounted":[],"events":73,"eventDigest":"be679914f207b18b6a68b38c0e3e3cbf297c8326824e763b2c2b763825b1e91e","stateDigest":"4e78a8d64c7d1d801589321328bff6f3ae0628eceadfccff52d9fda92f4e6ff4","publicationDigest":"e2dd25775b3d14f2cedde1b77e4950b3afba07604c1fd25729ccf2bb23599d3c","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":90,"eventDigest":"21bd25274c851677efaa881c7ce55b6a783338cf99ef40f2590b484d27a2511f","stateDigest":"c8c1fdefb489542e03f6313f84e624976965a05a9495f2f9bf84e153483a9ba2","publicationDigest":"f1ca37c4ef4e862b7d5bd99c4dce5834768f603c08265c67050c6f2a482334b3","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":130,"eventDigest":"632f324605749c78f17aa7553d8023c65ba609a6e76e765b4982184715d36f5c","stateDigest":"29fa43a7e84efee1e849ed45924ce65f1a98dbb9d1466b4ba84eb565c3315fcd","publicationDigest":"1eb1e4b384db4eecf98dab6261e98e7c8211e582bdc48cdb6636f86b2055173e","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[3.909999999999999,1.334865011070615e-16,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":169,"eventDigest":"44dd100ff93829ac4a4292591f74272963931e464ad94a157e7b46de110da226","stateDigest":"4a9426bf0cedcc0d72ad3cebaf391235a72f9b24ed41f1190b42ea6f4aa89a20","publicationDigest":"16020da7d5b82624214515f60f5a746f2c39bd60a684233a564436b3339c4bf3","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[3.909999999999999,1.334865011070615e-16,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":178,"eventDigest":"86a0354a362b31f42e84d79b6b25be1cd030bf86ca8745cec288ab18079c1eaa","stateDigest":"5bd0b8709a535a4a65143437eaff34d688e3149cab24b8e28287fa6b1322f7c9","publicationDigest":"ad0269a9d9b634c3eabddd07c30010ab24b2dc5f4a44e3bc65fca8a89691712d","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":179,"eventDigest":"8d9a68b8cf3c8e62375ee0d90bef8ba199770463965f8e3b85e1b90a98b24356","stateDigest":"885b9d1efd694f3804c749e7c31df090aa3291f3b7d6d36dd8ae70dd0bd950b2","publicationDigest":"2e05eff4fd1fc20be944d37dba78363097380627cad3116443ba44986149ea19","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":179,"eventDigest":"8d9a68b8cf3c8e62375ee0d90bef8ba199770463965f8e3b85e1b90a98b24356","stateDigest":"885b9d1efd694f3804c749e7c31df090aa3291f3b7d6d36dd8ae70dd0bd950b2","publicationDigest":"2e05eff4fd1fc20be944d37dba78363097380627cad3116443ba44986149ea19","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[4.629999999999999,4.531193156845207e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":212,"eventDigest":"5fbf357dcbe99eee81135dd0f9af72bb90e83f68d99be45c2d57199f2b72b72b","stateDigest":"4230532e0e8e7746c7455ee63d1b705a3bc021c188627f103a0355cec2674852","publicationDigest":"c9f3fd5e8ef27f4b8ea19323219877c9dc00bcea594513861a6a49469bd874f5","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":220,"eventDigest":"aad6cdf4c0bf62a958cc3b325a79b9aca35f1ee49d8d994b691e42a1915cdaed","stateDigest":"09fc0b31c0c248d052556756a97ac79ea00c34598c014de4539fe4dbffff9a6f","publicationDigest":"50313d67883c018feb844eef5e40b9a24173ef6e18247f7d2f2cf013b12ef511","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":220,"eventDigest":"aad6cdf4c0bf62a958cc3b325a79b9aca35f1ee49d8d994b691e42a1915cdaed","stateDigest":"09fc0b31c0c248d052556756a97ac79ea00c34598c014de4539fe4dbffff9a6f","publicationDigest":"50313d67883c018feb844eef5e40b9a24173ef6e18247f7d2f2cf013b12ef511","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_army","backend":"replace","quakec":false},"frameCount":114, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"f966718b70494ba904a74f8c6cba8a31d5638d05b29bc583552c89652b41e0d6","stateDigest":"79ce9bb331069936c66369ff660adb15ed5404c4e2b7575cd9b003ae0f8789ba","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"34f49af16c685a19496bd3ddf3b9c996bdbf42526e3243f18dd4c08a00b9cb6b","stateDigest":"dc5006edac663d7bac0b5af53317e9a7373110294f6eb31b06b4fba13e65838e","publicationDigest":"b756f351ac994d8dae0738773129a46697c3a7495d0ee349660151244ecb7cc4","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":15,"eventDigest":"c2446b610d69ad546f8c3ad2d332e918aa134e1708469cf785658835a7dd60b9","stateDigest":"b52677ce0deaa562379262557047e8a35302dad10be2a6b1484029b719bda64f","publicationDigest":"324e88d7885fb0aeae320c19489380c839d26dd6bc0cf692ec3235cc7104a744","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":15,"eventDigest":"c2446b610d69ad546f8c3ad2d332e918aa134e1708469cf785658835a7dd60b9","stateDigest":"b52677ce0deaa562379262557047e8a35302dad10be2a6b1484029b719bda64f","publicationDigest":"324e88d7885fb0aeae320c19489380c839d26dd6bc0cf692ec3235cc7104a744","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":22,"eventDigest":"2ad7de0f4aac64dd694f28af63edb1e710a9c8c367ba00b021d350382247bf2e","stateDigest":"1ea56b9afb51fb8ddd2233b3ea404305fe944715cd05c987aea4b30dd9a17494","publicationDigest":"c1c0a09defbb4dfbc7c5b319d31376bc6a8b641f94d79948046ffacd4eccd2b1","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":22,"eventDigest":"2ad7de0f4aac64dd694f28af63edb1e710a9c8c367ba00b021d350382247bf2e","stateDigest":"1ea56b9afb51fb8ddd2233b3ea404305fe944715cd05c987aea4b30dd9a17494","publicationDigest":"c1c0a09defbb4dfbc7c5b319d31376bc6a8b641f94d79948046ffacd4eccd2b1","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":36,"eventDigest":"e4a67f357e6a9dc8b5d7f938023fad4048b0ac161bf982c6737eeb078a382c91","stateDigest":"4a2280cfb61504764795802dbd74453614f4ba5d0e56b97bb1f61702bea01a7e","publicationDigest":"002143dd4e76d72d15d568ffd177a9089a12ae510b2b3ea59d4b97d200df4d62","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":48,"eventDigest":"ce00f4bff47b63bda166ec78c44d72528093c2ab23238c9946e8e1e56345bfb7","stateDigest":"636a726573cac7d370a75158f80f6bccc9ab17a7e25dce3f075de08a08fcd7d3","publicationDigest":"1e60036b04d403049e15de471eaf51d3fe76ef9427f57d16162026519d9fc72e","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":51,"eventDigest":"3593a546cef1b8c55ab7c5697d76143bdb37f12015e4a6246365731b02dfaa80","stateDigest":"027ce0f6f5a3ad5245d592d0a1fee3df6a618ff7d19bd35b8a62f93732e875d0","publicationDigest":"3353b1bc163eb791b6c57bda0bcced4a7887346b53a8ca92997802d127cc2bde","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":66,"eventDigest":"360986a325e69dccfc3a028cd4440616e00adada995101701cfb54048b8104e6","stateDigest":"c9576d5abe084e6a211595c461f89e77af84bb2897476c61599c3a595f33b4c5","publicationDigest":"2db50b6c95256fd96f64f3357979e217059e397e0e0414b5bef779028136e216","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":79,"eventDigest":"cddf04aae5199a9858f276133e20d3dd43b8cba2ed43e007cba064ff4af1830c","stateDigest":"3de77399ec4a4e238746ac7c217a1212d253d7bd6fab7cedd11c0bf14687fea8","publicationDigest":"f9168c07da6554b9d86f8298339495dd13a45d85104d60a0fad92cc6f6396af2","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":95,"eventDigest":"400f9a4c1209d6aa5a93809da39c206f3445c07b7c521077f6e8a50bb7d24a30","stateDigest":"c90d52f10d324df972b158e24fe463cc825366ad6c52733e50ea25a5ed334d56","publicationDigest":"aa4151f8520bc32823ec0408bec6cef447e081cd8d01520d710c1ba41f77f22e","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":102,"eventDigest":"da6bdcc0757d9b9e2f1483f4fd5af119d6828614463045d6fc63a2b1d12d5771","stateDigest":"eeb748ebfa670a04f9a31541a1628a20fb5bf93c9670f41726235629708dd087","publicationDigest":"ff1b215b316aecc58864c98f133029ba3c8e2805c007a2d4c3901691a771b6f0","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":103,"eventDigest":"7af1c6575712bd6b3c525bd006d36dc6f46c3f91c3574edf0a4b6ed710e208c6","stateDigest":"ab9899802289e7fef134d0d7d2a46769c23e34dbc6de5b044fa1ce12402b965f","publicationDigest":"df94044f76cc9cf23f5d4bb0bdaa5f2394f515fcadb6ec4024ac5ff936d6b802","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":103,"eventDigest":"7af1c6575712bd6b3c525bd006d36dc6f46c3f91c3574edf0a4b6ed710e208c6","stateDigest":"ab9899802289e7fef134d0d7d2a46769c23e34dbc6de5b044fa1ce12402b965f","publicationDigest":"df94044f76cc9cf23f5d4bb0bdaa5f2394f515fcadb6ec4024ac5ff936d6b802","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":118,"eventDigest":"d0a9747c20ddc90e4a0eef2f95d499d453ad83f1c252cf4889879072352c52dc","stateDigest":"70cc2a0432b20db8c159987882ff65716fd0151f8210480850612d6cf987bd62","publicationDigest":"a4d7a7938cd119a42ec4c54819bf60d801b359b9ad6839f7251b21d554d2d27d","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":126,"eventDigest":"feee130d901393f269134248f21eea3e759b58f99bb7229d3c3dce6f5254244f","stateDigest":"c63083b415263ca60937423a54134a626bb346c5231d9d439c99ab9b1121afd8","publicationDigest":"a2e7f8f9c9fbb253c0d86b1ca83871523faa1ef2683937638165f3da53b2abeb","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":126,"eventDigest":"feee130d901393f269134248f21eea3e759b58f99bb7229d3c3dce6f5254244f","stateDigest":"c63083b415263ca60937423a54134a626bb346c5231d9d439c99ab9b1121afd8","publicationDigest":"a2e7f8f9c9fbb253c0d86b1ca83871523faa1ef2683937638165f3da53b2abeb","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_army","backend":"replace","quakec":true},"frameCount":114, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"f966718b70494ba904a74f8c6cba8a31d5638d05b29bc583552c89652b41e0d6","stateDigest":"79ce9bb331069936c66369ff660adb15ed5404c4e2b7575cd9b003ae0f8789ba","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"34f49af16c685a19496bd3ddf3b9c996bdbf42526e3243f18dd4c08a00b9cb6b","stateDigest":"dc5006edac663d7bac0b5af53317e9a7373110294f6eb31b06b4fba13e65838e","publicationDigest":"b756f351ac994d8dae0738773129a46697c3a7495d0ee349660151244ecb7cc4","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[4.629999999999999,4.531193156845207e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":42,"eventDigest":"6542e90093122fedbf0897305c44aab75bd99a63ecde2d29f2250573f82ec1f6","stateDigest":"37c547129faabf400a831dfc9b53d96f29296af87164a3af03ee4885f317c897","publicationDigest":"1556bd190eac13c2c5e59357775698cbf778e56dd4b6e596fef40bf6d02df117","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[4.629999999999999,4.531193156845207e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":42,"eventDigest":"6542e90093122fedbf0897305c44aab75bd99a63ecde2d29f2250573f82ec1f6","stateDigest":"37c547129faabf400a831dfc9b53d96f29296af87164a3af03ee4885f317c897","publicationDigest":"1556bd190eac13c2c5e59357775698cbf778e56dd4b6e596fef40bf6d02df117","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[4.43,6.980486755139913e-17,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":72,"eventDigest":"e8a02fdce8d7d57ce5e7e271d375f7e6c27e14f00ee215c567f70d292813eb52","stateDigest":"488309b6074ddbd0528b516f12243025fb3c842317a262cc192ff409fc414338","publicationDigest":"10997755b1b7af17b2b876b0e41199517b71fa9ffc2ca160cbf2afc1f6765659","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[4.43,6.980486755139913e-17,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":72,"eventDigest":"e8a02fdce8d7d57ce5e7e271d375f7e6c27e14f00ee215c567f70d292813eb52","stateDigest":"488309b6074ddbd0528b516f12243025fb3c842317a262cc192ff409fc414338","publicationDigest":"10997755b1b7af17b2b876b0e41199517b71fa9ffc2ca160cbf2afc1f6765659","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[4.43,6.980486755139913e-17,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":88,"eventDigest":"21e79196d2f598dc43aaf2f2003c8dcf97c5b1a01ca7f67c72664bcb6213f094","stateDigest":"67ec0eb18ec231b10a18dc4c5167f012aa5e1ce9eda7501d360c59d56dd07bec","publicationDigest":"a8263cfe0d0ff1c9d54c85625fa19a6564ccc90a670f333377acab3086d93e1e","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[4.43,6.980486755139913e-17,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":114,"eventDigest":"0366c59cf7082fae34601d9a5bd9e79ceb2f4eaaa88f091443d527be686876ba","stateDigest":"c8485bf0f3a4fd8b8b0dc763e780b715595bd0a47e75f9b36b0e138f76734232","publicationDigest":"44b9323d0f2fad0e257e4f98239be52bc28c212f0883d610c86d576891003f0e","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[4.43,6.980486755139913e-17,0.48],"yaw":-180}]},"visible":[],"mounted":[],"events":118,"eventDigest":"59cf8a5900edb3bccdb12db5f4ec981b5c02d36dfcef01ae621bf36557fae0fc","stateDigest":"5ef5ad98dc4f8fb754a19e668e37cffb76cbfb5f3ab39f26a555b74453645cb3","publicationDigest":"ac6227e20fe12960c078f8c4f4f6ba7d62d7ae932fe2a9c2904b22054ef9aa38","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":145,"eventDigest":"80e51e6d710c6d8761ff701190686d3da8cb96d3bfbfbb0a58cc4ba4d02b2699","stateDigest":"82b2562d659f2fbc4ff4c0d0fa9e3f395abf7ebf6a12755e9006c44bdf6c3b7b","publicationDigest":"fbd805c5df25098f8173af46a2b4e1e75e449991b4201c2cc4f7aa596df4db76","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":247,"eventDigest":"4a27e2cd3fb94636b84fbc02716c146434f0e23d33aca7278fe77c7a20397989","stateDigest":"4e64cf2066a202b45f5f3cca0a3db52987002f769335d3acd6cd7d9816552605","publicationDigest":"cb9c75e7a12ed8b275cfbf541cf67f96004983f5e486158585ae999c5e78544b","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[3.909999999999999,1.334865011070615e-16,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":296,"eventDigest":"521286a0d6b4ccb1e74de8f559b9fcefa6fbcef70688329ad9b11bf7f9a259c6","stateDigest":"8dcac63e201b805c27e56a4033868ee2cebb1e4a53bec061a2121e8ad42ac500","publicationDigest":"e69872003a3ed84cf264f77959c96ebb8496bacfd5dcadaa12241bf18d876c32","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[3.909999999999999,1.334865011070615e-16,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":315,"eventDigest":"1794bcff546cd81c0b7e878f63f024144cd5d64274278d24c6790e18916d4216","stateDigest":"ca841d9559e2c0117590cac203a8ac9064eb30bea13530976b367a5d1503c238","publicationDigest":"954310f28a63aa15f1c9c08700687ab75f9598b0fa3d88092d3322ae86d33897","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":316,"eventDigest":"5c63c44bb96375c54762a0ad17426ffdd113914e6dcf43afbb2a080a1f827b1c","stateDigest":"6005f12dce32f223974b44ba59fb77c45eb41d0415a3a9b3e9ebb2ed386f7e0f","publicationDigest":"566e0dfa1f3c181decbb4455c18de1fdf26e40c5a87c5004c1f90ab14438877c","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":316,"eventDigest":"5c63c44bb96375c54762a0ad17426ffdd113914e6dcf43afbb2a080a1f827b1c","stateDigest":"6005f12dce32f223974b44ba59fb77c45eb41d0415a3a9b3e9ebb2ed386f7e0f","publicationDigest":"566e0dfa1f3c181decbb4455c18de1fdf26e40c5a87c5004c1f90ab14438877c","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":30,"origin":[4.629999999999999,4.531193156845207e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":359,"eventDigest":"90dfd9be762e2d17d26b625622fe919b47bd3fcc61c6c6e3ee3868e323cff713","stateDigest":"d308ef4d1beff9f2dc90754cd80c636b81aae61e4f16470776ad6adcd5a13ebb","publicationDigest":"5c38461c47119a7afd13abc0819be587da05b5828652214bcfa625932fca6b0f","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":367,"eventDigest":"17714051cfcb42aca2c62894282b9f9157316627ffb5fcca1dfe0edb80ebaeaa","stateDigest":"c6573f63a1fe6fb335e42f6c84947965aa8fe089b607b19661228995f0c8fc23","publicationDigest":"d0e37122ced2ee58906a7bdda17d59e33b89eab759b1bb7dc0478a6e118978b8","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":367,"eventDigest":"17714051cfcb42aca2c62894282b9f9157316627ffb5fcca1dfe0edb80ebaeaa","stateDigest":"c6573f63a1fe6fb335e42f6c84947965aa8fe089b607b19661228995f0c8fc23","publicationDigest":"d0e37122ced2ee58906a7bdda17d59e33b89eab759b1bb7dc0478a6e118978b8","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_dog","backend":"frameset","quakec":false},"frameCount":86, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"a79b60a4f6cf97c99a4ba9f5f6273d0c05f6a690f4fc742185c78ba43142dbd1","stateDigest":"5d678f591b6d18b3f21ff73991b9ea984f50d0a323b2838a23e560cdc408ca82","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"994eb4080119a8d900404e359dc6c48a652ad0aab6c5d0d8ee1176f3073c7dd8","stateDigest":"911f25e30d258bc8f6f1100562dcec2289bfe642c3268173b23024325998c951","publicationDigest":"c0c7d926777c8b30b61e8f80203be01d8fa20e82d6e233a5b25a49fa2516758e","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":10,"eventDigest":"f4ccb9becf5a576a9f4bc47513fc2d16a741923ae13202c5c5ea12c4d4d2ce45","stateDigest":"022e2fd0f7da70dc934460fb47b1b5d157b91b3ac25526b0ab19dd875d9306e1","publicationDigest":"32e06eee4a5498e89a57151ac9db5dabc45eeb8805ac9224369311e3cffc319a","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":10,"eventDigest":"f4ccb9becf5a576a9f4bc47513fc2d16a741923ae13202c5c5ea12c4d4d2ce45","stateDigest":"022e2fd0f7da70dc934460fb47b1b5d157b91b3ac25526b0ab19dd875d9306e1","publicationDigest":"32e06eee4a5498e89a57151ac9db5dabc45eeb8805ac9224369311e3cffc319a","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":12,"eventDigest":"c875dc349f6d0dd3f4efbdd6b886281cc48cb4a46bc4766aa28846dde16143cc","stateDigest":"e620032f63c70125940350199982a1dd9126cd148e196d51a9bcc00e85a68472","publicationDigest":"7745abc7e3c06472f36f081b3bc0cae4b7a5e1834917bb09d20121d932407d17","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":12,"eventDigest":"c875dc349f6d0dd3f4efbdd6b886281cc48cb4a46bc4766aa28846dde16143cc","stateDigest":"e620032f63c70125940350199982a1dd9126cd148e196d51a9bcc00e85a68472","publicationDigest":"7745abc7e3c06472f36f081b3bc0cae4b7a5e1834917bb09d20121d932407d17","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":16,"eventDigest":"20cc6bc64eae046ef4e3a83d8dd94f0011acf658627c7f6a735ab3789c9aef60","stateDigest":"fa5b192d8f999b7b7d81ad223c0f78d864ae5247753e7bb123342e483a476342","publicationDigest":"d74b54f1990625a607b1144dcd8ada1ce5ba573be2750783865af658da0e46f3","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":20,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":23,"eventDigest":"321aa0080f8a7bdef03554947d3383cc3d6470b74f74cbace6ec9249781d7a88","stateDigest":"169d0014fb8a76600c17921e84bcabc9f5bc4508cf4e98b9a314fc7f71647dcb","publicationDigest":"155a350f4c4aa550c35e81593f4c30c9176c4969f3e01000637bc54e87a51e7f","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":20,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":26,"eventDigest":"8ee28eb65dae9ff3bff5b63bd95a74a224060feeb98c2d333fdc9dd79c90d6be","stateDigest":"668e2685cd725414620e98f12f203ed536356566c03a799f8be6531a575a569e","publicationDigest":"c903eb72d08cd4e9ce9bf19bd2bbe631ef2661dcf75c835b4bf5e94bb1b32095","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":20,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":36,"eventDigest":"ea89a535edd2e28c08172bc7a3a2e381ea4e4a34f58dd511d82c81d0a5df3200","stateDigest":"89edf574265272d66caa672c082e72a925b9d99e4f6328be0a3fa35d43541eb1","publicationDigest":"ce0edded1444acedd0afcd0e1e34ff36f97149efae834425a5cf96f0f2d01c15","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":44,"eventDigest":"ab884b1c7893abaa9d71d707b73708ee6e71102778ff8b868e8e01dbb3abe645","stateDigest":"26675ec4a2ca00351d9eee064426c2f184db9c5ff268304f63cc79026d786976","publicationDigest":"61548cea9ff87468c6a681c36880022d8483f98b0d458f8a851a5a05eea1813e","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":20,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":55,"eventDigest":"64aeb39564d16a6278f8017e7dd7c9428afe2a61b660a151ededcea8c67f7ea0","stateDigest":"bdd4febdb424d415f5ad85e7b7b6345a198ccd0b5901c491987caeea337bda66","publicationDigest":"1cd5311d02aa345a4fc95c3d796fa4d8d1ee1c51819c63d030a37d51b7553f32","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":20,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":57,"eventDigest":"591835b93641f345f054415c4d5ce911dc8d73074cff187c45f8a7952bc1a8e7","stateDigest":"ea8e727c9b4bc2112e1cc6cfb846a1c371e8682437f83e3beae27f08a23f7e5a","publicationDigest":"f0d216d1a69afb554999e80769e78053386116d152a916b692d04c12d484e572","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":58,"eventDigest":"0111e38978d67a4359779fa0199ccc41bfcdc48cb948d51bf4e4d3887a170e3f","stateDigest":"850ed02e7a54b824e47f4d7f1605edaf40cc882f0ed36ce1e6e802578f49e3b8","publicationDigest":"5e2add4274f986ff3e0cb09ad397791bd844a48745cd44f49fb7376bd12409d2","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":58,"eventDigest":"0111e38978d67a4359779fa0199ccc41bfcdc48cb948d51bf4e4d3887a170e3f","stateDigest":"850ed02e7a54b824e47f4d7f1605edaf40cc882f0ed36ce1e6e802578f49e3b8","publicationDigest":"5e2add4274f986ff3e0cb09ad397791bd844a48745cd44f49fb7376bd12409d2","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":68,"eventDigest":"bfc526f71dfa629de9cab01ee8f091627a275bc2b78a317ace3c69d22d328077","stateDigest":"0d7e8157c44e0d00cb68c14ac251ea2ed33a9710eb36df1e546f6efd3a2f24aa","publicationDigest":"a8a6ef50a6fe313c48a522f14cb3c02c7bfc5f5c6293016c5538cea952109359","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":76,"eventDigest":"009c05dbc71cb4557879f85fa674ca90a1de1fdcd417eba2f86f9f97287c4347","stateDigest":"cb0a17c292d1f1e836e451fb3c57072bf76ab72974398b76d6d8a8f5b4a451de","publicationDigest":"685fbb0ccde76667fed60c699c4a67b67a8e77c0e9cc394325971a1b3e3e3de0","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":76,"eventDigest":"009c05dbc71cb4557879f85fa674ca90a1de1fdcd417eba2f86f9f97287c4347","stateDigest":"cb0a17c292d1f1e836e451fb3c57072bf76ab72974398b76d6d8a8f5b4a451de","publicationDigest":"685fbb0ccde76667fed60c699c4a67b67a8e77c0e9cc394325971a1b3e3e3de0","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_dog","backend":"frameset","quakec":true},"frameCount":86, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"a79b60a4f6cf97c99a4ba9f5f6273d0c05f6a690f4fc742185c78ba43142dbd1","stateDigest":"5d678f591b6d18b3f21ff73991b9ea984f50d0a323b2838a23e560cdc408ca82","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"994eb4080119a8d900404e359dc6c48a652ad0aab6c5d0d8ee1176f3073c7dd8","stateDigest":"911f25e30d258bc8f6f1100562dcec2289bfe642c3268173b23024325998c951","publicationDigest":"c0c7d926777c8b30b61e8f80203be01d8fa20e82d6e233a5b25a49fa2516758e","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[4.359999999999999,7.83773951454306e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":30,"eventDigest":"440a6151883cb88bf81f4cca4aed26a60bce3551eb956199176347872aa20484","stateDigest":"633ec490e790ad7b37f13669f23b6237c58e08db5074612badc9426dd6de9ef7","publicationDigest":"8185c221d49f60288cc18b30325694aea94a95fd623340122058f6201d24a642","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[4.359999999999999,7.83773951454306e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":30,"eventDigest":"440a6151883cb88bf81f4cca4aed26a60bce3551eb956199176347872aa20484","stateDigest":"633ec490e790ad7b37f13669f23b6237c58e08db5074612badc9426dd6de9ef7","publicationDigest":"8185c221d49f60288cc18b30325694aea94a95fd623340122058f6201d24a642","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[3.319999999999999,2.0574066225675533e-16,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":56,"eventDigest":"9070e0998e4951a7334e9887fabeef62d3ab1aac3cfbcc6ba7cf306795b8b2bb","stateDigest":"76c487c2c3b686ce529efc6df9a7dcf540eb4938c0b6e5d71577e39583b81208","publicationDigest":"01bcf53590f589233b03eb6feab4c52cab68ac782c15b83a27895db59fbcfd15","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[3.319999999999999,2.0574066225675533e-16,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":56,"eventDigest":"9070e0998e4951a7334e9887fabeef62d3ab1aac3cfbcc6ba7cf306795b8b2bb","stateDigest":"76c487c2c3b686ce529efc6df9a7dcf540eb4938c0b6e5d71577e39583b81208","publicationDigest":"01bcf53590f589233b03eb6feab4c52cab68ac782c15b83a27895db59fbcfd15","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[2.679999999999999,2.841180574021859e-16,0.5],"yaw":-180}]},"visible":[1],"mounted":[1],"events":79,"eventDigest":"72ba2b711d57d1d95edb339936826f859656216cd409c218cfe351b990c38a5a","stateDigest":"497582c19af5f1e69d835189838c65164d0d8a0acbc59e8c4f5c81cbda77a0cb","publicationDigest":"0dfbd01859375e6c57925e422dcdfcb774a88e2b13ee6ae783b6550ea7303ada","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":20,"origin":[1.7799999999999991,1.7389984547892412e-16,0.9199999999999999],"yaw":-180}]},"visible":[1],"mounted":[1],"events":100,"eventDigest":"b81f149e65888c1ced65e976e91ba1577c3c04acc68b5186ab905e8ae46835a5","stateDigest":"1c22cf62fdca26bb3c2eff77bb78fd02b23ec9c8fcbb63fe84bd2484982d6d58","publicationDigest":"732b64a4559bf9b84e885baffce10fdd9f3adbd4eda11c9497b9d760ae6a985c","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":20,"origin":[1.7799999999999991,1.7389984547892412e-16,0.9199999999999999],"yaw":-180}]},"visible":[],"mounted":[],"events":107,"eventDigest":"3ecbb504c019aef00a1fb49a6630e51e34c8304ca6912fd92a91f3307204d7b4","stateDigest":"00b8c8bb8da2cba9a5e227b808c1f6892820430353da040cffdae1eb34d1ddcb","publicationDigest":"457af8389e5d7bb2ccfb6e1564f6311add7c117f973b5fc80f12950ca446742b","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":20,"origin":[4.1000000000000005,-1.1021821192326179e-16,1.06],"yaw":-180}]},"visible":[1],"mounted":[1],"events":136,"eventDigest":"a449170f6428bce87937c73978e55480edf29a6259f14fc02666739f488ad3ac","stateDigest":"32d016cbe57948357a1be4a0af67936aba2e6f74cf97b8d8f0c365a7fd23eb4a","publicationDigest":"0c8e02ae118ca1b315c5e5b9369c9c8f34946d7c57532e46b86b5bac8d4787db","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[4.1000000000000005,-1.1021821192326179e-16,1.06],"yaw":-180}]},"visible":[1],"mounted":[1],"events":169,"eventDigest":"d318ccd9968ef7e109e1c614f5a10da90f99954475a0bd4653f52606afa7e294","stateDigest":"81764cc3d1c74cd03b11d1d695c61f0c4d7decb92d023566a79a20bcfdea53e3","publicationDigest":"ef1bf9aa4957ec30e6d85fccb9acc28286b2908d96324671e79e9591fa7822a8","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":20,"origin":[1.7799999999999991,1.7389984547892412e-16,0.9199999999999999],"yaw":-180}]},"visible":[1],"mounted":[1],"events":187,"eventDigest":"b60ba6a1740b42329d3bce4d46cf7ed8aa8dcfea69c954058ea90a10981ff838","stateDigest":"789387fb3c663f5d9cbcd147887de2491ef38c89ff7ac31470245ab09cd0dfbb","publicationDigest":"150bafc0a32d584631905b878ffea0b27bda2997c7363b79b0acf217fd5042db","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":20,"origin":[1.7799999999999991,1.7389984547892412e-16,0.9199999999999999],"yaw":-180}]},"visible":[1],"mounted":[1],"events":193,"eventDigest":"6b2d625e486082e57df6c6c699a777d395984908005b22fe100d8e608bb2ff79","stateDigest":"789387fb3c663f5d9cbcd147887de2491ef38c89ff7ac31470245ab09cd0dfbb","publicationDigest":"d8dc9b49b8e8b7fd558b029480ae61605c5680399359381f15b6a6f256ac2a1e","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":194,"eventDigest":"84c6f3d1e9fc2928ee890d484251c027bcb82ba3c372000fced8782228c649a2","stateDigest":"850ed02e7a54b824e47f4d7f1605edaf40cc882f0ed36ce1e6e802578f49e3b8","publicationDigest":"c1fb580b19b6581ac97917f975c450807be8ad26651d905265d4d84c46685d1b","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":194,"eventDigest":"84c6f3d1e9fc2928ee890d484251c027bcb82ba3c372000fced8782228c649a2","stateDigest":"850ed02e7a54b824e47f4d7f1605edaf40cc882f0ed36ce1e6e802578f49e3b8","publicationDigest":"c1fb580b19b6581ac97917f975c450807be8ad26651d905265d4d84c46685d1b","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[4.359999999999999,7.83773951454306e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":227,"eventDigest":"051dd5cbd2ee7e2ddd33e11d76b37b398cdc1e7ca17ec9c3ba951224476790c5","stateDigest":"ba2353d8a771fc87bffae76408bb63adf6b004c85ef2de544cae3fac55dd1938","publicationDigest":"a0b1faa35244b0daa4a0795367bbc6c9bc0570cda5d63e0f1860d2ca81584af8","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":235,"eventDigest":"cc2d44988ddc5656194f2f164b2f6dcf28256ff0698f309702c9b2dfcb163980","stateDigest":"09fc0b31c0c248d052556756a97ac79ea00c34598c014de4539fe4dbffff9a6f","publicationDigest":"65cca0358b11b9dea7c7c8215f2abb31b1c59e4676050729e36212d568e7e404","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":235,"eventDigest":"cc2d44988ddc5656194f2f164b2f6dcf28256ff0698f309702c9b2dfcb163980","stateDigest":"09fc0b31c0c248d052556756a97ac79ea00c34598c014de4539fe4dbffff9a6f","publicationDigest":"65cca0358b11b9dea7c7c8215f2abb31b1c59e4676050729e36212d568e7e404","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_dog","backend":"replace","quakec":false},"frameCount":86, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"a79b60a4f6cf97c99a4ba9f5f6273d0c05f6a690f4fc742185c78ba43142dbd1","stateDigest":"6064ca390c19a60550992ebb19aa251f14dfd65110bd93d5a5d7f0e1118006f8","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"a09c4ea61e212f7ea290bffae7e70b4dea8d8dba7a0019936de41ddb21693415","stateDigest":"e8fc500c5ce07e6603c8b3d11eb89e77a0cdfdc4ab1ee63fabcc21927a3c7454","publicationDigest":"c0c7d926777c8b30b61e8f80203be01d8fa20e82d6e233a5b25a49fa2516758e","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":15,"eventDigest":"b30eadcc2124e3fae8ac149551c61e411ea698f65278674c147a174819839a0a","stateDigest":"a97e7940a71629f7b3b7f85efc4f105444f757096e8d80dbff2ad5c7f8549bd3","publicationDigest":"b102fbf264baed39b65168e3e6026a3982132e6c02ab7459ee0043f0aa94b789","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":15,"eventDigest":"b30eadcc2124e3fae8ac149551c61e411ea698f65278674c147a174819839a0a","stateDigest":"a97e7940a71629f7b3b7f85efc4f105444f757096e8d80dbff2ad5c7f8549bd3","publicationDigest":"b102fbf264baed39b65168e3e6026a3982132e6c02ab7459ee0043f0aa94b789","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":22,"eventDigest":"89074c215de31114567ca3b06ed1d0a26d406c2f6e6d0c082ca4dd11c23a5684","stateDigest":"3023cdd9c9d07d2b4e01f13c37213cc73cc6d50a587a1e9b53d39b04645d0bda","publicationDigest":"c12c21244fefe3d152805aa1312d5c6a87e34e7b6b3dafd3f8e0dc4d97797ca9","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":22,"eventDigest":"89074c215de31114567ca3b06ed1d0a26d406c2f6e6d0c082ca4dd11c23a5684","stateDigest":"3023cdd9c9d07d2b4e01f13c37213cc73cc6d50a587a1e9b53d39b04645d0bda","publicationDigest":"c12c21244fefe3d152805aa1312d5c6a87e34e7b6b3dafd3f8e0dc4d97797ca9","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":36,"eventDigest":"7fd56718e2394c35db7cf224cd1c0cd777e6466a9ecd27035127f26534b7d2f8","stateDigest":"3d82f843a8b4ffcda7199445a4e4c960f6ae3590a86b30e4b6d629e6628f88b1","publicationDigest":"de9d2dfaeeb00ad4818b47264140b93d9f8352487d397a9db3a58d5d9e75f3ff","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":20,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":48,"eventDigest":"12d1c690c2869f591d207d1ecdea9ee9c6d3eb5eac799d22a7320648115431b5","stateDigest":"8e925fd860613632353aed6b4921ce476177e1ad967da0f9c5d50d0e105c93ad","publicationDigest":"d31a4f4d19491f63e45ff52f8a97022ad4d7336e1b7703b6d858a18ad264d7e1","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":20,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":51,"eventDigest":"4b56b5f135e02f1dd57d8469650f3cac769db3454b4ee10975cc460dc7db29ce","stateDigest":"bfebc2f3b9ddd89811df088fe8e11d8e3c5f4d380e3c5f73bb415ddb118af47a","publicationDigest":"78344435da46c8ef7e8244a50496a64f1fe815632b20522683bc3a21d3b0dd0a","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":20,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":66,"eventDigest":"e2d7f6f557350b0e227946f909d624f867b8a663f7099befb64e49b2f1abb13c","stateDigest":"2900db4de47005b7c9671a314606a3e28a1583214598168a49dc22fccf36604d","publicationDigest":"90cb129a7f5bb1aa8dbfdc0fda76e19edb7b130e3ccb52f9b0c6f5c11bb5b63c","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":79,"eventDigest":"490f223c8a18e63d76b26385e860f21a62a45e25e72691df365579314704a0be","stateDigest":"d71f6157888d2170b83b45f64501a0e5dfa0c72e1771a1d236a74b63b3216e38","publicationDigest":"5d0c03359735126a1a0cfda436171a5f6a7ce9ccbafef499e49fdf98df7c23cf","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":20,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":95,"eventDigest":"87f38ff3688d77b1bf82c6b20834431f3a4d1722f326e31f793f57b1be9b7302","stateDigest":"4c81b2a55c5bb4432c5a972aad49f21b7d1c5851bbe9100064694cbf8f9f094c","publicationDigest":"8222e39b6cd1122209725d2e46bfc11e16c6ab055f6237ffec02b1391d549bed","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":20,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":102,"eventDigest":"a93ceda005596eac7f4e700a2e36d7267bfc2be0896573173ecd76d14c8d96ca","stateDigest":"a7a6d114a4666f02234eb77b6901b113c62cae4531146b1e35ad99fe36ca5b81","publicationDigest":"821908e6a52ab708208af924c5849046e7e626a7631768e6d5fc65985053e3f3","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":103,"eventDigest":"e79ff45dcf3a7a44db45fd0d99ede7ffed6c08ab4a027470ff975b11e4747165","stateDigest":"ab9899802289e7fef134d0d7d2a46769c23e34dbc6de5b044fa1ce12402b965f","publicationDigest":"53536655c1f86a6d0ebc510e2f11432f1159c88e0d84977edc47cdcec6605c1e","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":103,"eventDigest":"e79ff45dcf3a7a44db45fd0d99ede7ffed6c08ab4a027470ff975b11e4747165","stateDigest":"ab9899802289e7fef134d0d7d2a46769c23e34dbc6de5b044fa1ce12402b965f","publicationDigest":"53536655c1f86a6d0ebc510e2f11432f1159c88e0d84977edc47cdcec6605c1e","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":118,"eventDigest":"f3dfad27a0832c62b24a22de1816bf1a868714930b70ca82377605787838d0ac","stateDigest":"754ac1040f513d76e15749bdc09ed909549583e4181824a172e3961bb945d683","publicationDigest":"47de28b18493ee7b8a041b38ce0f451d903ac73ab4f590db24b19e53cca88cf6","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":126,"eventDigest":"25cfb035c2067615e3dc736e50e6c64af40db90379ea96433e4c943e69fe2267","stateDigest":"c63083b415263ca60937423a54134a626bb346c5231d9d439c99ab9b1121afd8","publicationDigest":"8a1742a3c5535fc406a86dc91202b595afc8dc788b906114a5a095802d73e721","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":126,"eventDigest":"25cfb035c2067615e3dc736e50e6c64af40db90379ea96433e4c943e69fe2267","stateDigest":"c63083b415263ca60937423a54134a626bb346c5231d9d439c99ab9b1121afd8","publicationDigest":"8a1742a3c5535fc406a86dc91202b595afc8dc788b906114a5a095802d73e721","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_dog","backend":"replace","quakec":true},"frameCount":86, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"a79b60a4f6cf97c99a4ba9f5f6273d0c05f6a690f4fc742185c78ba43142dbd1","stateDigest":"6064ca390c19a60550992ebb19aa251f14dfd65110bd93d5a5d7f0e1118006f8","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"a09c4ea61e212f7ea290bffae7e70b4dea8d8dba7a0019936de41ddb21693415","stateDigest":"e8fc500c5ce07e6603c8b3d11eb89e77a0cdfdc4ab1ee63fabcc21927a3c7454","publicationDigest":"c0c7d926777c8b30b61e8f80203be01d8fa20e82d6e233a5b25a49fa2516758e","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[4.359999999999999,7.83773951454306e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":40,"eventDigest":"936701cfe30d4279e52698db443a972154722dc0c89d233295d926a204c7ac4a","stateDigest":"b3153a4633db6184891f5a020cca35db423be76311a95a8c2dc70369cd69b06b","publicationDigest":"185973df98af37b3042d298f2506c2e8a492e6a3e947f55e1078f85e5f36354c","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[4.359999999999999,7.83773951454306e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":40,"eventDigest":"936701cfe30d4279e52698db443a972154722dc0c89d233295d926a204c7ac4a","stateDigest":"b3153a4633db6184891f5a020cca35db423be76311a95a8c2dc70369cd69b06b","publicationDigest":"185973df98af37b3042d298f2506c2e8a492e6a3e947f55e1078f85e5f36354c","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[3.319999999999999,2.0574066225675533e-16,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":76,"eventDigest":"751d78e7c8fea1fd3149c6c28e353ddb37e85203024c2e38ddd314a2d311f186","stateDigest":"bf4c9ed4b6000ca3fc6b6edd5067008cc0d2b5b5d0c47008467ad73f42a275b7","publicationDigest":"d23a38b70d73c4792ebf1488bb7cdcc6c0f988a8b37b02d9581ee8b8c9bcd0f1","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[3.319999999999999,2.0574066225675533e-16,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":76,"eventDigest":"751d78e7c8fea1fd3149c6c28e353ddb37e85203024c2e38ddd314a2d311f186","stateDigest":"bf4c9ed4b6000ca3fc6b6edd5067008cc0d2b5b5d0c47008467ad73f42a275b7","publicationDigest":"d23a38b70d73c4792ebf1488bb7cdcc6c0f988a8b37b02d9581ee8b8c9bcd0f1","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[2.679999999999999,2.841180574021859e-16,0.5],"yaw":-180}]},"visible":[1],"mounted":[1],"events":104,"eventDigest":"42717d519237a3c9c10f085ca07c6871ceaa1bbdb52b5e717a63d55e9c9f14c9","stateDigest":"551d5dc61dd495b8dcb8bf633d331f340e37f53316bc7dbec2228dc853130b66","publicationDigest":"16713a0280be79910e9c75a32ea326b03476d9ce44d967b7500ce0fe4463d1ba","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":20,"origin":[1.7799999999999991,1.7389984547892412e-16,0.9199999999999999],"yaw":-180}]},"visible":[1],"mounted":[1],"events":125,"eventDigest":"eb00442c5a71a785f459d4b3a44139b1768cc850340e9323ab8f08a4dd3978f6","stateDigest":"902c5d05bb200c6b7bac863d977b78294f86118cdcea5c79482a8e3a8ce14551","publicationDigest":"be703eeb21f1f052410e3a7b0fec0a75fc79dfbe115674078f0a4c173b124101","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":20,"origin":[1.7799999999999991,1.7389984547892412e-16,0.9199999999999999],"yaw":-180}]},"visible":[],"mounted":[],"events":132,"eventDigest":"0aaa730fdacfafdca96e8dbf69b9a080550f2da31f0c604117ed325d8cfc5f5e","stateDigest":"1b657beb0efe650a77a4c2e9ffe0afe9417356d4bfc7019736e7fcb9335963f5","publicationDigest":"24f9e0e5bfcb2b9fc88ce848b91fe9defc03e33c573e5fdbb5057f31df9858bd","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":20,"origin":[4.1000000000000005,-1.1021821192326179e-16,1.06],"yaw":-180}]},"visible":[1],"mounted":[1],"events":171,"eventDigest":"9c2c269192c77d5348270b1ee46fece268961961863509feac66b95bdcb88deb","stateDigest":"11ce82435ee5be50fc25d770f3b3f02c4b617e1a1709a94c52d9c918fb1ee1f3","publicationDigest":"f5d2dfcf197ef3e10a5a3473c8522fe25f13c00b5ae71be5f0741a513906592a","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[4.1000000000000005,-1.1021821192326179e-16,1.06],"yaw":-180}]},"visible":[1],"mounted":[1],"events":256,"eventDigest":"aee67e762d8444a85a5e2a1aa161085fa5445c39a44ee3393a124dd6ce398203","stateDigest":"e030892749039bc24d5271f12366f89d72c07446fd2b089c053503534c56af63","publicationDigest":"c8fccf0f3785857ae9b396c4501b94248f04b1c2db51f6aeafbe1370c5820828","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":20,"origin":[1.7799999999999991,1.7389984547892412e-16,0.9199999999999999],"yaw":-180}]},"visible":[1],"mounted":[1],"events":274,"eventDigest":"4c9fb6e6ff02045219ad60533fb5bf66b0a4e2292b6117cd721977c408645534","stateDigest":"45829b7e8749ab774ec501804713c495e887aa8e4670dae7542ae8e88dac0e2e","publicationDigest":"d234d6556cec0d61acaf4fa1f45c193756a0d4d3b98c8719c75c708a274c6cae","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":20,"origin":[1.7799999999999991,1.7389984547892412e-16,0.9199999999999999],"yaw":-180}]},"visible":[1],"mounted":[1],"events":280,"eventDigest":"8a20020819535dbd57aa4f8229ef0525d495942e19748f364fa84872d5d2dcbb","stateDigest":"45829b7e8749ab774ec501804713c495e887aa8e4670dae7542ae8e88dac0e2e","publicationDigest":"6a4eb3edfedd7db624fca7ff8858087eeb6741a211e5bb3476a8874fd52d6250","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":281,"eventDigest":"19a979fa0165a7b0269af871ac4025004864702eafb725623ae66bb26ef0a1db","stateDigest":"ab9899802289e7fef134d0d7d2a46769c23e34dbc6de5b044fa1ce12402b965f","publicationDigest":"13f7be3646336808ceaa48c0e6c28428d67d494a4767499bc6545e303636ca69","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":281,"eventDigest":"19a979fa0165a7b0269af871ac4025004864702eafb725623ae66bb26ef0a1db","stateDigest":"ab9899802289e7fef134d0d7d2a46769c23e34dbc6de5b044fa1ce12402b965f","publicationDigest":"13f7be3646336808ceaa48c0e6c28428d67d494a4767499bc6545e303636ca69","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":25,"origin":[4.359999999999999,7.83773951454306e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":324,"eventDigest":"f92cb1a9cfbc64ba0d5ed66cacdd0b265e8b448b060c2b5b07fad704231b366f","stateDigest":"59bdca1df9bcf64b44b704de56416732ec10acb03486a8fa95499f37bf9e4938","publicationDigest":"0a4b30c90446c424859042beb5e1d5d7c0ad7c06ca700acf4aaaee21c0b0a648","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":332,"eventDigest":"934ed743296728db008cfcb2950208ca85b57d8d5d02636641084c826cf22125","stateDigest":"c6573f63a1fe6fb335e42f6c84947965aa8fe089b607b19661228995f0c8fc23","publicationDigest":"bf7f040b7f64403eac66ef1de25b8dbc38da83730b2171b7260e13c9c09dc494","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":332,"eventDigest":"934ed743296728db008cfcb2950208ca85b57d8d5d02636641084c826cf22125","stateDigest":"c6573f63a1fe6fb335e42f6c84947965aa8fe089b607b19661228995f0c8fc23","publicationDigest":"bf7f040b7f64403eac66ef1de25b8dbc38da83730b2171b7260e13c9c09dc494","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_knight","backend":"frameset","quakec":false},"frameCount":97, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"f19093288faeb6049c1ee238f7f1b91fd9834d637e4ae2fd4305c4bf86f890c8","stateDigest":"49fa886f648f52542a7262bb091d01d99afbfda2e25744b986f470bc1a216d21","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"401bcb55bd4923ae52d654b3bc72d1f45c8161efa1bdcd3a9f651a7525e59621","stateDigest":"4ff9243c2f28efded8ed16bddbca4820b274864ea17f9448237e4254273349a0","publicationDigest":"8ac5941f1ce9f10ab67d57aab4ede984d223e5c702af4dbce8a1bfb6d0e6ecdd","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":10,"eventDigest":"fb6c4334e18cae46bc2bf68f2c1ceacf22c14756178dcc793d0912469c915388","stateDigest":"4fe585241b9875fa2528f8812a659be3d16cd8dbe9f6cf5a36de2324ff49790d","publicationDigest":"42e3ebf0d0f2d7611b80e87177eedcdc0f4ace1e46a850cd16bd0adada420221","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":10,"eventDigest":"fb6c4334e18cae46bc2bf68f2c1ceacf22c14756178dcc793d0912469c915388","stateDigest":"4fe585241b9875fa2528f8812a659be3d16cd8dbe9f6cf5a36de2324ff49790d","publicationDigest":"42e3ebf0d0f2d7611b80e87177eedcdc0f4ace1e46a850cd16bd0adada420221","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":12,"eventDigest":"b0fa813e4f219ac8c57a6bf4e13842a892bd8f121871c4eb2123da8a70e3f4dd","stateDigest":"9870529a39063df6bf801061afe145969a7a84295b5ca428b7c11076b4cc2a8c","publicationDigest":"e80d7cb68c374efd1b2537d7330320ff7ed18afea802d832514a62fb12bc855b","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":12,"eventDigest":"b0fa813e4f219ac8c57a6bf4e13842a892bd8f121871c4eb2123da8a70e3f4dd","stateDigest":"9870529a39063df6bf801061afe145969a7a84295b5ca428b7c11076b4cc2a8c","publicationDigest":"e80d7cb68c374efd1b2537d7330320ff7ed18afea802d832514a62fb12bc855b","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":16,"eventDigest":"0bcde1a411a14bbd8498bb135d86c6150b1d8d236980cdb7d8d7e6716d9e0d6c","stateDigest":"72929f65b4d271c1b4ac747a7cf5d9c6750efe242b82f601512474a298cc0c73","publicationDigest":"b5a3de480823116e41124544d279305e71afc8296eba29b0e16da6209e0fcb60","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":70,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":23,"eventDigest":"95886db2a923436352810a25babf325f81d77cf27134f41b9405ca988a987584","stateDigest":"9847d7ffe48b8980143a8041cc5739ca4f4c89b5c0415f32445cf154bca87fc1","publicationDigest":"d78491ca27fe10f4f21492c8265b708651598a12b8d746e4be560531ac5b2010","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":70,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":26,"eventDigest":"e2716e90a80ae166a216f20199fb634f4177d999ad7873dc126ca6b31ae203b1","stateDigest":"f611e9d34f1887df1ffc9329ea48b268af76e341a287c8c967ae84aa45b80750","publicationDigest":"f9c738742cf9465a15f8d060dcf06928971cdda2d74119bf4abae5d93c66fade","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":70,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":36,"eventDigest":"70ec1deabe84b131a2298c7e4a0bd4e8d440ec5a433dda6bb2a7d30efc64232f","stateDigest":"e848e033f2e32019e52367d8b9e5f6899ddb7c8c1f0873e0fd6e54f4f2b2f0a8","publicationDigest":"89c0e709875fb23feb16c5b8c5251362bac049b98b6f5237d354cdccefbb1b11","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":44,"eventDigest":"2db6043203bd88b6a1ea6c70927f870a75df8bc9c53b6c130d23a3b6e511c900","stateDigest":"127a7b1cd52b9a5175be845ac6a373bf7e4bee19c32ab563060e6e56c084ed13","publicationDigest":"9ad02a25e965139c3438e8fb1ccb91f922504554b9063363120cd0167b51b82d","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":70,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":55,"eventDigest":"ff3146e383890474dbdf28f70dd46f7c86b1a1574bb8364f09baca093b679581","stateDigest":"490dfbaf6ecb73f769ec559fb67f6631997479720662897133e3d74850d3b56b","publicationDigest":"7416a94dc271f7c211df43d097d76b8396efc33f0a81254368acd4cf9e3d1f38","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":70,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":57,"eventDigest":"71bf584d0168ef7a45c9d4029778177b4cd135d7ab78d81bf776210644362963","stateDigest":"ef588d32f064ad65c380c8bc5dfb1c07ba0b077bd2242651445213d076ce67a3","publicationDigest":"857e5fa3d4b2ab2497c3b5d42f75b335e9feaa8962492abfa317d6450148ace1","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":58,"eventDigest":"8d967e7e5ad183b6b278e1731820a33456d6044e145dba614f91cfece723883a","stateDigest":"850ed02e7a54b824e47f4d7f1605edaf40cc882f0ed36ce1e6e802578f49e3b8","publicationDigest":"af154a56fed46acebe0e7980b71c46df9fb752eab6e476fbc47146726af4413e","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":58,"eventDigest":"8d967e7e5ad183b6b278e1731820a33456d6044e145dba614f91cfece723883a","stateDigest":"850ed02e7a54b824e47f4d7f1605edaf40cc882f0ed36ce1e6e802578f49e3b8","publicationDigest":"af154a56fed46acebe0e7980b71c46df9fb752eab6e476fbc47146726af4413e","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":68,"eventDigest":"67d1bb2797157abf5eacf7d27559e95a0de7b7130af88ccba41e92b8f41ff9a8","stateDigest":"3aec1ffb8f1a35322400ae39d7289eb05b840f4c8ad6032c9b5a10b8cff1ffd4","publicationDigest":"0470867c3ec0a7c637ac076612fb44dfb374e9f6cca472f09102b95d968be1a7","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":76,"eventDigest":"ab1cb37c073b16b526f75b758a2cd55d2cdaf8344223bff3ed873582ff7b49bf","stateDigest":"cb0a17c292d1f1e836e451fb3c57072bf76ab72974398b76d6d8a8f5b4a451de","publicationDigest":"1c23aa8f975f5cee83921e8427e589707f088ab1055bbeb927469b5ff048d47e","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":76,"eventDigest":"ab1cb37c073b16b526f75b758a2cd55d2cdaf8344223bff3ed873582ff7b49bf","stateDigest":"cb0a17c292d1f1e836e451fb3c57072bf76ab72974398b76d6d8a8f5b4a451de","publicationDigest":"1c23aa8f975f5cee83921e8427e589707f088ab1055bbeb927469b5ff048d47e","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_knight","backend":"frameset","quakec":true},"frameCount":97, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"f19093288faeb6049c1ee238f7f1b91fd9834d637e4ae2fd4305c4bf86f890c8","stateDigest":"49fa886f648f52542a7262bb091d01d99afbfda2e25744b986f470bc1a216d21","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"401bcb55bd4923ae52d654b3bc72d1f45c8161efa1bdcd3a9f651a7525e59621","stateDigest":"4ff9243c2f28efded8ed16bddbca4820b274864ea17f9448237e4254273349a0","publicationDigest":"8ac5941f1ce9f10ab67d57aab4ede984d223e5c702af4dbce8a1bfb6d0e6ecdd","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[4.4799999999999995,6.368163355566237e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":30,"eventDigest":"7fb40aa16557e682b6c5819e8816199bb8809c8fbb7082e4760f10f52a14a4c4","stateDigest":"191bd8c6b2d0739563fc3dc43cab34547ad23b93c8fe8e3d8620e95fa30eed6a","publicationDigest":"3cef21199e8d118e1a7c4b81e91b39c74a8bd6827e57c4e241fefbe29e354785","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[4.4799999999999995,6.368163355566237e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":30,"eventDigest":"7fb40aa16557e682b6c5819e8816199bb8809c8fbb7082e4760f10f52a14a4c4","stateDigest":"191bd8c6b2d0739563fc3dc43cab34547ad23b93c8fe8e3d8620e95fa30eed6a","publicationDigest":"3cef21199e8d118e1a7c4b81e91b39c74a8bd6827e57c4e241fefbe29e354785","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[4.079999999999999,1.1266750552155652e-16,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":56,"eventDigest":"3b3af24719def88d34f59cfc468082daa8dea05de60ed94980ecd63e5cc7beeb","stateDigest":"5145df2dd5dbe80a465a7a493755ff0a018dd4a4ffc7cf241425fa96cdd1880c","publicationDigest":"bb76fa6fbcdb2087047682dd48176d0f22777dd7eddb4bce95805123cb30ce08","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[4.079999999999999,1.1266750552155652e-16,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":56,"eventDigest":"3b3af24719def88d34f59cfc468082daa8dea05de60ed94980ecd63e5cc7beeb","stateDigest":"5145df2dd5dbe80a465a7a493755ff0a018dd4a4ffc7cf241425fa96cdd1880c","publicationDigest":"bb76fa6fbcdb2087047682dd48176d0f22777dd7eddb4bce95805123cb30ce08","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[3.3599999999999985,2.0084207506016595e-16,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":82,"eventDigest":"51b4db8cf6420d1159d24e8c1a96c05e4c380f6a7ce25260bce35866ff103e35","stateDigest":"add7e84b2a3181074252603e21ba2bfa03ff5c7d0d1b70f378d6c1552acb0606","publicationDigest":"84bd326386fa8fdbbe2b203ca80e6650033a6280820eb40f5dae78f8a2f5772f","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":70,"origin":[3.3599999999999985,2.0084207506016595e-16,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":90,"eventDigest":"3fae90e8aeca4d1b7dbfc68b109ffd1ca94015006d835fef0496962d7c630cdd","stateDigest":"3ae4e526c60c19a63c1d7eabe46b0f9ec554fca96d296a75ef8030efb66612f8","publicationDigest":"dbfa52a3e36f6ceaee2e0a8a6c64bb7b5d1391cdcbf94649556168307e395241","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":70,"origin":[3.3599999999999985,2.0084207506016595e-16,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":93,"eventDigest":"215a1e2ceb2bca794112c88777d1df0a61e21e12f54ab2a214d276d38d038a48","stateDigest":"e46b6bc1ba7d6233fb0e0c8782e63a5dd31732fc26aed7b09057e5fe1e2ae975","publicationDigest":"902692b00b6df6f1ecc84da255a99a8ff4930bd9d8419d57d17409b5ec91c3f5","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":70,"origin":[4.4799999999999995,6.368163355566237e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":123,"eventDigest":"67536fb68d3c5f39863f94b058025a302c73a69cfe2deb9daeedb58fca657ab8","stateDigest":"8b12c1f7d80ea583ebc205bd659b715ee2d559eb9ff08ea253e33a5aeaf03ba3","publicationDigest":"83043c1430006aa7fa29ff7438c03566ac935073fbb5f897025764ba2f1fb188","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[4.4799999999999995,6.368163355566237e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":162,"eventDigest":"1ef51cf4c719acfc36ee65ff48b6dd21a70b2c10268e2ad80ce55e807057b541","stateDigest":"3a2e4fa2381413fd92d1b3323b20d650caefe6436428d5da60845d23336213fa","publicationDigest":"f159455d4492f50289b0e7a5533a4cbc281458e27114b1edcff4bcc19b55d3b4","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":70,"origin":[2.8000000000000003,2.694222958124175e-16,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":200,"eventDigest":"a6971e47c1346ec3faa19bf2c41491cd8e2cd88621b3b216f507f787cea2f3c0","stateDigest":"aa16cae73f6bc0895c29c1a6121549fde5a123200d7f69bfe1d85e7a5baa9d59","publicationDigest":"8e0de34c9fdd3b534ee640d062c4b6bee1e17d1be32b18fb82d6d5653145b1fe","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":70,"origin":[2.8000000000000003,2.694222958124175e-16,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":204,"eventDigest":"1d8f6d0ebb0009735faf161abf2fc7bfa39e683ceae3cb866faacb0aa73651a9","stateDigest":"cce253a4ef68e779bdb6611a12d0d528dcf73ae5cd350a09cabc2a7c95db88ba","publicationDigest":"e594886d025f9c89060937e8716c1eb52684569d55586deeaaa2982af422bcd9","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":205,"eventDigest":"41524c41d02b828335757a57199658d0cbd59c10636149be89e397ce2db9ddd4","stateDigest":"885b9d1efd694f3804c749e7c31df090aa3291f3b7d6d36dd8ae70dd0bd950b2","publicationDigest":"a38658e5ac13bfe11c9c00c3bf9af59dc7cdebfb9f8d215d1763d1d87faeac04","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":205,"eventDigest":"41524c41d02b828335757a57199658d0cbd59c10636149be89e397ce2db9ddd4","stateDigest":"885b9d1efd694f3804c749e7c31df090aa3291f3b7d6d36dd8ae70dd0bd950b2","publicationDigest":"a38658e5ac13bfe11c9c00c3bf9af59dc7cdebfb9f8d215d1763d1d87faeac04","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[4.4799999999999995,6.368163355566237e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":238,"eventDigest":"3f4c2dd904639bc36a9799a565591d0f7cbed5212093a9245e1374fececf6caa","stateDigest":"3c3c4010e5d2918f92e6ca92d123e40daa673f5e1c4c69dfe2238386a3505731","publicationDigest":"467adb71714e84f3fca10cb727b51461cd400f2f11d5cd6b4939062196905023","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":246,"eventDigest":"e17ea507f2cdc1c4dd018ae457c970f3bb9bd0557c0a74249379e228f87b80a7","stateDigest":"09fc0b31c0c248d052556756a97ac79ea00c34598c014de4539fe4dbffff9a6f","publicationDigest":"3882f73cd58dbca0b460545da0494ca9e6fb19fc84022e38872cd38fa9b93cca","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":246,"eventDigest":"e17ea507f2cdc1c4dd018ae457c970f3bb9bd0557c0a74249379e228f87b80a7","stateDigest":"09fc0b31c0c248d052556756a97ac79ea00c34598c014de4539fe4dbffff9a6f","publicationDigest":"3882f73cd58dbca0b460545da0494ca9e6fb19fc84022e38872cd38fa9b93cca","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_knight","backend":"replace","quakec":false},"frameCount":97, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"f19093288faeb6049c1ee238f7f1b91fd9834d637e4ae2fd4305c4bf86f890c8","stateDigest":"f4f2e21cbb54e6dabd654fdacd6ad988ea05ff1efe69f6e06ac106a137f150a2","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"c892e27dcde526f890b119563a379fab812d68e47feed6e8506d7cfea74c75f1","stateDigest":"ca384867d2bae4ff61053f47c932b2f6e83d4fb942add04a90ae5f5fabce5d0a","publicationDigest":"8ac5941f1ce9f10ab67d57aab4ede984d223e5c702af4dbce8a1bfb6d0e6ecdd","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":15,"eventDigest":"5a53234d9aa3ac577338810b479316c5e079d72d5cb441bac714b32060cbe8d6","stateDigest":"3b6a45a1e1e6f6b57762db8217613695bc729ed91d2e9a11aa49ac23add5a8c9","publicationDigest":"f2b977137768b6cf7370ddd4e08c188e9423a263d8b046f217d405f38ab2d891","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":15,"eventDigest":"5a53234d9aa3ac577338810b479316c5e079d72d5cb441bac714b32060cbe8d6","stateDigest":"3b6a45a1e1e6f6b57762db8217613695bc729ed91d2e9a11aa49ac23add5a8c9","publicationDigest":"f2b977137768b6cf7370ddd4e08c188e9423a263d8b046f217d405f38ab2d891","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":22,"eventDigest":"34e315a732f79ce31b64a1e051f3e95d1cdaa2981a0265fb58147149fe679e5b","stateDigest":"3f8516065aeaea5d1edae5e5fd197176ae624998558c027441910890a8d7e228","publicationDigest":"1020ac0e9fe59a3cb454332f25de6b70722d57f63c13dc8ce9a84b6aeaf31d9e","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":22,"eventDigest":"34e315a732f79ce31b64a1e051f3e95d1cdaa2981a0265fb58147149fe679e5b","stateDigest":"3f8516065aeaea5d1edae5e5fd197176ae624998558c027441910890a8d7e228","publicationDigest":"1020ac0e9fe59a3cb454332f25de6b70722d57f63c13dc8ce9a84b6aeaf31d9e","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":36,"eventDigest":"120f29a0e95449849192b89a2644e73ff8af755721caf548fab938a55d5b1155","stateDigest":"9e1f9908654b8728c0e043b57fd3f1b134988b4a0ad3620cdf49b70b88d012cd","publicationDigest":"4424d298029a64fa68ec43887adc51b90abcdb097170dba91f4e11e0823510ef","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":70,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":48,"eventDigest":"4dbfc1e8ed20e6a9e4fb79a43cb68c81649b33353b16a9b32eaaeb7be33c0f37","stateDigest":"1c00f9dda1c5101917aa595f91a83a6d52d48903b0f1618ba201ffde4784d15c","publicationDigest":"2a4e589f83e502622f9b7e35a9c23335a6a2232ccdc7f6df2ff26793e780e95b","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":70,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":51,"eventDigest":"d7d9034e18c38b830f2e4f387bb2c8bdea1559f569286ad2278e2b0ef5cf07f1","stateDigest":"3985504fb290790e375924d93d6122354968ac3c575a47c1610b4b78e5349f93","publicationDigest":"be6e3ab6467dd0e733f778e8dd5fa071e52ad51550385c30825eea2f4c6237fb","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":70,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":66,"eventDigest":"826a1005acd5eafe2b431dcd19aecef1218d813f1581ac8feff69c6c9eccc410","stateDigest":"ec222d810546bbe92a6e562016b554c19b349d5288a476c705e8519deeb62747","publicationDigest":"83b41b9963c144f521521b2e828d7a52b8cb7f6dff20bf813f79c9ad4c2fdc88","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":79,"eventDigest":"de2529c637fd4d7dccee5ff467b74ca7a3fd8dd570d1cf30798ae5ecf5dd0ed4","stateDigest":"7802a25324e9b5a1c51c2ee0156143cabea0c6483ef67194c15415785f4addb6","publicationDigest":"469967d0e3957b07e30056888a6e6eeedb1bad18e8aa3d23aa1d6286e2382e28","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":70,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":95,"eventDigest":"5e11deba40c8904f25e62d0115ac9c1cbc1ee68376f1883ac92d3b74861ad6b2","stateDigest":"7187ddd12b3c3ab9bb5b41b1dadb0cad4467e4283e923c196c69159b35401b22","publicationDigest":"74adc1d150f25a066a3810d551b4db7ce043ee817925e5bc73ee9673faa3b475","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":70,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":102,"eventDigest":"d490036d03b628afcec59a609370e83fe1cebb8237945939cd93e2abe20a7019","stateDigest":"6bc95b36ab2539172ef54bee9c29b71d441d1cfec65b4358f3cf3bc499c2dbe0","publicationDigest":"9290d20f88ff345a1bd3c556cc9c99fc4bafc316b06b94eb16cb21edce93760a","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":103,"eventDigest":"96d8707f52748608a4ab68eab0221fec90d59d3438ae94cf0eda6aa435c1672e","stateDigest":"ab9899802289e7fef134d0d7d2a46769c23e34dbc6de5b044fa1ce12402b965f","publicationDigest":"7359037096b060de4771c092df0bda4effeb96a95169633cc5ebbb557ebf5890","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":103,"eventDigest":"96d8707f52748608a4ab68eab0221fec90d59d3438ae94cf0eda6aa435c1672e","stateDigest":"ab9899802289e7fef134d0d7d2a46769c23e34dbc6de5b044fa1ce12402b965f","publicationDigest":"7359037096b060de4771c092df0bda4effeb96a95169633cc5ebbb557ebf5890","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":118,"eventDigest":"0b9ef15c1cae2abefa6638fa5d0107c67f0684fd921a02890b4060b614dd5c41","stateDigest":"53ad9c53d0dbb511786184bac94015c0059726edaf934fbfda2ce140ce03cba9","publicationDigest":"ae1bd2921e33e4506030d0351219d69bdb8a59815c5425bce8775371aa6eb3b0","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":126,"eventDigest":"c0d9454fa301e19d7a2f240ab4e2cf929a754e004b7435b2bb394f7c03fe7c27","stateDigest":"c63083b415263ca60937423a54134a626bb346c5231d9d439c99ab9b1121afd8","publicationDigest":"4238883d7e1b4061310a71b65f715c949f8cdb614baa6f87878d292979aa7657","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":126,"eventDigest":"c0d9454fa301e19d7a2f240ab4e2cf929a754e004b7435b2bb394f7c03fe7c27","stateDigest":"c63083b415263ca60937423a54134a626bb346c5231d9d439c99ab9b1121afd8","publicationDigest":"4238883d7e1b4061310a71b65f715c949f8cdb614baa6f87878d292979aa7657","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_knight","backend":"replace","quakec":true},"frameCount":97, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"f19093288faeb6049c1ee238f7f1b91fd9834d637e4ae2fd4305c4bf86f890c8","stateDigest":"f4f2e21cbb54e6dabd654fdacd6ad988ea05ff1efe69f6e06ac106a137f150a2","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"c892e27dcde526f890b119563a379fab812d68e47feed6e8506d7cfea74c75f1","stateDigest":"ca384867d2bae4ff61053f47c932b2f6e83d4fb942add04a90ae5f5fabce5d0a","publicationDigest":"8ac5941f1ce9f10ab67d57aab4ede984d223e5c702af4dbce8a1bfb6d0e6ecdd","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[4.4799999999999995,6.368163355566237e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":40,"eventDigest":"3ee2508473037e8763ba322aa726072fd7513c1c92c5bd721b09d46ec7fde6e0","stateDigest":"b40509e35ec46e7b8fcda83fe4980f90e45bfff299adab932b1237816381b103","publicationDigest":"e270f8fac9025437dac4cec68243326c316e9a79e5f91724ef28f7635e9678fa","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[4.4799999999999995,6.368163355566237e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":40,"eventDigest":"3ee2508473037e8763ba322aa726072fd7513c1c92c5bd721b09d46ec7fde6e0","stateDigest":"b40509e35ec46e7b8fcda83fe4980f90e45bfff299adab932b1237816381b103","publicationDigest":"e270f8fac9025437dac4cec68243326c316e9a79e5f91724ef28f7635e9678fa","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[4.079999999999999,1.1266750552155652e-16,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":76,"eventDigest":"aec1633017c71412e0b1b7e476640a299ddb13bc9ae84243781877cd56c9bc4f","stateDigest":"20ca5cddb9bd55940abb9b25ee86eea0768eaaf9b39b13e9185abd252821c469","publicationDigest":"0f57107c6b84a160572384db20a53e878faa4b9751b584877e82072adbbcec17","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[4.079999999999999,1.1266750552155652e-16,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":76,"eventDigest":"aec1633017c71412e0b1b7e476640a299ddb13bc9ae84243781877cd56c9bc4f","stateDigest":"20ca5cddb9bd55940abb9b25ee86eea0768eaaf9b39b13e9185abd252821c469","publicationDigest":"0f57107c6b84a160572384db20a53e878faa4b9751b584877e82072adbbcec17","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[3.3599999999999985,2.0084207506016595e-16,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":112,"eventDigest":"73bba5893a42a6f41b925ddcf1625476729abd552a496329a2f390174543ed84","stateDigest":"ca2a8d12fb16ec77599a4dbfde5f891a4ad93a2bc115d69b7f73b8376fa8df1f","publicationDigest":"a77cd490e8e1d9908851eac5ba99251f340a9d9b9d6a4b5afda0ff07d6cf84fc","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":70,"origin":[3.3599999999999985,2.0084207506016595e-16,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":130,"eventDigest":"bd51af6dd4ef50ffa2e67a48a9739e16769cefc299366fe843e7483d8c1444ef","stateDigest":"897b7af8a66fbb0fa6b7bd801df6c8bcb8c9b3c2cfc1cc29c109f92028cb541b","publicationDigest":"2d139e09c458bc18e44e792126b6a5110e71b3e1841b627c977d0d0d30d69d6c","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":70,"origin":[3.3599999999999985,2.0084207506016595e-16,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":133,"eventDigest":"540a0e690be298fa02efe4ca16dbc618280692ff7b767290a1b7f73a8236acb5","stateDigest":"b060358f41965c548bb87fdd52fa51c3d3d04a97308433b50cce9e86e2f087d7","publicationDigest":"3387c394b2762cd3d546f57c4bdb7a5a5d0d50d7cd839ed632bacb3247d3635f","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":70,"origin":[4.4799999999999995,6.368163355566237e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":173,"eventDigest":"710caaa421a2f612f2284145734a3ca4931c0a31b2e6dd49d2c136f972400b3f","stateDigest":"2d3f14fcfa7385f4bece652d098491b5e4701f286b4295bc1ace850e618a9ecf","publicationDigest":"425f4653e0f58bab695c6b2a6afab10f3a8c0e1a3af41c1b3719297a27dcb0c9","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[4.4799999999999995,6.368163355566237e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":274,"eventDigest":"379796937dc92ff41fdc9c42fb653648dc60f5b532da689a1aa07a2f406cc22f","stateDigest":"61103f53709c42ab151c397c2c0d1f4e978993b764c9dbc4f3f25d38692582a2","publicationDigest":"d25aaa6a44965c7b14aee3a5a0dc118764e6fd48612f7e5a23ba7dc93c740db2","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":70,"origin":[2.8000000000000003,2.694222958124175e-16,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":317,"eventDigest":"ce99bd0771715680c05f47da518e43801d4611ef34925602127912cb29206384","stateDigest":"628d1d12fcb542674c4092a2b50dda29e8b82836ec5fd10002fe8127cfc756df","publicationDigest":"05acdddaa88098568f4d505a3fe9b54c0b08a642353513000bf3014a251de2d8","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":70,"origin":[2.8000000000000003,2.694222958124175e-16,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":321,"eventDigest":"f7df00804b8bf466bb735d05201903195d469fe7e7dac1287267bbc6ea4f9596","stateDigest":"95d6d68e9c82851eaf2ad7bb1bb19961e77a4f47117f5623a7042a3a8ec3564c","publicationDigest":"38251c7ad401132132fb8ce9fb6bccbce5ccb907b06f3625d6a04c9aca8d8f23","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":322,"eventDigest":"72fa4efe9fee2b1cbd2e8666132b9d7abee5c3ecd9de225717740832cae1a5fb","stateDigest":"6005f12dce32f223974b44ba59fb77c45eb41d0415a3a9b3e9ebb2ed386f7e0f","publicationDigest":"72720be137b75acc03f1ccfc99849a84072cc2ef496aca4ec3ad7a50ba20caeb","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":322,"eventDigest":"72fa4efe9fee2b1cbd2e8666132b9d7abee5c3ecd9de225717740832cae1a5fb","stateDigest":"6005f12dce32f223974b44ba59fb77c45eb41d0415a3a9b3e9ebb2ed386f7e0f","publicationDigest":"72720be137b75acc03f1ccfc99849a84072cc2ef496aca4ec3ad7a50ba20caeb","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[4.4799999999999995,6.368163355566237e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":365,"eventDigest":"20ebefe1f032bbff98846212caa29abec8438d449c4a6978b9864ad948ea9606","stateDigest":"a7bb27b6344e40b89a17ed604c64dcd9aaa508b864c7af233d1b253b7caa120b","publicationDigest":"44492a36cfc0b0f4ed37f25119ed07ff4726b8cfe1a8dc8d4074658eab06cb9a","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":373,"eventDigest":"d3846e850aa02aa444a57a8b7ff7a5d4c537c8c3ef5baf640bbb05a16495767f","stateDigest":"c6573f63a1fe6fb335e42f6c84947965aa8fe089b607b19661228995f0c8fc23","publicationDigest":"96a7f922cd166f93a958712795ce9dcb34b3f3dc5be9219b2e68c0202b7df0b7","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":373,"eventDigest":"d3846e850aa02aa444a57a8b7ff7a5d4c537c8c3ef5baf640bbb05a16495767f","stateDigest":"c6573f63a1fe6fb335e42f6c84947965aa8fe089b607b19661228995f0c8fc23","publicationDigest":"96a7f922cd166f93a958712795ce9dcb34b3f3dc5be9219b2e68c0202b7df0b7","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_ogre","backend":"frameset","quakec":false},"frameCount":136, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"7160748eae6e381a054e2ae74ffb98eb5a98b31c2e33d19166e2a52692f5557f","stateDigest":"fa70d99d743fe572bf18947f519b0f9ea72041730e848baa9dc06e5ba2fbc688","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"713e1352b631bba2b05468d375e0c39cb141ad01b17be036a0bf88638357b58a","stateDigest":"cf5fb9388a4eee584d1558431cdb080e2246cc1ddc3840b23347f245b5449c99","publicationDigest":"2abf42019bb3fc8b81e9842eff4809f93e8f644ced0023e0114d77083a413ac9","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":10,"eventDigest":"eadf7443b01aeebe45e28cec7680054a7ed60466b725533aa346d623ba9b9f34","stateDigest":"b971a0560fcd597a2d850708cad3f70539e8f566a448627ce289171bd1bc7766","publicationDigest":"a24032168483638f95347b2fae8b8be6c1e4d091c69091f559d77cd1cdd348b7","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":10,"eventDigest":"eadf7443b01aeebe45e28cec7680054a7ed60466b725533aa346d623ba9b9f34","stateDigest":"b971a0560fcd597a2d850708cad3f70539e8f566a448627ce289171bd1bc7766","publicationDigest":"a24032168483638f95347b2fae8b8be6c1e4d091c69091f559d77cd1cdd348b7","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":12,"eventDigest":"59309b25b24f90d19fb5db49ea1845ad6b0490885f54b74d3997f90b35f5dd86","stateDigest":"0ee473bd2ef7de1d8ec52ca5068fe9cddde98321c3c9098839b76d649fd0586a","publicationDigest":"df564dadf00b4a9716fbff7ca47f4626e0d8fcbbf57741955447f5af873e695a","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":12,"eventDigest":"59309b25b24f90d19fb5db49ea1845ad6b0490885f54b74d3997f90b35f5dd86","stateDigest":"0ee473bd2ef7de1d8ec52ca5068fe9cddde98321c3c9098839b76d649fd0586a","publicationDigest":"df564dadf00b4a9716fbff7ca47f4626e0d8fcbbf57741955447f5af873e695a","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":16,"eventDigest":"258664addc0c2c09364e5293c3be05163cd83d519deb4704cdf0ae4d5d8f5fa8","stateDigest":"47e4b2163a765f388ec3608749285dca0dc719996382ff0e9ce3bc2669c229e7","publicationDigest":"4040e5f9b716449b860a5b14d27a13946979de237bbbf858910605cab828137b","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":195,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":23,"eventDigest":"f8545d3d6b156b19998a4ea0420d7a1967f05d64a65a076d92404bf11459716d","stateDigest":"29c22e6bdbfd1fc242ffe4f033f60179645d8bc23f6b1da45244da4d5d73f4d7","publicationDigest":"1038371a2549837cd40010cdac8abd97de5c99778e3bf8204a0dd3a4b52c247d","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":195,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":26,"eventDigest":"d7286ce9e70c92d9ef6162ed77eeec63879a194654343e751d650f58bcdc270f","stateDigest":"23f2d060b0974e4d259a26e752269fc882539708acdbf563abc9d9af109649fa","publicationDigest":"624e43d4b6eeecc496af9149b1e17963f67e5ec4af5203daa4e82ce463a7fb0b","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":195,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":36,"eventDigest":"6e891ed38e947aaeae16bdc70e0853c5531de6ffedf878726b63c47da31efda7","stateDigest":"19a5cb6fef7ab2f1f09a9b8fb337a93b56b4af69164d4d7c8538af7095900c3a","publicationDigest":"18c652385ba9c3293b76c85eda260c3ea7efe875b6934b133bd6e8399b79839a","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":44,"eventDigest":"d48a62bac610aa4ec794d8898702d4f9910bfe1069d849c32b90de43f3dd5e9b","stateDigest":"033f20541080c0e5ceb97145946b461fdad4930a59daf1ca3b71e35d4a1dc45b","publicationDigest":"357f7f9a451cb72e434ff2c70a847fed6801ea314647f34ae7b23a1afa79c263","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":195,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":55,"eventDigest":"2c4dec7d3dba1d27bad9c6b232111f16a31dc79bdfe211893cd25cb8bbe7a333","stateDigest":"45ceb12f7d5714043f0e095128e099b2e4cee66337f43293bbf05a283ad803cf","publicationDigest":"17c9bb469407884eda541bc19241e83ffabc84e8f0d03f99655d00c628e9fd2a","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":195,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":57,"eventDigest":"9e84c8121fd1eff34783c696b60675ed45e94647443130545939fe5004f09f25","stateDigest":"14a1c85794ae97a05d6c1632b19f7c063d243954a5b0d580c624e17778f7d663","publicationDigest":"75396e309d6f2aca51cce6b58048216f173383df3002e7d0fd577735912be5e4","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":58,"eventDigest":"96ecd3e67579c0cf1dba714d3e34ae4e928ee337fb990134adf6b3837f8eedb9","stateDigest":"850ed02e7a54b824e47f4d7f1605edaf40cc882f0ed36ce1e6e802578f49e3b8","publicationDigest":"9da9e775a61d3d21e1963607ed151fdb58bebed587705981cdc09d499918ed4a","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":58,"eventDigest":"96ecd3e67579c0cf1dba714d3e34ae4e928ee337fb990134adf6b3837f8eedb9","stateDigest":"850ed02e7a54b824e47f4d7f1605edaf40cc882f0ed36ce1e6e802578f49e3b8","publicationDigest":"9da9e775a61d3d21e1963607ed151fdb58bebed587705981cdc09d499918ed4a","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":68,"eventDigest":"de40d4631327e2ffb1d05efad870ebbfa8da67daa591241e86e9d78464fc20a1","stateDigest":"57b4467caee96eb00d378290f9a15738bb272c487aa85bc10c540c1832b16882","publicationDigest":"880d4a92fa767c20cdb380ca7d1ef929f96b275291a636acf43b00b077e2aad8","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":76,"eventDigest":"f0f612cbd39fd4eb4f45e63b5a00aa88822112f7bad6e598730ba04c4071ae6d","stateDigest":"cb0a17c292d1f1e836e451fb3c57072bf76ab72974398b76d6d8a8f5b4a451de","publicationDigest":"709e0cc9deacf1a2c50a88b70760738e499fa8a529127185cfbc3673dd0dba1c","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":76,"eventDigest":"f0f612cbd39fd4eb4f45e63b5a00aa88822112f7bad6e598730ba04c4071ae6d","stateDigest":"cb0a17c292d1f1e836e451fb3c57072bf76ab72974398b76d6d8a8f5b4a451de","publicationDigest":"709e0cc9deacf1a2c50a88b70760738e499fa8a529127185cfbc3673dd0dba1c","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_ogre","backend":"frameset","quakec":true},"frameCount":136, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"7160748eae6e381a054e2ae74ffb98eb5a98b31c2e33d19166e2a52692f5557f","stateDigest":"fa70d99d743fe572bf18947f519b0f9ea72041730e848baa9dc06e5ba2fbc688","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"713e1352b631bba2b05468d375e0c39cb141ad01b17be036a0bf88638357b58a","stateDigest":"cf5fb9388a4eee584d1558431cdb080e2246cc1ddc3840b23347f245b5449c99","publicationDigest":"2abf42019bb3fc8b81e9842eff4809f93e8f644ced0023e0114d77083a413ac9","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":18,"eventDigest":"894e57fefbf15c331f83afee0f5281eb09b7126da6f8477cec2c4503bcc88c71","stateDigest":"4de431363312de2a79dabd2def627211bf76d3e079ba490a4c3abc82ae0b4080","publicationDigest":"78f8c20ee34c4a59b248da84cdeb966897d7f96eea20560964366470dc3c778a","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":18,"eventDigest":"894e57fefbf15c331f83afee0f5281eb09b7126da6f8477cec2c4503bcc88c71","stateDigest":"4de431363312de2a79dabd2def627211bf76d3e079ba490a4c3abc82ae0b4080","publicationDigest":"78f8c20ee34c4a59b248da84cdeb966897d7f96eea20560964366470dc3c778a","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":29,"eventDigest":"bbec7326311410ed3a2bcc9301b10d2379f494864cb82345d964675a16990014","stateDigest":"6e2b4919d79dae158d04b3e00e27e0ee2e5ed8eac63042db1f22d0763b55a7dc","publicationDigest":"2d2f7a0e15c4ebbed90f221d6a4e32f5a0a8f792a5a6cee1e83b32398a7d8d0a","liveHandles":2,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":41,"eventDigest":"8eff78b4ee1393a60c75e35c8434a0764b4ca17106484ace2a0feeb3db7bb871","stateDigest":"6e2b4919d79dae158d04b3e00e27e0ee2e5ed8eac63042db1f22d0763b55a7dc","publicationDigest":"d0b47d4163b902687f95352ce39a4988ebf35e39c697628fc3e294666fb153a2","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":47,"eventDigest":"fcce381a9b447bd2f669b47c360f22ba71014e5335d6c889d70fcaf51d22696c","stateDigest":"0ae47a3c23f084fcf2d256d5f70822b3b3c8de56d2fcaf271e1fed34959ac82c","publicationDigest":"817e8f08d17378276e39fb89b26c486773b0d3d42f4a8883f601aa50fc9f75e4","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":195,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":58,"eventDigest":"9bcd6d604ecc80833a3e933b7fbc85761d2cec4181e9c5114c27e48cc6d0cc4c","stateDigest":"4f0df35161804c5a84068abcfc18409acdc956cd0c0176e45412a064832d8fff","publicationDigest":"a3f25545f46eaf40cefae1b214ac5edfa9c51a0ffe6df6d8beb7b50016e038a4","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":195,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":68,"eventDigest":"f0fc0cfd21334dd0903d1f6b1a322525720e5ba42209bf183f27d2508c34e244","stateDigest":"8d3b75f4d8553825bc68de880aad55d905e3ec8d07364fca0c54203a0fb9647f","publicationDigest":"1eb0a5958f4baeb24e509aa1ec9dab9b8b283dd1b02fea7582b2025bec7e194a","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":195,"origin":[4.7,3.6739403974420595e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":98,"eventDigest":"5d165f976ef58f7984de0b7c4ff0974c4e46e6f48bdae4a8f9be7deb58cf6d86","stateDigest":"5212de534c1dc1175170c618a7d5a461cbf9c30e74354b892175501c1e0fa637","publicationDigest":"d9ea526c0997c49103bf3de3fbf2e7c5c10078f89a5b76ae0694842c03871659","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[4.7,3.6739403974420595e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":147,"eventDigest":"4a359a06d34cdf7d66ed735b8e7aa9533732ef53e3efe51b134040ad64492cf9","stateDigest":"8268914506d7127ec62d8b35e45f406a4e702459dcb29143ee003379a2c2e3ac","publicationDigest":"4002eb1a62b6a0267f3797af43e80cce8b28a2f3ce548702fc982c8d29f6295d","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":195,"origin":[4.579999999999999,5.143516556418883e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":186,"eventDigest":"5d439d5957b5ad17637fc4659821980570711b17c47ede244b9dd5c04a480eec","stateDigest":"5ee037f3579707f6115f513631bb8e089d4630886fe67c960e516df7756e44ba","publicationDigest":"c9f8b371ad2ea04ecc7211d1493103164498a3c91d17cb1c9a2ac30a4ea64008","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":195,"origin":[4.579999999999999,5.143516556418883e-17,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":196,"eventDigest":"cea8e2359467fffe4f39e7f436f648e0d437a93f29e212a6285ea577aedd9663","stateDigest":"7734cd0da9255d67e89e59be4efa1f48ae993f94efcabab1781e20f86f239d6c","publicationDigest":"a5576d83392e8596f6db0848b03e421e053f05286c925b9cbb9871e526b73a4b","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":197,"eventDigest":"345d8e594d1b6682448cbda24989c4315e2afc876d512ccbbbde69690b41d865","stateDigest":"885b9d1efd694f3804c749e7c31df090aa3291f3b7d6d36dd8ae70dd0bd950b2","publicationDigest":"e4bed07205108efc4c1c7989334025f140afaf7ab25ca20dea1c6b4042633722","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":197,"eventDigest":"345d8e594d1b6682448cbda24989c4315e2afc876d512ccbbbde69690b41d865","stateDigest":"885b9d1efd694f3804c749e7c31df090aa3291f3b7d6d36dd8ae70dd0bd950b2","publicationDigest":"e4bed07205108efc4c1c7989334025f140afaf7ab25ca20dea1c6b4042633722","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[4.699999999999999,3.6739403974420595e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":230,"eventDigest":"6e85a6f98ed9b252820253536f51ff9ec951518ea692b7e9e99c4eef0cc19e02","stateDigest":"b39816e924770cfff52f63358827960162551ce399321464d7a08cae3bb66996","publicationDigest":"5dc7155f21aab8baf2aa6cec1fefa4e2bf2431109cdb2b488b52299ab16370ac","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":238,"eventDigest":"5df8299b43864de6db93b3d81dee0ecec5555df1cfc1299ac2aa386c78aaab90","stateDigest":"09fc0b31c0c248d052556756a97ac79ea00c34598c014de4539fe4dbffff9a6f","publicationDigest":"2c71f659d8ae085aeb97ecd4dbe401b738e667a05961bb5afec549605120da28","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":238,"eventDigest":"5df8299b43864de6db93b3d81dee0ecec5555df1cfc1299ac2aa386c78aaab90","stateDigest":"09fc0b31c0c248d052556756a97ac79ea00c34598c014de4539fe4dbffff9a6f","publicationDigest":"2c71f659d8ae085aeb97ecd4dbe401b738e667a05961bb5afec549605120da28","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_ogre","backend":"replace","quakec":false},"frameCount":136, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"7160748eae6e381a054e2ae74ffb98eb5a98b31c2e33d19166e2a52692f5557f","stateDigest":"269d2df9ee8403217416d1986c77bded99d0bbe133341b19d1641d84be1ed41b","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"5839173fc105ffda366e69236cef7127d5872d8c265b27337dc1f5132030987a","stateDigest":"64b9f1f9e592c949cadab81faee1f7f2320d1d1e0ac9d40c227694a892f7e608","publicationDigest":"2abf42019bb3fc8b81e9842eff4809f93e8f644ced0023e0114d77083a413ac9","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":15,"eventDigest":"9aa8af9653fb44fbb584ff1020bc6ae5745654c24035df1f92cebcac4646187e","stateDigest":"02f54b10bd486841e702539a44ba182dd173c3cb7b616c9973858f6be32bc944","publicationDigest":"b2e321847183bb509fc971bc9d6bb372d724eeec266c20cee45e03214a687628","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":15,"eventDigest":"9aa8af9653fb44fbb584ff1020bc6ae5745654c24035df1f92cebcac4646187e","stateDigest":"02f54b10bd486841e702539a44ba182dd173c3cb7b616c9973858f6be32bc944","publicationDigest":"b2e321847183bb509fc971bc9d6bb372d724eeec266c20cee45e03214a687628","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":22,"eventDigest":"194c9486176378d62f85d4cb5f00a8a8b1c1a7f0bf703af195dcc3283b23645a","stateDigest":"0cdabbabd1bc6e96107370539ec37006c606f7aac8ea386967d1412c5939ba68","publicationDigest":"28e7affe7847168290335aef1df24963a6dce1c3824817d3eb5ec16275bfecf3","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":22,"eventDigest":"194c9486176378d62f85d4cb5f00a8a8b1c1a7f0bf703af195dcc3283b23645a","stateDigest":"0cdabbabd1bc6e96107370539ec37006c606f7aac8ea386967d1412c5939ba68","publicationDigest":"28e7affe7847168290335aef1df24963a6dce1c3824817d3eb5ec16275bfecf3","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":36,"eventDigest":"6d473c4920aff561201328b3bbe7ce824fde5543c3044dce114a23dd233f7a0c","stateDigest":"5eee80bbea5c874cc9ca3029bbbe9a983e09123418e511fb829d10e5dd80edf2","publicationDigest":"280ddcf62973a6294f729cda8b72663dd22fb9ceb71c155180a883583bdccc20","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":195,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":48,"eventDigest":"a1244fd812076cad96f85f6200e723962adac4fb845bfdbb5111d94b414c64db","stateDigest":"6f4cf623208fb46f718fbc8a48de9b679321e030a4ea90583f139bb156e3812f","publicationDigest":"7ad7938dd82d6a3ca9a43a915de8c881bbf71c87a76d5b9560e48ef0a4d4b352","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":195,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":51,"eventDigest":"b45f14b21187d0cf39606560a9704c91db749cbee48ab9ba9ae39b46ec9b9fa6","stateDigest":"96bb44574bc9ce5e7c7158bbf28ffba96902164174d30799e84a57713c08c045","publicationDigest":"3fa6cbbaf8fcf58233eefa59abed46af6bdad76bba152e32ebf776d493ae5c05","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":195,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":66,"eventDigest":"f4ec8b9ce2d173f9d4a61ae73cc66a6c598b2c6ef1c22c7b0e19e8b64118bc06","stateDigest":"554703df01677436a383fbc28c16b46998fd6f33bdd714e4ca804263c8f3a6d9","publicationDigest":"52775d6c75d4b140fef401f745de79e8a62cb6b610501794c64a5191d783d708","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":79,"eventDigest":"688c0fc47e3aa67739f012c29bf01e1f64135de56c7f1a666c3b47ab4e701f31","stateDigest":"7dd000a9593dde312e18eac7f55ed2d611a1f243cbfc136d3eab5058c77559d1","publicationDigest":"693aa0c2a589cb1f40b6d81d5c59cf42bc83ff4f15d45de99d5a250227086355","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":195,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":95,"eventDigest":"cafefa46b8e6f074b289113c713a7dc95b3940b65e960d33c7a932f6385f6914","stateDigest":"46e9d3b6b92ce453d432e089bf70b3015077132dedc8064afac422cd3770b0e0","publicationDigest":"cc77c93f1f415b7b67f0da11d5c66aa924127f08d7dacf4ba06c2018c8704b62","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":195,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":102,"eventDigest":"8b9b6b51e3fba1e0b9a982aacb1f8b6b4869fa9eaa71707107da0ca26260b810","stateDigest":"43e7bf597556e2c28fa563453727c0b0024bbe58a3e084866e24eabce8f6a456","publicationDigest":"722ee30a1434ccd2af033397183d29552c41e0d3a5be4c089d4f7cee6f87efdb","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":103,"eventDigest":"6030625809d11c5ab7438c44ed76731822fc3a9253f0d26c80745c64fc8da7d3","stateDigest":"ab9899802289e7fef134d0d7d2a46769c23e34dbc6de5b044fa1ce12402b965f","publicationDigest":"2c03b7777882fe17bf39b249bb4f57752ace7fbc247679f6cace1ffe33883ad1","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":103,"eventDigest":"6030625809d11c5ab7438c44ed76731822fc3a9253f0d26c80745c64fc8da7d3","stateDigest":"ab9899802289e7fef134d0d7d2a46769c23e34dbc6de5b044fa1ce12402b965f","publicationDigest":"2c03b7777882fe17bf39b249bb4f57752ace7fbc247679f6cace1ffe33883ad1","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":118,"eventDigest":"ea3ccc015e724c97103edae2801023dc186c7144a4b34ca44cf880bbf8d2b1d5","stateDigest":"efd6c38722b18d30bee34d1203f585f7465dcd608c100ef29aa3f58039773132","publicationDigest":"071792c5a83df5928ac8397394d7ea5980f7c4b1a9bb4fd4c9cb823f3421a0ca","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":126,"eventDigest":"b2f794e640c930c27d977460667806f0ff4ea38896aa5d460f2b6a55a3cc1559","stateDigest":"c63083b415263ca60937423a54134a626bb346c5231d9d439c99ab9b1121afd8","publicationDigest":"d6a70f66de937696bad79474e68dc00ffb449d574c89b5c1e415c1d024f0649d","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":126,"eventDigest":"b2f794e640c930c27d977460667806f0ff4ea38896aa5d460f2b6a55a3cc1559","stateDigest":"c63083b415263ca60937423a54134a626bb346c5231d9d439c99ab9b1121afd8","publicationDigest":"d6a70f66de937696bad79474e68dc00ffb449d574c89b5c1e415c1d024f0649d","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_ogre","backend":"replace","quakec":true},"frameCount":136, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"7160748eae6e381a054e2ae74ffb98eb5a98b31c2e33d19166e2a52692f5557f","stateDigest":"269d2df9ee8403217416d1986c77bded99d0bbe133341b19d1641d84be1ed41b","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"5839173fc105ffda366e69236cef7127d5872d8c265b27337dc1f5132030987a","stateDigest":"64b9f1f9e592c949cadab81faee1f7f2320d1d1e0ac9d40c227694a892f7e608","publicationDigest":"2abf42019bb3fc8b81e9842eff4809f93e8f644ced0023e0114d77083a413ac9","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":28,"eventDigest":"b61e75a3025d83a95614b016009ed881f8690741bfce26342da42fac199305b4","stateDigest":"57b54038ba69fec584fb8b8dcd5c6e9f5821e5ea933c21939266c56d1a396655","publicationDigest":"6e2545818655f06ed666398432575555d4277f4abe2c3faf7b5f937cefa9f9a7","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":28,"eventDigest":"b61e75a3025d83a95614b016009ed881f8690741bfce26342da42fac199305b4","stateDigest":"57b54038ba69fec584fb8b8dcd5c6e9f5821e5ea933c21939266c56d1a396655","publicationDigest":"6e2545818655f06ed666398432575555d4277f4abe2c3faf7b5f937cefa9f9a7","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":44,"eventDigest":"b2409e912a933438a295df87259f533d462243e49201ccc46c8ba710d20856bc","stateDigest":"87ed2725b5713e4335af146b4c44ac375767ddba255e3371c320b84f3d5b22bd","publicationDigest":"b1ca061ecf2031383d3716e84326f164b61a68ab95850f827273f9faca74afd7","liveHandles":2,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":56,"eventDigest":"95ac154000c783c630736e4a63c39896c8a8c509ff39184a6fcd92984db6cf1e","stateDigest":"87ed2725b5713e4335af146b4c44ac375767ddba255e3371c320b84f3d5b22bd","publicationDigest":"1e5b265e64b700b97e9ed0352560644d6d05e8fc91b7060410da9a1ccc3670e1","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":72,"eventDigest":"57f3e5a4aed2291b06fff188df8bcc8e943b057d4ab3321dc7fdfbe573ac4e9e","stateDigest":"42d0b4916e1cf340359d188b22ec8260cb8e7ff226567e5018790572bf14a982","publicationDigest":"3d393d4313fc822fa871f191ff17e7bf727133166949d8a1508bf35ae41dcc3b","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":195,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":98,"eventDigest":"ca60887ab581d7a2ae1d286e52bfa467c3b41573ca834ca0b14ac577c4770354","stateDigest":"8ace6f14ff359c51aa7bc28d4e7efae4d898d6f72f897feb3d1f97c5ca22d41d","publicationDigest":"527e2567993c6c270978b0825611f377335416dc78f810d446fd3b6d8f4f8797","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":195,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":113,"eventDigest":"0ccacd312f4f61351a27cd0cd875a0bd577022db1213eb5994f78bd7dc5457f6","stateDigest":"b667b82501474096df9deaf3353f4f4d8cdebdb10e05a661452625d992fd58ae","publicationDigest":"02e4b9176009563a017c18ae261b04ce31fc4a5d5321ba9fbcca92cf99cf5dbc","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":195,"origin":[4.7,3.6739403974420595e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":153,"eventDigest":"ed8ee1a20dc7a1d7f89137d2225be0619f2a6fa125074c8e66e7999d0f64baf4","stateDigest":"f422e7226705bdd19f2becbc4aef030df26a70599678098770c459b7088c2390","publicationDigest":"8498131393ec0da22b81f97f9bcca31b1fab7906c1aa5af47ee478cc9a98e554","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[4.7,3.6739403974420595e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":279,"eventDigest":"8e82befd68942ad9f33bfd47a2bbb8b67574fb6c5b5d3b1798f0a4a091ba6d61","stateDigest":"3be8423d796bfd22b67974ee9353eb93c124875f0b9a2550278afab303e3a19b","publicationDigest":"3d43c7b46c7b20134611158f778d51e270b2dc071b97e01c7ec29396b6e4bbd7","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":195,"origin":[4.579999999999999,5.143516556418883e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":328,"eventDigest":"26ef8fb1277fd4002c59adb9dbad03aedf64052be8297943d321739f3fb68d9c","stateDigest":"ca314cf22cab2e18a42643ed09b8d94ae92f173a99adaa4d896d99046d9ae2ff","publicationDigest":"70b40a4040da4b5ffbfd4a67bc2f1865a9483c12eb275657e0fbbb39ee168d54","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":195,"origin":[4.579999999999999,5.143516556418883e-17,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":348,"eventDigest":"1700a8eddea61f4384c8581cc3b990679a62ec0310991d598b897bb9d55c34a4","stateDigest":"db874f0426fa68e6c41c946d625eef7e55b95808896cd1b42c4e6061502ec526","publicationDigest":"6cdda2d14af670daa9b7c908d31056b5471cf2804a4bd8e6b5fe81ec01a7c313","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":349,"eventDigest":"1ab92e7f0bc67fd3118b51c9dbb8cd42b503000a0e82b2c0eab00e116dc9d50b","stateDigest":"6005f12dce32f223974b44ba59fb77c45eb41d0415a3a9b3e9ebb2ed386f7e0f","publicationDigest":"7a1e5a968f1ae10d808f188b6ad97f774b09e6805857e46759537e1e8c350f3e","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":349,"eventDigest":"1ab92e7f0bc67fd3118b51c9dbb8cd42b503000a0e82b2c0eab00e116dc9d50b","stateDigest":"6005f12dce32f223974b44ba59fb77c45eb41d0415a3a9b3e9ebb2ed386f7e0f","publicationDigest":"7a1e5a968f1ae10d808f188b6ad97f774b09e6805857e46759537e1e8c350f3e","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":200,"origin":[4.699999999999999,3.6739403974420595e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":392,"eventDigest":"63468e929b4b9e51065e4a082ca2812a24c8e7915266761a6935c4e3789ced95","stateDigest":"5acae7506ef8455a1e740b73199e6107237445e39b6e715650c501803c481a6e","publicationDigest":"9f287c5bd069d7ba3e07391e72c86c98c407fd35299a03e418b8b0759b749252","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":400,"eventDigest":"97f2569e8f5f6f92d8f3f5255e0ce4428008bda6f0c451bb67df9a9d6cb19397","stateDigest":"c6573f63a1fe6fb335e42f6c84947965aa8fe089b607b19661228995f0c8fc23","publicationDigest":"fa97c164fdf0243cca055e0c43f7fb9a52e28aaf0a1aa3f2dd9b2ace771e2165","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":400,"eventDigest":"97f2569e8f5f6f92d8f3f5255e0ce4428008bda6f0c451bb67df9a9d6cb19397","stateDigest":"c6573f63a1fe6fb335e42f6c84947965aa8fe089b607b19661228995f0c8fc23","publicationDigest":"fa97c164fdf0243cca055e0c43f7fb9a52e28aaf0a1aa3f2dd9b2ace771e2165","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_demon1","backend":"frameset","quakec":false},"frameCount":69, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"ff4cab930ccf52784b73fd6d37c5d4b9934e4994d6083f6e8633f754a7e32f7a","stateDigest":"e537ed0359e39a9020f616f945a2c96013f417eb38b317a4f041ca3d6f4ee883","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"912944ca322c4423938a1ce3d5c1db0474010aadfb97ce9d6d5372cc13df7e39","stateDigest":"d2c384bd20bda140ccaf5fd0907749225e88e8a6f51d361bd522736f5e8f467c","publicationDigest":"f48ac7b7c1dce0e273de3d8a10b1ce36e89bd34ee6df55d984f6d4861c8b7fba","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":10,"eventDigest":"0783caa8224cacd7ab7d7ae2c82299cc7440db44e9b540ee3d18453d7f9f5c0c","stateDigest":"622b79220ca5613418f3edafc2682d8845a7fec55da9adf1a88367f5e77e92d9","publicationDigest":"e23fe870faa14120c25d68a469a641a8694ddd07cfedd2853802470c27cf08f0","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":10,"eventDigest":"0783caa8224cacd7ab7d7ae2c82299cc7440db44e9b540ee3d18453d7f9f5c0c","stateDigest":"622b79220ca5613418f3edafc2682d8845a7fec55da9adf1a88367f5e77e92d9","publicationDigest":"e23fe870faa14120c25d68a469a641a8694ddd07cfedd2853802470c27cf08f0","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":12,"eventDigest":"9706706f1ce5a205a2c6b3a9936f322d9ecef2f79e852ac5ffcafff4f87ad634","stateDigest":"684bf49958a77e490d4c4b7fc14725cb0cdbb624764eadc3c25f3044f9e7b361","publicationDigest":"7c8dc59f37645dbfe370b660eaa2e93ccfe6d6e226d3f8dffd352619a105b137","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":12,"eventDigest":"9706706f1ce5a205a2c6b3a9936f322d9ecef2f79e852ac5ffcafff4f87ad634","stateDigest":"684bf49958a77e490d4c4b7fc14725cb0cdbb624764eadc3c25f3044f9e7b361","publicationDigest":"7c8dc59f37645dbfe370b660eaa2e93ccfe6d6e226d3f8dffd352619a105b137","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":16,"eventDigest":"2541a77f11ba771e6168e8f1ca9b1de2b7f9617bcac85dd1182b5c68f5b52ca8","stateDigest":"cc7e42d8dc50e89f05e48dc0c7133af0fc8a13a7c91c161ac40a392ea6c3f091","publicationDigest":"f4ea02f1c53bbb8c491ab08cb2224358e93bbb1f6b4549b82b97561c12af4dbd","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":295,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":23,"eventDigest":"268bf0be5d5a7123cb833e5db897da2a5f0f9bfde8c0124c696436b488818d1b","stateDigest":"953b9b8200cd4eb9b35cb2f2a428f0398fddc49eaa47dcd47ca41977b24c58f2","publicationDigest":"2e8d55e44bae14f9d0ac95c18df1c8940a687f29ec93e339d48b92e381225d26","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":295,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":26,"eventDigest":"f46b28db16bcfc2f54456bca0e6e49efde0ab9cb87542658e42fe93b8f662d1d","stateDigest":"73d82d6d032c1d8a8c6f23dd1e439e89e61410016b662085a1616db17d39d6db","publicationDigest":"ed90c896e17fd6d79c39be33e7114bd4f671e2a108bf023e0a0786ced8f5c07f","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":295,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":36,"eventDigest":"0f6134f486bfd4f2ed14543a8d875858075cbff29f26c44e799efcfc80118585","stateDigest":"c63a82a2f344c4ae1c44942dedb9a6be72f504a0519e6c963fedc6eb1cbca10c","publicationDigest":"9849eb2a142ab823a19df5b3dee79f6c726ae30d41916059451d67bf6c4a0743","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":44,"eventDigest":"2477953faf5085df2bd120e9fc19b3677ed1a0fded48387be3dc87219e38c66b","stateDigest":"b5a00654b8dfbf5e487ff411b119a1221bbbafea9a6ac9648cd1a23bf2a83544","publicationDigest":"d8ab6ad2d00f2ece9cfb2e75518b8d1c1569b67148669c1ed42250687f443f01","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":295,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":55,"eventDigest":"1004edc32d5c291436ea7aa992764c3430c5deb7b1d31be9db8c5bb1ad6210f3","stateDigest":"23a67e89ddacdd624ad6e634928e35d6d80d7948f8d2b14f5fed6a7bdb3cd014","publicationDigest":"5ce57016dda1ec6a8d8e865250f73bdf4e30a59733388310ff5b2ca0b475df7d","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":295,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":57,"eventDigest":"bda94b8c6fe28d8be5866eae88b6c4cee0cc78fbebc28237c0e38a6c25c9f18d","stateDigest":"743509262d74d760a0c915d0020421b782fdd56c96e41913cf2cdb290f2109b0","publicationDigest":"a7cf358122651a088f72f04f6c1184d5b7f2cbce0a473261388a1adf742c15dc","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":58,"eventDigest":"a4578220846d9441eb2acadb38b8bcc2f81031aea8472d23bc41968a73848068","stateDigest":"850ed02e7a54b824e47f4d7f1605edaf40cc882f0ed36ce1e6e802578f49e3b8","publicationDigest":"6eb97ec46fe1329e8609c406fee0f0d5d5feb0f315722b4f295b4b8e9ea559f6","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":58,"eventDigest":"a4578220846d9441eb2acadb38b8bcc2f81031aea8472d23bc41968a73848068","stateDigest":"850ed02e7a54b824e47f4d7f1605edaf40cc882f0ed36ce1e6e802578f49e3b8","publicationDigest":"6eb97ec46fe1329e8609c406fee0f0d5d5feb0f315722b4f295b4b8e9ea559f6","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":68,"eventDigest":"dc3cb3b740953d377aa4f70686b5cec565797788d8eb295e16c90c3cf8fe17bb","stateDigest":"eca1efcb9ca1557c7c28b65c2c70ca91678febc2b7ab0dbfdcd419e192a65db3","publicationDigest":"37ac12c41d5372a317372a137b80d796f22ab29d3fb77efac0ec96b71241faee","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":76,"eventDigest":"323c4c2634699e0eb3494c44798465e53cf1a6eed3ea07cbdb906a27c3512f9d","stateDigest":"cb0a17c292d1f1e836e451fb3c57072bf76ab72974398b76d6d8a8f5b4a451de","publicationDigest":"b57cc6f59afd1d6e2750572a3dcf979975f9d5c765a7daa3b59b0b2f989828ab","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":76,"eventDigest":"323c4c2634699e0eb3494c44798465e53cf1a6eed3ea07cbdb906a27c3512f9d","stateDigest":"cb0a17c292d1f1e836e451fb3c57072bf76ab72974398b76d6d8a8f5b4a451de","publicationDigest":"b57cc6f59afd1d6e2750572a3dcf979975f9d5c765a7daa3b59b0b2f989828ab","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_demon1","backend":"frameset","quakec":true},"frameCount":69, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"ff4cab930ccf52784b73fd6d37c5d4b9934e4994d6083f6e8633f754a7e32f7a","stateDigest":"e537ed0359e39a9020f616f945a2c96013f417eb38b317a4f041ca3d6f4ee883","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"912944ca322c4423938a1ce3d5c1db0474010aadfb97ce9d6d5372cc13df7e39","stateDigest":"d2c384bd20bda140ccaf5fd0907749225e88e8a6f51d361bd522736f5e8f467c","publicationDigest":"f48ac7b7c1dce0e273de3d8a10b1ce36e89bd34ee6df55d984f6d4861c8b7fba","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[4.45,6.735557395310443e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":30,"eventDigest":"b63c4b36865fdd02937583b89077574f3cfd9f368c4d3dfe300c5a28e3cd0492","stateDigest":"1e618b728c5f469f25a71d416c65317c4fb4ea7fd25a44ff918e13dce763e730","publicationDigest":"90633caa1e8cd7c62398c67fecfaec5cfa21a6ab0c848229da20ecd64d2c5c89","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[4.45,6.735557395310443e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":30,"eventDigest":"b63c4b36865fdd02937583b89077574f3cfd9f368c4d3dfe300c5a28e3cd0492","stateDigest":"1e618b728c5f469f25a71d416c65317c4fb4ea7fd25a44ff918e13dce763e730","publicationDigest":"90633caa1e8cd7c62398c67fecfaec5cfa21a6ab0c848229da20ecd64d2c5c89","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[3.73,1.5553014349171386e-16,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":49,"eventDigest":"1caff10c0026d0f21a5393ec4e0e010e1937954c817ccacfafef36af48bf2070","stateDigest":"2157f0bc480c8b10d0e2ffc2c143cf5113ac26f944c87032bf8728fa7c4b2c5d","publicationDigest":"0f391b47bd86a299dd929e86d2618a33128beb4bb9265bff8bfc3cebd6dc61c6","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[3.73,1.5553014349171386e-16,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":49,"eventDigest":"1caff10c0026d0f21a5393ec4e0e010e1937954c817ccacfafef36af48bf2070","stateDigest":"2157f0bc480c8b10d0e2ffc2c143cf5113ac26f944c87032bf8728fa7c4b2c5d","publicationDigest":"0f391b47bd86a299dd929e86d2618a33128beb4bb9265bff8bfc3cebd6dc61c6","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[3.73,1.5553014349171386e-16,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":55,"eventDigest":"faa0803e3f398741e5244fe7b5b5705366d2ae53b5798f4cd1ec812112b30419","stateDigest":"a6cdbfc015e577aff33b355752df42e2dc9c3230917846df0a19e3fcf75fae3b","publicationDigest":"e7bab2448934b35df0ffe0c2fa222114f60aa974871344d8790397104e626109","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":295,"origin":[3.53,1.8002307947466092e-16,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":79,"eventDigest":"05a2b0c267893d096f38e9330007f0fef666c141db6d3541dae52163a248f232","stateDigest":"3ede92ce3493b7d1a26c8d5b5e64674a599e52ef2a6173090c31ad5c074dc056","publicationDigest":"6384252ddf35beb3acb519be4a47eee5c418b71faf05e157884712c590de12fc","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":295,"origin":[3.53,1.8002307947466092e-16,0.48],"yaw":-180}]},"visible":[],"mounted":[],"events":86,"eventDigest":"821cd37c1337a339ba90f27a3019473812bc89b8790faabeff55e01a0b116504","stateDigest":"6e3b9ba79c83d8ce51db41eec45e39bfc5112785b3935acfcf26af998db6d348","publicationDigest":"e3a980bd1307d0192a6bfd37eb98896b0ce510a6eafd62da2ea218a4d3f91d47","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":295,"origin":[4.449999999999999,6.735557395310443e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":118,"eventDigest":"cda398e27f8cc4652506025562a898b0952862a66d3ea302c5579dc266752639","stateDigest":"17157f5bfe6d9daca9f6ad3de8d184ed8341154517751e8c960e5dfec2f77238","publicationDigest":"1ddf17cb1cca33aa1db85ddee0e34f78301a345f10b693338bb12252fd930418","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[4.449999999999999,6.735557395310443e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":153,"eventDigest":"8be3ad570d6869159b167e744f01daf5c4836dd18d0eca26cb8e8d1773983ce9","stateDigest":"6496e2ee48d13e9cf7164a63001dd3033d60fc4919ddf088788cebc64eace34b","publicationDigest":"9a84f7292fecef82462c7573375c170749ab7f5a2c0d88c877c4439996baff1c","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":295,"origin":[2.8299999999999996,2.657483554149756e-16,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":191,"eventDigest":"05fa275b509dd76c3743feb8b58f5232d8c75f1f0c8aa71c111d2b943d8da873","stateDigest":"c193095e181b2ca4732e2d9b1079a9f00be1c4d0ed7e1ff70d388cc43c3a0935","publicationDigest":"c8ba223714d83806eb7255ed42a414189ca9cfa7702352345a4ca391021b358f","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":295,"origin":[2.8299999999999996,2.657483554149756e-16,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":198,"eventDigest":"7f39fbf869ace45253863eab37b439fb2957e44b3eafdea09323c3fdf47a5a3e","stateDigest":"d6c44b28f862047bed56084eaeac9e637c34612d86e16dc6b3ec23679fbd5b7c","publicationDigest":"d4ed931b23ccf378d2311f150d306c55230b10ff4abd16a5a8cf64f5f4686077","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":199,"eventDigest":"7c287acb9925ade4bd0a40f62ae56b3beda533dd4de457d00d98554050a515ea","stateDigest":"885b9d1efd694f3804c749e7c31df090aa3291f3b7d6d36dd8ae70dd0bd950b2","publicationDigest":"b15e540ff3235b132b11ed5dcba28f9705d14db174fc9238e95200bec9c37bde","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":199,"eventDigest":"7c287acb9925ade4bd0a40f62ae56b3beda533dd4de457d00d98554050a515ea","stateDigest":"885b9d1efd694f3804c749e7c31df090aa3291f3b7d6d36dd8ae70dd0bd950b2","publicationDigest":"b15e540ff3235b132b11ed5dcba28f9705d14db174fc9238e95200bec9c37bde","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[4.45,6.735557395310443e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":232,"eventDigest":"ba5c66017d70b6bdaae124730603c1290d23c214cb8703e7e627231dcfbd0c83","stateDigest":"f361f3753405ffc56af58f63774fc57f60150e8d4813e5982e0ef8f4ce20e75d","publicationDigest":"ccc6d068c2b7cfff6821a02b18b1b9303fc4e2ff9e2412a639ad48c5a61a9d3b","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":240,"eventDigest":"abc0b535e7c363179d7e12574cb22afae37196ea4d11fc381c4390b6d0b978b1","stateDigest":"09fc0b31c0c248d052556756a97ac79ea00c34598c014de4539fe4dbffff9a6f","publicationDigest":"a5a96b755d16ca18f66151d44d2c300b0abd1f01f9183291f7a8a3bb42c0ba95","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":240,"eventDigest":"abc0b535e7c363179d7e12574cb22afae37196ea4d11fc381c4390b6d0b978b1","stateDigest":"09fc0b31c0c248d052556756a97ac79ea00c34598c014de4539fe4dbffff9a6f","publicationDigest":"a5a96b755d16ca18f66151d44d2c300b0abd1f01f9183291f7a8a3bb42c0ba95","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_demon1","backend":"replace","quakec":false},"frameCount":69, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"ff4cab930ccf52784b73fd6d37c5d4b9934e4994d6083f6e8633f754a7e32f7a","stateDigest":"f9f81cc75be9cc87dba9d6aa068fd176cf314416eab3daa114401b44c3f7cdea","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"5331c612857ad68918f53d647c3c4d9c4c8acb35f293e08717d917a27e5cbd27","stateDigest":"ae3db92ec832f898808522fd651addec28079fb561ce68b5453b2dbd486440d4","publicationDigest":"f48ac7b7c1dce0e273de3d8a10b1ce36e89bd34ee6df55d984f6d4861c8b7fba","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":15,"eventDigest":"26090fa2c3a92411a67680af2fd2908eda83847dc738947501073030b65d2c97","stateDigest":"4cc97e28419696e321aa8832415da2905dcc17677aafec2943fb188691608367","publicationDigest":"ce3e935f92eba5412e49483050989b12fb11d80ff937eb770ec09df55234ec62","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":15,"eventDigest":"26090fa2c3a92411a67680af2fd2908eda83847dc738947501073030b65d2c97","stateDigest":"4cc97e28419696e321aa8832415da2905dcc17677aafec2943fb188691608367","publicationDigest":"ce3e935f92eba5412e49483050989b12fb11d80ff937eb770ec09df55234ec62","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":22,"eventDigest":"70c4862fc615cbdd4e28b39073034c24a4c9a5d21df90d9c22cd3b5bc900edc3","stateDigest":"a8a2d029f8100d9598138c032cc4e88508eb084c41d69d1976c8b914cc509237","publicationDigest":"39ffb8a0c3ab2373ccacd2b4432007de952bf7f8d2384c5409ef84b28c3224a2","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":22,"eventDigest":"70c4862fc615cbdd4e28b39073034c24a4c9a5d21df90d9c22cd3b5bc900edc3","stateDigest":"a8a2d029f8100d9598138c032cc4e88508eb084c41d69d1976c8b914cc509237","publicationDigest":"39ffb8a0c3ab2373ccacd2b4432007de952bf7f8d2384c5409ef84b28c3224a2","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":36,"eventDigest":"32edfc42806239acf8de513775839d1c633e16f37be36ebacbb78aff85efc33a","stateDigest":"4155f12c65d71289fc2c8be7597b6b779a1f0165fde84594470dd850ab15e35a","publicationDigest":"386ec14669f3a25dea368e7f0eb02ad321265760c6b40c991dfe5e6c38709d0c","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":295,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":48,"eventDigest":"6e197faec37255cbe51ce7d6bdc04a500bbf19096c87b01aa47eabbbb59c0ffb","stateDigest":"53402c9bcf798b7b6432ea02721ed5138f331eb8271c5f999ed1bd99b7b850bf","publicationDigest":"e96bfc504465f8e24f43807814b71ca959b602adc5ec718263a509fe3b5d4529","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":295,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":51,"eventDigest":"681db6148e5cbb02e0973bd48640eb58e3b3244860306294ad73064b2fe5c5a5","stateDigest":"00ecef89f8c88fe0f7fafc14f7ac2ecdb762060dde6b52d8abbb78c4a68f4540","publicationDigest":"67f6b433a20f33877932f4d01234dad597cfddda3333a29c654f4e6fc684db16","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":295,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":66,"eventDigest":"282b9c85102f10ddf417fd1fe996288a3fa1108d25a270e46d225ec6e4c901a8","stateDigest":"478da4f9c95cb50873000bd07e23151624da1f769f4a9060bcb751fffb5063ad","publicationDigest":"9df87c9fab9319d45355738ce6d52260d5be5cbe2016a44316c90d0474951fff","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":79,"eventDigest":"9a99e75ea12a76f222aca14cf3580572328d0b2690b86d7a4e2c30bcab5f37c6","stateDigest":"fa4278f2e1b41217e99633bc25afcc14d9df5951bffed21c4e92cd7f963d5f93","publicationDigest":"d151e5eb6e08fd106fc53a10227e2af35e8cca273becdb7a3bb037b6ed2191e0","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":295,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":95,"eventDigest":"2da2b4a39c3070f59fdacea8abda83489250047738e3439dcba2af3f67d14e08","stateDigest":"5b70fa4a682d04a786885f257efbbbfdd288edc9b27718f8e3083556378528d5","publicationDigest":"788dce42142f4a5a4781b15a0551a7dedb52afbedbf8c1361b2671bb645bf9da","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":295,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":102,"eventDigest":"20650682b8841d358b2bef142de113c132ec462644940b66ff78da6cd9305b44","stateDigest":"489c8526aeaa13ec2b32380e2e827916b67da81b7c10fcbc1f82bf699a09f8a4","publicationDigest":"7635e5e7613909ddff07bfc41f718308722e06a8edd2ab7297de5a2eb432b3d8","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":103,"eventDigest":"1f1523d21cfcfaf646547a62c1828cbd01d7fd3411a4d95f4125e2e2e35a51f2","stateDigest":"ab9899802289e7fef134d0d7d2a46769c23e34dbc6de5b044fa1ce12402b965f","publicationDigest":"220c94fefac918961d41ced834ded4d3c8866df6ce5c75ecc95643bc923592d3","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":103,"eventDigest":"1f1523d21cfcfaf646547a62c1828cbd01d7fd3411a4d95f4125e2e2e35a51f2","stateDigest":"ab9899802289e7fef134d0d7d2a46769c23e34dbc6de5b044fa1ce12402b965f","publicationDigest":"220c94fefac918961d41ced834ded4d3c8866df6ce5c75ecc95643bc923592d3","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":118,"eventDigest":"9b5ced4d8a398fa057b5172e8c95c2efa017677bc3010eeb32a1cf60bf403a1e","stateDigest":"62adf53725ed20541282deb189825fd543aa25eb5de21308e2dc7095d17ff6d4","publicationDigest":"10195449be6a14e729f873cd7ac1a10f643b1c62d66a78fd8d9599a63a2396d0","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":126,"eventDigest":"289a5f913f41360b2d5409a0cc5c55a0011eff84728715bed0d7af09a4a3e973","stateDigest":"c63083b415263ca60937423a54134a626bb346c5231d9d439c99ab9b1121afd8","publicationDigest":"8c5ce6c4570d37ecc8a2f6aa358ffdaaaf5d0171d70038574ae9b9b3fe7be1c8","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":126,"eventDigest":"289a5f913f41360b2d5409a0cc5c55a0011eff84728715bed0d7af09a4a3e973","stateDigest":"c63083b415263ca60937423a54134a626bb346c5231d9d439c99ab9b1121afd8","publicationDigest":"8c5ce6c4570d37ecc8a2f6aa358ffdaaaf5d0171d70038574ae9b9b3fe7be1c8","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_demon1","backend":"replace","quakec":true},"frameCount":69, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"ff4cab930ccf52784b73fd6d37c5d4b9934e4994d6083f6e8633f754a7e32f7a","stateDigest":"f9f81cc75be9cc87dba9d6aa068fd176cf314416eab3daa114401b44c3f7cdea","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"5331c612857ad68918f53d647c3c4d9c4c8acb35f293e08717d917a27e5cbd27","stateDigest":"ae3db92ec832f898808522fd651addec28079fb561ce68b5453b2dbd486440d4","publicationDigest":"f48ac7b7c1dce0e273de3d8a10b1ce36e89bd34ee6df55d984f6d4861c8b7fba","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[4.45,6.735557395310443e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":40,"eventDigest":"31bc41dedf400fe748015fc507682d34e351a1da418649d1ba0f73516e87a1d4","stateDigest":"45b8d8b18f1e290313f53b670d59fec0e2ab05f531c689174a70d4f3d642ee92","publicationDigest":"5a8845952c35063487d2809bb4c0c933445c85f0157d062ae2cb2fc8e9cdb4e7","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[4.45,6.735557395310443e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":40,"eventDigest":"31bc41dedf400fe748015fc507682d34e351a1da418649d1ba0f73516e87a1d4","stateDigest":"45b8d8b18f1e290313f53b670d59fec0e2ab05f531c689174a70d4f3d642ee92","publicationDigest":"5a8845952c35063487d2809bb4c0c933445c85f0157d062ae2cb2fc8e9cdb4e7","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[3.73,1.5553014349171386e-16,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":69,"eventDigest":"bfb983fc048159f7cde105240535e48720b7a728ada59d52071aae1572506d7d","stateDigest":"9a7e0edeeb30f3302025e8b48ba216ac382f6865867c090a22131b5e9a8944d4","publicationDigest":"f5ddd8801cb6481cde6d385eec4172e6b029666d13e4b1b1aa0c555035bae581","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[3.73,1.5553014349171386e-16,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":69,"eventDigest":"bfb983fc048159f7cde105240535e48720b7a728ada59d52071aae1572506d7d","stateDigest":"9a7e0edeeb30f3302025e8b48ba216ac382f6865867c090a22131b5e9a8944d4","publicationDigest":"f5ddd8801cb6481cde6d385eec4172e6b029666d13e4b1b1aa0c555035bae581","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[3.73,1.5553014349171386e-16,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":85,"eventDigest":"39e35fc3951104df9425116612ae7c2db2c9481885c9a7189143f4c186b0308e","stateDigest":"5ac5eac9583227d15d43a8bab51b3024c5098be5c533261d39e4e0e860e7fda8","publicationDigest":"79e669561667569fc7b192fbffbeb25eafab60b21c90b756f4e0382985c424b8","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":295,"origin":[3.53,1.8002307947466092e-16,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":124,"eventDigest":"97db236a664cb40de1ac781a37e7ab79f9c0baeccc95ded493fa3def93aa44ca","stateDigest":"d599228cb888acdae4ad8dca2d7c693c309809543ff1c15cc7c74b5c7fc6122c","publicationDigest":"83d7afafec42f6c5b05f8d4a2093c2b461cd5dc3d296e76c982687baadd55d9b","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":295,"origin":[3.53,1.8002307947466092e-16,0.48],"yaw":-180}]},"visible":[],"mounted":[],"events":131,"eventDigest":"6c8df24a8edf34517a917e5a2a2d6613a4221d9b2cf54745c3bec2651bd10491","stateDigest":"79a0e011c80b73206e95d2fb17bd3f2581ea627ef2958da3330278b0a871a210","publicationDigest":"78e3f6a67a7bcab4b6a70947ab0df295027016560cf9ea238b26064ab6f36097","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":295,"origin":[4.449999999999999,6.735557395310443e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":173,"eventDigest":"fb40132d2b476a3e19b2085b50f4ca0812bf81abd958f331432a1319cd2de4d8","stateDigest":"924969421354cb29a50c6139b93c2e47a11638e0b6aed28c3bf72b0ee048c64e","publicationDigest":"db165a126a23316fbbd7c2557e166b8b90a901137cf61217054ccf2bf8b19c57","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[4.449999999999999,6.735557395310443e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":260,"eventDigest":"f210b10580a4908ba972ea22561b56f35d9ad26fce99eb6381d28705670efa6b","stateDigest":"83b1c547ef53b9c02887e002ab31df1b2d7c570aa2aaf2735a61776dc63021f2","publicationDigest":"797d8a3ede955c62c0c2cfa6f26868bed9911ea80aff67293c4e4eea48fe6f27","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":295,"origin":[2.8299999999999996,2.657483554149756e-16,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":303,"eventDigest":"085641074cf13e102356c85b7f49c24e0bfa06e56dbb23fcd402d610af8249be","stateDigest":"c47344d990008f2e39d19370cffa21307402a444c5e5b07b892baa1f92e13796","publicationDigest":"b6c4b466a214631c41907273d3dfd0dbab3b96e6ba272c66936cd150d6dc78a6","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":295,"origin":[2.8299999999999996,2.657483554149756e-16,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":310,"eventDigest":"68ab4b5bfac0e2cf8ab6644f37cea5544b5f8fb8545e08e64866978553912a56","stateDigest":"acfd09eb5008f4887c5707158855b3a95a8f9a2acaa784266cd069698ca1673c","publicationDigest":"f7b5bab6cc11864e14faf1f88744bfd82be2e3ee620ae0ee842078c84544d5fa","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":311,"eventDigest":"adb011cc73aeae238b7b9114d41c64d7b6ae359c205937d9a852e05134f287f4","stateDigest":"6005f12dce32f223974b44ba59fb77c45eb41d0415a3a9b3e9ebb2ed386f7e0f","publicationDigest":"507ed7d95ca74c99bb817a052f691deb136ef02ec4cb8087085a210cc09b9a3a","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":311,"eventDigest":"adb011cc73aeae238b7b9114d41c64d7b6ae359c205937d9a852e05134f287f4","stateDigest":"6005f12dce32f223974b44ba59fb77c45eb41d0415a3a9b3e9ebb2ed386f7e0f","publicationDigest":"507ed7d95ca74c99bb817a052f691deb136ef02ec4cb8087085a210cc09b9a3a","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":300,"origin":[4.45,6.735557395310443e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":354,"eventDigest":"05e6f68267dd5e940c86d2cd1f9e87c134ddca987d6d4312f2f666c6ddd4555d","stateDigest":"67221b411869f69dd0d031b214d76837bd34e37054062ddab0cf303ad3969b00","publicationDigest":"62f1e54bf322485f30d1dfe1bc609e42a52084f8390f25cb825a0f67786cc564","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":362,"eventDigest":"811e5acb4d65b131dc62dc688132e9e5c6b335dd9681c5313f0b6c5221ff8577","stateDigest":"c6573f63a1fe6fb335e42f6c84947965aa8fe089b607b19661228995f0c8fc23","publicationDigest":"55bca1ef463532eab934d41985c96d4cd770fb717564a7d0a488a2e63785f9ad","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":362,"eventDigest":"811e5acb4d65b131dc62dc688132e9e5c6b335dd9681c5313f0b6c5221ff8577","stateDigest":"c6573f63a1fe6fb335e42f6c84947965aa8fe089b607b19661228995f0c8fc23","publicationDigest":"55bca1ef463532eab934d41985c96d4cd770fb717564a7d0a488a2e63785f9ad","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_wizard","backend":"frameset","quakec":false},"frameCount":54, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[5,0,1],"yaw":180}]},"visible":[],"mounted":[],"events":0,"eventDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","stateDigest":"8119ddc7c08d1ac424d5d3ec64a3be9f49d110011b804a1ce477cbf30580b9ae","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":7,"eventDigest":"946b03cf5daa6b4e4bf6bad1c1e378b3299624fe2110eb31e419792727b31300","stateDigest":"2fc5c72bac904c5c9ec5397cb27dd48ee002eda8f93af5175d84b09583090ccc","publicationDigest":"e208c706870252d018d99d5e90daf6ff75373bbdf81463d72f9856b3b08be0b6","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":9,"eventDigest":"758fee6b506296db3af2e758368197df5a65f599fa2c7d0f148d9dd6e2283876","stateDigest":"18a6426e89dcff036b9c0af8b8f3af56aa0214cc63aced8fa356492e63dc374d","publicationDigest":"88ba6096181a245ed7110a8465ea1f9f2832e6caeed2cb808d0cb4b81200ff18","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":9,"eventDigest":"758fee6b506296db3af2e758368197df5a65f599fa2c7d0f148d9dd6e2283876","stateDigest":"18a6426e89dcff036b9c0af8b8f3af56aa0214cc63aced8fa356492e63dc374d","publicationDigest":"88ba6096181a245ed7110a8465ea1f9f2832e6caeed2cb808d0cb4b81200ff18","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":11,"eventDigest":"0b39092e3fb0b0d8cde1dd615c13b5aebfe5b895359e94b772b895fc0bfc1c4b","stateDigest":"1bd48d100e32bafd6315d6f982e07c61c98838308c7b58331f2c85616e8fea0d","publicationDigest":"f0d6e6600f4591103de59235cd7eb0a08e26a8248a1d4fe9c7938b42ac2ec9c0","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":11,"eventDigest":"0b39092e3fb0b0d8cde1dd615c13b5aebfe5b895359e94b772b895fc0bfc1c4b","stateDigest":"1bd48d100e32bafd6315d6f982e07c61c98838308c7b58331f2c85616e8fea0d","publicationDigest":"f0d6e6600f4591103de59235cd7eb0a08e26a8248a1d4fe9c7938b42ac2ec9c0","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":15,"eventDigest":"b33c3ff6948fc96458f995e30f28cd2e40ed3c2276865ec5eaa8fc5705ef7b40","stateDigest":"23e3137150fb5c14991e771540db53bd5a4990d5b2802bbdb154e6b291ae45aa","publicationDigest":"8b031c5e00be29e1545de08e8ccbcd207c779b60921f41271d7ae0224e40ad06","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":22,"eventDigest":"122de4de1be568af02952e468ada8312e0b6669ab14745f38f1c5f2a69f43f8a","stateDigest":"9fa39c265397deb586ebcd3a8c13e0a8ed5136acd66e9f2917c6e81a992d7cfa","publicationDigest":"35b5aa61a3028a0ee881c751eff1a00c7b908de9d9ee4ffaa38bfd62fc9163e7","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,1],"yaw":180}]},"visible":[],"mounted":[],"events":25,"eventDigest":"a2c6f5c4a99b4c18d326641d7060dcd79e851f978b550c2dd82f42586584f63b","stateDigest":"1503288046b42738f4f73969b966ef026b5f87f91d0367a306981272e2a0ceb6","publicationDigest":"a513a43358922240cb8074b5698ec44d370d2a8bc6e21ca498ad875392046c81","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":35,"eventDigest":"97593607992ae1f8bf45157483720026052b29280f252c477ce7e7f2d1efff61","stateDigest":"b1e64e3303e80a3b4e7274a84846b52926414045f56d448190f3648afddeaaae","publicationDigest":"06c19232acc8ba508e11b661ed5983e28e525f6ac7cb1944fcc04259f8416086","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":43,"eventDigest":"72a1bc8444f0bed03843197112e0951aaca1d28f0f48f7fb9f2b5ffb36ad851c","stateDigest":"bbc35d0aa1bddaa080ce2f04a232f1dbb92d558ec814625ddf0734e756f918bf","publicationDigest":"7d853554d47cbf31d33c883517685a5bb6a578f43b5ea8666f81f582a631a9e6","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":53,"eventDigest":"11978b276904fba42ea125ffa918ee03cd8083c2c50a87649b96e2f7408a9c0d","stateDigest":"c276e2eb3ac1fafa6adbd0028bf450515ac9f5b15b403a42d3879df836823819","publicationDigest":"19cf6f186a8b307c26761a9e981ae54e122be654f7029a67f3412b84c65a24b4","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":55,"eventDigest":"deb8bd2819c6df15fc14e78d91f8d5f4d8d1fb475cea4f2e216b3861bba7fce2","stateDigest":"63a770ea591a3e36bab1963990098f51043ed39ff9591ded2fab9699ed916046","publicationDigest":"109a30cbb697b3cc18602613454f546220073a7f70ce79e63d501c4cbf502848","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":56,"eventDigest":"701f24eca424dcd8fa2833818e5d1e436bd9d6e32cd8f537208870779045baee","stateDigest":"850ed02e7a54b824e47f4d7f1605edaf40cc882f0ed36ce1e6e802578f49e3b8","publicationDigest":"d0f6842f067c53799dd1ba5ea2c29513128ae8d6e19f6c9efdebd783f837103e","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":56,"eventDigest":"701f24eca424dcd8fa2833818e5d1e436bd9d6e32cd8f537208870779045baee","stateDigest":"850ed02e7a54b824e47f4d7f1605edaf40cc882f0ed36ce1e6e802578f49e3b8","publicationDigest":"d0f6842f067c53799dd1ba5ea2c29513128ae8d6e19f6c9efdebd783f837103e","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":65,"eventDigest":"351b582e3f5890aa92ddb752d2cf1de0a164144da01acf38bb26f20a22e68d48","stateDigest":"fe0f485b1795c4e8ee6733651813a7256d711207839495c00e57f89f577a9206","publicationDigest":"d95f163f2505f4bcb77b2c071c9908a3aa8abe4f5b9ccca41890823877a68265","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":73,"eventDigest":"0ad395be165504fbe47fdbf8e42af7cc7a8e5806169bcfd6987daf0adf31e7fa","stateDigest":"cb0a17c292d1f1e836e451fb3c57072bf76ab72974398b76d6d8a8f5b4a451de","publicationDigest":"b85ffd5da05c141e5d6e8cef28b30c4ff4cf0852cbfe4174c338f61bee7a786b","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":73,"eventDigest":"0ad395be165504fbe47fdbf8e42af7cc7a8e5806169bcfd6987daf0adf31e7fa","stateDigest":"cb0a17c292d1f1e836e451fb3c57072bf76ab72974398b76d6d8a8f5b4a451de","publicationDigest":"b85ffd5da05c141e5d6e8cef28b30c4ff4cf0852cbfe4174c338f61bee7a786b","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_wizard","backend":"frameset","quakec":true},"frameCount":54, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[5,0,1],"yaw":180}]},"visible":[],"mounted":[],"events":0,"eventDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","stateDigest":"8119ddc7c08d1ac424d5d3ec64a3be9f49d110011b804a1ce477cbf30580b9ae","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":7,"eventDigest":"946b03cf5daa6b4e4bf6bad1c1e378b3299624fe2110eb31e419792727b31300","stateDigest":"2fc5c72bac904c5c9ec5397cb27dd48ee002eda8f93af5175d84b09583090ccc","publicationDigest":"e208c706870252d018d99d5e90daf6ff75373bbdf81463d72f9856b3b08be0b6","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[4.946666666666666,6.53144959545255e-18,1],"yaw":-180}]},"visible":[1],"mounted":[1],"events":26,"eventDigest":"6cfcb849581a5da30d388207ec5346e46b4e57d5928f46f7566e3c14e2facc4f","stateDigest":"42b27235dd04d40f6409d3b95f1de2fa4797bf0a7c8edee97f0e2e88a2ddc027","publicationDigest":"e7e2f0a4f50bdc366ce0c148333f033cde407f0217c9c3f92fcd73bf6534ff73","liveHandles":1,"maxRemovals":0,"raf":1,"timers":2}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[4.946666666666666,6.53144959545255e-18,1],"yaw":-180}]},"visible":[1],"mounted":[1],"events":26,"eventDigest":"6cfcb849581a5da30d388207ec5346e46b4e57d5928f46f7566e3c14e2facc4f","stateDigest":"42b27235dd04d40f6409d3b95f1de2fa4797bf0a7c8edee97f0e2e88a2ddc027","publicationDigest":"e7e2f0a4f50bdc366ce0c148333f033cde407f0217c9c3f92fcd73bf6534ff73","liveHandles":1,"maxRemovals":0,"raf":1,"timers":2}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[4.946666666666666,6.53144959545255e-18,1],"yaw":-180}]},"visible":[1],"mounted":[1],"events":49,"eventDigest":"665f8e8509162eafd8b87a536527ff2bb79e7456bf3fb80ea0785fd78611475f","stateDigest":"432e08015632d6fa89ac383619927c4d9c6036787e790d0dd8ac333e229af303","publicationDigest":"6ebba87d90a97870b627a5c82adaaf8e8f9bfcacd698210c8dbc1d4d2ec8f9e0","liveHandles":3,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[4.946666666666666,6.53144959545255e-18,1],"yaw":-180}]},"visible":[1],"mounted":[1],"events":61,"eventDigest":"10bf610f9e547a343b4bac5ef8517e09b3eaeafa617b93e6d2a7a0049ea66a2c","stateDigest":"432e08015632d6fa89ac383619927c4d9c6036787e790d0dd8ac333e229af303","publicationDigest":"195ccf0b8bdf45f469a079c611d50a4a516d80774c472f0817f6a362debf3846","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[4.946666666666666,6.53144959545255e-18,1],"yaw":-180}]},"visible":[1],"mounted":[1],"events":67,"eventDigest":"a5ee7cf94d7e4c2244f189a76532190eddbbb5f636114cf94b2ba0e4d2915188","stateDigest":"e7c47f230fb571605ab67e739f47cb2b233c3e98b72d20750a9ff89a4ad40ee7","publicationDigest":"cd7cc25be9baef1c1deabd175105fcd081997fccd1b12a2a58f7b5d3335698b8","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[4.946666666666666,6.53144959545255e-18,1],"yaw":-180}]},"visible":[1],"mounted":[1],"events":75,"eventDigest":"0d9c804fc14b41af3d0853b3cc659af8dae39886f7f10ef5d9c56edb91170564","stateDigest":"c34532a75c38275a7ca1bdca2630896fbf48e48fe66a79b795ae32dd9c70d6c7","publicationDigest":"84092bc9e08eeb0499e08a7e15038711b8faea4a0508b65ea78bf09e8689aeac","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[4.946666666666666,6.53144959545255e-18,1],"yaw":-180}]},"visible":[],"mounted":[],"events":80,"eventDigest":"10a56673a63706dc740f64a1a5dc3e09bdb814faeff679d13a70ca76836af0cd","stateDigest":"dda1657a8765dbfbe74c83094aa92ef991bebecd10f8beac683ffe7dc7985008","publicationDigest":"ee47ff00101a641bc5821026823bee2d560161d507581d989b06285a036c50ad","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":100,"eventDigest":"7af0c864a827ba17b16b5eb84ada79c24f157c88b8f9066f505182df98afd030","stateDigest":"3ce7d5854be689eee353e1c2be2867e0c869c27fd53bb9a91664543c92561d0f","publicationDigest":"e20c2d1497eef1a1ee25c43a66cc7556b4fe9b26bc07c5dfbcc50f422df1c843","liveHandles":1,"maxRemovals":1,"raf":1,"timers":2}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":132,"eventDigest":"9d54a633a39c1dd3038d418ea7c863e1bfe483023afa7b12a8caa67bf3a0cb02","stateDigest":"3cac209682d6e3f1782309ae2331a368e0b0b6c76a8f5d17fe123b4e09c22707","publicationDigest":"f5c6466ce79370622a2d31481fe15a60d82ae9195a06df5104c2532324ea8591","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[4.4,7.347880794884105e-17,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":169,"eventDigest":"34dc7a10a02685d51eba152572659df224662b6a3cbceb72993753a6031d241f","stateDigest":"2b85626619ed721b3b8ea5cd0c85a523c7a83582aaae8e3b6a3fb4d6f63e83e3","publicationDigest":"f9d5d3788011e6152b299300799e6a6219e1198c72e791ce88531dfe07ee2151","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[4.4,7.347880794884105e-17,1],"yaw":-180}]},"visible":[1],"mounted":[1],"events":183,"eventDigest":"ce8ec1899eafbc7e15bba0162c7cd461c738a3d04d64c17c42c0dcdf0e8d8b74","stateDigest":"970436e972ee731ee347935281fb35ef7e7790bee6982f86f7202eea2cb5768b","publicationDigest":"3b956f3cca92a4a87bfc98e24be97306359530f2bf60f467b6a2d424afec7dd2","liveHandles":1,"maxRemovals":1,"raf":1,"timers":2}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":184,"eventDigest":"c08b89dea4e39fb643f153012102424c0e4f053858b66f721ba2d8e902ebb558","stateDigest":"885b9d1efd694f3804c749e7c31df090aa3291f3b7d6d36dd8ae70dd0bd950b2","publicationDigest":"f7af923ce60f27e9505dbd327b8d42f43aac428a20f86f831090dae6ec46b7d9","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":184,"eventDigest":"c08b89dea4e39fb643f153012102424c0e4f053858b66f721ba2d8e902ebb558","stateDigest":"885b9d1efd694f3804c749e7c31df090aa3291f3b7d6d36dd8ae70dd0bd950b2","publicationDigest":"f7af923ce60f27e9505dbd327b8d42f43aac428a20f86f831090dae6ec46b7d9","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[4.52,5.878304635907295e-17,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":215,"eventDigest":"7e12c42c46968fe38c374f2a633a486d63444c38f243cfb4bedd54bd627633fa","stateDigest":"a63c449786ddfaedb47bfcd095d022b315a2534c6d40429fbf7ae577f834562f","publicationDigest":"3b70eefc4aede98921a90b0cd7dde112f141818c9d864262f7b01b3dfa3d4ecf","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":223,"eventDigest":"4d7a560d54efe254065509accb4afa7d3046d56b509f49965c8dcaffca31221e","stateDigest":"09fc0b31c0c248d052556756a97ac79ea00c34598c014de4539fe4dbffff9a6f","publicationDigest":"8ce54636d0f503dc4a49f93e130702ed2537d6fec27c1545945797b12ace57b9","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":223,"eventDigest":"4d7a560d54efe254065509accb4afa7d3046d56b509f49965c8dcaffca31221e","stateDigest":"09fc0b31c0c248d052556756a97ac79ea00c34598c014de4539fe4dbffff9a6f","publicationDigest":"8ce54636d0f503dc4a49f93e130702ed2537d6fec27c1545945797b12ace57b9","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_wizard","backend":"replace","quakec":false},"frameCount":54, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[5,0,1],"yaw":180}]},"visible":[],"mounted":[],"events":0,"eventDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","stateDigest":"cc2284589b5e46e8f93161c61189f831e23bcd4e7be74d3d4d0c3aa0de3eab8b","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":7,"eventDigest":"2ea43b3f7ddf61207b06041a2f7ecbddfc7e756f88895945c88fa8ffae336c2f","stateDigest":"ef737fcb97af756ae3e1352f454140fd69d2b68bb090ebd7405556305f0ef18a","publicationDigest":"e208c706870252d018d99d5e90daf6ff75373bbdf81463d72f9856b3b08be0b6","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":14,"eventDigest":"11e0036b52042196f23926fa5b1b4ee96aeb3d3f07e2366be1b818d4d8cec062","stateDigest":"ec9993deb7726a7a790898a867bda350f6d5ee787ed87ef2b165f405e4674ab2","publicationDigest":"a14250f986b4d148566a15280b4dd06de536ba77ded9f5116497952228479ea8","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":14,"eventDigest":"11e0036b52042196f23926fa5b1b4ee96aeb3d3f07e2366be1b818d4d8cec062","stateDigest":"ec9993deb7726a7a790898a867bda350f6d5ee787ed87ef2b165f405e4674ab2","publicationDigest":"a14250f986b4d148566a15280b4dd06de536ba77ded9f5116497952228479ea8","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":21,"eventDigest":"1203fbfc5843b4ad077b4fc37205a53cf0345f2d4e77671433449d39da8d2804","stateDigest":"5b170bbdb4fd1b3847f969b3b967f22122ba4a839ab485ac05baf371aa97c485","publicationDigest":"fd8c2a4a6c66163c60c6db1ce2b22a77a26319295e505784303ee613a41fd845","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":21,"eventDigest":"1203fbfc5843b4ad077b4fc37205a53cf0345f2d4e77671433449d39da8d2804","stateDigest":"5b170bbdb4fd1b3847f969b3b967f22122ba4a839ab485ac05baf371aa97c485","publicationDigest":"fd8c2a4a6c66163c60c6db1ce2b22a77a26319295e505784303ee613a41fd845","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":35,"eventDigest":"118082f89fb5484dc6908b996ed1a84c5dbd43c7ffdc04db258ef20bd3be2df9","stateDigest":"63d1073a05146c3de3094b8d7296607ffd60c2d9bf3bfb32fabd3d1bcc708a80","publicationDigest":"2eea50dd8a3fa561eb05afc4677115083e10abce50f3d91da8c192395ca3c603","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":47,"eventDigest":"e43886fbaf92ba5b3eb652d92cb64e5edd4b11c3733187bdd0bc0cb8edb25807","stateDigest":"03d2e433cf27e376b46332c4e22b9ddc5d6097c80694126225066e277a2b6708","publicationDigest":"3bce6e87921f13a383dc3ef96bf26ae013c336b8d6a5548444ef333fd707c64c","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,1],"yaw":180}]},"visible":[],"mounted":[],"events":50,"eventDigest":"0574f26777fc99e80f12b38dd72dc12ea71a414b18e094d851ecbf1358d14d58","stateDigest":"9284438e6e8bc2f040d394b521797fafef4604641d007007d7823c6ac038a4d9","publicationDigest":"7501b264d5a5d58f76db96c14f79c5282c6f238a7c10d6be0467a7276c36bc36","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":65,"eventDigest":"3fc9b9e1007957ed33264cd5c17f443239e3be2e0e9f8c308c6c8dacc122f997","stateDigest":"28996c6374b018ed53322d868022a3e989cdbf7b196ecc9c4d40d7684edf54ed","publicationDigest":"10474c7553d8a353e12e118e4c06d8930e660e2af2002d47ba2f657dc2c3288a","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":78,"eventDigest":"96af1a917a6312f54b107d27f4d9ad7d27e6d35bb6fc35a3ef63b0645bbb937b","stateDigest":"1ad85b7915a77b29ddb64588cdc7b4700f51897e6e32edbae4a677a38103dd61","publicationDigest":"da9007e51fbc0a401bf09da8b500a1eff387d1686ce56f97423f5f7e165debc7","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":93,"eventDigest":"5281a2798f34d4a5d80025c12856b80a5596d679dc9d645d2462213696e1de33","stateDigest":"6f9a0dd08e73742c3fcc5c44f5379df06b6b44eff1bf462a2ef5d2bb1e370dd0","publicationDigest":"545c6ce5cdaf119a588a6b1a7bd37173569252a4ee8d43f0b36161053d2a1994","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":100,"eventDigest":"287716c968eab1a6539b99bb3212f8e3e4b2588d2f6d149e638dac350a5a0d5e","stateDigest":"657d2edd587210ea3d15987f7e03c046521f2529caf14f67c52e97f10edd3afe","publicationDigest":"4dc60a161e36ea66e53b4a661214c74512dd667c870c79d276202f915dd22118","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":101,"eventDigest":"2bb1fbdb072202825e9f2d70198cc142d64b89d11654b5107353b38337cb7c66","stateDigest":"ab9899802289e7fef134d0d7d2a46769c23e34dbc6de5b044fa1ce12402b965f","publicationDigest":"c1f252d6c1092f6ce137670ca8d19fe5c7c9c0a6f7e3ba339a416c859cdd95f9","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":101,"eventDigest":"2bb1fbdb072202825e9f2d70198cc142d64b89d11654b5107353b38337cb7c66","stateDigest":"ab9899802289e7fef134d0d7d2a46769c23e34dbc6de5b044fa1ce12402b965f","publicationDigest":"c1f252d6c1092f6ce137670ca8d19fe5c7c9c0a6f7e3ba339a416c859cdd95f9","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":115,"eventDigest":"edbbcd46cabf6a7340fee986d3ec8f148ccdf7a924375101b877bade814c5a93","stateDigest":"f0fbc79e7ed3c7474c4d6d92fc14bfd78427d0644e29eee02ebb3c15af5a5a93","publicationDigest":"9040f06cb1648f643b77d6288e3b9210980da566ef62e09ada4a8d25bd86c943","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":123,"eventDigest":"a730fdbf1eb8ec45435d6f64ed512951436b1a137ae93a0e7a2c016a04a1603a","stateDigest":"c63083b415263ca60937423a54134a626bb346c5231d9d439c99ab9b1121afd8","publicationDigest":"53ec751ab0507ab47cc68eaaf1263283c72e203d8785841a20d35dbc69e2ec03","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":123,"eventDigest":"a730fdbf1eb8ec45435d6f64ed512951436b1a137ae93a0e7a2c016a04a1603a","stateDigest":"c63083b415263ca60937423a54134a626bb346c5231d9d439c99ab9b1121afd8","publicationDigest":"53ec751ab0507ab47cc68eaaf1263283c72e203d8785841a20d35dbc69e2ec03","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_wizard","backend":"replace","quakec":true},"frameCount":54, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[5,0,1],"yaw":180}]},"visible":[],"mounted":[],"events":0,"eventDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","stateDigest":"cc2284589b5e46e8f93161c61189f831e23bcd4e7be74d3d4d0c3aa0de3eab8b","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":7,"eventDigest":"2ea43b3f7ddf61207b06041a2f7ecbddfc7e756f88895945c88fa8ffae336c2f","stateDigest":"ef737fcb97af756ae3e1352f454140fd69d2b68bb090ebd7405556305f0ef18a","publicationDigest":"e208c706870252d018d99d5e90daf6ff75373bbdf81463d72f9856b3b08be0b6","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[4.946666666666666,6.53144959545255e-18,1],"yaw":-180}]},"visible":[1],"mounted":[1],"events":36,"eventDigest":"a9d6034e553d5e9d26ef0aca15e493bb63dcfc9a63f74634bd1ca724fb7e2a7c","stateDigest":"de18d3c634ad82a50f54ae959207e5fd4f58dff594823d98a0f5333941fba051","publicationDigest":"7269a6430d882664bbd4921dfcea374c63fb58a6ffe2e0a520bf914784921f32","liveHandles":1,"maxRemovals":1,"raf":1,"timers":2}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[4.946666666666666,6.53144959545255e-18,1],"yaw":-180}]},"visible":[1],"mounted":[1],"events":36,"eventDigest":"a9d6034e553d5e9d26ef0aca15e493bb63dcfc9a63f74634bd1ca724fb7e2a7c","stateDigest":"de18d3c634ad82a50f54ae959207e5fd4f58dff594823d98a0f5333941fba051","publicationDigest":"7269a6430d882664bbd4921dfcea374c63fb58a6ffe2e0a520bf914784921f32","liveHandles":1,"maxRemovals":1,"raf":1,"timers":2}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[4.946666666666666,6.53144959545255e-18,1],"yaw":-180}]},"visible":[1],"mounted":[1],"events":69,"eventDigest":"82d2d9d6c768c38273dabcabaf1a6a3847a9bedd83d9a13b5e3988a41497e5b0","stateDigest":"cacb7931313a46dc5054d240487e07b0a71534e2dfde03ab9591436b4bd1f760","publicationDigest":"45c5b6bcb58546c10f3c4f18ec316bba37941824e8b7fafbc168eabf63a61aeb","liveHandles":3,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[4.946666666666666,6.53144959545255e-18,1],"yaw":-180}]},"visible":[1],"mounted":[1],"events":81,"eventDigest":"ff30cac533a39bdafbb153176c41af6e390576ecb280b3d1a89f1018f73fe04f","stateDigest":"cacb7931313a46dc5054d240487e07b0a71534e2dfde03ab9591436b4bd1f760","publicationDigest":"186309efa586de90e83f4c275e5d6c7f7a5e695045d7eff5cc3152ee18d30c83","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[4.946666666666666,6.53144959545255e-18,1],"yaw":-180}]},"visible":[1],"mounted":[1],"events":97,"eventDigest":"e54380cf9998319838704e05e4a7617ffbdd125f16030cd16fe36a109ae81cad","stateDigest":"cdec405faf9ffef68026721896c9c72ed8e19f2368edc812ca69a9a426111064","publicationDigest":"524496c463cc5d77edbccd0945d4729e16c5305f90faac964e304f47b4849b00","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[4.946666666666666,6.53144959545255e-18,1],"yaw":-180}]},"visible":[1],"mounted":[1],"events":115,"eventDigest":"6177ee4c362b6e8248155d10fdedfd50011accd9a78909cd7609e74ebb34413a","stateDigest":"6922af574c000517b7aaa11f63a922c41098b2e0d547e7748cb595ad2b7a8182","publicationDigest":"6b3cfd30552ec5b80943ad51d216f65183a2b3fb4c542c7ea4c13ec587cf07c5","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[4.946666666666666,6.53144959545255e-18,1],"yaw":-180}]},"visible":[],"mounted":[],"events":120,"eventDigest":"75e8f30b19b5bf056a64933cc956618ec4a7391c372a4c13239d0b7a29cffeca","stateDigest":"ab8765f46fa2015f8919a0a3b44e6b709b3967de79c819be0acd8c667b2c0f33","publicationDigest":"c493739682ca0420467eb6a8d5ecff24ccd39b36aaa5a2818f15a4aaa88a3e48","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":150,"eventDigest":"73d338bc2728870d467846f71450079b567408ccce80da98914f96702fe6adc4","stateDigest":"df444b7c98c12798c10d1554f467acad72e7cd086f34550574c2617c31a952db","publicationDigest":"0a24acf81cec0b6bd140c71b2828be2499c2357e0df73bbb9ea39ca53a6525ac","liveHandles":1,"maxRemovals":1,"raf":1,"timers":2}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":229,"eventDigest":"3cb10288773900e4108682e470827a042ec3abb5ace0420b8b8a45768f46b220","stateDigest":"7c43c574e0054e0ec6a4625333c3d9ea3c7db54f61c817b5653982aca4b10564","publicationDigest":"266b411892bf278f859df45a72d9ca5ceac429e350181a2006aeb7a80e9a819e","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[4.4,7.347880794884105e-17,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":276,"eventDigest":"60e39230d3966f982cfe25add5ec818d7b329d9e061bde349cbc1764bbd9f324","stateDigest":"4733c39c3cc39011fa9469243d27f12b836644cc117d75e70cc97160b359e354","publicationDigest":"0ea898ecd949bc026d407dd8b2e4183b27f1d78baefa1f56cee996dd33deaf5f","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":75,"origin":[4.4,7.347880794884105e-17,1],"yaw":-180}]},"visible":[1],"mounted":[1],"events":300,"eventDigest":"c20ee7450d42a0eacb1bb3dc3b886b2416076a145f938fe3fa5c06c2c41cda31","stateDigest":"696589b66bc72bae485c3ea0d7afb87a989e76221e5da1bc91819fa0807bc239","publicationDigest":"cafef1f18e41db0afdd8cd4ca00b78e015126f8af5499f9e6d3f9647ea705739","liveHandles":1,"maxRemovals":1,"raf":1,"timers":2}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":301,"eventDigest":"8ba8212ae6928e0fe2c7db8a8aa60569abf7238f4bc5827988573745c79ce4e8","stateDigest":"6005f12dce32f223974b44ba59fb77c45eb41d0415a3a9b3e9ebb2ed386f7e0f","publicationDigest":"fa40761258212baa57e2d2868b36c628f120971e1910024118c5f266bf98bf94","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":301,"eventDigest":"8ba8212ae6928e0fe2c7db8a8aa60569abf7238f4bc5827988573745c79ce4e8","stateDigest":"6005f12dce32f223974b44ba59fb77c45eb41d0415a3a9b3e9ebb2ed386f7e0f","publicationDigest":"fa40761258212baa57e2d2868b36c628f120971e1910024118c5f266bf98bf94","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":80,"origin":[4.52,5.878304635907295e-17,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":342,"eventDigest":"6c98b713c0b1312efebcfaa40d993d33dfd7676674377e58c2408a1e695b4890","stateDigest":"d80f4d37b6d6822494ce8067e4531bc8f5065c1fdbbdbe2c8d78ae537ff38612","publicationDigest":"5d17b2d937c3a95fcc5d61b1d83e96eed92d32cf236f69be5df9a683f0e03693","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":350,"eventDigest":"a28f396344880d23ae2958cf1cf98f274bfc66c4b4266c20217499449b5203dc","stateDigest":"c6573f63a1fe6fb335e42f6c84947965aa8fe089b607b19661228995f0c8fc23","publicationDigest":"7c8bd8655fbf9bc728a412134a1bd8ba46c07dd13087db2d72555be7155af734","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":350,"eventDigest":"a28f396344880d23ae2958cf1cf98f274bfc66c4b4266c20217499449b5203dc","stateDigest":"c6573f63a1fe6fb335e42f6c84947965aa8fe089b607b19661228995f0c8fc23","publicationDigest":"7c8bd8655fbf9bc728a412134a1bd8ba46c07dd13087db2d72555be7155af734","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_shambler","backend":"frameset","quakec":false},"frameCount":94, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"7263275b2778bda6338a5d25b6b1e38b50082dab8acb89532ec3bc1f15f2908a","stateDigest":"703fff5b99c120a44da1d5783d7518526e37ba4c3ffa25ab21688b00bb879269","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"04f91593f15347e813cecc780080bdf47939a354435d23a6436fc3883f109c55","stateDigest":"768ddf6471e54c6c7d67a321721dddcb6eb128f6fb2b2b29962fb6042818e436","publicationDigest":"05203bd04118d3285bbfed9cfc1245f26e7ca398fd63fada19c18c0f6f51959f","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":10,"eventDigest":"83e6b13b1a91b9fd17608e0ff356a966edf14eab268913fbc72bcfe2bb487738","stateDigest":"7f702c8a5947dceb763e2693379949cc5e621242a6d49ca5892af21b875af490","publicationDigest":"1cafb1be6927162689fe92b89f17c06654c34a87819aa76ae3c02770b6fd3b3f","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":10,"eventDigest":"83e6b13b1a91b9fd17608e0ff356a966edf14eab268913fbc72bcfe2bb487738","stateDigest":"7f702c8a5947dceb763e2693379949cc5e621242a6d49ca5892af21b875af490","publicationDigest":"1cafb1be6927162689fe92b89f17c06654c34a87819aa76ae3c02770b6fd3b3f","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":12,"eventDigest":"f2f23effd0031ebf8248a65590bbc6c99e28bca3625219ebf6726449bb841595","stateDigest":"338e285c0c19c56a39815e7b80ec9cff60ca289ff8c10a2f8297903ff3807c2b","publicationDigest":"651e6ed17614c4ac188882860bc5fc67d76ad58130eeeee3d91dba407aa718a8","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":12,"eventDigest":"f2f23effd0031ebf8248a65590bbc6c99e28bca3625219ebf6726449bb841595","stateDigest":"338e285c0c19c56a39815e7b80ec9cff60ca289ff8c10a2f8297903ff3807c2b","publicationDigest":"651e6ed17614c4ac188882860bc5fc67d76ad58130eeeee3d91dba407aa718a8","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":16,"eventDigest":"7ace6a34970b9d67200abd0915a65ee05742e27b2854e6b3d1b8a97be21fca78","stateDigest":"08ace156d6d97a25fab7e962e9fb922218e9eb4c654388bae7788af956bb090c","publicationDigest":"68cc20bf8bf7daaea20de20af7cd5b1bf3ee7f35ea3d01e04c4fdea16fc52b4a","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":595,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":23,"eventDigest":"eb8c81cc4095022fc13eadca77dc8719cb5ffffe6f6731b20529f8cfcf6fafcc","stateDigest":"ddcb461edc6e23a2e29898c7824aca2fcef8f8f41fba3968898a63d5b1e55ac3","publicationDigest":"3f94d24af8ca2fb330b742ae60e071faeffb5a7abfd185ade303817a20380def","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":595,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":26,"eventDigest":"2cb81749bbac5e064e46be64ebe59f2639b8678829808dfb96f6602d97afc519","stateDigest":"4f5a3424dd0213f13455062ddae78ca4f927af1089823eea8120df4546aea3f7","publicationDigest":"458b9cae0822f31885c368e2679a6b4ed153fb7763c66a0dae4a39b4de67119b","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":595,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":36,"eventDigest":"c2fb45aff8913ae5fc50d2319146062d9ad10ca1f49a4f03cefd8dd68af73bbb","stateDigest":"0424248fafdb17c05f3322fa04ffe5c9d337f2eccab076eb28c995abc102a70e","publicationDigest":"c52502804f6aaf9d3fb71485b204bfb16e06351e971dbb6fe16ac13511292f05","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":44,"eventDigest":"72e19d880dae709a44914b50710b47b52a64f9c659d97cce9434503da0a39bf3","stateDigest":"af1b8788357f78377f09b0ccf179e25bbf90d1db455a39f6d7702801ffc4a0d8","publicationDigest":"b152e82503f4f5e8e50bf3ee8d44b3e42b43f661b57d5884ddf3a624f5170c2b","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":595,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":55,"eventDigest":"df039996d88420b91383776a291cbb702c9173e06135b009398911f570339914","stateDigest":"1e84949b5e87f9951531b3ea953d64b6b4ce132a612909ee74d3280082605327","publicationDigest":"673d3f0b65f08f2039756fa6844dcdc5fa508a469fc6b1118e362f77886af584","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":595,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":57,"eventDigest":"0fa2d1b80154fa8fe7a4725cd3b43c8b46a91e60e3bd8b53624c786ee4c0750a","stateDigest":"9167378d7585782500e26076ab8b4a3fb95818e2fc4a022ea7797d8cec060ddf","publicationDigest":"b87ea5d28ebb80b3da52fb2e54877baf108afb4a8cabbdc7a6fd89335be8c1d6","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":58,"eventDigest":"b1cc3c02273f64ffa0a0b0cad5542bc923f2ce7b73e1cca3d4ca6a95464afb12","stateDigest":"850ed02e7a54b824e47f4d7f1605edaf40cc882f0ed36ce1e6e802578f49e3b8","publicationDigest":"c359db7e3a0ed831294e704eee569f3b7588c9bb4cd7c2366e9236fe758daa46","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":58,"eventDigest":"b1cc3c02273f64ffa0a0b0cad5542bc923f2ce7b73e1cca3d4ca6a95464afb12","stateDigest":"850ed02e7a54b824e47f4d7f1605edaf40cc882f0ed36ce1e6e802578f49e3b8","publicationDigest":"c359db7e3a0ed831294e704eee569f3b7588c9bb4cd7c2366e9236fe758daa46","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":68,"eventDigest":"2e8cb21a4b5976886f2898985fad142b6f4d9227aaab71a320f7a29f1b4d61e6","stateDigest":"c5169c37544a44666ba364f748e4e90443b7c0e3671435815b2af66bad429fa0","publicationDigest":"030933a37f110275e4668f42a7adb52b32f9306e183e50c69a976c84c5fe8fcd","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":76,"eventDigest":"4de496415813a70d9bd39629eafaf78d1546ab9e734111ab095c5b45cee3009b","stateDigest":"cb0a17c292d1f1e836e451fb3c57072bf76ab72974398b76d6d8a8f5b4a451de","publicationDigest":"24b7a3def98e0566fc5e6a3fbdad59ab28009c57dbcb79e9bd7ac0b16aac97a8","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":76,"eventDigest":"4de496415813a70d9bd39629eafaf78d1546ab9e734111ab095c5b45cee3009b","stateDigest":"cb0a17c292d1f1e836e451fb3c57072bf76ab72974398b76d6d8a8f5b4a451de","publicationDigest":"24b7a3def98e0566fc5e6a3fbdad59ab28009c57dbcb79e9bd7ac0b16aac97a8","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_shambler","backend":"frameset","quakec":true},"frameCount":94, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"7263275b2778bda6338a5d25b6b1e38b50082dab8acb89532ec3bc1f15f2908a","stateDigest":"703fff5b99c120a44da1d5783d7518526e37ba4c3ffa25ab21688b00bb879269","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"04f91593f15347e813cecc780080bdf47939a354435d23a6436fc3883f109c55","stateDigest":"768ddf6471e54c6c7d67a321721dddcb6eb128f6fb2b2b29962fb6042818e436","publicationDigest":"05203bd04118d3285bbfed9cfc1245f26e7ca398fd63fada19c18c0f6f51959f","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":20,"eventDigest":"b686720e94e5d9c5737aee6b4b53034fb3ecdee5ef32031cf6125ee5a6f64ef7","stateDigest":"5a3d5eddfd21a0d19c2fa7449682389d9ce80bf10c87fb2f77ad23dd2b4123f7","publicationDigest":"f9539194ec4bdccee8fe36f4b87d34c4a9000e9b35a96d048daf41023f382a8c","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":20,"eventDigest":"b686720e94e5d9c5737aee6b4b53034fb3ecdee5ef32031cf6125ee5a6f64ef7","stateDigest":"5a3d5eddfd21a0d19c2fa7449682389d9ce80bf10c87fb2f77ad23dd2b4123f7","publicationDigest":"f9539194ec4bdccee8fe36f4b87d34c4a9000e9b35a96d048daf41023f382a8c","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":26,"eventDigest":"22fc42555e14bfcd69025cb099e013d9f617fa85248754182ccc2a940f987390","stateDigest":"2a5b67737ff6d5959dcaccd3dd1aaaec83879db6ad0f2b48665baacd3731c99a","publicationDigest":"d65eead2e5df77b80de8f1b222acaedea8d6762c131e45aac869f3ce9603afba","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":26,"eventDigest":"22fc42555e14bfcd69025cb099e013d9f617fa85248754182ccc2a940f987390","stateDigest":"2a5b67737ff6d5959dcaccd3dd1aaaec83879db6ad0f2b48665baacd3731c99a","publicationDigest":"d65eead2e5df77b80de8f1b222acaedea8d6762c131e45aac869f3ce9603afba","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":36,"eventDigest":"fac5cbf1e17af3a69fa69fe56009ecac0a70163fe22da41db5f1fd7c088d6658","stateDigest":"339bd9bae194ea0bfb72bedd918527ab17166d7e87141238602ced119ad96224","publicationDigest":"e49968ac1cccc360dcad45d4ca55e3d8f590c5d463696e2b8c11608f63a60e15","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":595,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":48,"eventDigest":"477fc8ad0b7bb5ab74bf3c0a091b27a04c8c96780372a6a98b14b1658843a22d","stateDigest":"a9adc71f5b1edeba2b0eea430d32aa93ae57ecb579063c098efb97a2cfd18226","publicationDigest":"836c392a43840057022df2fe6baff3bac4bb5bbeffc544eb8791afd8c2ed4b66","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":595,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":60,"eventDigest":"63bac877c94800ee41c14b5001b0f343ec75c8d18d5854bb6df4eb4a4c1ffe0a","stateDigest":"8760b1ac60282cd04199e08022b5f765aa8386da250142c45bfa23f77e399019","publicationDigest":"5d62089a6159b89849427610c0f54e1e21e782f9224637fdbdfc1504c902bbe1","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":595,"origin":[4.4,7.34788079488411e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":89,"eventDigest":"0d30f6274e8af65d5c256526e80d0d631e4a23c659567dff6e641211163ea418","stateDigest":"10cfcce72b6220d791f8bd3d031461f01a5f936399d9a043bc26250ff6e8a5bc","publicationDigest":"91ee5e87e3cc4dbe72961ef14f309295884ae093b82343d0f666894b75f6e82f","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[4.4,7.34788079488411e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":128,"eventDigest":"941885b4a1468b2105ad10e19e4469ea63f6784400ab3aaad529391b454bd099","stateDigest":"d3a8d94b38c8251aff4ee63c970c33e0cfeb739469146e082894e27bcca824a6","publicationDigest":"10246925e7946fcad99eed5cf994c95bbe898b0b6493ae000bcc9d99ae7bbf76","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":595,"origin":[4.4,7.347880794884121e-17,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":164,"eventDigest":"ac62bab6eeefaa3c5c4b31bd9dfd3b74f4c1b334fec7ea683aec7d36780463bc","stateDigest":"b447c6db9e8191bde8922641cad1a30d93a827748c3a742e1832e43d652dc01f","publicationDigest":"d708d6d26c8495f7adc1860290e95daf48b16e8c8f5e32182c3c93ee31f3ee9b","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":595,"origin":[4.4,7.347880794884121e-17,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":176,"eventDigest":"23bcd1e61f2c305d35e5c1cc53a6d6b200707153c12d91b62d611262bbfba4e2","stateDigest":"4f760671fb050c1503bcac7c01f502f027363ad2ca26dca46fe4bf0486f95c4d","publicationDigest":"e85e6c6ed2a73f371f403fb8b4406edfee25dace3e5ab1d5fba1321478cc24de","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":177,"eventDigest":"63fd4639de6b53d33cc679da3ca7ff273ae0191e55be6d2b95ab4cf506f9a8ac","stateDigest":"43781a5acadcf9a3154e237d458d8950379435bd505a49d130421079824ed749","publicationDigest":"c319fdf5c73e240c89b113ed4d4bd8f2b0b1e4a171fba7501a6eb4e54db8a3cc","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":177,"eventDigest":"63fd4639de6b53d33cc679da3ca7ff273ae0191e55be6d2b95ab4cf506f9a8ac","stateDigest":"43781a5acadcf9a3154e237d458d8950379435bd505a49d130421079824ed749","publicationDigest":"c319fdf5c73e240c89b113ed4d4bd8f2b0b1e4a171fba7501a6eb4e54db8a3cc","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[4.4,7.347880794884121e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":209,"eventDigest":"7edce1b3de8790e9760d2a79f86d3dc4b1f775d00165ba49412e9439d112849c","stateDigest":"b3e7e9c8f8c04993bdb425e0c7e9f977cabf1c8df0a8f04206c7dc5dc27a7a9d","publicationDigest":"538d754601ca23a09fc235553fe95abc04fbda45e4a308c344b868e09422fd78","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":217,"eventDigest":"d3264699c1e51acdeb14c63f7c93535e282e7c9f2aa480fd11a9e3da895a4156","stateDigest":"09fc0b31c0c248d052556756a97ac79ea00c34598c014de4539fe4dbffff9a6f","publicationDigest":"fdda833087d07c290e96f3aaaf9cf1e5afc259082f8fcc74bee537870d3617cf","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":217,"eventDigest":"d3264699c1e51acdeb14c63f7c93535e282e7c9f2aa480fd11a9e3da895a4156","stateDigest":"09fc0b31c0c248d052556756a97ac79ea00c34598c014de4539fe4dbffff9a6f","publicationDigest":"fdda833087d07c290e96f3aaaf9cf1e5afc259082f8fcc74bee537870d3617cf","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_shambler","backend":"replace","quakec":false},"frameCount":94, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"7263275b2778bda6338a5d25b6b1e38b50082dab8acb89532ec3bc1f15f2908a","stateDigest":"0736ee109234a2fb4620d5c8460eb936a8c27f2ca7524729bd24af272e4cba8e","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"8662df6238e133cff38b2b42652889d0624ec261fa4657da05730fc3d6baab5e","stateDigest":"f67542668f932b20cf01816200f78dbfecb5030acf0677aa9fbd50010967d293","publicationDigest":"05203bd04118d3285bbfed9cfc1245f26e7ca398fd63fada19c18c0f6f51959f","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":15,"eventDigest":"ba9bf5c5bd837cfc28ade87c874b5d1646335195558baa1cb47aa02cf412b549","stateDigest":"76932f9d43eb5cd90d091518d27eb9ea86495107aedc7116c8b8f98b8c7b9f9a","publicationDigest":"de2ac76647f2d5a2a739440e4c175fefec37eb2a533b7384fd96bad0871789ef","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":15,"eventDigest":"ba9bf5c5bd837cfc28ade87c874b5d1646335195558baa1cb47aa02cf412b549","stateDigest":"76932f9d43eb5cd90d091518d27eb9ea86495107aedc7116c8b8f98b8c7b9f9a","publicationDigest":"de2ac76647f2d5a2a739440e4c175fefec37eb2a533b7384fd96bad0871789ef","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":22,"eventDigest":"88f7dda2639106784b61b32f69a17e83581592fb6016155e63e7fe0ed5cd8575","stateDigest":"d742251ac1257722e3a27d43c1115a6f01a323495aee8ad82705601700808e1a","publicationDigest":"b3bc2268437cbd353bcbe58bd739970c2f8258a8aae9752908baf6c5732bdeb5","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":22,"eventDigest":"88f7dda2639106784b61b32f69a17e83581592fb6016155e63e7fe0ed5cd8575","stateDigest":"d742251ac1257722e3a27d43c1115a6f01a323495aee8ad82705601700808e1a","publicationDigest":"b3bc2268437cbd353bcbe58bd739970c2f8258a8aae9752908baf6c5732bdeb5","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":36,"eventDigest":"a6df0f78d855c47fd842a46c68486d402d05b64c7f2442f7f10483dcf36f816e","stateDigest":"d56225c6a535b964f89a415d8ad7eaa6bdf34eeef536cc274e559f09636b5a79","publicationDigest":"65e9aa5d550e1906b47e5e6e7764f123a20a35c17065c2107a3523ff3cf8d0d0","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":595,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":48,"eventDigest":"6e4b6e9c48154373be4e92d0dcd5764027e77f195d4be318e17db35659964c4f","stateDigest":"d2dbbdc371d08703c9e6d8c95e4d1ff80a5729cba7481961231c29b23ad5adf6","publicationDigest":"8ffeec12d70df3e831d2a8e09fca1903c7030bd3ce9e5906e244390c4c19331d","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":595,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":51,"eventDigest":"8519f94ee9bb6c2472d4193fe34b87ffe291342aaafe84a4037d715beb686f20","stateDigest":"5eadbebde56c3f0a2c9270167f7ba8135389e37cc27029b6539e58354c2d86e1","publicationDigest":"c1eb695e0fc460df8bf27760cdef66ad2b11fcb05b504e721aef2b6648f66d8a","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":595,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":66,"eventDigest":"d28c03e26b06b1992c380bc3ab0d56fef34806c42d9ead041aa3caffefbb0c8d","stateDigest":"1d548420a96e7d6e8dff70aca58efaad868cdfb5962c6135324e541f9967e48c","publicationDigest":"50eee73f962236923814aad26b8bf71b382a6108393767ffcb6cda0bb5a695a0","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":79,"eventDigest":"3c93a9b38ead83c893723ce0fa943f05a9a359893c20c28b5704969f378e6785","stateDigest":"5918d09eb5c529987f480fd7d473ebfe27f22bc3c68c752ae13072b49044c79b","publicationDigest":"74d9c4c11ec96a38eb5252aa2f50c12a2fc91887460eaae59eee1538edc3e3bd","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":595,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":95,"eventDigest":"4c9fefafe8b5f7dad8786a6c4e23fead65c9c71e2b68ab94493205123c530807","stateDigest":"260ca1b2347d96d36ddbdab99e70043aeb7373c76fd842b9683f6e16713a6ea4","publicationDigest":"86729aa1f68e790e2c02364e5c4a91e27dcb2de960200543b01dc6ac70c20715","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":595,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":102,"eventDigest":"23563610742d4bb2d7358b70851d40c9390c0a0e4505fe7769c6276943912f8f","stateDigest":"3a958e3e71146395bdbd582b69edb81b03c9f5dcab8efda01ba5c38c2f0ca1d5","publicationDigest":"31b3e5b812dcf01db0eda9ecd0fbc1578637c7a8da517ea301a654229f18e996","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":103,"eventDigest":"12cd0820dae52b04d08038c3fa6778d0b8d62e518a3693a598204d21190aa764","stateDigest":"ab9899802289e7fef134d0d7d2a46769c23e34dbc6de5b044fa1ce12402b965f","publicationDigest":"1da1b12c2f8a0cba0e6fe0f85805cd0ef2503e2adee063a4d673d77b9b5d5656","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":103,"eventDigest":"12cd0820dae52b04d08038c3fa6778d0b8d62e518a3693a598204d21190aa764","stateDigest":"ab9899802289e7fef134d0d7d2a46769c23e34dbc6de5b044fa1ce12402b965f","publicationDigest":"1da1b12c2f8a0cba0e6fe0f85805cd0ef2503e2adee063a4d673d77b9b5d5656","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":118,"eventDigest":"1031e4ae3c1be6fee1a0673cbe94f7de1baa79d3919e337bc1ba6299846c7af2","stateDigest":"efaada4f61e058cf6db44b2501a249677642947b6f1bb6d9622b727ed2479050","publicationDigest":"1af90ede4e29d8b21b23767ed91eb0cb5b141af188dcb5de1ee695c23e35977a","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":126,"eventDigest":"d52dfece3011cf721ed17b55d03e2114438fc052baf8eb7b17f037b4d305ed39","stateDigest":"c63083b415263ca60937423a54134a626bb346c5231d9d439c99ab9b1121afd8","publicationDigest":"b60856b93082c29edb20d4e3d3aa40cdce3c2719d22b15ee23c7637019a47a0d","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":126,"eventDigest":"d52dfece3011cf721ed17b55d03e2114438fc052baf8eb7b17f037b4d305ed39","stateDigest":"c63083b415263ca60937423a54134a626bb346c5231d9d439c99ab9b1121afd8","publicationDigest":"b60856b93082c29edb20d4e3d3aa40cdce3c2719d22b15ee23c7637019a47a0d","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_shambler","backend":"replace","quakec":true},"frameCount":94, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"7263275b2778bda6338a5d25b6b1e38b50082dab8acb89532ec3bc1f15f2908a","stateDigest":"0736ee109234a2fb4620d5c8460eb936a8c27f2ca7524729bd24af272e4cba8e","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"8662df6238e133cff38b2b42652889d0624ec261fa4657da05730fc3d6baab5e","stateDigest":"f67542668f932b20cf01816200f78dbfecb5030acf0677aa9fbd50010967d293","publicationDigest":"05203bd04118d3285bbfed9cfc1245f26e7ca398fd63fada19c18c0f6f51959f","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":30,"eventDigest":"55b5cb5349cc93a17c480c63416bbb0146be714b3d1fda97f51f6bd0abbfa14e","stateDigest":"72fd0d4cca795f7077d3a1b1c20617d9e2ac78a4197c48bb930a7ab901973bf0","publicationDigest":"74ab3bbe50f91b98d536bf71ddd7b95f1c248e8195c466ca34e8f1e5f066e111","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":30,"eventDigest":"55b5cb5349cc93a17c480c63416bbb0146be714b3d1fda97f51f6bd0abbfa14e","stateDigest":"72fd0d4cca795f7077d3a1b1c20617d9e2ac78a4197c48bb930a7ab901973bf0","publicationDigest":"74ab3bbe50f91b98d536bf71ddd7b95f1c248e8195c466ca34e8f1e5f066e111","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":46,"eventDigest":"8eddb6fc0eb1f4bd0aff0e535961875f7d1cbb42be90bc4f20c5ac09d99cc827","stateDigest":"1199c96c6cfd04440e4d530ed8b4fa908c20a710b736400bbc35c98b062946bb","publicationDigest":"810e089d3e7d2dce15d3e2f09ccb37a9fdedcc52a7688466c2b8d51c90219b1e","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":46,"eventDigest":"8eddb6fc0eb1f4bd0aff0e535961875f7d1cbb42be90bc4f20c5ac09d99cc827","stateDigest":"1199c96c6cfd04440e4d530ed8b4fa908c20a710b736400bbc35c98b062946bb","publicationDigest":"810e089d3e7d2dce15d3e2f09ccb37a9fdedcc52a7688466c2b8d51c90219b1e","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":66,"eventDigest":"894155e54cb64c1523729d03d50ba8606e1a1671be091dd82ec95ef0038ea5a5","stateDigest":"91e7527cba8356426379ebf00c0676b75077b173aabdcfcd8cd31346819f6823","publicationDigest":"f6a375de35e5c41ad962d3440e6df9a8c2119465d156444dfa5d694ce32aa0f4","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":595,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":88,"eventDigest":"7a5c8387991ed55d645b7559c38e99f3ba5fb47c4181c49a132fc57b1423c384","stateDigest":"0c7dcd78d16236bc56ccd19db97b55c000f1be05c96b0eff65ed372b3f2ef0f3","publicationDigest":"4d8a412be2d4e8390cdc57b932d7813f0979bfc6f4d0e3a2598234efefd6fa1d","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":595,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":105,"eventDigest":"0282dd110fad5dce85d0eef8b4ce9da36f54caf08b70a10cacc36d11ba01e5fe","stateDigest":"6f4053e63f1404d4c7258fa1927151064e8977ea043cfdecfa6305d9d74373d5","publicationDigest":"23e6212f5bd4fa3aa76b5219d4f77ac3f7d77a8cb21936f5ad1d7fc1d6e64de4","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":595,"origin":[4.4,7.34788079488411e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":144,"eventDigest":"fe0a814c556fefa1a505171f9def6ecfd0b9ec1d458c093ab974fea4ae92a994","stateDigest":"7deab6364830f5bcd356f3e038790107d2cfd3ea5c1e3b04c8f52a81bf4eab07","publicationDigest":"83b239f418f579d045328f3b30b71a0e04d98aa4dcc9318acba97d3075db5810","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[1],"shootables":[{"dead":true,"entityIndex":1,"health":-1,"origin":[4.4,7.34788079488411e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":245,"eventDigest":"11d3d4271b78c137a4e88eb0026cc317ff760ebb96fe8a1af53a1aa0f2fb7f68","stateDigest":"e20934987d187b25272f49c9febcd9e588df0088112f801f284ff5601b7c6e9a","publicationDigest":"01ee3621463a5a2bebf127edb239a0ca0fd4a466c4881d315dea4b7142164a72","liveHandles":1,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":595,"origin":[4.4,7.347880794884121e-17,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":296,"eventDigest":"8d7ff88f4ce6584dc6ffe7bf75f4eed7b4269ec6f218f54a5922bc1094484765","stateDigest":"a91ecc8fa6af03423acd83df26a3d20751c7a3b69b37d837caa4cdb2735e660e","publicationDigest":"538d13b2afb7b21d114e6c109354a6ef6b644d006efe0b955789c829fa63502a","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":595,"origin":[4.4,7.347880794884121e-17,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":318,"eventDigest":"4839cd0445660fae2b685465258d9c848f58b458faa7f93c10a74f9892ddec4f","stateDigest":"d3d2a2c0bdabbe85edc83c8aac7f1ee6df032c889adcb4b1f1cea5f86882af03","publicationDigest":"e9b55a1d0f58850362fa8cd4a83d7a0577b82b100c464e25918d09786cebc6ec","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":319,"eventDigest":"60e518081a16e8ff156a254794097846762c5192824bd07d95504124c6c3e7de","stateDigest":"38f0280dde7e67ce1196bec164a8f3cf188db68914805f1c729f09eac898583c","publicationDigest":"df917b6b31dfd5f16e22d5571aa5f3cd7b1c5662c28c16de139f9a77e16ba675","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":319,"eventDigest":"60e518081a16e8ff156a254794097846762c5192824bd07d95504124c6c3e7de","stateDigest":"38f0280dde7e67ce1196bec164a8f3cf188db68914805f1c729f09eac898583c","publicationDigest":"df917b6b31dfd5f16e22d5571aa5f3cd7b1c5662c28c16de139f9a77e16ba675","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":600,"origin":[4.4,7.347880794884121e-17,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":361,"eventDigest":"46d1114dd4db9941a0798e37625bface36d521bce3cf43be7a094d5b288a15f2","stateDigest":"0154e72456a3273770477eb9628a1cbb16442839d8956e470ca923bc9d6c6a53","publicationDigest":"04603fdba51bb3ec37f36e8daa53c51fc9fce6b57c1868886c8d0a3d301e8314","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":369,"eventDigest":"9f04fe7fd625488662e550a9230c9aa96e674d0b71ca7563a038013bdcd6a8d4","stateDigest":"c6573f63a1fe6fb335e42f6c84947965aa8fe089b607b19661228995f0c8fc23","publicationDigest":"1118d2119aef7c158f81b1c253f16d17d30d1b105bba291afed91b9e062f5978","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":369,"eventDigest":"9f04fe7fd625488662e550a9230c9aa96e674d0b71ca7563a038013bdcd6a8d4","stateDigest":"c6573f63a1fe6fb335e42f6c84947965aa8fe089b607b19661228995f0c8fc23","publicationDigest":"1118d2119aef7c158f81b1c253f16d17d30d1b105bba291afed91b9e062f5978","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_zombie","backend":"frameset","quakec":false},"frameCount":192, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"ad8183381e3da91d774878e54d94228d31d113f5f413d92b0ec7cd7814be0867","stateDigest":"93ff112210c6a33269ec9e81eb696c7d975062aeaee9560ebcdeff9b18578727","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"b4c1bde02d914f32cb324a91c1fca5421c119b0d55ccfbaa05c847ef23462b21","stateDigest":"84fe4b843f656768ad8244778dd866c2d7d5db75e141bfcdc50318e85ef34242","publicationDigest":"c8c24963b7801ec756dc095cf1c6b5b9625c7bf13a4f215ab01c4e41e91f7619","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":10,"eventDigest":"4bff5de47a545954be624349ea5da5a24fc284521ed38e54e20072373306d8c4","stateDigest":"c293c278eccf3124f8aa4e467fbadfad67e7e8c5c9c285a77f0d85cbf8f8ac28","publicationDigest":"874ec0fb62ea8d8aa9c5eeb5c1f7f212b9c2156e2efb8967bf8c0bd85e969d63","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":10,"eventDigest":"4bff5de47a545954be624349ea5da5a24fc284521ed38e54e20072373306d8c4","stateDigest":"c293c278eccf3124f8aa4e467fbadfad67e7e8c5c9c285a77f0d85cbf8f8ac28","publicationDigest":"874ec0fb62ea8d8aa9c5eeb5c1f7f212b9c2156e2efb8967bf8c0bd85e969d63","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":12,"eventDigest":"75f50e565d72774190cdd2efe9167a0413f1c4121c1bc175d2c16068fbb636ce","stateDigest":"6797e062f73a67134fe14a76171c76c336da88ae7d19136275c177e8c1dd3998","publicationDigest":"0d5ca27120d02d6125f5772fbd25a10243dd73759d0cb737002972b6d1a83bb4","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":12,"eventDigest":"75f50e565d72774190cdd2efe9167a0413f1c4121c1bc175d2c16068fbb636ce","stateDigest":"6797e062f73a67134fe14a76171c76c336da88ae7d19136275c177e8c1dd3998","publicationDigest":"0d5ca27120d02d6125f5772fbd25a10243dd73759d0cb737002972b6d1a83bb4","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":16,"eventDigest":"f1815ad3bf5f7d40dd4dc770763f54653747cfd4774f7acf2e6a52813b1c76e0","stateDigest":"c4170ec42c7f8d3adc16e758fdf76844e33a80ab933da303795755aa49b0b608","publicationDigest":"0f070c1ddd330e72b6ead2698a1d230693fafbea21fad31bca5b76d1254113bb","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":23,"eventDigest":"0820c4764e735b5a5113e1bd1de9be98fb37d3737c6ed5c385a70f1efccece01","stateDigest":"0e429d06a353a4a544831f15ebe09b808bbf495af0f6523ac4eb1bfbded6e178","publicationDigest":"a443c14e422ed7e5872b2de04e01e4abe98d341fbe8caa08d36b02bea6a8fe97","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":26,"eventDigest":"6fb4bad4082ffc34ffaf5bf6f48a03a6837293705b68ec0f95213fd3214de98e","stateDigest":"74fabff3770071d9c9a6b5e93fe5a90c8485dacb752008ef76cf3d1d419c0aa3","publicationDigest":"7b82fbcaf01dd23ec9805675e678c5ac10419d363620cc63f19e707cb4d8cddb","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":36,"eventDigest":"79e99109c846ac00f2e1cdf84c62adcfd22fc6fc505d4b80a4bf6d46d781a862","stateDigest":"135b936cc0ce21aed14927823bee77a5fdab2f6e860aec04f1ad9a59f2566643","publicationDigest":"0345e0ac4849c9565813e3e6d10bce027dbb44c4aca42cbcac70cdb3278e4898","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":45,"eventDigest":"f2c32c59c56e3c9b04c6ec887282a040216d5dc9b085923a5f02bb0009cbf464","stateDigest":"0c720ac2d7c027a20e228dcd03f398485d1692deb2c7580b3e9caa6498656b8f","publicationDigest":"a042f38077140734b20ef1c18000d1be59cc4773ba54d95fa3767883fc2b74c3","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":55,"eventDigest":"0c2df6bb5594fc37de4ef66bd91799b425dfb7586e28db53e33937e474004532","stateDigest":"1d69e420c474cc21362bd4b935cdb5a1ca66baba2405f806ec73515edd2cf746","publicationDigest":"3c073a9ac672f89bbd0e9e3afb8d729634b85c4a75804fed653a0d12401784e2","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":57,"eventDigest":"23b810eaa7b14566c321d471dc14b463f078a1cfb8d01532dcdb36efabefd613","stateDigest":"2e37e7a73a4bf2352d54656a2eb053b75e08794eb56bf2eeffdfbbe6e1d7af53","publicationDigest":"61d96c048c6df6fa7911269a71d5e7233d8a6fcb2e695aae355f00ec5c5b4074","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":58,"eventDigest":"085ee5af365895aaa805974b27af556db5d2d7622c5c634ab1baefcbb262a8ef","stateDigest":"850ed02e7a54b824e47f4d7f1605edaf40cc882f0ed36ce1e6e802578f49e3b8","publicationDigest":"d6bc7ab2f479cfbb2521d08016dd96fe01956da2376c24970ce59c9af6ef03c3","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":58,"eventDigest":"085ee5af365895aaa805974b27af556db5d2d7622c5c634ab1baefcbb262a8ef","stateDigest":"850ed02e7a54b824e47f4d7f1605edaf40cc882f0ed36ce1e6e802578f49e3b8","publicationDigest":"d6bc7ab2f479cfbb2521d08016dd96fe01956da2376c24970ce59c9af6ef03c3","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":68,"eventDigest":"ee4496361bca0fcfb3a926c3ae9c1f0329d3f1a5f006255b95c599481b6040c0","stateDigest":"4f6c4b1430765a4d60ae443253ba8e0a4c1960026086b3864b3385e3d9ea76da","publicationDigest":"4d7db7f620181f9d76e7ac4aeddea69dca4de7690259721f76154c78ca571a32","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":79,"eventDigest":"541ab1bc8f57e2a0bb7f1912f7babe471a4c964d9a46b67b651c506b0c3a2205","stateDigest":"cb0a17c292d1f1e836e451fb3c57072bf76ab72974398b76d6d8a8f5b4a451de","publicationDigest":"2f5bf61ff0367283b2f89564041cc44d31ccf1cd0aa1f92b89f04a332f95e1e0","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":79,"eventDigest":"541ab1bc8f57e2a0bb7f1912f7babe471a4c964d9a46b67b651c506b0c3a2205","stateDigest":"cb0a17c292d1f1e836e451fb3c57072bf76ab72974398b76d6d8a8f5b4a451de","publicationDigest":"2f5bf61ff0367283b2f89564041cc44d31ccf1cd0aa1f92b89f04a332f95e1e0","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_zombie","backend":"frameset","quakec":true},"frameCount":192, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"ad8183381e3da91d774878e54d94228d31d113f5f413d92b0ec7cd7814be0867","stateDigest":"93ff112210c6a33269ec9e81eb696c7d975062aeaee9560ebcdeff9b18578727","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"b4c1bde02d914f32cb324a91c1fca5421c119b0d55ccfbaa05c847ef23462b21","stateDigest":"84fe4b843f656768ad8244778dd866c2d7d5db75e141bfcdc50318e85ef34242","publicationDigest":"c8c24963b7801ec756dc095cf1c6b5b9625c7bf13a4f215ab01c4e41e91f7619","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[4.996666666666667,4.082155997157844e-19,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":23,"eventDigest":"6476177c0d0ee9bae7f834e746e687e7702dd1f58df9c6b7801d2edf768fe087","stateDigest":"ba4f8caa4a5b7d670a6e2f1469056894911263d2f414f0cff9a486f6db8c42e9","publicationDigest":"22955fe3d809852f2800735738635d43475d0c8ca694b558d5184c9f40e3fed6","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[4.996666666666667,4.082155997157844e-19,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":23,"eventDigest":"6476177c0d0ee9bae7f834e746e687e7702dd1f58df9c6b7801d2edf768fe087","stateDigest":"ba4f8caa4a5b7d670a6e2f1469056894911263d2f414f0cff9a486f6db8c42e9","publicationDigest":"22955fe3d809852f2800735738635d43475d0c8ca694b558d5184c9f40e3fed6","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[4.996666666666667,4.082155997157844e-19,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":29,"eventDigest":"4d45bcf667bbe3d508fc7f017136a9644b0a99fc41102ef48ebb59e8550ba087","stateDigest":"2eaf733b43371f7065940a828535011f1384cf19a6e9196b300150ac6a6c6eb1","publicationDigest":"06112a149542cdd9da7d7c3cdc2ba58738bf347a91cec815e502215889c0ad60","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[4.996666666666667,4.082155997157844e-19,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":29,"eventDigest":"4d45bcf667bbe3d508fc7f017136a9644b0a99fc41102ef48ebb59e8550ba087","stateDigest":"2eaf733b43371f7065940a828535011f1384cf19a6e9196b300150ac6a6c6eb1","publicationDigest":"06112a149542cdd9da7d7c3cdc2ba58738bf347a91cec815e502215889c0ad60","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[4.996666666666667,4.082155997157844e-19,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":35,"eventDigest":"7598a5e8207adaa34011741fd44f066fd2aaacb891a1d2af156efae0624f1eea","stateDigest":"3da70497ba5be6559487587c642a88f93f52b560e9bb846310b7c8783fff56cc","publicationDigest":"ab1a94c98f2b748285216c5001f25c08ccadc816d320eeab121f3e9e1f586990","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[4.996666666666667,4.082155997157844e-19,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":43,"eventDigest":"e7f0601ff2268b4347a66518d2d772702ec7494584224fe1026f95850d57ee8a","stateDigest":"4970d5c3d703da51f25cdac32a197f480b26a5f77a1f61cb5036eaa1239d6bf4","publicationDigest":"b87adb0ea7415f1fabda8b5c6b24377ac19316b92ceb9bb09f38c52f1e4bd25f","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[4.996666666666667,4.082155997157844e-19,0.48],"yaw":-180}]},"visible":[],"mounted":[],"events":52,"eventDigest":"59e4bee90e5f033bd52eebc78431cff2dea91d848733711e6201250103559893","stateDigest":"7839dd278fc9d5b1c8b9c4beb37bfe3752895ca07aec140c872323187e451f81","publicationDigest":"b2a7772283f6a5d1343f26726e23f7dd3788505893de89dc691dec08e7d9ed6c","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[4.970000000000001,3.673940397442059e-18,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":81,"eventDigest":"1aa5d137c8b9149fa9f548432e848db1ae2b3ab3e0e54343ebda2ff41575bb7d","stateDigest":"090902a44f461502ece6567b0a9ca490ac049bab6027e39f0b13e457cec8c891","publicationDigest":"c5988000ac07244d3a9b7da7d651f5ec7c9cca4c9a3f86afca4f75a76b3f667a","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":90,"eventDigest":"914bcd105d03a9b8ca3997db2211f7be496037eb454d2cee28bf48f4c2714e56","stateDigest":"0c720ac2d7c027a20e228dcd03f398485d1692deb2c7580b3e9caa6498656b8f","publicationDigest":"f9fd45428f9ff8697b559f5c37acb489d1082135e39f0c61869f4805efeefae9","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[4.956666666666668,5.306802796305197e-18,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":127,"eventDigest":"dcf6a9759889ec49c56039c1b6e1aea2ba6ab39ff9e8ce59a0ac0f968206633e","stateDigest":"55eafc6508b75a8d8a73f079b8c4b4339ae9167d0a247f3ecd21670f01ac94f3","publicationDigest":"344dc4cb0981d0bd4fe47342c9093e71e8b8cdd67ac11931fc8275289e47a9a0","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[4.956666666666668,5.306802796305197e-18,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":140,"eventDigest":"77890c7304ce1aaa2a9728e81e768d3d5883a8bb3b3880406798cc757ec4443b","stateDigest":"e65d86524353b04a5f4f6bb30f31307dc9d5c0fd408578055daeb188fb7ed47c","publicationDigest":"c0ad75609657c7211a76c9a3a6fcc6a37f1b3d65c98acd120fb32f23bd0543c6","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":141,"eventDigest":"7302d6bc48d61741ca6c19fa9c44d9cc6e6fbc6f768d04e1c7b64b6344cdc1d7","stateDigest":"885b9d1efd694f3804c749e7c31df090aa3291f3b7d6d36dd8ae70dd0bd950b2","publicationDigest":"bc385329a5756418b3b8c8f364961dd925a2e494a67328f3311928fe66b6ad83","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":141,"eventDigest":"7302d6bc48d61741ca6c19fa9c44d9cc6e6fbc6f768d04e1c7b64b6344cdc1d7","stateDigest":"885b9d1efd694f3804c749e7c31df090aa3291f3b7d6d36dd8ae70dd0bd950b2","publicationDigest":"bc385329a5756418b3b8c8f364961dd925a2e494a67328f3311928fe66b6ad83","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[4.970000000000001,3.673940397442059e-18,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":173,"eventDigest":"c4d8032979740f4c9e77421ba4408d2d267d7c21cfb9dd75f0ce37dac681b376","stateDigest":"09d3d2525c4581b22e84a77649edc7154c47f275ccc831df8015367141882800","publicationDigest":"3d4790abc160f1bc3aa07baede890a5ec51035170c63b5b1af9eb53b5b201603","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":181,"eventDigest":"f2bfc6559359cb15629c746702877c8d0ed43d9ef2b1e660e313f0d2d2539bb6","stateDigest":"09fc0b31c0c248d052556756a97ac79ea00c34598c014de4539fe4dbffff9a6f","publicationDigest":"3348a21f3b958ba6a610c1d62d34ee6aa695badbb19956dbc9228c999e73efa2","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":181,"eventDigest":"f2bfc6559359cb15629c746702877c8d0ed43d9ef2b1e660e313f0d2d2539bb6","stateDigest":"09fc0b31c0c248d052556756a97ac79ea00c34598c014de4539fe4dbffff9a6f","publicationDigest":"3348a21f3b958ba6a610c1d62d34ee6aa695badbb19956dbc9228c999e73efa2","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_zombie","backend":"replace","quakec":false},"frameCount":192, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"ad8183381e3da91d774878e54d94228d31d113f5f413d92b0ec7cd7814be0867","stateDigest":"a8438a286e960c97de55a27b79e457ac711a1ae1e81350e449d4959aedc0508d","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"60d2c64a47918a85ea41436d68702ee6d7996324619a4e437147d816b671776c","stateDigest":"497580fe9fce3fcbf757c0fc7b0b0735b38a4bb48a471ee8d80d517040a4769e","publicationDigest":"c8c24963b7801ec756dc095cf1c6b5b9625c7bf13a4f215ab01c4e41e91f7619","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":15,"eventDigest":"49fbc1d72a6f3a7b42c35b7f8ed2e61c89490f549aa01a98f22bad77cfa29bbb","stateDigest":"75dce461a25ac0f273a50abb6d320b3a481c9668a437b7b076a07b7fb66ed0c3","publicationDigest":"d20df1058dac7ccba5c87c45624371aa7fa2f2ca2bee2e907120b2accb8cabdf","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":15,"eventDigest":"49fbc1d72a6f3a7b42c35b7f8ed2e61c89490f549aa01a98f22bad77cfa29bbb","stateDigest":"75dce461a25ac0f273a50abb6d320b3a481c9668a437b7b076a07b7fb66ed0c3","publicationDigest":"d20df1058dac7ccba5c87c45624371aa7fa2f2ca2bee2e907120b2accb8cabdf","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":22,"eventDigest":"3971c16424376291e6f811226c313ca9f77be0e786f4d50c162792a863382288","stateDigest":"f780f6d37973aad3f438dcdeef024f5207e42ddec6bb7c661b1fce94ba704564","publicationDigest":"96ac095b72240c875da6d46a5d50d243e47393101ce85d9c66c8a9797f1e5c3a","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":22,"eventDigest":"3971c16424376291e6f811226c313ca9f77be0e786f4d50c162792a863382288","stateDigest":"f780f6d37973aad3f438dcdeef024f5207e42ddec6bb7c661b1fce94ba704564","publicationDigest":"96ac095b72240c875da6d46a5d50d243e47393101ce85d9c66c8a9797f1e5c3a","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":36,"eventDigest":"c2993c5892a8adc5477d6e9088b31daeda7bd18e149ca6fe58bdf5780a0803da","stateDigest":"0b742d23f5025723a148fbdc0da89e1ea42450e66dae5ed110239ee920f789b1","publicationDigest":"44351132084cbfa39558ed693a46eb77e1b7b8a3f14cf73b2767e453238e0b50","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":48,"eventDigest":"43944167fcac291b3b94f5264486687a53037551c213955af065b92e453181f4","stateDigest":"c30635a1bb2fa51c00b252425c386d7dd27b3da2436cc4ac7cd92607d338e893","publicationDigest":"24cddecdce4e4b08945eebedf399f1916262e1d27c92df2d9ac688faed91916c","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":51,"eventDigest":"ac122e1717479611ecf7d5b36733e953e516d227ef620b8d02a138420b764105","stateDigest":"6de793ba9332a6e05ba475ffafbf70ddc539ee5ea5444f8eae5d88880339bfc0","publicationDigest":"565e6e1db1a6e87d60bb6953508904024da712713f16bac3c770ded6837f62cf","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":66,"eventDigest":"9e98470f9313cc354eeb79da95fa3f172dcf05cc8a15ecdb5933868ef56d2e26","stateDigest":"f4e88bf7c63d0501428d19b08ac0536a06b4deb8a2502f1b7c7a23a618b11292","publicationDigest":"f4567ce4478c35d12ec3943bc2f4e0297f42ee437794d0ddd09c954c2aa55719","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":75,"eventDigest":"a66fa03e845d9754cc89a2dc09f4652fa508f929dc642867946fd86f5ff85b38","stateDigest":"0c720ac2d7c027a20e228dcd03f398485d1692deb2c7580b3e9caa6498656b8f","publicationDigest":"aea73d369f238856f4c1f0e79ddb28d33e57aa63f1e1c1199b3cf26596a2554e","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":90,"eventDigest":"67805faa1dfb0aa81dc246d654fcc9179f031eddb7410eca5161683e70703b15","stateDigest":"5426c63f05d01eb0245a6d7a63336195bdb328f907639a7a5e905ede9d706b10","publicationDigest":"0787c469d0c3beb81e02417b9249b2b1c88b2826e9de53229db939e47f257eea","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":97,"eventDigest":"a6753ecd0f62169b7554516c0b397800e79a2a00dd517b3dbf4ca1a46eef9c37","stateDigest":"2c4beaf6b8f3325b9c789e49bdd634391acf28c42feaf6a847651682d214f834","publicationDigest":"4952b41b9e4285b7150e29db298f14aaa805cd9a603c777d106d2da9edb0dcd0","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":98,"eventDigest":"4c7414e9d2f0a960fea5f29fb97075b97aecb01d985245f8792c74f4372b6c27","stateDigest":"ab9899802289e7fef134d0d7d2a46769c23e34dbc6de5b044fa1ce12402b965f","publicationDigest":"bd7d07d88b03039f0379dd4bf457cf4b71fd7597c03cd81779d79ce746105efb","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":98,"eventDigest":"4c7414e9d2f0a960fea5f29fb97075b97aecb01d985245f8792c74f4372b6c27","stateDigest":"ab9899802289e7fef134d0d7d2a46769c23e34dbc6de5b044fa1ce12402b965f","publicationDigest":"bd7d07d88b03039f0379dd4bf457cf4b71fd7597c03cd81779d79ce746105efb","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":113,"eventDigest":"198fbc9214bb49c20d698323d07b9aa3078d6c19e1fa70a87f59b6283ed6dc09","stateDigest":"6f16814cf5eeca3398c940fbfb728ab4abfa8bc68c53ea62989cefd45d550e32","publicationDigest":"51d491172b2d5fe722a1d3794a33fef76f7b964a43f3679b161b69ca4ab152b4","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":124,"eventDigest":"52c474de19ed261c8e24440a306375651c241e832e97b8cd8fe40f00785068e3","stateDigest":"c63083b415263ca60937423a54134a626bb346c5231d9d439c99ab9b1121afd8","publicationDigest":"917941a557e2f484c9b06289a0fb63d359abce2fb2737961d8e5ad9b2dd0e0bd","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":124,"eventDigest":"52c474de19ed261c8e24440a306375651c241e832e97b8cd8fe40f00785068e3","stateDigest":"c63083b415263ca60937423a54134a626bb346c5231d9d439c99ab9b1121afd8","publicationDigest":"917941a557e2f484c9b06289a0fb63d359abce2fb2737961d8e5ad9b2dd0e0bd","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_zombie","backend":"replace","quakec":true},"frameCount":192, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[],"mounted":[],"events":1,"eventDigest":"ad8183381e3da91d774878e54d94228d31d113f5f413d92b0ec7cd7814be0867","stateDigest":"a8438a286e960c97de55a27b79e457ac711a1ae1e81350e449d4959aedc0508d","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[5,0,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":8,"eventDigest":"60d2c64a47918a85ea41436d68702ee6d7996324619a4e437147d816b671776c","stateDigest":"497580fe9fce3fcbf757c0fc7b0b0735b38a4bb48a471ee8d80d517040a4769e","publicationDigest":"c8c24963b7801ec756dc095cf1c6b5b9625c7bf13a4f215ab01c4e41e91f7619","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[4.996666666666667,4.082155997157844e-19,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":33,"eventDigest":"f33612b6daddbdc7cd53a1eae8429ad6b339a4ca80d33262da98d5ef9c4e7923","stateDigest":"290670f9fcd3dff988b5427ff0d97ba900a43e17fda4d38e7464ebe69c57f533","publicationDigest":"d652f2ddd9afb50e4114a827e12f7b3f57cc916466331fd081649255bd3093d4","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[4.996666666666667,4.082155997157844e-19,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":33,"eventDigest":"f33612b6daddbdc7cd53a1eae8429ad6b339a4ca80d33262da98d5ef9c4e7923","stateDigest":"290670f9fcd3dff988b5427ff0d97ba900a43e17fda4d38e7464ebe69c57f533","publicationDigest":"d652f2ddd9afb50e4114a827e12f7b3f57cc916466331fd081649255bd3093d4","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[4.996666666666667,4.082155997157844e-19,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":49,"eventDigest":"925b20dc66f6e36edf2d92b48b69bb7ef4e3c749321ba92c1636d3339c5df851","stateDigest":"b96d5806595885e60ac36e6b65c50ade550200208ec5ce23619b4ca1151a6e0a","publicationDigest":"0c71c323b33577ef6b4b99b24676201e6f95d027cea3af2a9e442dbf1203b5bd","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[4.996666666666667,4.082155997157844e-19,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":49,"eventDigest":"925b20dc66f6e36edf2d92b48b69bb7ef4e3c749321ba92c1636d3339c5df851","stateDigest":"b96d5806595885e60ac36e6b65c50ade550200208ec5ce23619b4ca1151a6e0a","publicationDigest":"0c71c323b33577ef6b4b99b24676201e6f95d027cea3af2a9e442dbf1203b5bd","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[4.996666666666667,4.082155997157844e-19,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":65,"eventDigest":"e879c14711bb44da24e9a50beb655cf4a47a2d49a8489187bc6997f86fa32b48","stateDigest":"7eebb548bd16cd91f5f292e5f17f4c7097a7fdc1cc383afcdd5e928041c803b5","publicationDigest":"ab98ce70e63b76ed35a3f5e4308a6158814315352a1b262cf1e61a949dfa91a6","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[4.996666666666667,4.082155997157844e-19,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":83,"eventDigest":"5df99ff60599e05b4b912c24d15717f1a5512f089301a1dce77cb499cf07b89f","stateDigest":"aba3026e53eb60fa3dfdf317d586fa48af791d96ce9e7d78ade7573fa1b5ede2","publicationDigest":"0ee0075bf5f6e388fffddf0f2aa73c04ad205a75d49f0e3f2364caba6df38576","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[4.996666666666667,4.082155997157844e-19,0.48],"yaw":-180}]},"visible":[],"mounted":[],"events":97,"eventDigest":"498d1c66d52e21499d7c00349bfc8d876991b0da90085078c0980745e52cbf16","stateDigest":"782ebeb12ff5da527e97b183fecf510334c87c09b821dda102af8ad51fcedf6e","publicationDigest":"fe2cbcbbd88dc57365a0357d06507c69b03ec618ca42b09f83ff36d6f1eed249","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[4.970000000000001,3.673940397442059e-18,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":136,"eventDigest":"45a6b6ae75101952cdd1172930cb2785ee35e0a8b5aa21d2e6ba590c189a1fe5","stateDigest":"1ba3d82c374accdf7e4da5d5a6eda618ce17b9dc21782e20606344df216ba473","publicationDigest":"3f7c495d7644aeede41c23655925f9346896d58f722d4c78309cd0efac3943e8","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":145,"eventDigest":"dd7e76427acea965218215a3e6f3db1bb84c034746a1f83b2b776601678c7a60","stateDigest":"0c720ac2d7c027a20e228dcd03f398485d1692deb2c7580b3e9caa6498656b8f","publicationDigest":"606c52197cffcabab867fa0f0beac6891a8eac9f6c55e46bcc19f5c9fb66018b","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[4.956666666666668,5.306802796305197e-18,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":192,"eventDigest":"839267b233099f4c511d285c1370b01817019ddef5a6f32045035e9ec13e419a","stateDigest":"7e2325916426fd34e83e79b0f82a9490c585699caba23139c1bd9b6ee181ff26","publicationDigest":"0955ee9a7c876d2d584d23031203d8d1c9ef3571f93c43cbf9789883bb021246","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[4.956666666666668,5.306802796305197e-18,0.48],"yaw":-180}]},"visible":[1],"mounted":[1],"events":220,"eventDigest":"f6b14dfb7aca513d73f82f788bffbdffae1b626ccb718f7768f06475da641987","stateDigest":"b96a95c7432dc35c4a044025f8435ce7d1307183b6498e63415be9c0816c2929","publicationDigest":"cfcbf7e6bf70d3766772a24ec4846096533c4940f51cd28dd6addd3dfe94ab62","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":221,"eventDigest":"e9fde5c75d1af2f81a2e6e9c1b19778e89bd3b31175e3608692e6b711fd40ba0","stateDigest":"6005f12dce32f223974b44ba59fb77c45eb41d0415a3a9b3e9ebb2ed386f7e0f","publicationDigest":"1557cd244fc4db53574503836c5b2f3a977154085c3da136c91af25c06438f2c","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":221,"eventDigest":"e9fde5c75d1af2f81a2e6e9c1b19778e89bd3b31175e3608692e6b711fd40ba0","stateDigest":"6005f12dce32f223974b44ba59fb77c45eb41d0415a3a9b3e9ebb2ed386f7e0f","publicationDigest":"1557cd244fc4db53574503836c5b2f3a977154085c3da136c91af25c06438f2c","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":60,"origin":[4.970000000000001,3.673940397442059e-18,0.48],"yaw":180}]},"visible":[1],"mounted":[1],"events":263,"eventDigest":"5b16089a770e0c0d4a2728b7bd7c4f05fb575d7863ec44811bce9c88b17065ea","stateDigest":"fb9d6729449438c4987d77d7825895efbb760f05dcb39f80790713a454d243ae","publicationDigest":"0d24e1fa1cb269bd63f9f8eeb616e4081f27626140256f53d68b933d4d531e5a","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[],"progress":{"destroyedEntityIndexes":[1],"shootables":[]},"visible":[],"mounted":[],"events":271,"eventDigest":"db7c8e8d5c6589ee454a99b7164179a4a647f1b1e681114712f94ae5f64b9bc4","stateDigest":"c6573f63a1fe6fb335e42f6c84947965aa8fe089b607b19661228995f0c8fc23","publicationDigest":"4d7cff0256ca58af261fd74ab5ad0da6bf4d2f1de9d81fd91f2fdceca40367c5","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":271,"eventDigest":"db7c8e8d5c6589ee454a99b7164179a4a647f1b1e681114712f94ae5f64b9bc4","stateDigest":"c6573f63a1fe6fb335e42f6c84947965aa8fe089b607b19661228995f0c8fc23","publicationDigest":"4d7cff0256ca58af261fd74ab5ad0da6bf4d2f1de9d81fd91f2fdceca40367c5","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_boss","backend":"frameset","quakec":false},"frameCount":106, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[],"mounted":[],"events":0,"eventDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","stateDigest":"e57b5269ce8c3f97a41417d0275776851c092c7f7a825b799190d510d7c1fbbb","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":7,"eventDigest":"a86d9666d1e765e6459350bb0ce4eed94e3958fc72db76965f851a657e046d9a","stateDigest":"dfde38069077e31d5e88964a96e05cca8089f711f0cd8fc7a86ed5418679f71e","publicationDigest":"73d8f1b1bfe12892a6ebc029366721058085098759a4a85ce909a029ca7785cc","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":9,"eventDigest":"3a76d497edb945c98a9d11c6857e86191dba1ba21e16e3814902a4d48fd00e43","stateDigest":"47782ab659b036d1408c6222f87fc63d8afc1f12a7f167851112f7d3df03385d","publicationDigest":"c4476ff66dacf9267870b4e0c9cc1b8b09aef4b3562c3669e2012ed3f2fd79fa","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":9,"eventDigest":"3a76d497edb945c98a9d11c6857e86191dba1ba21e16e3814902a4d48fd00e43","stateDigest":"47782ab659b036d1408c6222f87fc63d8afc1f12a7f167851112f7d3df03385d","publicationDigest":"c4476ff66dacf9267870b4e0c9cc1b8b09aef4b3562c3669e2012ed3f2fd79fa","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":11,"eventDigest":"49cc0211813c2e8fed314a06b3c2004ca2182a1b58c8ed0a9f9d25195d95e17c","stateDigest":"1f7a9ce77f0f39aafd8957af5233c73eddee0c4e67bfaf94336e64d27532b60e","publicationDigest":"ea1281869c6a187626301ca5d55e710f904961eafa77dab57fbfe8f0a01e321a","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":11,"eventDigest":"49cc0211813c2e8fed314a06b3c2004ca2182a1b58c8ed0a9f9d25195d95e17c","stateDigest":"1f7a9ce77f0f39aafd8957af5233c73eddee0c4e67bfaf94336e64d27532b60e","publicationDigest":"ea1281869c6a187626301ca5d55e710f904961eafa77dab57fbfe8f0a01e321a","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":15,"eventDigest":"cb578d71554745141de73d4b7eaee65b264e1fdefbe143bc5280e8b7c25cfe2b","stateDigest":"40c1377d1f6895bfbf0c7e8db1566a96dafead6d88c802fb6871676dfbc9c6af","publicationDigest":"18bfcc71097422e3ffd735d60597dc4544d43b5658430309a9794885b9172ec2","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":18,"eventDigest":"1dedeeef45477ebd4fbcda7f57876123f6e69e9646a9f9ce046edf3db1fb8a4e","stateDigest":"67ff03081cb46b87b01fd9872930c4990d29e930df83d758df9ced14450cbda7","publicationDigest":"4652087ede012ffd5c1abc20a340d2f9fffbe69a3d2bc4b61755c5274a7d052c","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[],"mounted":[],"events":21,"eventDigest":"b0229e34a408d197569a8aceed708b5cc0c6af3e14423a3ee658825c5236c868","stateDigest":"a95ed244cf16350c83f21d1691f87e4fa3e89bd1f4f09ec6a365798598d61456","publicationDigest":"59decef030d7b025ffa1fa7f30d0d2c2dd61748e5d7b35fe6ec9920eb98dc693","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":31,"eventDigest":"25c36d20386c6591c36a6aa53b56ff3fa63321053e69e75f271b51578d70d12b","stateDigest":"cbfcb0064eeaedc220b0d14dfe9fa7dfe8bd830a2f3c0a9ea323a7dbdc249c50","publicationDigest":"bb9c55e6abeb1354bd29f7f1e7f0d4ab73438c25e8a54b16c953667903f3bc77","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":59,"eventDigest":"bba92d922dffaf8671a3317ac1285868652317af0d3d2694a40cff748fceffea","stateDigest":"d62f9dd380814e1696f0a5c05233496b23dcb4266634bca382ec710ad43631ad","publicationDigest":"55bad0deb148459a38c65e4fb1d42039c017e5172f2bf5dae375efd741abe5b9","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":69,"eventDigest":"e2c2c390d2691dc567a3585de706be0312b6cab6b7807284d85afc9f82b3afee","stateDigest":"9f55adf1509b8082616138f128e1aa77d63dbcf578fba0bf519d01025bf6ef2e","publicationDigest":"8528b5091383c65932bd6626a35313a1f138b002a0cf24b8d029e55708721393","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":71,"eventDigest":"b73c3abb410f057fa3a63ebd2c812fd86f96e876d68246b4f69ad3ffc59e6d92","stateDigest":"08097d61006b9acd397f2d0d43f6ffe358ad802cc36fe896f4d97ad07cebe657","publicationDigest":"717d5a7298382995a6920e2300e6ed20eb35abf0a8a0f7495c2fe2f426a59867","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":72,"eventDigest":"2cca510bf6497b733e4cfe17ea5962e209545b0ebbc84869c2ac5d99715cb4c3","stateDigest":"850ed02e7a54b824e47f4d7f1605edaf40cc882f0ed36ce1e6e802578f49e3b8","publicationDigest":"639f02b898e42fd9a46291f433def52421a88ced3ccd6d15b616d58af6a91e06","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":72,"eventDigest":"2cca510bf6497b733e4cfe17ea5962e209545b0ebbc84869c2ac5d99715cb4c3","stateDigest":"850ed02e7a54b824e47f4d7f1605edaf40cc882f0ed36ce1e6e802578f49e3b8","publicationDigest":"639f02b898e42fd9a46291f433def52421a88ced3ccd6d15b616d58af6a91e06","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":81,"eventDigest":"b3de9d400d041c676605530c3b3c8dc80495b3714840e2aa7a58893dd8be1e28","stateDigest":"4ac1c93e2bc197b5437c542d2c16cec994af7283a7386a5c33d7e0b69890884a","publicationDigest":"51879ed5b5582c405dd4b0bd9e2bb25a50808e506877ee5bc7ea64db09b66146","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":108,"eventDigest":"04a8195047ea816ce4acf1f0dc1e3421534de1fd5fc87b08641c5201828dfbff","stateDigest":"12d731a20829b72dd89d14934a56d86c0bc1d572150c53f7c651bfea01fb30b1","publicationDigest":"ac968e29a5f0a0f607bd6dc4696642bd3415f0d58711e6f5303793ec3930d735","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":109,"eventDigest":"81ea5f18889026a26a23143d6b390318324a64c4d3ddb040d6dbdccc019a5dbd","stateDigest":"cb0a17c292d1f1e836e451fb3c57072bf76ab72974398b76d6d8a8f5b4a451de","publicationDigest":"8c3014df092b1d6ac922c1425410c44a3d5d75652c02268ae80109dc440c1cae","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_boss","backend":"frameset","quakec":true},"frameCount":106, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[],"mounted":[],"events":0,"eventDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","stateDigest":"e57b5269ce8c3f97a41417d0275776851c092c7f7a825b799190d510d7c1fbbb","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":7,"eventDigest":"a86d9666d1e765e6459350bb0ce4eed94e3958fc72db76965f851a657e046d9a","stateDigest":"dfde38069077e31d5e88964a96e05cca8089f711f0cd8fc7a86ed5418679f71e","publicationDigest":"73d8f1b1bfe12892a6ebc029366721058085098759a4a85ce909a029ca7785cc","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":15,"eventDigest":"e6ee706ef2e21a5c9dc7612bc14b61e04b709e848b74291a4ab499d952955657","stateDigest":"52850931cf24fd1f8afb7ca5b7fecf746dc9ffb9346b4987e280dcb0e60d4614","publicationDigest":"d0b7fb828c149cef25b2f12806735389b4a2b76ff9fb385731a7b7cec1da0337","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":15,"eventDigest":"e6ee706ef2e21a5c9dc7612bc14b61e04b709e848b74291a4ab499d952955657","stateDigest":"52850931cf24fd1f8afb7ca5b7fecf746dc9ffb9346b4987e280dcb0e60d4614","publicationDigest":"d0b7fb828c149cef25b2f12806735389b4a2b76ff9fb385731a7b7cec1da0337","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":21,"eventDigest":"18f3bb2bd9bd494346fd4a4f49f586571b3daa27d7432aaf977b0564f053e49d","stateDigest":"a3c02222b0246fe447a9771716822bf45a5ac021d794207ac7ec5c2383eef459","publicationDigest":"bb3348732f93b7d7387c8c6591a5a3d49dd7fe7a610c24cb69402b1af82dd652","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":21,"eventDigest":"18f3bb2bd9bd494346fd4a4f49f586571b3daa27d7432aaf977b0564f053e49d","stateDigest":"a3c02222b0246fe447a9771716822bf45a5ac021d794207ac7ec5c2383eef459","publicationDigest":"bb3348732f93b7d7387c8c6591a5a3d49dd7fe7a610c24cb69402b1af82dd652","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":27,"eventDigest":"f8ee7cae49f2e040e81430101733832dfec5efad5f8183231827a36fc4c2f513","stateDigest":"6b7c8057181bf9f5f52cb2e81fd6f8c5cbed8ad1645f8f32e637367941867b0d","publicationDigest":"3e1598506f7d761dca86d55bd53956507467f3fbb56882dc3b7facab3607e79f","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":34,"eventDigest":"bbc8759be0bd29cdd1656692e64e7538e8a6a7fcf9e5a348bbfd10b61b4f4685","stateDigest":"6660ba2abdad8af553c4850370a99969462c93e522b5e259e93ea0304c3ea4c7","publicationDigest":"f09835c4d6453bfed6d047a67e09f5fdedd1735ffed9272ca864863cb090a0eb","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[],"mounted":[],"events":38,"eventDigest":"757f3367f419ab5aa58d7f40e5ff328b257f50dc7900fb761febd7b8c1326db3","stateDigest":"956398fcf62bce5ccd68e8ffef9ef640d4703356eda2fc4db2216ecf6434c839","publicationDigest":"55e11426c4447f33699a3f1c72c5b09509f3ae551a782eb4c5ee9acbd76fed50","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":56,"eventDigest":"43e0744463bae33e089a29a74b818f9382ba52a2aae18949a674ee85b90f049a","stateDigest":"9fadc3641b49b594b844788acf70a9a3ca6b367422419ff60003b2aea60e7f6c","publicationDigest":"59311e461fa451070dc806e937f7c3cfd66661849e1d445bb134b6f5eb2d057d","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":152,"eventDigest":"971521514c3eb37ddb70ddbd966db87dc30c70828005a360756d1d5a1ef1250d","stateDigest":"189de34027856de6b30c5809e227dc5d16449f76f8f38bc6f0235ae8b7a9be8f","publicationDigest":"3df6511442272aac4c9429df0b594432456d034b18fdb8e68c185ab55f1d09e7","liveHandles":2,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":173,"eventDigest":"601b9e02d6b190dd21e091cf7ecf0a36c3a42c0e66efd9ee890d5be800ebde9a","stateDigest":"54215d4b5e3c8993640717e0c6dacb8b3facd68f49fae80ebf1bc44210e47842","publicationDigest":"dea2ccfce41defbf1af005f61f6b95c650058c9b69881c9fbccf9068350be6bf","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":183,"eventDigest":"c1a2b00669485828d1424fbb9fd044911278d0907c5f811f085c6e0f412dbe11","stateDigest":"54215d4b5e3c8993640717e0c6dacb8b3facd68f49fae80ebf1bc44210e47842","publicationDigest":"dea2ccfce41defbf1af005f61f6b95c650058c9b69881c9fbccf9068350be6bf","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":184,"eventDigest":"e9ee5a01105d74723e1cd04d3fab587089b1db1e90af7a22247b1db570860b90","stateDigest":"850ed02e7a54b824e47f4d7f1605edaf40cc882f0ed36ce1e6e802578f49e3b8","publicationDigest":"7422eca5b577ee02ed06279dfde990e540b90b6bac415d5be0abfcdbb111fe27","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":184,"eventDigest":"e9ee5a01105d74723e1cd04d3fab587089b1db1e90af7a22247b1db570860b90","stateDigest":"850ed02e7a54b824e47f4d7f1605edaf40cc882f0ed36ce1e6e802578f49e3b8","publicationDigest":"7422eca5b577ee02ed06279dfde990e540b90b6bac415d5be0abfcdbb111fe27","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":203,"eventDigest":"35a85785d8d637a2d567989009b624c5e727db0e03bccb44c2fd0592bcf840c9","stateDigest":"6ede74260e7cf8cb1b93465e58a70b2fc10e5932675e248ab4b009cdab728277","publicationDigest":"1282f8370a07d8c49b1ebde4a76ba1dccdf2e7460acda66c742cbf9749b175f3","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":296,"eventDigest":"e8ddb0afcacca606d15509e614900e0f1a2718e31892a32120daba650a912b2f","stateDigest":"832995b1089dfad2e3a898e9386ff9a8777fd0d0498ad6e6b08424497a06500e","publicationDigest":"aa261c182719add5ba044c95a3cdb378b3c386763d5f0cf5d1caa85570a2b331","liveHandles":2,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":298,"eventDigest":"3d9934154065fff3130bef968453212c80fbd88c361dc9b151b5fde8ccf7508b","stateDigest":"cb0a17c292d1f1e836e451fb3c57072bf76ab72974398b76d6d8a8f5b4a451de","publicationDigest":"1c3493006881a80ff8423156abd16ec6c1b0f0435e9ca60b3a8ecd6035bfbc11","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_boss","backend":"replace","quakec":false},"frameCount":106, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[],"mounted":[],"events":0,"eventDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","stateDigest":"59e310914023e74d5875bf0437966b781e53b0f963d73b7a83b78198f491033a","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":7,"eventDigest":"5e919893aeaff2e1d3cdc0400271c4b108fd2f6d349796ad6d877fac8dd5efc5","stateDigest":"f54fe23a6517b4a23d1333007508aa30e7b9ffe1f572b5f2bbffe5e3852f4acb","publicationDigest":"73d8f1b1bfe12892a6ebc029366721058085098759a4a85ce909a029ca7785cc","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":14,"eventDigest":"4247243045ffe8e493375b0a54c40c98d4cc4d39a350eadbdba476e64c209461","stateDigest":"2e5e91ede817eac6ba1d641b400a1d5c14263bf30bdc965d7e53a69e498f8a9b","publicationDigest":"0eae3cdf29ed585676c72c95ec3636066db5c1a0fa34bff97383463c35fdc74b","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":14,"eventDigest":"4247243045ffe8e493375b0a54c40c98d4cc4d39a350eadbdba476e64c209461","stateDigest":"2e5e91ede817eac6ba1d641b400a1d5c14263bf30bdc965d7e53a69e498f8a9b","publicationDigest":"0eae3cdf29ed585676c72c95ec3636066db5c1a0fa34bff97383463c35fdc74b","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":21,"eventDigest":"77c98be64f13f1fc6e7fa8c18d1a1345ddebdf076f4bd6694f819679c9147f81","stateDigest":"12e2d5ec2c204060bc83591d26e5822be5491b1cfc29a05860be6a25c4cce8b5","publicationDigest":"1a2690fd1bf426e2338c8ecb261173912552fd04898a5a6c9f9f0372f489cd33","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":21,"eventDigest":"77c98be64f13f1fc6e7fa8c18d1a1345ddebdf076f4bd6694f819679c9147f81","stateDigest":"12e2d5ec2c204060bc83591d26e5822be5491b1cfc29a05860be6a25c4cce8b5","publicationDigest":"1a2690fd1bf426e2338c8ecb261173912552fd04898a5a6c9f9f0372f489cd33","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":35,"eventDigest":"405fc20300f58fe26285111f321b619441d0065a31742b69a50297b6f3ff534c","stateDigest":"632f1e0de4713faef1da63426cb51d4fb3d791be9835055f4f8b197dceaa5158","publicationDigest":"f795679e81066c102cb51d237c70725904d96edc8fcd19c2bdbfeb78faf0efee","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":43,"eventDigest":"9648b599ed78d2c526497880290d8f73e20248daf9cffd68407ae4555945cedf","stateDigest":"fec266bb54490910ee291ac19f151e9568c3178aa2719333f736961ab6136d88","publicationDigest":"15424d4d64daa962f3da6cb08365ddcbdc0d21d2ed97ed97041952ca6b4e26bd","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[],"mounted":[],"events":46,"eventDigest":"34e65737ba31cab8a3fbe0c3bb40363f6663dddf3b858d1a4cdb4f8739ed2501","stateDigest":"4f2349e0988c71d7cccd9846bf55363fc06d15619e3989b9943d18ff4be63166","publicationDigest":"4e7b3128c28bc030e2ebbd66c0ecf560a8a3ed936cd9f69ec69f4f8ee9a07938","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":61,"eventDigest":"bfd96c2b1a1b3ee05c6dbee1208dba79936d5075fd36cf60309cfc794ef16d5b","stateDigest":"55ee7f8c1d24348d6fdb394bf88a47e5f93e593f85027359e241423e4ac916f4","publicationDigest":"d1cce27ae2699d9223b2949cc5c279f48115746a7b371985f1ae294118b1fd44","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":154,"eventDigest":"b7f3eb2d78723ba10bfb0f24d7ad5696fd8728ed72d6b3dbaf635f1a301348b7","stateDigest":"504a34032ea455f57ca4836d194a0c53db127175042e20f618258d71a74750c1","publicationDigest":"e176ae0cd7f7eae108dab6cddca684b12276cd7ce50afe5a4ed693d8c0e73ab4","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":169,"eventDigest":"6b75a552ad0c67da16055e1bb59fe0bc45d93d1c52b7cac84b20408766bcf3c4","stateDigest":"05b98c347071b4094b223d90057034997168a0c04efc653ba9e9f0bdbe4a84c4","publicationDigest":"8585c866cea9ca60112248531d29b1baaf9e331ac9121c5134c90064ed703fed","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":176,"eventDigest":"a5c02e3f65bc56753cb6f3930099f47d7393534cb7e41f51b72a6d2b3b04c252","stateDigest":"3a02ef06cea3e2f1a854ca3a0bab2523793f6633394a0186f2fd9031d4ff0334","publicationDigest":"81f727eb109460bae7073449f7861ffac89976b97444e66dd2fc3ab1754bf00c","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":177,"eventDigest":"d51915a6e92c9de77b6f1035293cb95c975a0bb2adad441714a6e4175eb5162b","stateDigest":"ab9899802289e7fef134d0d7d2a46769c23e34dbc6de5b044fa1ce12402b965f","publicationDigest":"f3d5682d2d8f6825fea9b6cf04fd2c21161d424f2c6d77cac3f91c3e5ba74997","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":177,"eventDigest":"d51915a6e92c9de77b6f1035293cb95c975a0bb2adad441714a6e4175eb5162b","stateDigest":"ab9899802289e7fef134d0d7d2a46769c23e34dbc6de5b044fa1ce12402b965f","publicationDigest":"f3d5682d2d8f6825fea9b6cf04fd2c21161d424f2c6d77cac3f91c3e5ba74997","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":191,"eventDigest":"6516b81b0d52635e00f440477ca5ed79a1408225633620db256666288196514c","stateDigest":"69e5786793e4ba46ea727bd341c1f4dfaa885465919e627528bb013f8ce543e9","publicationDigest":"0172fa4dbdd3f01599645f0f5bc284f640a8055d5cb29cefd896358ad4f29f0f","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":283,"eventDigest":"0b8ae995eb7a6dd728b547da6d997b49daf32ff0261524f1d3ad8497538f0513","stateDigest":"ce27b88eccf8a5b124882b99e89fc5817060a274403ff438c9737f8e15d5002c","publicationDigest":"328893cb68a692652890bcba2d8eaf70906baa9ca374e2fd089ad1ad2b0e9cee","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":284,"eventDigest":"46b9e44d0cfa02f083d5575b6c05c31697fce5157082866bd1d026652bc399bc","stateDigest":"c63083b415263ca60937423a54134a626bb346c5231d9d439c99ab9b1121afd8","publicationDigest":"62932dc003348002a20fced58dae6b1eaaddd43532cea0088c6bc8a459b0e8ae","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]}, + {"scenario":{"classname":"monster_boss","backend":"replace","quakec":true},"frameCount":106, + "checkpoints": [ + {"label":"spawn-unmounted","now":1000,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[],"mounted":[],"events":0,"eventDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","stateDigest":"59e310914023e74d5875bf0437966b781e53b0f963d73b7a83b78198f491033a","publicationDigest":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","liveHandles":0,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"mounted","now":1000,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":7,"eventDigest":"5e919893aeaff2e1d3cdc0400271c4b108fd2f6d349796ad6d877fac8dd5efc5","stateDigest":"f54fe23a6517b4a23d1333007508aa30e7b9ffe1f572b5f2bbffe5e3852f4acb","publicationDigest":"73d8f1b1bfe12892a6ebc029366721058085098759a4a85ce909a029ca7785cc","liveHandles":1,"maxRemovals":0,"raf":1,"timers":0}, + {"label":"attack-start","now":1150,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":20,"eventDigest":"2ac9b2292b1b88b87af4116cc9bb55f02b01ea5055a5d6fb93058652bc1cde36","stateDigest":"88c208b4722777288babcf2708a0b687a58f8f5b2823f2209d337f0121a09075","publicationDigest":"5ca78ccfb39ecb8f4ae1efc22d4920e0fe7cd311bc343d71fe525580b0d9bd7f","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"paused","now":1650,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":20,"eventDigest":"2ac9b2292b1b88b87af4116cc9bb55f02b01ea5055a5d6fb93058652bc1cde36","stateDigest":"88c208b4722777288babcf2708a0b687a58f8f5b2823f2209d337f0121a09075","publicationDigest":"5ca78ccfb39ecb8f4ae1efc22d4920e0fe7cd311bc343d71fe525580b0d9bd7f","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"resumed","now":1900,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":36,"eventDigest":"3d33baf284250584d3513621d66eaab33a80a8be66680be2beb7c31552d183e8","stateDigest":"f9d5d2c577b93312a58edd1fd24670f522d75eee3f2a14c39cd6e102240226db","publicationDigest":"4a470a4935692a83b8e42a55759daa5add627eb2e271fc8e4f5dbe105d928101","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"frozen","now":2200,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":36,"eventDigest":"3d33baf284250584d3513621d66eaab33a80a8be66680be2beb7c31552d183e8","stateDigest":"f9d5d2c577b93312a58edd1fd24670f522d75eee3f2a14c39cd6e102240226db","publicationDigest":"4a470a4935692a83b8e42a55759daa5add627eb2e271fc8e4f5dbe105d928101","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"unfrozen","now":2400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":52,"eventDigest":"56637edc052f3a5218e2b7f640eb6040828a5c1b85811531fd62df7ef2b4b783","stateDigest":"6660dc94e96e825a31c1dae95244deb9f15bb165775f02ff503f9ceee4f30b3f","publicationDigest":"9f6148ef906d69418a958ca1415d2a9928e5818b8f78dfab0eaa46d20ec00dda","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"pain","now":2550,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":69,"eventDigest":"daabe8e470d8a81f5844a755d3d05e836898a75990435634e42438273603a297","stateDigest":"a87c44200b8fe096067fd045f2a8b591ab24f43b22d16610d160feb85044c10b","publicationDigest":"43c3427fb019a1ae1f85d3490d8432e7bb6ef1364ffcc02ae91fbbabe8df7bcb","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-unmounted","now":3250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[],"mounted":[],"events":73,"eventDigest":"dd6ec4f75cd2d3a0b701e29343f7bfa0bb140f6850e6e8179d499fb94c2f611d","stateDigest":"f90f687cb0156150415de8b8093b90d6248d7a75a0e6d08716bfcc35668fc76c","publicationDigest":"97b7d121e64ed44e648e0d4b11ef986e499ce530bfaf554f28dd19bf0daab93d","liveHandles":0,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"remounted","now":3400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":101,"eventDigest":"525252457015f3f20a3f2ee781a47700a51541b0be16035d570bff6dcd1c3995","stateDigest":"4d2c8697267dcfe2846cb4653fb8658dcd99d3bf58ba14fdfef5791572270b06","publicationDigest":"4a4d8706611b3ce6a8c2bfb281f8d35012d392d9113af5e2e07e2e1503b877a9","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"lethal-damage","now":5400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":297,"eventDigest":"3e3763d5c706fe913546faacaac63ffaf82ebe4355de9645c41494f39a9cd759","stateDigest":"fc514665815ac276dd55c50f016fc1aa20eb3c8f53ae51e0511c560f69d3a3d2","publicationDigest":"cf7d8c04306fac3798eb5f6371d44abf03ddfa8d22004212851e1733776400f8","liveHandles":2,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"restored","now":5600,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":328,"eventDigest":"f2fabda35f74331d972feb30b69ea73be5e358bce893cc0c99ba058a1f7be392","stateDigest":"0559a76eb9d196bd6223c4a833a3a3f7bfa16b7f423b0059d50c14fe029869f6","publicationDigest":"442012d99b6ba465aae2fab1ad89feb2b528c0422ff8c42aa24460b181295b3e","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"attack-before-clear","now":5750,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":348,"eventDigest":"72f50d8447f3a2c6a970008406c1ffabca290fe5a4e20205d0d9d577aad70a5b","stateDigest":"0559a76eb9d196bd6223c4a833a3a3f7bfa16b7f423b0059d50c14fe029869f6","publicationDigest":"59da273a5a2284f963bafe993e31e59731a0d996be3be25d892ab17e87d9af15","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"cleared-with-attack","now":5750,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":349,"eventDigest":"a56c0ffa511179764638e2f178c86babb7105bdf39817bf09a95f550f43c0990","stateDigest":"ab9899802289e7fef134d0d7d2a46769c23e34dbc6de5b044fa1ce12402b965f","publicationDigest":"48f3f7432ed68699a2af9a8c7df15b58b7428f9e0cc3a59a61c783756d408609","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"after-clear-callbacks","now":7250,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":349,"eventDigest":"a56c0ffa511179764638e2f178c86babb7105bdf39817bf09a95f550f43c0990","stateDigest":"ab9899802289e7fef134d0d7d2a46769c23e34dbc6de5b044fa1ce12402b965f","publicationDigest":"48f3f7432ed68699a2af9a8c7df15b58b7428f9e0cc3a59a61c783756d408609","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0}, + {"label":"respawn-reused-index","now":7400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":378,"eventDigest":"6b49aedd7c44dd8357ef64fb0a5b6eca0dfbed354d774a5146a4f0f1d9203afc","stateDigest":"53a2eb8406281b81bee6209a4c4bd6ef271b67aa6b4c9b2b26420f0132716169","publicationDigest":"cedad2084d2b5b81a764e5bbb2ee2cb610250fbde81603bfacfe38ae12219efa","liveHandles":1,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"gib-damage","now":9400,"targets":[1],"progress":{"destroyedEntityIndexes":[],"shootables":[{"dead":false,"entityIndex":1,"health":500,"origin":[5,0,1],"yaw":180}]},"visible":[1],"mounted":[1],"events":571,"eventDigest":"4028790bcea5980050d45aa31001d0e30388ef184b0da7bf55d6814aaf28863b","stateDigest":"811b1f3f759217b02403ec833353e44a218d3078bfc1d37d6386c89f6ca684c1","publicationDigest":"109fb402fec3c6cafd9d8d175d66f7e5766357e6c7c0bc677437e494901607c0","liveHandles":2,"maxRemovals":1,"raf":1,"timers":0}, + {"label":"final-clear","now":10900,"targets":[],"progress":{"destroyedEntityIndexes":[],"shootables":[]},"visible":[],"mounted":[],"events":573,"eventDigest":"3f4b7bfc6bdb57d35418fbd2eb78b06e34682d7c69134e182d535b351dfa413b","stateDigest":"c63083b415263ca60937423a54134a626bb346c5231d9d439c99ab9b1121afd8","publicationDigest":"8a257e83a59b9559eb8c2fab08ae042d144a45d804b5c9054fc6b0c4487df90d","liveHandles":0,"maxRemovals":1,"raf":0,"timers":0} + ]} + ] +} diff --git a/test/runtime/loadingReadinessOwnership.test.mjs b/test/runtime/loadingReadinessOwnership.test.mjs new file mode 100644 index 0000000..a44568e --- /dev/null +++ b/test/runtime/loadingReadinessOwnership.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Window } from "happy-dom"; +import { importTsModule } from "../importTsModule.mjs"; + +const window = new Window({ url: "http://localhost/" }); +globalThis.window = window; +globalThis.document = window.document; +test.after(async () => { await window.happyDOM.abort(); }); +const { createQuakeLoadingFlow } = await importTsModule("src/runtime/app/loadingFlow.ts", { + define: { __POLYCSS_VERSION__: '"0.2.6"' }, +}); +const { createQuakeViewmodelAssetFlow } = await importTsModule("src/runtime/app/viewmodelAssetFlow.ts"); +const noop = () => {}; +const deferred = () => { + let resolve; + const promise = new Promise(r => { resolve = r; }); + return { promise, resolve }; +}; +function flow(onLoadingChange) { + return createQuakeLoadingFlow({ + dom: {}, initialLoading: true, previewEnabled: false, currentMapName: () => "e1m1", + hasCurrentResult: () => true, isDisposed: () => false, isGameplayStarted: () => false, + isLevelTransitionActive: () => false, isMainMenuOpen: () => false, isMenuPanelOpen: () => false, + clearAttackInput: noop, clearBonusOverlay: noop, clearCrosshairTarget: noop, clearCrouchInput: noop, + clearDebugFlyInput: noop, clearMobileMoveInput: noop, clearMoveInput: noop, clearWeaponViewPunch: noop, + hideStatsOverlay: noop, onLoadingChange, renderBitmapText: noop, setControlsLoading: noop, + syncCrosshairTarget: noop, syncDebugFlyMode: noop, syncStatsOverlayAvailability: noop, trace: noop, + }); +} + +test("a pending old weapon cannot mount or release loading after its scene loses ownership", async () => { + const weapon = deferred(); + let current = true, mounts = 0; + const changes = []; + const models = createQuakeViewmodelAssetFlow({ + activeWeapon: () => null, isDisposed: () => false, + viewmodel: { mount: () => mounts++ }, + }); + const loading = flow(value => changes.push(value)); + const readiness = loading.completeSceneReadiness(weapon.promise, models.mount, undefined, () => current); + current = false; + weapon.resolve({ source: "progs/v_shot.mdl" }); + await readiness; + assert.equal(mounts, 0); + assert.deepEqual(changes, []); + assert.equal(loading.isLoading(), true); +}); + +test("losing ownership during presented-frame readiness keeps the new overlay active", async () => { + let current = true; + const changes = []; + const loading = flow(value => changes.push(value)); + const originalRaf = window.requestAnimationFrame; + window.requestAnimationFrame = callback => setImmediate(() => { current = false; callback(performance.now()); }); + try { + await loading.completeSceneReadiness(Promise.resolve({}), async () => {}, undefined, () => current); + assert.equal(loading.isLoading(), true); + assert.deepEqual(changes, []); + } finally { window.requestAnimationFrame = originalRaf; } +}); + +test("startup reads the latest route after delayed shared metadata", async () => { + const metadata = deferred(); + const calls = []; + let map = "e1m1", ready = false; + const loading = flow(noop); + const startup = loading.loadStartup({ + fetchManifest: async () => ({ maps: [] }), setAssetManifest: noop, + loadProgramMetadata: () => metadata.promise, loadPickupModels: async () => {}, preloadWeapon: async () => ({}), + onReady: () => { ready = true; }, routeFromLocation: () => ({ mapName: map }), + routeIsDirect: () => true, routeShouldNormalize: () => false, sceneUrl: () => "/q/map.json", + setCurrentMapName: noop, setMenuCurrentLevel: noop, syncRoutePresentation: noop, + loadMap: async name => { assert.equal(ready, true); calls.push(name); return { isCurrent: () => true }; }, + }); + map = "e1m2"; + metadata.resolve(); + await startup; + assert.deepEqual(calls, ["e1m2"]); +}); diff --git a/test/runtime/mapLoadModules.ts b/test/runtime/mapLoadModules.ts new file mode 100644 index 0000000..8fc7125 --- /dev/null +++ b/test/runtime/mapLoadModules.ts @@ -0,0 +1,5 @@ +// Bundle these modules together so integration tests share the product error type. +export { createQuakeAppMapLoader } from "../../src/runtime/app/session"; +export { createQuakeMenuController } from "../../src/runtime/menu"; +export { createCssQuakeSaveSession } from "../../src/runtime/app/saveSession"; +export { createQuakePlayerLifecycleFlow } from "../../src/runtime/app/playerLifecycleFlow"; diff --git a/test/runtime/mapLoadOwnership.test.mjs b/test/runtime/mapLoadOwnership.test.mjs new file mode 100644 index 0000000..bc652d8 --- /dev/null +++ b/test/runtime/mapLoadOwnership.test.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { importTsModule } from "../importTsModule.mjs"; + +const define = { "import.meta.env": '{"DEV":false}' , __POLYCSS_VERSION__: '"0.2.6"', __CSSQUAKE_VERSION__: '"test"' }; +const { createQuakeAppMapLoader } = await importTsModule("src/runtime/app/session.ts", { define }); +const deferred = () => { + let resolve, reject; + const promise = new Promise((a, b) => { resolve = a; reject = b; }); + return { promise, resolve, reject }; +}; +function harness(overrides = {}) { + const pending = new Map(); + const events = []; + const options = { + completeSceneReadiness: async (_weapon, _progress, isCurrent) => { if (isCurrent?.() ?? true) events.push("ready"); }, + createProgressTracker: () => ({ setStatus() {}, startTask: () => () => events.push("progress") }), + fetchScene: (_url, name) => { const request = deferred(); pending.set(name, request); return request.promise; }, + isDisposed: () => false, mapLoadView: () => null, + prepareScene: scene => () => events.push(`mount:${scene.name}`), onCurrentMapChange: name => events.push(`map:${name}`), + preloadMapAssets: async () => {}, preloadSceneAssets: async () => {}, preloadWeapon: async () => ({}), + resumeGameplayAfterMapLoad: () => events.push("resume"), sceneUrl: name => `/q/${name}.json`, + setGameplayStarted: () => events.push("gameplay"), setLoading: value => events.push(`loading:${value}`), + syncUrlView() {}, updateUrl: name => events.push(`url:${name}`), ...overrides, + }; + return { loader: createQuakeAppMapLoader(options), pending, events }; +} + +test("only the latest map load may mount, publish its route, or resume gameplay", async () => { + const { loader, pending, events } = harness(); + const first = loader.loadMap("e1m1", { resumeGameplay: true }); + const second = loader.loadMap("e1m2", { resumeGameplay: true }); + pending.get("e1m2").resolve({ name: "e1m2" }); + assert.equal((await second).isCurrent(), true); + const afterSecond = [...events]; + pending.get("e1m1").resolve({ name: "e1m1" }); + assert.equal(await first, false); + assert.deepEqual(events, afterSecond); + assert.ok(events.includes("mount:e1m2")); + assert.ok(events.includes("resume")); +}); + +test("a failed superseded request cannot release the new loading screen", async () => { + const { loader, pending, events } = harness(); + const first = loader.loadMap("e1m1"); + const second = loader.loadMap("e1m2"); + pending.get("e1m1").reject(new Error("old request failed")); + assert.equal(await first, false); + assert.equal(events.includes("loading:false"), false); + pending.get("e1m2").resolve({ name: "e1m2" }); + await second; +}); + +test("readiness and progress lose ownership when another map starts", async () => { + const ready = deferred(); + const entered = deferred(); + let isFirstCurrent, completeFirstTask; + const { loader, pending, events } = harness({ + completeSceneReadiness: async (_weapon, progress, isCurrent) => { + if (isFirstCurrent) return; + isFirstCurrent = isCurrent; + completeFirstTask = progress.startTask("first"); + entered.resolve(); + await ready.promise; + }, + }); + const first = loader.loadMap("e1m1"); + pending.get("e1m1").resolve({ name: "e1m1" }); + await entered.promise; + const second = loader.loadMap("e1m2"); + assert.equal(isFirstCurrent(), false); + completeFirstTask(); + assert.equal(events.includes("progress"), false); + ready.resolve(); + assert.equal(await first, false); + assert.equal(events.includes("gameplay"), false); + pending.get("e1m2").resolve({ name: "e1m2" }); + await second; +}); + +test("weapon rejection is observed while the scene request is still pending", async () => { + let lateProgress; + const { loader, pending, events } = harness({ preloadWeapon: async progress => { lateProgress = progress.startTask("Weapon"); throw new Error("weapon unavailable"); } }); + const load = loader.loadMap("e1m1"); + await assert.rejects(load, /weapon unavailable/); + assert.ok(events.includes("loading:false")); + lateProgress(); + assert.equal(events.includes("progress"), false); + pending.get("e1m1").resolve({ name: "e1m1" }); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(events.some(event => event.startsWith("mount:")), false); +}); + +test("disposal during preparation prevents any scene publication", async () => { + let disposed = false; + const { loader, pending, events } = harness({ isDisposed: () => disposed }); + const load = loader.loadMap("e1m1"); + disposed = true; + pending.get("e1m1").resolve({ name: "e1m1" }); + assert.equal(await load, false); + assert.deepEqual(events, ["loading:true"]); +}); + +test("a completion loses ownership when the same map reloads, including after it has resolved", async () => { + const { loader, pending } = harness(); + assert.equal(loader.currentLoad(), false); + const first = loader.loadMap("e1m1"); + pending.get("e1m1").resolve({ name: "e1m1" }); + const completion = await first; + assert.equal(loader.currentLoad(), completion); + const second = loader.loadMap("e1m1"); + assert.equal(completion.isCurrent(), false); + assert.equal(loader.currentLoad(), false); + pending.get("e1m1").resolve({ name: "e1m1" }); + assert.equal((await second).isCurrent(), true); + assert.equal(completion.isCurrent(), false); +}); + +test("disposal revokes a successfully completed load", async () => { + let disposed = false; + const { loader, pending } = harness({ isDisposed: () => disposed }); + const load = loader.loadMap("e1m1"); + pending.get("e1m1").resolve({ name: "e1m1" }); + const completion = await load; + disposed = true; + assert.equal(completion.isCurrent(), false); + assert.equal(loader.currentLoad(), false); +}); diff --git a/test/runtime/menuLoadOwnership.test.mjs b/test/runtime/menuLoadOwnership.test.mjs new file mode 100644 index 0000000..a03cbd8 --- /dev/null +++ b/test/runtime/menuLoadOwnership.test.mjs @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Window } from "happy-dom"; +import { importTsModule } from "../importTsModule.mjs"; + +const { createQuakeMenuController, createQuakeAppMapLoader } = await importTsModule("test/runtime/mapLoadModules.ts", { + define: { "import.meta.env": '{"DEV":false}', __POLYCSS_VERSION__: '"0.2.6"' }, +}); +const window = new Window(); +for (const key of ["window", "document", "Element", "Node", "HTMLElement", "HTMLButtonElement"]) { + globalThis[key] = key === "window" ? window : key === "document" ? window.document : window[key]; +} +test.after(async () => { await window.happyDOM.abort(); }); + +test("a load superseded between resolution and the menu continuation cannot lock controls", async () => { + let loading = false, locksWhileLoading = 0, nextLoad; + const pending = new Map(); + let currentMap = "e1m1"; + const loader = createQuakeAppMapLoader({ + createProgressTracker: () => ({ setStatus() {}, startTask: () => () => {} }), + fetchScene: (_url, name) => new Promise(resolve => pending.set(name, resolve)), + preloadWeapon: async () => ({}), preloadSceneAssets: async () => {}, preloadMapAssets: async () => {}, + completeSceneReadiness: async () => { loading = false; }, + isDisposed: () => false, mapLoadView: () => null, prepareScene: () => () => {}, + onCurrentMapChange: name => { currentMap = name; }, + resumeGameplayAfterMapLoad() {}, sceneUrl: name => `/q/${name}.json`, + setLoading: value => { loading = value; }, syncUrlView() {}, updateUrl() {}, + setGameplayStarted: () => { + if (currentMap === "e1m1") queueMicrotask(() => { nextLoad = loader.loadMap("e1m2"); }); + }, + }); + document.body.innerHTML = '
'; + const menu = createQuakeMenuController({ + enabled: true, host: document.querySelector("#host"), mainMenu: document.querySelector("#menu"), + levelPanel: document.querySelector("#level"), + controls: { update() {}, lock: () => { if (loading) locksWhileLoading++; }, addEventListener() {}, removeEventListener() {} }, + // Match the async App wrapper between the menu and the loader. + onSelectLevel: async name => loader.loadMap(name), clearCrosshairTarget() {}, syncCrosshairTarget() {}, + }); + try { + document.querySelector("button").click(); + pending.get("e1m1")({}); + await new Promise(resolve => setImmediate(resolve)); + assert.ok(nextLoad, "the newer load must be active before checking the old menu continuation"); + assert.equal(locksWhileLoading, 0); + } finally { + pending.get("e1m2")?.({}); + await nextLoad; + menu.dispose(); + } +}); + +for (const action of ["new-game", "load", "level"]) { + for (const completed of [false, true]) { + test(`${action} ${completed ? "completion" : "supersession"} ${completed ? "locks" : "does not steal"} controls`, async () => { + let finish, locks = 0, calls = 0; + const load = () => { calls++; return new Promise(resolve => { finish = resolve; }); }; + document.body.innerHTML = `
+
+
`; + const menu = createQuakeMenuController({ + enabled: true, host: document.querySelector("#host"), mainMenu: document.querySelector("#menu"), + singlePlayerPanel: document.querySelector("#single"), levelPanel: document.querySelector("#level"), + controls: { update() {}, lock: () => locks++, addEventListener() {}, removeEventListener() {} }, + onSelectNewGame: load, onLoadGame: load, onSelectLevel: load, canLoadGame: () => true, + clearCrosshairTarget() {}, syncCrosshairTarget() {}, + }); + try { + document.querySelector(action === "level" ? "#level button" : "#single button").click(); + assert.equal(calls, 1); + finish(completed ? { isCurrent: () => true } : false); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(locks, completed ? 1 : 0); + } finally { menu.dispose(); } + }); + } +} + +for (const action of ["new-game", "load", "level"]) { + for (const supersede of [false, true]) { + test(`${action}: a settled failure ${supersede ? "cannot reopen a menu over a newer load" : "still opens its recovery menu"}`, async () => { + let nextLoad; + const pending = new Map(); + const loader = createQuakeAppMapLoader({ + createProgressTracker: () => ({ setStatus() {}, startTask: () => () => {} }), + fetchScene: (_url, name) => new Promise((resolve, reject) => pending.set(name, { resolve, reject })), + preloadWeapon: async () => ({}), preloadSceneAssets: async () => {}, preloadMapAssets: async () => {}, + completeSceneReadiness: async () => {}, isDisposed: () => false, mapLoadView: () => null, + prepareScene: () => () => {}, onCurrentMapChange() {}, resumeGameplayAfterMapLoad() {}, + sceneUrl: name => `/q/${name}.json`, syncUrlView() {}, updateUrl() {}, setGameplayStarted() {}, + setLoading: active => { + if (!active && supersede) queueMicrotask(() => { nextLoad = loader.loadMap("e1m2"); }); + }, + }); + document.body.innerHTML = `
+
+
`; + const load = async () => loader.loadMap("e1m1"); + const menu = createQuakeMenuController({ + enabled: true, host: document.querySelector("#host"), mainMenu: document.querySelector("#menu"), + singlePlayerPanel: document.querySelector("#single"), levelPanel: document.querySelector("#level"), + controls: { update() {}, lock() { assert.fail("failed loads must not lock"); }, addEventListener() {}, removeEventListener() {} }, + onSelectNewGame: load, onLoadGame: load, onSelectLevel: load, canLoadGame: () => true, + clearCrosshairTarget() {}, syncCrosshairTarget() {}, + }); + const originalError = console.error; + const errors = []; + console.error = error => errors.push(error); + try { + document.querySelector(action === "level" ? "#level button" : "#single button").click(); + pending.get("e1m1").reject(new Error("map unavailable")); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(Boolean(nextLoad), supersede); + assert.equal(document.querySelector(action === "level" ? "#level" : "#single").hidden, supersede); + assert.equal(errors.length, supersede ? 0 : 1); + } finally { + console.error = originalError; + pending.get("e1m2")?.resolve({}); + await nextLoad; + menu.dispose(); + } + }); + } +} diff --git a/test/runtime/renderBundleAssetRecovery.test.mjs b/test/runtime/renderBundleAssetRecovery.test.mjs new file mode 100644 index 0000000..e1c5581 --- /dev/null +++ b/test/runtime/renderBundleAssetRecovery.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createServer } from "node:http"; +import { Window } from "happy-dom"; +import { importTsModule } from "../importTsModule.mjs"; + +const window = new Window({ url: "http://localhost/", settings: { disableCSSFileLoading: false } }); +for (const key of ["window", "document", "HTMLElement", "HTMLLinkElement", "HTMLTemplateElement"]) { + globalThis[key] = key === "window" ? window : key === "document" ? window.document : window[key]; +} +test.after(async () => { await window.happyDOM.abort(); }); +const { preloadQuakeRenderBundleAssets: preload } = await importTsModule("src/runtime/renderBundleMesh.ts"); +const bundle = (extra = {}) => ({ + version: 1, kind: "polycss-mesh", polycssVersion: "0.2.6", textureLighting: "baked", textureQuality: 1, + meshHtml: '
', assetUrls: [], assetUrlsComplete: true, + leafMetadata: [], polygonCount: 1, leafCount: 1, atlasLeafCount: 1, ...extra, +}); + +test("stylesheet failure rejects all waiters and retry creates a fresh link", async () => { + let requests = 0; + const server = createServer((_request, response) => { + const first = ++requests === 1; + response.writeHead(first ? 503 : 200, { "Content-Type": "text/css" }); + response.end(first ? "unavailable" : ".polycss-mesh { display: block; }"); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + try { + const value = bundle({ styleUrl: `http://127.0.0.1:${server.address().port}/retry.css` }); + const first = preload(value); + const joined = preload(value); + const failed = document.querySelector("link"); + await Promise.all([assert.rejects(first, /retry.css/), assert.rejects(joined, /retry.css/)]); + assert.equal(failed.isConnected, false); + const retry = preload(value); + const replacement = document.querySelector("link"); + assert.notEqual(replacement, failed); + await retry; + await preload(value); + assert.equal(document.querySelectorAll("link").length, 1); + assert.equal(requests, 2); + } finally { await new Promise(resolve => server.close(resolve)); } +}); + +test("image failure rejects and the next attempt retries; successful requests stay shared", async () => { + let created = 0; + const original = globalThis.Image; + globalThis.Image = class { + constructor() { this.attempt = ++created; } + decode() { return Promise.resolve(); } + set src(_url) { queueMicrotask(() => this.attempt === 1 ? this.onerror() : this.onload()); } + }; + try { + const value = bundle({ assetUrls: ["/q/retry.png"] }); + await assert.rejects(preload(value), /retry.png/); + await Promise.all([preload(value), preload(value)]); + await preload(value); + assert.equal(created, 2); + } finally { globalThis.Image = original; } +}); + +for (const failure of ["http", "json", "version", "frame"]) { + test(`frame bank ${failure} failure can recover without reloading the page`, async () => { + let attempts = 0; + const original = globalThis.fetch; + globalThis.fetch = async () => { + const failed = ++attempts === 1; + return { + ok: !(failed && failure === "http"), + json: async () => { + if (failed && failure === "json") throw new Error("bad JSON"); + return { version: failed && failure === "version" ? 2 : 3, + frames: failed && failure === "frame" ? [] : [[['1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1', "", ""]]] }; + }, + }; + }; + try { + const value = bundle({ leafFrameStylesUrl: `/q/retry-${failure}.json` }); + await assert.rejects(preload(value)); + await preload(value); + assert.equal(attempts, 2); + assert.equal(value.leafFrameStyles.length, 1); + } finally { globalThis.fetch = original; } + }); +} diff --git a/test/runtime/routeLoadOwnership.test.mjs b/test/runtime/routeLoadOwnership.test.mjs new file mode 100644 index 0000000..815ff9a --- /dev/null +++ b/test/runtime/routeLoadOwnership.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Window } from "happy-dom"; +import { importTsModule } from "../importTsModule.mjs"; + +const define = { "import.meta.env": '{"DEV":false}', __POLYCSS_VERSION__: '"0.2.6"' }; +const { createQuakeRouteFlow } = await importTsModule("src/runtime/app/routeFlow.ts", { define }); +const window = new Window({ url: "http://localhost/?map=e1m3" }); +globalThis.window = window; +test.after(async () => { await window.happyDOM.abort(); }); +const tick = () => new Promise(resolve => setImmediate(resolve)); + +test("history navigation while loading keeps the latest map and suppresses stale presentation", async () => { + const calls = [], presentations = [], errors = []; + let loading = false, currentMap = "e1m3"; + const routes = createQuakeRouteFlow({ + applyView: () => presentations.push("view"), canLoadMap: () => true, + clearStartupState: () => presentations.push("present"), currentMapName: () => currentMap, + currentView: () => null, hasCurrentScene: () => true, hideMainMenu() {}, + isDisposed: () => false, isLoading: () => loading, + loadMap: (map, options) => { + loading = true; + return new Promise((resolve, reject) => calls.push({ map, options, resolve, reject })); + }, + mapExists: () => true, menuEnabled: true, setAssetsRegenerating: error => errors.push(error), + setGameplayStarted() {}, setLoadingError: error => errors.push(error), showMainMenu() {}, + startMap: () => "e1m1", viewFromUrlView: view => view, viewToUrlView: view => view, + }); + window.history.replaceState({}, "", "/?map=e1m2"); + routes.handlePopState(); + window.history.replaceState({}, "", "/?map=e1m1"); + routes.handlePopState(); + assert.deepEqual(calls.map(call => call.map), ["e1m2", "e1m1"]); + assert.equal(calls[1].options.urlMode, "none"); + calls[0].reject(new Error("superseded request")); + await tick(); + assert.deepEqual(errors, []); + assert.deepEqual(presentations, []); + loading = false; + currentMap = "e1m1"; + calls[1].resolve({ isCurrent: () => true }); + await tick(); + assert.deepEqual(presentations, ["present"]); +}); + +test("popstate before manifest and model bootstrap does not start an incomplete map", () => { + const routes = createQuakeRouteFlow({ isDisposed: () => false, canLoadMap: () => false }); + assert.doesNotThrow(() => routes.handlePopState()); +}); + +test("a same-map view request during loading must replace the pending load", async () => { + let applied = false, loaded = false; + const routes = createQuakeRouteFlow({ + canLoadMap: () => true, isDisposed: () => false, isLoading: () => true, + currentMapName: () => "e1m1", hasCurrentScene: () => true, mapExists: () => true, + startMap: () => "e1m1", applyView: () => { applied = true; }, + loadMap: async () => { loaded = true; return false; }, + }); + window.history.replaceState({}, "", "/?map=e1m1&view=0,0,0,0,0,0"); + routes.handlePopState(); + await tick(); + assert.equal(loaded, true); + assert.equal(applied, false); +}); diff --git a/test/runtime/saveLoadOwnership.test.mjs b/test/runtime/saveLoadOwnership.test.mjs new file mode 100644 index 0000000..4df92ce --- /dev/null +++ b/test/runtime/saveLoadOwnership.test.mjs @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Window } from "happy-dom"; +import { importTsModule } from "../importTsModule.mjs"; + +const { createQuakeAppMapLoader, createCssQuakeSaveSession, createQuakePlayerLifecycleFlow } = await importTsModule("test/runtime/mapLoadModules.ts", { + define: { "import.meta.env": '{"DEV":false}', __POLYCSS_VERSION__: '"0.2.6"' }, +}); +const window = new Window({ url: "http://localhost" }); +globalThis.window = window; +test.after(async () => { await window.happyDOM.abort(); }); +const noop = () => {}; +const completion = () => ({ isCurrent: () => true }); +const slot = { + version: 1, savedAt: 123, mapName: "e1m1", view: { origin: [1, 2, 3], rotX: 88, rotY: 270 }, + player: { health: 25 }, pickups: {}, shootables: {}, movers: {}, targets: {}, +}; +const saveNoops = Object.fromEntries([ + "clearAttackInput", "clearMoveInput", "clearMobileMoveInput", "clearLevelComplete", "clearPlayerDeath", + "clearCrouchInput", "clearWeaponViewPunch", "clearCrosshairHit", "clearCrosshairTarget", "clearBonusOverlay", + "clearMegahealthRot", "clearPowerupTimers", "resetActiveTriggers", "resetWeapons", "reschedulePowerupTimers", + "syncHud", "syncViewmodel", "syncWorldVisibility", "syncShootablesVisibility", "syncCrosshairTarget", + "setGameplayStarted", "trace", "notify", +].map(name => [name, noop])); +function saveSession(overrides = {}) { + window.localStorage.setItem("cssquake.save.v1", JSON.stringify(slot)); + const restored = []; + const options = { + ...saveNoops, currentLoad: () => false, mapExists: () => true, hasCurrentScene: () => true, + currentOrigin: () => slot.view.origin, + ...Object.fromEntries(["Targets", "DamageableBrushes", "Movers", "Pickups", "Shootables", "Player"].map(name => + [`restore${name}`, value => restored.push({ name, value })])), + syncSceneCameraAt: (...value) => restored.push({ name: "Camera", value }), + ...overrides, + }; + return { session: createCssQuakeSaveSession(options), restored }; +} + +for (const ready of [false, true]) { + test(`save restoration ${ready ? "reuses a ready scene" : "loads an unready scene even when its map matches"}`, async () => { + const loaded = completion(); + let loads = 0; + const { session, restored } = saveSession({ + currentLoad: () => ready ? loaded : false, + loadMap: async () => { loads++; return loaded; }, + }); + assert.equal(await session.load(), loaded); + assert.equal(loads, ready ? 0 : 1); + assert.deepEqual(restored.map(value => value.name), ["Targets", "DamageableBrushes", "Movers", "Pickups", "Shootables", "Player", "Camera"]); + assert.deepEqual(restored.find(value => value.name === "Player").value, slot.player); + assert.deepEqual(restored.at(-1).value, [slot.view.origin, 88, 270]); + }); +} + +test("a newer same-map load between completion and save restoration prevents applying old progress", async () => { + const pending = []; + let nextLoad, started = 0; + const loader = createQuakeAppMapLoader({ + createProgressTracker: () => ({ setStatus() {}, startTask: () => noop }), + fetchScene: () => new Promise(resolve => pending.push(resolve)), + preloadWeapon: async () => ({}), preloadSceneAssets: async () => {}, preloadMapAssets: async () => {}, + completeSceneReadiness: async () => {}, isDisposed: () => false, mapLoadView: () => null, + prepareScene: () => noop, onCurrentMapChange: noop, resumeGameplayAfterMapLoad: noop, + sceneUrl: name => `/q/${name}.json`, setLoading: noop, syncUrlView: noop, updateUrl: noop, + setGameplayStarted: () => { + if (++started === 1) queueMicrotask(() => { nextLoad = loader.loadMap("e1m1"); }); + }, + }); + const { session, restored } = saveSession({ currentLoad: loader.currentLoad, loadMap: async name => loader.loadMap(name) }); + const saved = session.load(); + pending[0]({}); + assert.equal(await saved, false); + assert.deepEqual(restored, []); + pending[1]({}); + await nextLoad; +}); + +for (const ready of [false, true]) { + test(`New Game ${ready ? "respawns the ready scene and carries ownership to its caller" : "reloads an unready scene instead of leaving the menu stuck"}`, async () => { + const loaded = completion(); + let respawns = 0, loads = 0; + const lifecycle = createQuakePlayerLifecycleFlow({ + currentResult: () => ({}), currentMapName: () => "e1m1", currentLoad: () => ready ? loaded : false, + loadMap: async () => { loads++; return loaded; }, + player: () => ({ respawn: () => respawns++ }), controls: { update: noop }, + ...Object.fromEntries(["clearMegahealthRot", "clearPowerups", "clearMoveInput", "clearAttackInput", + "clearMobileMoveInput", "clearDebugFlyInput", "clearWeaponViewPunch", "removeBodyClasses", + "clearTextCenterPrint", "setGameplayStarted"].map(name => [name, noop])), + }); + assert.equal(await lifecycle.startNewGame(), loaded); + assert.equal(loads, ready ? 0 : 1); + assert.equal(respawns, ready ? 1 : 0); + }); +} diff --git a/test/runtime/sceneOwnership.test.mjs b/test/runtime/sceneOwnership.test.mjs new file mode 100644 index 0000000..2e841f9 --- /dev/null +++ b/test/runtime/sceneOwnership.test.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { importTsModule } from "../importTsModule.mjs"; + +const { createQuakeSceneState } = await importTsModule("src/runtime/app/sceneState.ts"); +const { createQuakeSceneMountFlow } = await importTsModule("src/runtime/app/sceneMountFlow.ts"); +const { createQuakeAppMapLoader } = await importTsModule("src/runtime/app/session.ts", { + define: { "import.meta.env": '{"DEV":false}', __POLYCSS_VERSION__: '"0.2.6"' }, +}); + +function retainedScene() { + const state = createQuakeSceneState(); + const scene = { label: "old", gameLogic: null }; + const entity = { index: 17, classname: "trigger_hurt", properties: { dmg: "10" } }; + const collision = { traceUse: () => ({ fraction: 0 }), touchingTriggers: () => [] }; + state.writer.setCurrentScene(scene); + state.writer.setCollisionWorld(collision); + state.writer.setEntityIndex(new Map([[17, entity]])); + state.writer.setModelPivot({ x: 1, y: 2, z: 3 }); + state.advanceTransition(); + const events = []; + const controller = name => new Proxy({}, { get: (_target, method) => () => events.push(`${name}.${String(method)}`) }); + const options = { + state, + ...Object.fromEntries(["audio", "damageableBrushes", "movers", "pickups", "player", "pointHazards", "shootables", "targets", "triggers", "viewmodel", "weapons", "world"].map(name => [name, controller(name)])), + beforeDisposeScene: () => events.push("beforeDisposeScene"), + clearPreControllerState: () => events.push("clearPreControllerState"), + clearPostControllerState: () => events.push("clearPostControllerState"), + onModelPivotChange: () => events.push("pivot"), + }; + return { state, scene, entity, collision, events, flow: createQuakeSceneMountFlow(options) }; +} + +for (const collision of [undefined, { runtime: { brushes: [], planes: [] } }, { runtime: { brushes: [{}], planes: [{}] } }]) { + test(`scene preflight preserves the live scene when collision is ${collision ? collision.runtime.brushes.length ? "incomplete" : "empty" : "missing"}`, async () => { + const { state, scene, entity, collision: oldCollision, events, flow } = retainedScene(); + let currentMap = "e1m1"; + const loader = createQuakeAppMapLoader({ + fetchScene: async () => ({ label: "invalid", collision }), + prepareScene: flow.prepareScene, + onCurrentMapChange: map => { currentMap = map; }, + preloadMapAssets: async () => {}, preloadSceneAssets: async () => {}, preloadWeapon: async () => ({}), + completeSceneReadiness: async () => {}, + createProgressTracker: () => ({ setStatus() {}, startTask: () => () => {} }), + mapLoadView: () => null, isDisposed: () => false, sceneUrl: map => map, + setLoading() {}, setGameplayStarted() {}, syncUrlView() {}, updateUrl() {}, resumeGameplayAfterMapLoad() {}, + }); + await assert.rejects(loader.loadMap("e1m2"), /collision|groundGrid/); + assert.equal(currentMap, "e1m1"); + assert.equal(state.view.scene, scene); + assert.equal(state.view.collisionWorld, oldCollision); + assert.deepEqual(flow.entitiesForIndexes([17, 17, 99]), [entity]); + assert.deepEqual(state.view.modelPivot, { x: 1, y: 2, z: 3 }); + assert.equal(state.view.transitionSerial, 1); + assert.equal(flow.lineOfSight([0, 0, 0], [1, 0, 0]), false); + assert.deepEqual(events, [], "Preflight must not release old controllers or handles"); + }); +} + +test("scene disposal retains controller order and clears every shared read", () => { + const { state, events, flow } = retainedScene(); + const heldView = state.view; + flow.disposeCurrentScene(); + assert.deepEqual(events, [ + "beforeDisposeScene", "clearPreControllerState", "viewmodel.remove", "world.clear", + "movers.clear", "pickups.clear", "shootables.clear", "clearPostControllerState", + "player.resetForSceneDispose", "damageableBrushes.clear", "targets.clear", "pointHazards.clear", + "pivot", "audio.syncAmbientEntities", "weapons.reset", + ]); + assert.equal(heldView.scene, null); + assert.equal(heldView.collisionWorld, null); + assert.equal(heldView.entities.size, 0); + assert.equal(heldView.transitionSerial, 0); + assert.deepEqual(heldView.modelPivot, { x: 0, y: 0, z: 0 }); + assert.deepEqual(flow.entitiesForIndexes([17]), []); + assert.equal(flow.lineOfSight([0, 0, 0], [1, 0, 0]), true); +}); diff --git a/test/runtime/shootableOwnership.test.mjs b/test/runtime/shootableOwnership.test.mjs new file mode 100644 index 0000000..e0f8a25 --- /dev/null +++ b/test/runtime/shootableOwnership.test.mjs @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { importTsModule } from "../importTsModule.mjs"; +import { runShootableOwnershipScenario } from "./shootableOwnershipScenario.mjs"; + +const api = await importTsModule("test/runtime/shootableOwnershipModules.ts"); +const reference = JSON.parse(readFileSync(new URL("./fixtures/shootableOwnershipMain.json", import.meta.url), "utf8")); +for (const expected of reference.cases) { + const { classname, backend, quakec } = expected.scenario; + test(`main gameplay and mesh lifetime: ${classname}, ${backend}, QuakeC ${quakec}`, () => { + const actual = runShootableOwnershipScenario(api, expected.scenario); + assert.equal(actual.frameCount, expected.frameCount); + for (let i = 0; i < expected.checkpoints.length; i++) { + assert.deepEqual(actual.checkpoints[i], expected.checkpoints[i], `First divergence at ${expected.checkpoints[i].label}`); + } + assert.equal(actual.checkpoints.length, expected.checkpoints.length); + }); +} diff --git a/test/runtime/shootableOwnershipModules.ts b/test/runtime/shootableOwnershipModules.ts new file mode 100644 index 0000000..fc490f4 --- /dev/null +++ b/test/runtime/shootableOwnershipModules.ts @@ -0,0 +1,5 @@ +export { createQuakeShootablesController } from "../../src/runtime/shootables"; +export { createQuakeMonsterStateRunner } from "../../src/runtime/quakeMonsterStateRunner"; +export { registerQuakeTraceMarkSink } from "../../src/runtime/debug/traceMarks"; +export { QUAKE_MONSTER_LOGIC } from "../../src/generated/quakeMonsterLogic"; +export { quakeShootableModelPath } from "../../src/runtime/shootables/monsterMetadata"; diff --git a/test/runtime/shootableOwnershipScenario.mjs b/test/runtime/shootableOwnershipScenario.mjs new file mode 100644 index 0000000..6a83929 --- /dev/null +++ b/test/runtime/shootableOwnershipScenario.mjs @@ -0,0 +1,127 @@ +import { createHash } from "node:crypto"; +import { Window } from "happy-dom"; + +export const ownershipCases = ["monster_army", "monster_dog", "monster_knight", "monster_ogre", "monster_demon1", "monster_wizard", "monster_shambler", "monster_zombie", "monster_boss"] + .flatMap(classname => ["frameset", "replace"].flatMap(backend => [false, true].map(quakec => ({ classname, backend, quakec })))); + +const digest = value => createHash("sha256").update(JSON.stringify(value)).digest("hex"); + +// This drives the production controller at fixed times. Mesh handles record publication; +// real prepared meshes are covered separately by the headless browser fixtures. +export function runShootableOwnershipScenario(api, scenario) { + const saved = Object.fromEntries(["window", "document", "performance"].map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + const window = new Window(); + let now = 1000, nextId = 0, paused = false, frozen = false, inView = true, inPvs = true; + let origin = [0, 0, 1]; + const raf = new Map(), timers = new Map(), handles = [], events = [], checkpoints = []; + Object.defineProperty(globalThis, "window", { configurable: true, value: window }); + Object.defineProperty(globalThis, "document", { configurable: true, value: window.document }); + Object.defineProperty(globalThis, "performance", { configurable: true, value: { now: () => now } }); + window.__cssQuakeDebugDomMetadata = true; + window.requestAnimationFrame = callback => { const id = ++nextId; raf.set(id, callback); return id; }; + window.cancelAnimationFrame = id => raf.delete(id); + window.setTimeout = (callback, delay = 0) => { const id = ++nextId; timers.set(id, { callback, at: now + delay }); return id; }; + window.clearTimeout = id => timers.delete(id); + window.requestIdleCallback = undefined; + const stopTrace = api.registerQuakeTraceMarkSink(event => events.push({ trace: event })); + const emit = event => events.push({ at: now, ...structuredClone(event) }); + function mesh(entity, model, frameIndex = 0) { + const id = handles.length; + const element = document.createElement("div"); + document.body.append(element); + let frame = frameIndex; + const handle = { + element, entityIndex: entity.index, removed: 0, + remove() { this.removed++; emit({ remove: id }); element.remove(); }, + setTransform(transform) { emit({ transform: id, value: transform }); }, + }; + if (scenario.backend === "frameset" && model?.animationFrameSet) handle.setFrameIndex = next => { + const changed = next !== frame; + if (changed) { frame = next; emit({ frame: id, value: next }); } + return changed; + }; + handles.push(handle); + emit({ mount: id, entity: entity.index, frame: frameIndex }); + return handle; + } + function advance(ms) { + for (let elapsed = 0; elapsed < ms; elapsed += 50) { + now += 50; + for (const [id, timer] of [...timers]) if (timers.has(id) && timer.at <= now) { timers.delete(id); timer.callback(); } + for (const [id, callback] of [...raf]) if (raf.has(id)) { raf.delete(id); callback(now); } + } + } + const entity = { index: 1, classname: scenario.classname, angle: 180, origin: { x: 5, y: 0, z: 1 }, properties: { classname: scenario.classname, angle: "180" } }; + const frameCount = Math.max(...Object.values(api.QUAKE_MONSTER_LOGIC[scenario.classname].chains).flatMap(chain => chain.states.map(state => state.frameIndex))) + 1; + const modelPath = api.quakeShootableModelPath(entity); + const model = { + source: modelPath, bounds: { min: [-0.4, -0.4, -0.7], max: [0.4, 0.4, 0.7] }, + animationFrames: Array.from({ length: frameCount }, (_, index) => ({ name: `frame${index}` })), + ...(scenario.backend === "frameset" ? { animationFrameSet: {} } : {}), + }; + const library = { models: { [modelPath]: model } }; + let controller; + try { + controller = api.createQuakeShootablesController({ + addMesh: mesh, damagePlayer: (damage, context) => { emit({ damagePlayer: damage, context }); return true; }, + fireTarget: (...args) => emit({ fireTarget: args }), onDestroyed: item => emit({ destroyed: item.index }), + floorAt: () => 0, getPlayerEyeHeight: () => 1, getPlayerForward: () => [1, 0, 0], getPlayerOrigin: () => origin, + hasLineOfSight: () => true, isInPlayerView: () => inView, leafIndexAt: () => 0, + visibleLeavesAt: () => new Set(inPvs ? [0] : []), prewarmLeavesAt: () => new Set(inPvs ? [0] : []), + monsterRuntimeEnabled: () => true, isGameplayPaused: () => paused, enemiesFrozen: () => frozen, + createMonsterStateRunner: classname => api.createQuakeMonsterStateRunner(classname, { enabled: scenario.quakec }), + enemyRandomSalt: 12345, pixelate() {}, pointToPoly: point => [point.x, point.y, point.z], + schedulePresentationResync() {}, shouldSpawn: () => true, playSound: path => { emit({ sound: path }); return true; }, + }); + const checkpoint = label => { + const targets = [...controller.weaponTargets()].map(target => target.entity.index); + const progress = structuredClone(controller.snapshotProgress()); + const culling = controller.debugCullingSnapshot(origin); + const publication = handles.map((handle, id) => ({ id, entity: handle.entityIndex, removed: handle.removed, html: handle.element.outerHTML })); + checkpoints.push({ label, now, targets, progress, visible: culling.visibleIndexes, mounted: culling.mountedIndexes, + events: events.length, eventDigest: digest(events), stateDigest: digest(culling), publicationDigest: digest(publication), + liveHandles: handles.filter(handle => !handle.removed).length, maxRemovals: Math.max(0, ...handles.map(handle => handle.removed)), + raf: raf.size, timers: timers.size }); + }; + controller.spawn([entity], library); + checkpoint("spawn-unmounted"); + controller.syncVisibility(origin, true); + controller.debugMountEntity(1); + checkpoint("mounted"); + controller.debugForceEnemyAttack(1, origin); + advance(150); + checkpoint("attack-start"); + paused = true; advance(500); checkpoint("paused"); + paused = false; advance(250); checkpoint("resumed"); + frozen = true; advance(300); checkpoint("frozen"); + frozen = false; advance(200); checkpoint("unfrozen"); + controller.damage(1, 5); advance(150); checkpoint("pain"); + const save = structuredClone(controller.snapshotProgress()); + controller.debugForceEnemyAttack(1, origin); + inView = false; inPvs = false; origin = [-100, 0, 1]; + controller.syncVisibility(origin, true); advance(700); controller.syncVisibility(origin, true); + checkpoint("attack-unmounted"); + origin = [0, 0, 1]; inView = true; inPvs = true; + controller.debugSetOrigin(1, [5, 0, 1]); controller.syncVisibility(origin, true); controller.debugMountEntity(1); + advance(150); checkpoint("remounted"); + const health = controller.snapshotProgress().shootables[0]?.health ?? 0; + controller.damage(1, health + 1); advance(2000); controller.syncVisibility(origin, true); checkpoint("lethal-damage"); + controller.spawn([entity], library); + controller.restoreProgress(save); controller.syncVisibility(origin, true); advance(200); checkpoint("restored"); + controller.debugMountEntity(1); + controller.debugForceEnemyAttack(1, origin); advance(150); checkpoint("attack-before-clear"); + controller.clear(); checkpoint("cleared-with-attack"); + advance(1500); checkpoint("after-clear-callbacks"); + controller.spawn([entity], library); controller.syncVisibility(origin, true); controller.debugMountEntity(1); advance(150); + checkpoint("respawn-reused-index"); + controller.damage(1, 10000); advance(2000); checkpoint("gib-damage"); + controller.clear(); advance(1500); checkpoint("final-clear"); + return { scenario, frameCount, checkpoints }; + } finally { + controller?.clear(); stopTrace(); + for (const [key, descriptor] of Object.entries(saved)) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); else delete globalThis[key]; + } + window.happyDOM.abort(); + } +} diff --git a/test/runtime/shootablePresentation.test.mjs b/test/runtime/shootablePresentation.test.mjs new file mode 100644 index 0000000..cc2e3c3 --- /dev/null +++ b/test/runtime/shootablePresentation.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Window } from "happy-dom"; +import { importTsModule } from "../importTsModule.mjs"; +const { createQuakeShootablePresentation } = await importTsModule("src/runtime/shootables/presentation.ts"); + +function actor(index = 1) { + return Object.freeze({ + entity: Object.freeze({ index, classname: "monster_army", origin: Object.freeze({ x: 1, y: 2, z: 3 }), properties: {} }), + origin: Object.freeze([1, 2, 3]), yaw: 0, dead: false, + enemy: Object.freeze({ animationFrameIndex: 0 }), + model: Object.freeze({ renderScale: 1, animationFrames: [0, 1, 2, 3, 4] }), + }); +} +function harness() { + const window = new Window(); + const handles = []; + let fail = false; + const options = { + addMesh(_entity, _model, frame) { + if (fail) return null; + const element = window.document.createElement("div"); + window.document.body.append(element); + const handle = { element, frame, removes: 0, remove() { this.removes++; element.remove(); }, setTransform() {} }; + handles.push(handle); + return handle; + }, + pointToPoly: point => [point.x, point.y, point.z], pixelate() {}, schedulePresentationResync() {}, + lifecycle: () => ({ deathAnimating: false, persistentCorpse: false }), nextFrameIndex: () => 1, + markTrace() {}, onHandlesChanged() {}, + }; + return { owner: createQuakeShootablePresentation(options), anotherOwner: () => createQuakeShootablePresentation(options), + handles, setFail: value => { fail = value; } }; +} + +test("presentation isolates actor identity and controller instances, including reused entity indexes", () => { + const { owner, anotherOwner, handles } = harness(); + const first = actor(), reusedIndex = actor(), otherOwner = anotherOwner(); + owner.mount(first, false); owner.setVisible(first, true); + owner.mount(first, false); // Repeated acquisition cannot orphan the first handle. + owner.mount(reusedIndex, false); otherOwner.mount(first, false); + owner.remove(first); owner.remove(first); + assert.equal(owner.hasHandle(first), false); + assert.equal(owner.isVisible(first), false); + assert.equal(owner.hasHandle(reusedIndex), true); + assert.equal(otherOwner.hasHandle(first), true); + assert.deepEqual(handles.map(handle => handle.removes), [1, 0, 0]); + owner.remove(reusedIndex); otherOwner.remove(first); + assert.deepEqual(handles.map(handle => handle.removes), [1, 1, 1]); + assert.deepEqual(Object.keys(first).sort(), ["dead", "enemy", "entity", "model", "origin", "yaw"], "No presentation fields may be written onto simulation state"); +}); + +test("failed frame replacement preserves the currently visible handle", () => { + const { owner, handles, setFail } = harness(); + const shootable = actor(); + owner.mount(shootable, false); owner.setVisible(shootable, true); + setFail(true); owner.activateFrame(shootable, 1, false); + assert.equal(owner.handleCount(shootable), 1); + assert.equal(owner.isVisible(shootable), true); + assert.equal(handles[0].removes, 0); + setFail(false); owner.activateFrame(shootable, 1, false); + assert.deepEqual(handles.map(handle => handle.removes), [1, 0]); + owner.remove(shootable); + assert.deepEqual(handles.map(handle => handle.removes), [1, 1]); +}); + +test("frame pool eviction protects the active and next frame and removes each retained handle once", () => { + const { owner, handles } = harness(); + const shootable = actor(); + assert.equal(owner.mount(shootable, true), "pool"); + owner.setVisible(shootable, true); + for (const frame of [1, 2, 3, 4]) owner.ensureFrame(shootable, frame); + owner.trimFrames(shootable); + assert.equal(owner.frameHandleCount(shootable), 3); + assert.equal(owner.hasFrame(shootable, 0), true); + assert.equal(owner.hasFrame(shootable, 1), true); + owner.activateFrame(shootable, 4, true); + assert.equal(handles.find(handle => handle.frame === 4).element.getAttribute("aria-hidden"), null); + assert.equal(handles[0].element.getAttribute("aria-hidden"), "true"); + owner.remove(shootable); owner.remove(shootable); + assert.deepEqual(handles.map(handle => handle.removes), [1, 1, 1, 1, 1]); + assert.equal(owner.frameHandleCount(shootable), 0); +}); + +test("failed mount cannot publish visible residency or animate an absent mesh", () => { + const { owner, handles, setFail } = harness(); + const shootable = actor(); + setFail(true); owner.mount(shootable, false); owner.setVisible(shootable, true); + assert.equal(owner.hasHandle(shootable), false); + assert.equal(owner.isVisible(shootable), false); + assert.equal(owner.activateFrame(shootable, 1, false), null); + assert.equal(handles.length, 0); +}); diff --git a/test/runtime/shootablePrewarm.test.mjs b/test/runtime/shootablePrewarm.test.mjs index 448c716..5f51d6f 100644 --- a/test/runtime/shootablePrewarm.test.mjs +++ b/test/runtime/shootablePrewarm.test.mjs @@ -28,6 +28,9 @@ test("timed-out shootable prewarm drain mounts the selected small batch", () => ]); const mounted = []; const queues = createQuakeShootablePrewarmQueues({ + hasHandle: (shootable) => shootable.handle !== null, + isVisible: (shootable) => shootable.visible, + hasFrame: (shootable, frame) => shootable.frameHandles.has(frame), canPoolAnimationFrame: () => false, canPrewarmShootable: () => true, ensureAnimationFrame: () => undefined, @@ -84,6 +87,9 @@ test("prewarm drain keeps one-mesh minimum when idle time is exhausted", () => { ]); const mounted = []; const queues = createQuakeShootablePrewarmQueues({ + hasHandle: (shootable) => shootable.handle !== null, + isVisible: (shootable) => shootable.visible, + hasFrame: (shootable, frame) => shootable.frameHandles.has(frame), canPoolAnimationFrame: () => false, canPrewarmShootable: () => true, ensureAnimationFrame: () => undefined, diff --git a/test/typescript/README.md b/test/typescript/README.md new file mode 100644 index 0000000..12c989c --- /dev/null +++ b/test/typescript/README.md @@ -0,0 +1,7 @@ +`pnpm typecheck` rejects new TypeScript diagnostics in the browser and both PartyKit entry points. The baseline records 174 diagnostics from main at `7d796145b9a972f9da5e399a6802e86f8450ea83`, checked with TypeScript 5.9.3 and this repository's tsconfig. + +This is a debt baseline, not a clean type check. `pnpm typecheck:all` reports every remaining error. Strict null checks are enabled; implicit `any` is still allowed. Preparation scripts and Vite configuration are outside this initial scope. + +The comparison includes file, error code, message, source expression, and occurrence count. Moving an error does not fail the check. Fixing an old error cannot pay for a different new error, even when the total count falls. + +Fix new diagnostics in source. Do not refresh the baseline to make a PR pass. When existing diagnostics are fixed, their baseline entries may be removed in the same reviewed change. A compiler or configuration update must review diagnostic changes explicitly. diff --git a/test/typescript/baseline.json b/test/typescript/baseline.json new file mode 100644 index 0000000..13674f3 --- /dev/null +++ b/test/typescript/baseline.json @@ -0,0 +1,1050 @@ +{ + "commit": "7d796145b9a972f9da5e399a6802e86f8450ea83", + "compilerVersion": "5.9.3", + "diagnostics": [ + { + "file": "src/App.ts", + "code": 2339, + "message": "Property 'checked' does not exist on type 'HTMLButtonElement'.", + "source": "checked" + }, + { + "file": "src/App.ts", + "code": 2322, + "message": "Type 'QuakeMoversDebugStats' is not assignable to type 'Record'.\n Index signature for type 'string' is missing in type 'QuakeMoversDebugStats'.", + "source": "movers.debugStats()" + }, + { + "file": "src/App.ts", + "code": 2322, + "message": "Type 'QuakePickupDebugStats' is not assignable to type 'Record'.\n Index signature for type 'string' is missing in type 'QuakePickupDebugStats'.", + "source": "getPickups().debugStats()" + }, + { + "file": "src/App.ts", + "code": 2322, + "message": "Type 'QuakePlayerMovementDebug' is not assignable to type 'Record'.\n Index signature for type 'string' is missing in type 'QuakePlayerMovementDebug'.", + "source": "getPlayer().debugMovement()" + }, + { + "file": "src/App.ts", + "code": 2322, + "message": "Type 'QuakePlayerProgressSnapshot' is not assignable to type 'Record'.\n Index signature for type 'string' is missing in type 'QuakePlayerProgressSnapshot'.", + "source": "getPlayer().snapshotProgress()" + }, + { + "file": "src/App.ts", + "code": 2322, + "message": "Type 'QuakeTargetsProgressSnapshot' is not assignable to type 'Record'.\n Index signature for type 'string' is missing in type 'QuakeTargetsProgressSnapshot'.", + "source": "targetSystem.snapshotProgress()" + }, + { + "file": "src/App.ts", + "code": 2322, + "message": "Type 'QuakeTriggersDebugStats' is not assignable to type 'Record'.\n Index signature for type 'string' is missing in type 'QuakeTriggersDebugStats'.", + "source": "triggerSystem.debugStats()" + }, + { + "file": "src/App.ts", + "code": 2322, + "message": "Type 'QuakeViewmodelDebugSnapshot' is not assignable to type 'Record'.\n Index signature for type 'string' is missing in type 'QuakeViewmodelDebugSnapshot'.", + "source": "viewmodel.debugSnapshot()" + }, + { + "file": "src/App.ts", + "code": 2322, + "message": "Type '(target: EventTarget | null) => string | null' is not assignable to type '(target: EventTarget | null) => string'.\n Type 'string | null' is not assignable to type 'string'.\n Type 'null' is not assignable to type 'string'.", + "source": "eventTargetLabel" + }, + { + "file": "src/App.ts", + "code": 2322, + "message": "Type '(kind: string, details?: QuakePointerTraceDetails) => void' is not assignable to type '(kind: string, details: Record) => void'.\n Types of parameters 'details' and 'details' are incompatible.\n Type 'Record' is not assignable to type 'QuakePointerTraceDetails'.\n 'string' index signatures are incompatible.\n Type 'unknown' is not assignable to type 'QuakePointerTraceValue'.", + "source": "pointerTrace" + }, + { + "file": "src/App.ts", + "code": 2345, + "message": "Argument of type 'QuakeLandscapeRequestResult' is not assignable to parameter of type 'QuakeTraceMarkDetails'.\n Index signature for type 'string' is missing in type 'QuakeLandscapeRequestResult'.", + "source": "result" + }, + { + "file": "src/App.ts", + "code": 2352, + "message": "Conversion of type 'NonNullable' to type 'PromiseLike' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.\n Property 'then' is missing in type 'NonNullable' but required in type 'PromiseLike'.", + "source": "result as PromiseLike" + }, + { + "file": "src/App.ts", + "code": 2741, + "message": "Property 'color' is missing in type '{ activeFrameSet: undefined; animationFrames: QuakePickupModelAnimationFrame[]; attackFrameIndexesByWeapon: Record; clientId: string; ... 10 more ...; zOffset: number; }' but required in type 'QuakeRemotePlayerMeshMount'.", + "source": "return" + }, + { + "file": "src/App.ts", + "code": 2345, + "message": "Argument of type 'QuakeMultiplayerVec3' is not assignable to parameter of type 'Vec3'.\n The type 'QuakeMultiplayerVec3' is 'readonly' and cannot be assigned to the mutable type 'Vec3'.", + "source": "state.renderOrigin" + }, + { + "file": "src/App.ts", + "code": 2552, + "message": "Cannot find name 'QuakeWeaponWallImpactEvent'. Did you mean 'QuakeWeaponWallImpactEffect'?", + "source": "QuakeWeaponWallImpactEvent" + }, + { + "file": "src/App.ts", + "code": 2345, + "message": "Argument of type 'QuakeMultiplayerVec3' is not assignable to parameter of type 'Vec3'.\n The type 'QuakeMultiplayerVec3' is 'readonly' and cannot be assigned to the mutable type 'Vec3'.", + "source": "projectile.origin" + }, + { + "file": "src/App.ts", + "code": 2345, + "message": "Argument of type 'QuakeMultiplayerVec3' is not assignable to parameter of type 'Vec3'.\n The type 'QuakeMultiplayerVec3' is 'readonly' and cannot be assigned to the mutable type 'Vec3'.", + "source": "event.origin" + }, + { + "file": "src/App.ts", + "code": 4104, + "message": "The type 'QuakeMultiplayerVec3' is 'readonly' and cannot be assigned to the mutable type '[number, number, number]'.", + "source": "origin" + }, + { + "file": "src/App.ts", + "code": 4104, + "message": "The type 'QuakeMultiplayerVec3' is 'readonly' and cannot be assigned to the mutable type '[number, number, number]'.", + "source": "origin" + }, + { + "file": "src/App.ts", + "code": 2345, + "message": "Argument of type 'QuakeMultiplayerEnvelope<\"client\", \"client.world\", { clientId: string; event: { eventType: string; eventId: string; roomTime: number; entityIndex: number; change: string; data: { clientId: string; }; }; }>' is not assignable to parameter of type 'QuakeMultiplayerClientEnvelope'.\n Type 'QuakeMultiplayerEnvelope<\"client\", \"client.world\", { clientId: string; event: { eventType: string; eventId: string; roomTime: number; entityIndex: number; change: string; data: { clientId: string; }; }; }>' is not assignable to type 'QuakeMultiplayerClientWorldEnvelope'.\n Type '{ clientId: string; event: { eventType: string; eventId: string; roomTime: number; entityIndex: number; change: string; data: { clientId: string; }; }; }' is not assignable to type 'QuakeMultiplayerClientWorldPayload'.\n Type '{ clientId: string; event: { eventType: string; eventId: string; roomTime: number; entityIndex: number; change: string; data: { clientId: string; }; }; }' is not assignable to type '{ clientId: string; event: { eventType: \"world.changed\"; eventId: string; roomTime: number; entityId?: string | undefined; entityIndex?: number | undefined; change: string; data?: Record | undefined; }; intent?: undefined; }'.\n The types of 'event.eventType' are incompatible between these types.\n Type 'string' is not assignable to type '\"world.changed\"'.", + "source": "createQuakeMultiplayerEnvelope({\n direction: \"client\",\n type: \"client.world\",\n roomKey,\n sequence: ++quakeMultiplayerClientSequence,\n sentAt: Date.now(),\n payload: {\n clientId: QUAKE_MULTIPLAYER_LOCAL_CLIENT_ID,\n event: {\n eventType: \"world.changed\",\n eventId: `world-local-${quakeMultiplayerClientSequence}`,\n roomTime: 0,\n entityIndex,\n change,\n data: {\n ...data,\n clientId: QUAKE_MULTIPLAYER_LOCAL_CLIENT_ID,\n },\n },\n },\n })" + }, + { + "file": "src/App.ts", + "code": 2345, + "message": "Argument of type 'QuakeMultiplayerEnvelope<\"client\", \"client.hello\", { deathmatchSpawns: readonly QuakeMultiplayerSpawnPoint[] | undefined; pickupDefinitions: readonly QuakeMultiplayerPickupDefinition[] | undefined; ... 5 more ...; capabilities: string[]; }>' is not assignable to parameter of type 'QuakeMultiplayerClientEnvelope'.\n Type 'QuakeMultiplayerEnvelope<\"client\", \"client.hello\", { deathmatchSpawns: readonly QuakeMultiplayerSpawnPoint[] | undefined; pickupDefinitions: readonly QuakeMultiplayerPickupDefinition[] | undefined; ... 5 more ...; capabilities: string[]; }>' is not assignable to type 'QuakeMultiplayerClientHelloEnvelope'.\n Type '{ deathmatchSpawns: readonly QuakeMultiplayerSpawnPoint[] | undefined; pickupDefinitions: readonly QuakeMultiplayerPickupDefinition[] | undefined; ... 5 more ...; capabilities: string[]; }' is not assignable to type 'QuakeMultiplayerClientHelloPayload'.\n Types of property 'deathmatchSpawns' are incompatible.\n Type 'readonly QuakeMultiplayerSpawnPoint[] | undefined' is not assignable to type 'QuakeMultiplayerSpawnPoint[] | undefined'.\n The type 'readonly QuakeMultiplayerSpawnPoint[]' is 'readonly' and cannot be assigned to the mutable type 'QuakeMultiplayerSpawnPoint[]'.", + "source": "createQuakeMultiplayerEnvelope({\n direction: \"client\",\n type: \"client.hello\",\n roomKey,\n sequence: ++quakeMultiplayerClientSequence,\n sentAt: Date.now(),\n payload: {\n clientId: QUAKE_MULTIPLAYER_LOCAL_CLIENT_ID,\n displayName: QUAKE_MULTIPLAYER_LOCAL_DISPLAY_NAME,\n color: QUAKE_MULTIPLAYER_LOCAL_COLOR,\n matchSettings: quakeMultiplayerMatchSettings(),\n capabilities: [QUAKE_MULTIPLAYER_TRANSPORT, \"pose-sample\", \"gameplay-facts-v1\"],\n ...(gameplayFacts ? { gameplayFacts } : {}),\n deathmatchSpawns,\n pickupDefinitions,\n },\n })" + }, + { + "file": "src/App.ts", + "code": 2345, + "message": "Argument of type 'QuakeMultiplayerEnvelope<\"client\", \"client.pose\", { clientId: string; prototypeOnly: boolean; pose: { poseSequence: number; sampledAt: number; origin: [number, number, number]; velocity: number[]; rotX: number; rotY: number; grounded: boolean; alive: boolean; }; }>' is not assignable to parameter of type 'QuakeMultiplayerClientEnvelope'.\n Type 'QuakeMultiplayerEnvelope<\"client\", \"client.pose\", { clientId: string; prototypeOnly: boolean; pose: { poseSequence: number; sampledAt: number; origin: [number, number, number]; velocity: number[]; rotX: number; rotY: number; grounded: boolean; alive: boolean; }; }>' is not assignable to type 'QuakeMultiplayerClientPoseEnvelope'.\n Type '{ clientId: string; prototypeOnly: boolean; pose: { poseSequence: number; sampledAt: number; origin: [number, number, number]; velocity: number[]; rotX: number; rotY: number; grounded: boolean; alive: boolean; }; }' is not assignable to type 'QuakeMultiplayerClientPosePayload'.\n Types of property 'prototypeOnly' are incompatible.\n Type 'boolean' is not assignable to type 'true'.", + "source": "createQuakeMultiplayerEnvelope({\n direction: \"client\",\n type: \"client.pose\",\n roomKey,\n sequence: ++quakeMultiplayerClientSequence,\n sentAt,\n payload: {\n clientId: QUAKE_MULTIPLAYER_LOCAL_CLIENT_ID,\n prototypeOnly: true,\n pose: {\n poseSequence: ++quakeMultiplayerPoseSequence,\n sampledAt: sentAt,\n origin,\n velocity: [0, 0, 0],\n rotX: scene.camera.state.rotX ?? 88,\n rotY: scene.camera.state.rotY ?? 270,\n grounded: true,\n alive: !quakePlayerDead,\n },\n },\n })" + }, + { + "file": "src/App.ts", + "code": 2322, + "message": "Type '(kind: string, details?: QuakeTraceMarkDetails) => void' is not assignable to type '(name: string, details: Record) => void'.\n Types of parameters 'details' and 'details' are incompatible.\n Type 'Record' is not assignable to type 'QuakeTraceMarkDetails'.\n 'string' index signatures are incompatible.\n Type 'unknown' is not assignable to type 'QuakeTraceMarkValue'.", + "source": "trace" + }, + { + "file": "src/generated/quakeMonsterLogic.ts", + "code": 2322, + "message": "Type 'null' is not assignable to type 'number'.", + "source": "\"frameIndex\"" + }, + { + "file": "src/generated/quakeMonsterLogic.ts", + "code": 2322, + "message": "Type 'null' is not assignable to type 'number'.", + "source": "\"frameIndex\"" + }, + { + "file": "src/generated/quakeMonsterLogic.ts", + "code": 2322, + "message": "Type 'null' is not assignable to type 'number'.", + "source": "\"frameIndex\"" + }, + { + "file": "src/generated/quakeMonsterLogic.ts", + "code": 2322, + "message": "Type 'null' is not assignable to type 'number'.", + "source": "\"frameIndex\"" + }, + { + "file": "src/generated/quakeMonsterLogic.ts", + "code": 2322, + "message": "Type 'null' is not assignable to type 'number'.", + "source": "\"frameIndex\"" + }, + { + "file": "src/generated/quakeMonsterLogic.ts", + "code": 2322, + "message": "Type 'null' is not assignable to type 'number'.", + "source": "\"frameIndex\"" + }, + { + "file": "src/generated/quakeProgramFacts.ts", + "code": 2322, + "message": "Type '\"worldspawn\"' is not assignable to type 'QuakeProgramEntityKind'.", + "source": "\"kind\"" + }, + { + "file": "src/prepare/gameLogicFacts.ts", + "code": 18048, + "message": "'spawnflag.value' is possibly 'undefined'.", + "source": "spawnflag.value" + }, + { + "file": "src/prepare/gameLogicFacts.ts", + "code": 2322, + "message": "Type 'number | undefined' is not assignable to type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "value" + }, + { + "file": "src/prepare/gameLogicFacts.ts", + "code": 2345, + "message": "Argument of type 'QuakeGameLogicProgramValue | undefined' is not assignable to parameter of type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "...values" + }, + { + "file": "src/prepare/gameLogicFacts.ts", + "code": 2345, + "message": "Argument of type 'QuakeGameLogicProgramValue | undefined' is not assignable to parameter of type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "...values" + }, + { + "file": "src/prepare/gameLogicFacts.ts", + "code": 18048, + "message": "'callbackFact.assignments' is possibly 'undefined'.", + "source": "callbackFact.assignments" + }, + { + "file": "src/prepare/gameLogicFacts.ts", + "code": 18048, + "message": "'callbackFact.assignments' is possibly 'undefined'.", + "source": "callbackFact.assignments" + }, + { + "file": "src/prepare/scene.ts", + "code": 18048, + "message": "'headNode' is possibly 'undefined'.", + "source": "headNode" + }, + { + "file": "src/prepare/scene.ts", + "code": 18048, + "message": "'headNode' is possibly 'undefined'.", + "source": "headNode" + }, + { + "file": "src/runtime/app/debugApi.ts", + "code": 2322, + "message": "Type 'QuakePlayerMovementDebug' is not assignable to type 'Record'.\n Index signature for type 'string' is missing in type 'QuakePlayerMovementDebug'.", + "source": "runtime.controllers.player().debugMovement()" + }, + { + "file": "src/runtime/app/debugApi.ts", + "code": 2322, + "message": "Type 'QuakeTriggersDebugStats' is not assignable to type 'Record'.\n Index signature for type 'string' is missing in type 'QuakeTriggersDebugStats'.", + "source": "runtime.controllers.triggers.debugStats()" + }, + { + "file": "src/runtime/app/impactParticleFlow.ts", + "code": 2367, + "message": "This comparison appears to be unintentional because the types '\"blood\" | \"wall\"' and '\"explosion\"' have no overlap.", + "source": "kind === \"explosion\"" + }, + { + "file": "src/runtime/app/textPresentationFlow.ts", + "code": 2339, + "message": "Property 'generatedText' does not exist on type 'QuakeGameLogicResolvedMoverFact'.\n Property 'generatedText' does not exist on type 'QuakeGameLogicResolvedFuncButtonFact'.", + "source": "generatedText" + }, + { + "file": "src/runtime/collision.ts", + "code": 2339, + "message": "Property 'origin' does not exist on type 'never'.", + "source": "origin" + }, + { + "file": "src/runtime/debug/quakeDebug.ts", + "code": 2322, + "message": "Type 'number | undefined' is not assignable to type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "targetX" + }, + { + "file": "src/runtime/debug/quakeDebug.ts", + "code": 2322, + "message": "Type 'number | undefined' is not assignable to type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "targetY" + }, + { + "file": "src/runtime/debug/quakeDebug.ts", + "code": 2322, + "message": "Type 'number | undefined' is not assignable to type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "targetZ" + }, + { + "file": "src/runtime/debug/quakeDebug.ts", + "code": 2322, + "message": "Type 'number | undefined' is not assignable to type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "targetX" + }, + { + "file": "src/runtime/debug/quakeDebug.ts", + "code": 2322, + "message": "Type 'number | undefined' is not assignable to type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "targetY" + }, + { + "file": "src/runtime/debug/quakeDebug.ts", + "code": 2322, + "message": "Type 'number | undefined' is not assignable to type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "targetZ" + }, + { + "file": "src/runtime/debug/recording.ts", + "code": 2345, + "message": "Argument of type 'QuakeWorldVisibilityChurnStats' is not assignable to parameter of type 'Record'.\n Index signature for type 'string' is missing in type 'QuakeWorldVisibilityChurnStats'.", + "source": "first.snapshot.world.visibilityChurn" + }, + { + "file": "src/runtime/debug/recording.ts", + "code": 2345, + "message": "Argument of type 'QuakeShootablesVisibilityChurnStats' is not assignable to parameter of type 'Record'.\n Index signature for type 'string' is missing in type 'QuakeShootablesVisibilityChurnStats'.", + "source": "first.snapshot.shootables.visibilityChurn" + }, + { + "file": "src/runtime/hazards.ts", + "code": 18047, + "message": "'amount' is possibly 'null'.", + "source": "amount" + }, + { + "file": "src/runtime/hazards.ts", + "code": 2322, + "message": "Type 'number | null' is not assignable to type 'number'.\n Type 'null' is not assignable to type 'number'.", + "source": "amount" + }, + { + "file": "src/runtime/menu.ts", + "code": 2322, + "message": "Type 'HTMLButtonElement | null' is not assignable to type 'HTMLElement'.\n Type 'null' is not assignable to type 'HTMLElement'.", + "source": "multiplayerBackButton()" + }, + { + "file": "src/runtime/movers.ts", + "code": 18048, + "message": "'state.prebakedPlat' is possibly 'undefined'.", + "source": "state.prebakedPlat" + }, + { + "file": "src/runtime/multiplayer/deathmatch.ts", + "code": 2345, + "message": "Argument of type 'QuakeMultiplayerVec3' is not assignable to parameter of type 'Vec3'.\n The type 'QuakeMultiplayerVec3' is 'readonly' and cannot be assigned to the mutable type 'Vec3'.", + "source": "fire.origin" + }, + { + "file": "src/runtime/multiplayer/deathmatch.ts", + "code": 2345, + "message": "Argument of type 'QuakeMultiplayerVec3' is not assignable to parameter of type 'Vec3'.\n The type 'QuakeMultiplayerVec3' is 'readonly' and cannot be assigned to the mutable type 'Vec3'.", + "source": "fire.origin" + }, + { + "file": "src/runtime/multiplayer/deathmatch.ts", + "code": 2345, + "message": "Argument of type 'QuakeMultiplayerVec3' is not assignable to parameter of type 'Vec3'.\n The type 'QuakeMultiplayerVec3' is 'readonly' and cannot be assigned to the mutable type 'Vec3'.", + "source": "input.attacker.origin as QuakeMultiplayerVec3" + }, + { + "file": "src/runtime/multiplayer/deathmatch.ts", + "code": 2345, + "message": "Argument of type 'QuakeMultiplayerVec3' is not assignable to parameter of type 'Vec3'.\n The type 'QuakeMultiplayerVec3' is 'readonly' and cannot be assigned to the mutable type 'Vec3'.", + "source": "origin" + }, + { + "file": "src/runtime/multiplayer/facts.ts", + "code": 2345, + "message": "Argument of type 'QuakeMultiplayerPickupEffect' is not assignable to parameter of type 'Record'.\n Index signature for type 'string' is missing in type 'QuakeMultiplayerPickupEffect'.", + "source": "pickup.effect" + }, + { + "file": "src/runtime/multiplayer/facts.ts", + "code": 2345, + "message": "Argument of type 'QuakeMultiplayerPickupLifecycle' is not assignable to parameter of type 'Record'.\n Index signature for type 'string' is missing in type 'QuakeMultiplayerPickupLifecycle'.", + "source": "pickup.lifecycle" + }, + { + "file": "src/runtime/multiplayer/facts.ts", + "code": 2345, + "message": "Argument of type 'QuakeMultiplayerPickupFeedback' is not assignable to parameter of type 'Record'.\n Index signature for type 'string' is missing in type 'QuakeMultiplayerPickupFeedback'.", + "source": "pickup.feedback" + }, + { + "file": "src/runtime/multiplayer/loopback.ts", + "code": 2322, + "message": "Type '{ rotX: number; rotY: number; updatedAt: number; playerId?: string | undefined; clientId?: string | undefined; displayName?: string | undefined; color?: string; mapName?: string | undefined; ... 12 more ...; respawnAt?: number; }' is not assignable to type 'QuakeMultiplayerAuthoritativePlayerState'.\n Types of property 'playerId' are incompatible.\n Type 'string | undefined' is not assignable to type 'string'.\n Type 'undefined' is not assignable to type 'string'.", + "source": "playerState" + }, + { + "file": "src/runtime/multiplayer/loopback.ts", + "code": 2367, + "message": "This comparison appears to be unintentional because the types 'number' and 'boolean' have no overlap.", + "source": "snapshotIntervalMs === false" + }, + { + "file": "src/runtime/multiplayer/loopback.ts", + "code": 2345, + "message": "Argument of type '{ alive: boolean; velocity: number[]; respawnAt: undefined; updatedAt: number; spawnId?: string; origin: QuakeMultiplayerVec3; rotX: number; rotY: number; playerId: string; clientId: string; ... 10 more ...; pingMs?: number; }' is not assignable to parameter of type 'QuakeMultiplayerAuthoritativePlayerState'.\n Types of property 'velocity' are incompatible.\n Type 'number[]' is not assignable to type 'QuakeMultiplayerVec3'.\n Target requires 3 element(s) but source may have fewer.", + "source": "respawned" + }, + { + "file": "src/runtime/multiplayer/loopback.ts", + "code": 2322, + "message": "Type '{ alive: boolean; velocity: number[]; respawnAt: undefined; updatedAt: number; spawnId?: string; origin: QuakeMultiplayerVec3; rotX: number; rotY: number; playerId: string; clientId: string; ... 10 more ...; pingMs?: number; }' is not assignable to type 'QuakeMultiplayerAuthoritativePlayerState'.\n Types of property 'velocity' are incompatible.\n Type 'number[]' is not assignable to type 'QuakeMultiplayerVec3'.\n Target requires 3 element(s) but source may have fewer.", + "source": "player" + }, + { + "file": "src/runtime/multiplayer/movement.ts", + "code": 2345, + "message": "Argument of type 'QuakeMultiplayerVec3' is not assignable to parameter of type '[number, number, number]'.\n The type 'QuakeMultiplayerVec3' is 'readonly' and cannot be assigned to the mutable type '[number, number, number]'.", + "source": "target" + }, + { + "file": "src/runtime/multiplayer/movement.ts", + "code": 4104, + "message": "The type 'QuakeMultiplayerVec3' is 'readonly' and cannot be assigned to the mutable type '[number, number, number]'.", + "source": "origin" + }, + { + "file": "src/runtime/multiplayer/movement.ts", + "code": 2540, + "message": "Cannot assign to '2' because it is a read-only property.", + "source": "2" + }, + { + "file": "src/runtime/multiplayer/movement.ts", + "code": 2540, + "message": "Cannot assign to '2' because it is a read-only property.", + "source": "2" + }, + { + "file": "src/runtime/multiplayer/partyRoom.ts", + "code": 2420, + "message": "Class 'CssQuakeMultiplayerRoom' incorrectly implements interface 'Server'.\n Property 'options' is private in type 'CssQuakeMultiplayerRoom' but not in type 'Server'.", + "source": "CssQuakeMultiplayerRoom" + }, + { + "file": "src/runtime/multiplayer/partyRoom.ts", + "code": 2345, + "message": "Argument of type 'QuakeMultiplayerVec3' is not assignable to parameter of type 'Vec3'.\n The type 'QuakeMultiplayerVec3' is 'readonly' and cannot be assigned to the mutable type 'Vec3'.", + "source": "offset" + }, + { + "file": "src/runtime/multiplayer/partyRoom.ts", + "code": 2322, + "message": "Type 'Promise | null' is not assignable to type 'Promise'.\n Type 'null' is not assignable to type 'Promise'.", + "source": "promise" + }, + { + "file": "src/runtime/multiplayer/partyRoom.ts", + "code": 2345, + "message": "Argument of type 'Extract[\"payload\"] | Extract[\"payload\"] | Extract<...>[\"payload\"] | Extract<...>[\"payload\"] | Extract<...>[\"payload\"] | Extract<...>[\"payload\"]' is not assignable to parameter of type 'Extract[\"payload\"] & Extract[\"payload\"] & Extract<...>[\"payload\"] & Extract<...>[\"payload\"] & Extract<...>[\"payload\"] & Extract<...>[\"payload\"]'.\n Type 'Extract[\"payload\"]' is not assignable to type 'Extract[\"payload\"] & Extract[\"payload\"] & Extract<...>[\"payload\"] & Extract<...>[\"payload\"] & Extract<...>[\"payload\"] & Extract<...>[\"payload\"]'.\n Type 'QuakeMultiplayerRoomSnapshotPayload' is not assignable to type 'Extract[\"payload\"] & Extract[\"payload\"] & Extract<...>[\"payload\"] & Extract<...>[\"payload\"] & Extract<...>[\"payload\"] & Extract<...>[\"payload\"]'.\n Type 'QuakeMultiplayerRoomSnapshotPayload' is not assignable to type 'Extract[\"payload\"]'.\n Type 'QuakeMultiplayerRoomSnapshotPayload' is missing the following properties from type 'QuakeMultiplayerRoomEventPayload': sequence, event\n Type 'Extract[\"payload\"]' is not assignable to type 'Extract[\"payload\"]'.\n Type 'Extract' is not assignable to type 'Extract'.\n Type '{ type: TType; } & QuakeMultiplayerRoomSnapshotEnvelope' is not assignable to type 'Extract'.", + "source": "payload" + }, + { + "file": "src/runtime/multiplayer/partyRoom.ts", + "code": 2345, + "message": "Argument of type 'Extract[\"payload\"] | Extract[\"payload\"] | Extract<...>[\"payload\"] | Extract<...>[\"payload\"] | Extract<...>[\"payload\"] | Extract<...>[\"payload\"]' is not assignable to parameter of type 'Extract[\"payload\"] & Extract[\"payload\"] & Extract<...>[\"payload\"] & Extract<...>[\"payload\"] & Extract<...>[\"payload\"] & Extract<...>[\"payload\"]'.\n Type 'Extract[\"payload\"]' is not assignable to type 'Extract[\"payload\"] & Extract[\"payload\"] & Extract<...>[\"payload\"] & Extract<...>[\"payload\"] & Extract<...>[\"payload\"] & Extract<...>[\"payload\"]'.\n Type 'QuakeMultiplayerRoomSnapshotPayload' is not assignable to type 'Extract[\"payload\"] & Extract[\"payload\"] & Extract<...>[\"payload\"] & Extract<...>[\"payload\"] & Extract<...>[\"payload\"] & Extract<...>[\"payload\"]'.\n Type 'QuakeMultiplayerRoomSnapshotPayload' is not assignable to type 'Extract[\"payload\"]'.\n Type 'QuakeMultiplayerRoomSnapshotPayload' is missing the following properties from type 'QuakeMultiplayerRoomEventPayload': sequence, event\n Type 'Extract[\"payload\"]' is not assignable to type 'Extract[\"payload\"]'.\n Type 'Extract' is not assignable to type 'Extract'.\n Type '{ type: TType; } & QuakeMultiplayerRoomSnapshotEnvelope' is not assignable to type 'Extract'.", + "source": "payload" + }, + { + "file": "src/runtime/multiplayer/partyRoom.ts", + "code": 2322, + "message": "Type 'unknown' is not assignable to type 'number'.", + "source": "assetManifestVersion" + }, + { + "file": "src/runtime/multiplayer/partyRoom.ts", + "code": 1360, + "message": "Type 'typeof CssQuakeMultiplayerRoom' does not satisfy the expected type 'Worker'.\n Types of construct signatures are incompatible.\n Type 'new (room: Room, options?: CssQuakeMultiplayerRoomOptions) => CssQuakeMultiplayerRoom' is not assignable to type 'new (room: Room) => Server'.\n Type 'CssQuakeMultiplayerRoom' is not assignable to type 'Server'.\n Property 'options' is private in type 'CssQuakeMultiplayerRoom' but not in type 'Server'.", + "source": "satisfies" + }, + { + "file": "src/runtime/multiplayer/projectileAuthority.ts", + "code": 2345, + "message": "Argument of type 'QuakeMultiplayerVec3' is not assignable to parameter of type 'Vec3'.\n The type 'QuakeMultiplayerVec3' is 'readonly' and cannot be assigned to the mutable type 'Vec3'.", + "source": "origin" + }, + { + "file": "src/runtime/multiplayer/simulation.ts", + "code": 18048, + "message": "'selected.input' is possibly 'undefined'.", + "source": "selected.input" + }, + { + "file": "src/runtime/multiplayer/simulation.ts", + "code": 18048, + "message": "'selected.input' is possibly 'undefined'.", + "source": "selected.input" + }, + { + "file": "src/runtime/multiplayer/simulation.ts", + "code": 18048, + "message": "'selected.input' is possibly 'undefined'.", + "source": "selected.input" + }, + { + "file": "src/runtime/multiplayer/simulation.ts", + "code": 2345, + "message": "Argument of type 'QuakeMultiplayerVec3' is not assignable to parameter of type 'Vec3'.\n The type 'QuakeMultiplayerVec3' is 'readonly' and cannot be assigned to the mutable type 'Vec3'.", + "source": "player.origin as QuakeMultiplayerVec3" + }, + { + "file": "src/runtime/multiplayer/simulation.ts", + "code": 2345, + "message": "Argument of type 'QuakeMultiplayerVec3' is not assignable to parameter of type 'Vec3'.\n The type 'QuakeMultiplayerVec3' is 'readonly' and cannot be assigned to the mutable type 'Vec3'.", + "source": "quakeMultiplayerLiquidContentsPoint(player.origin, playerEyeHeight)" + }, + { + "file": "src/runtime/multiplayer/simulation.ts", + "code": 2345, + "message": "Argument of type 'QuakeMultiplayerVec3' is not assignable to parameter of type 'Vec3'.\n The type 'QuakeMultiplayerVec3' is 'readonly' and cannot be assigned to the mutable type 'Vec3'.", + "source": "player.origin as QuakeMultiplayerVec3" + }, + { + "file": "src/runtime/multiplayer/simulation.ts", + "code": 2345, + "message": "Argument of type 'QuakeMultiplayerVec3' is not assignable to parameter of type 'Vec3'.\n The type 'QuakeMultiplayerVec3' is 'readonly' and cannot be assigned to the mutable type 'Vec3'.", + "source": "quakeMultiplayerLiquidContentsPoint(player.origin, eyeHeight)" + }, + { + "file": "src/runtime/multiplayer/validation.ts", + "code": 2345, + "message": "Argument of type 'unknown' is not assignable to parameter of type 'number'.", + "source": "value.sentAt" + }, + { + "file": "src/runtime/multiplayer/validation.ts", + "code": 2352, + "message": "Conversion of type 'Record' to type 'QuakeMultiplayerAnyEnvelope' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.\n Type 'Record' is missing the following properties from type 'QuakeMultiplayerEnvelope<\"room\", \"room.pong\", QuakeMultiplayerPongPayload>': protocolVersion, direction, type, messageId, and 4 more.", + "source": "value as QuakeMultiplayerAnyEnvelope" + }, + { + "file": "src/runtime/multiplayer/validation.ts", + "code": 2352, + "message": "Conversion of type 'Record' to type 'QuakeMultiplayerAnyEnvelope' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.\n Type 'Record' is missing the following properties from type 'QuakeMultiplayerEnvelope<\"room\", \"room.pong\", QuakeMultiplayerPongPayload>': protocolVersion, direction, type, messageId, and 4 more.", + "source": "value as QuakeMultiplayerAnyEnvelope" + }, + { + "file": "src/runtime/multiplayer/validation.ts", + "code": 18046, + "message": "'value' is of type 'unknown'.", + "source": "value" + }, + { + "file": "src/runtime/multiplayer/validation.ts", + "code": 18046, + "message": "'value' is of type 'unknown'.", + "source": "value" + }, + { + "file": "src/runtime/multiplayer/validation.ts", + "code": 18046, + "message": "'value' is of type 'unknown'.", + "source": "value" + }, + { + "file": "src/runtime/multiplayer/world.ts", + "code": 2322, + "message": "Type 'QuakeMultiplayerShootableWorldHit' is not assignable to type 'QuakeMultiplayerShootableMoverHit | null'.\n Type 'QuakeMultiplayerShootableTriggerHit' is not assignable to type 'QuakeMultiplayerShootableMoverHit'.\n Types of property 'definition' are incompatible.\n Type '{ kind: \"trigger\"; entityIndex: number; classname: QuakeMultiplayerTriggerActivationClassname; bounds?: QuakeMultiplayerWorldBounds | undefined; ... 13 more ...; soundPath?: string | undefined; }' is missing the following properties from type '{ kind: \"mover\"; entityIndex: number; classname: QuakeMultiplayerMoverClassname; bounds?: QuakeMultiplayerWorldBounds | undefined; ... 12 more ...; soundPath?: string | undefined; }': speed, moveMs, fromOrigin, toOrigin", + "source": "hit" + }, + { + "file": "src/runtime/multiplayer/world.ts", + "code": 2322, + "message": "Type '{ kind: \"trigger\"; entityIndex: number; classname: QuakeMultiplayerTriggerActivationClassname; bounds?: QuakeMultiplayerWorldBounds | undefined; ... 13 more ...; soundPath?: string | undefined; } | { ...; }' is not assignable to type '{ kind: \"trigger\"; entityIndex: number; classname: QuakeMultiplayerTriggerActivationClassname; bounds?: QuakeMultiplayerWorldBounds | undefined; ... 13 more ...; soundPath?: string | undefined; }'.\n Type '{ kind: \"mover\"; entityIndex: number; classname: QuakeMultiplayerMoverClassname; bounds?: QuakeMultiplayerWorldBounds | undefined; ... 12 more ...; soundPath?: string | undefined; }' is missing the following properties from type '{ kind: \"trigger\"; entityIndex: number; classname: QuakeMultiplayerTriggerActivationClassname; bounds?: QuakeMultiplayerWorldBounds | undefined; ... 13 more ...; soundPath?: string | undefined; }': oneShot, waitMs", + "source": "definition" + }, + { + "file": "src/runtime/multiplayer/world.ts", + "code": 2339, + "message": "Property 'destinationEntityIndex' does not exist on type 'QuakeMultiplayerWorldDefinition'.\n Property 'destinationEntityIndex' does not exist on type '{ kind: \"changelevel\"; entityIndex: number; classname: \"trigger_changelevel\"; bounds?: QuakeMultiplayerWorldBounds | undefined; targetMap: string; }'.", + "source": "destinationEntityIndex" + }, + { + "file": "src/runtime/multiplayer/world.ts", + "code": 2339, + "message": "Property 'targetMap' does not exist on type 'QuakeMultiplayerWorldDefinition'.\n Property 'targetMap' does not exist on type '{ kind: \"teleport\"; entityIndex: number; classname: \"trigger_teleport\"; bounds?: QuakeMultiplayerWorldBounds | undefined; destinationEntityIndex: number; ... 4 more ...; activationWindowMs?: number | undefined; }'.", + "source": "targetMap" + }, + { + "file": "src/runtime/multiplayer/world.ts", + "code": 2345, + "message": "Argument of type 'number | undefined' is not assignable to parameter of type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "wait" + }, + { + "file": "src/runtime/multiplayer/world.ts", + "code": 2322, + "message": "Type 'QuakeMultiplayerVec3' is not assignable to type 'QuakeMultiplayerJson'.\n Type 'readonly [number, number, number]' is not assignable to type '{ [key: string]: QuakeMultiplayerJson; }'.\n Index signature for type 'string' is missing in type 'readonly [number, number, number]'.", + "source": "authoritativeOrigin" + }, + { + "file": "src/runtime/multiplayer/world.ts", + "code": 2322, + "message": "Type 'QuakeMultiplayerVec3 | null' is not assignable to type 'QuakeMultiplayerJson'.\n Type 'QuakeMultiplayerVec3' is not assignable to type 'QuakeMultiplayerJson'.\n Type 'readonly [number, number, number]' is not assignable to type '{ [key: string]: QuakeMultiplayerJson; }'.\n Index signature for type 'string' is missing in type 'readonly [number, number, number]'.", + "source": "hintOrigin" + }, + { + "file": "src/runtime/multiplayer/world.ts", + "code": 18048, + "message": "'angle' is possibly 'undefined'.", + "source": "angle" + }, + { + "file": "src/runtime/orientation.ts", + "code": 2339, + "message": "Property 'lock' does not exist on type 'ScreenOrientation'.", + "source": "lock" + }, + { + "file": "src/runtime/orientation.ts", + "code": 2339, + "message": "Property 'lock' does not exist on type 'ScreenOrientation'.", + "source": "lock" + }, + { + "file": "src/runtime/pickups.ts", + "code": 18048, + "message": "'rule.delaySeconds' is possibly 'undefined'.", + "source": "rule.delaySeconds" + }, + { + "file": "src/runtime/player.ts", + "code": 2322, + "message": "Type 'unknown' is not assignable to type 'number'.", + "source": "value" + }, + { + "file": "src/runtime/player.ts", + "code": 2345, + "message": "Argument of type 'number | null | undefined' is not assignable to parameter of type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "value" + }, + { + "file": "src/runtime/renderBundleMesh.ts", + "code": 18048, + "message": "'leaf' is possibly 'undefined'.", + "source": "leaf" + }, + { + "file": "src/runtime/renderBundleMesh.ts", + "code": 18048, + "message": "'polyIndex' is possibly 'undefined'.", + "source": "polyIndex" + }, + { + "file": "src/runtime/renderBundleMesh.ts", + "code": 2322, + "message": "Type 'number | undefined' is not assignable to type 'number | null'.\n Type 'undefined' is not assignable to type 'number | null'.", + "source": "return" + }, + { + "file": "src/runtime/renderBundleMesh.ts", + "code": 18048, + "message": "'faceIndex' is possibly 'undefined'.", + "source": "faceIndex" + }, + { + "file": "src/runtime/renderBundleMesh.ts", + "code": 2322, + "message": "Type 'number | undefined' is not assignable to type 'number | null'.\n Type 'undefined' is not assignable to type 'number | null'.", + "source": "faceIndex" + }, + { + "file": "src/runtime/renderBundleMesh.ts", + "code": 2345, + "message": "Argument of type 'QuakePackedRenderBundleLeafFrameStyle' is not assignable to parameter of type '[matrix?: string | undefined, background?: string | null | undefined, extraStyle?: string | null | undefined]'.\n Type at position 0 in source is not compatible with type at position 0 in target.\n Type 'string | null | undefined' is not assignable to type 'string | undefined'.\n Type 'null' is not assignable to type 'string | undefined'.", + "source": "frameStyle" + }, + { + "file": "src/runtime/renderBundleMesh.ts", + "code": 2345, + "message": "Argument of type 'QuakePackedRenderBundleLeafFrameStyle' is not assignable to parameter of type '[matrix?: string | undefined, background?: string | null | undefined, extraStyle?: string | null | undefined]'.\n Type at position 0 in source is not compatible with type at position 0 in target.\n Type 'string | null | undefined' is not assignable to type 'string | undefined'.\n Type 'null' is not assignable to type 'string | undefined'.", + "source": "frameStyle" + }, + { + "file": "src/runtime/renderBundleMesh.ts", + "code": 2345, + "message": "Argument of type 'string | null | undefined' is not assignable to parameter of type 'string | undefined'.\n Type 'null' is not assignable to type 'string | undefined'.", + "source": "firstExtraStyle" + }, + { + "file": "src/runtime/renderBundleMesh.ts", + "code": 2345, + "message": "Argument of type 'string | null | undefined' is not assignable to parameter of type 'string | undefined'.\n Type 'null' is not assignable to type 'string | undefined'.", + "source": "extraStyle" + }, + { + "file": "src/runtime/shootables.ts", + "code": 2322, + "message": "Type '{ entityIndex: number; classname: string; modelSource: string | null; origin: number[]; leafIndex: number | null; enemy: boolean; dead: boolean; health: number; visible: boolean; mounted: boolean; ... 57 more ...; moveGoalDecisions: { ...; }[]; }[]' is not assignable to type 'QuakeShootableDebugCullingEntry[]'.\n Type '{ entityIndex: number; classname: string; modelSource: string | null; origin: number[]; leafIndex: number | null; enemy: boolean; dead: boolean; health: number; visible: boolean; mounted: boolean; ... 57 more ...; moveGoalDecisions: { ...; }[]; }' is not assignable to type 'QuakeShootableDebugCullingEntry'.\n Types of property 'origin' are incompatible.\n Type 'number[]' is not assignable to type '[number, number, number]'.\n Target requires 3 element(s) but source may have fewer.", + "source": "entries" + }, + { + "file": "src/runtime/shootables.ts", + "code": 2322, + "message": "Type 'QuakeEnemyTargetReference | { kind: \"player\"; }' is not assignable to type 'QuakeEnemyTargetReference | null'.\n Type '{ kind: \"player\"; }' is missing the following properties from type 'QuakeEnemyTargetReference': classname, id", + "source": "enemy.currentTarget" + }, + { + "file": "src/runtime/shootables.ts", + "code": 18048, + "message": "'touch.minVelocityUnits' is possibly 'undefined'.", + "source": "touch.minVelocityUnits" + }, + { + "file": "src/runtime/shootables.ts", + "code": 2339, + "message": "Property 'origin' does not exist on type 'QuakeDamageActorReference'.\n Property 'origin' does not exist on type '{ kind: \"player\"; classname: \"player\"; id: \"player\"; }'.", + "source": "origin" + }, + { + "file": "src/runtime/shootables.ts", + "code": 2339, + "message": "Property 'origin' does not exist on type 'QuakeDamageActorReference'.\n Property 'origin' does not exist on type '{ kind: \"player\"; classname: \"player\"; id: \"player\"; }'.", + "source": "origin" + }, + { + "file": "src/runtime/shootables/combatFacts.ts", + "code": 2352, + "message": "Conversion of type '{ readonly monster_army: { readonly callbacks: { readonly th_stand: \"army_stand1\"; readonly th_walk: \"army_walk1\"; readonly th_run: \"army_run1\"; readonly th_missile: \"army_atk1\"; readonly th_pain: \"army_pain\"; readonly th_die: \"army_die\"; }; ... 5 more ...; readonly spawnProfile: { ...; }; }; ... 7 more ...; readonl...' to type 'Readonly; }>>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.\n Property '\"monster_army\"' is incompatible with index signature.\n Type '{ readonly callbacks: { readonly th_stand: \"army_stand1\"; readonly th_walk: \"army_walk1\"; readonly th_run: \"army_run1\"; readonly th_missile: \"army_atk1\"; readonly th_pain: \"army_pain\"; readonly th_die: \"army_die\"; }; ... 5 more ...; readonly spawnProfile: { ...; }; }' is not comparable to type '{ chains: Record; }'.\n Types of property 'chains' are incompatible.\n Type '{ readonly stand: { readonly start: \"army_stand1\"; readonly states: readonly [{ readonly calls: readonly [\"ai_stand\"]; readonly frame: \"stand1\"; readonly frameIndex: 0; readonly movement: readonly [{ readonly call: \"ai_stand\"; readonly distanceUnits: 0; }]; readonly name: \"army_stand1\"; readonly next: \"army_stand2\";...' is not comparable to type 'Record'.\n Property '\"stand\"' is incompatible with index signature.\n Type '{ readonly start: \"army_stand1\"; readonly states: readonly [{ readonly calls: readonly [\"ai_stand\"]; readonly frame: \"stand1\"; readonly frameIndex: 0; readonly movement: readonly [{ readonly call: \"ai_stand\"; readonly distanceUnits: 0; }]; readonly name: \"army_stand1\"; readonly next: \"army_stand2\"; readonly sounds: ...' is not comparable to type '{ states: QuakeMonsterFrameState[]; }'.\n Types of property 'states' are incompatible.\n The type 'readonly [{ readonly calls: readonly [\"ai_stand\"]; readonly frame: \"stand1\"; readonly frameIndex: 0; readonly movement: readonly [{ readonly call: \"ai_stand\"; readonly distanceUnits: 0; }]; readonly name: \"army_stand1\"; readonly next: \"army_stand2\"; readonly sounds: readonly []; }, ... 6 more ..., { ...; }]' is 'readonly' and cannot be assigned to the mutable type 'QuakeMonsterFrameState[]'.", + "source": "QUAKE_MONSTER_LOGIC as Readonly;\n}>>" + }, + { + "file": "src/runtime/shootables/enemyAcquisition.ts", + "code": 18048, + "message": "'scale' is possibly 'undefined'.", + "source": "scale" + }, + { + "file": "src/runtime/shootables/enemyAcquisition.ts", + "code": 2322, + "message": "Type 'number | undefined' is not assignable to type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "scale" + }, + { + "file": "src/runtime/shootables/enemyCombat.ts", + "code": 2339, + "message": "Property 'origin' does not exist on type 'QuakeDamageActorReference'.\n Property 'origin' does not exist on type '{ kind: \"player\"; classname: \"player\"; id: \"player\"; }'.", + "source": "origin" + }, + { + "file": "src/runtime/shootables/enemyCombat.ts", + "code": 2339, + "message": "Property 'origin' does not exist on type 'QuakeDamageActorReference'.\n Property 'origin' does not exist on type '{ kind: \"player\"; classname: \"player\"; id: \"player\"; }'.", + "source": "origin" + }, + { + "file": "src/runtime/shootables/enemyCombat.ts", + "code": 2345, + "message": "Argument of type 'number | undefined' is not assignable to parameter of type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "policy.cooldownMs" + }, + { + "file": "src/runtime/text.ts", + "code": 18048, + "message": "'value' is possibly 'undefined'.", + "source": "value" + }, + { + "file": "src/runtime/text.ts", + "code": 2322, + "message": "Type 'number | undefined' is not assignable to type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "value" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2322, + "message": "Type 'string | undefined' is not assignable to type 'string | null'.\n Type 'undefined' is not assignable to type 'string | null'.", + "source": "profile.modelPath" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2339, + "message": "Property 'bounce' does not exist on type 'QuakeLinearProjectileFireProfile | QuakeUnsupportedProjectileFireProfile'.\n Property 'bounce' does not exist on type 'QuakeUnsupportedProjectileFireProfile'.", + "source": "bounce" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2339, + "message": "Property 'directDamageRandom' does not exist on type 'QuakeLinearProjectileFireProfile | QuakeUnsupportedProjectileFireProfile'.\n Property 'directDamageRandom' does not exist on type 'QuakeUnsupportedProjectileFireProfile'.", + "source": "directDamageRandom" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2339, + "message": "Property 'explodeOnExpire' does not exist on type 'QuakeLinearProjectileFireProfile | QuakeUnsupportedProjectileFireProfile'.\n Property 'explodeOnExpire' does not exist on type 'QuakeUnsupportedProjectileFireProfile'.", + "source": "explodeOnExpire" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2339, + "message": "Property 'forwardOffsetUnits' does not exist on type 'QuakeLinearProjectileFireProfile | QuakeUnsupportedProjectileFireProfile'.\n Property 'forwardOffsetUnits' does not exist on type 'QuakeUnsupportedProjectileFireProfile'.", + "source": "forwardOffsetUnits" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2339, + "message": "Property 'gravity' does not exist on type 'QuakeLinearProjectileFireProfile | QuakeUnsupportedProjectileFireProfile'.\n Property 'gravity' does not exist on type 'QuakeUnsupportedProjectileFireProfile'.", + "source": "gravity" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2339, + "message": "Property 'lifetimeMs' does not exist on type 'QuakeLinearProjectileFireProfile | QuakeUnsupportedProjectileFireProfile'.\n Property 'lifetimeMs' does not exist on type 'QuakeUnsupportedProjectileFireProfile'.", + "source": "lifetimeMs" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2339, + "message": "Property 'monsterTouchHullExpansion' does not exist on type 'QuakeLinearProjectileFireProfile | QuakeUnsupportedProjectileFireProfile'.\n Property 'monsterTouchHullExpansion' does not exist on type 'QuakeUnsupportedProjectileFireProfile'.", + "source": "monsterTouchHullExpansion" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2339, + "message": "Property 'rightOffsetUnits' does not exist on type 'QuakeLinearProjectileFireProfile | QuakeUnsupportedProjectileFireProfile'.\n Property 'rightOffsetUnits' does not exist on type 'QuakeUnsupportedProjectileFireProfile'.", + "source": "rightOffsetUnits" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2339, + "message": "Property 'sourceZOffsetUnits' does not exist on type 'QuakeLinearProjectileFireProfile | QuakeUnsupportedProjectileFireProfile'.\n Property 'sourceZOffsetUnits' does not exist on type 'QuakeUnsupportedProjectileFireProfile'.", + "source": "sourceZOffsetUnits" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2339, + "message": "Property 'explosionBackoff' does not exist on type 'QuakeLinearProjectileFireProfile | QuakeUnsupportedProjectileFireProfile'.\n Property 'explosionBackoff' does not exist on type 'QuakeUnsupportedProjectileFireProfile'.", + "source": "explosionBackoff" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2339, + "message": "Property 'splashDamage' does not exist on type 'QuakeLinearProjectileFireProfile | QuakeUnsupportedProjectileFireProfile'.\n Property 'splashDamage' does not exist on type 'QuakeUnsupportedProjectileFireProfile'.", + "source": "splashDamage" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2339, + "message": "Property 'splashIgnoresDirectHit' does not exist on type 'QuakeLinearProjectileFireProfile | QuakeUnsupportedProjectileFireProfile'.\n Property 'splashIgnoresDirectHit' does not exist on type 'QuakeUnsupportedProjectileFireProfile'.", + "source": "splashIgnoresDirectHit" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2339, + "message": "Property 'splashRadius' does not exist on type 'QuakeLinearProjectileFireProfile | QuakeUnsupportedProjectileFireProfile'.\n Property 'splashRadius' does not exist on type 'QuakeUnsupportedProjectileFireProfile'.", + "source": "splashRadius" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2339, + "message": "Property 'verticalVelocity' does not exist on type 'QuakeLinearProjectileFireProfile | QuakeUnsupportedProjectileFireProfile'.\n Property 'verticalVelocity' does not exist on type 'QuakeUnsupportedProjectileFireProfile'.", + "source": "verticalVelocity" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2339, + "message": "Property 'damageEndForwardOffsetUnits' does not exist on type 'QuakeBeamFireProfile | QuakeUnsupportedBeamFireProfile'.\n Property 'damageEndForwardOffsetUnits' does not exist on type 'QuakeUnsupportedBeamFireProfile'.", + "source": "damageEndForwardOffsetUnits" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2339, + "message": "Property 'damageSourceZOffsetUnits' does not exist on type 'QuakeBeamFireProfile | QuakeUnsupportedBeamFireProfile'.\n Property 'damageSourceZOffsetUnits' does not exist on type 'QuakeUnsupportedBeamFireProfile'.", + "source": "damageSourceZOffsetUnits" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2339, + "message": "Property 'damageTraceOffsetUnits' does not exist on type 'QuakeBeamFireProfile | QuakeUnsupportedBeamFireProfile'.\n Property 'damageTraceOffsetUnits' does not exist on type 'QuakeUnsupportedBeamFireProfile'.", + "source": "damageTraceOffsetUnits" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2339, + "message": "Property 'sourceZOffsetUnits' does not exist on type 'QuakeBeamFireProfile | QuakeUnsupportedBeamFireProfile'.\n Property 'sourceZOffsetUnits' does not exist on type 'QuakeUnsupportedBeamFireProfile'.", + "source": "sourceZOffsetUnits" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2339, + "message": "Property 'tempEntity' does not exist on type 'QuakeBeamFireProfile | QuakeUnsupportedBeamFireProfile'.\n Property 'tempEntity' does not exist on type 'QuakeUnsupportedBeamFireProfile'.", + "source": "tempEntity" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2339, + "message": "Property 'underwaterDischarge' does not exist on type 'QuakeBeamFireProfile | QuakeUnsupportedBeamFireProfile'.\n Property 'underwaterDischarge' does not exist on type 'QuakeUnsupportedBeamFireProfile'.", + "source": "underwaterDischarge" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2345, + "message": "Argument of type 'number | undefined' is not assignable to parameter of type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "directDamage" + }, + { + "file": "src/runtime/weapons.ts", + "code": 2722, + "message": "Cannot invoke an object which is possibly 'undefined'.", + "source": "collisionWorld.traceUse" + }, + { + "file": "src/runtime/world.ts", + "code": 2345, + "message": "Argument of type 'number | null | undefined' is not assignable to parameter of type 'number | null'.\n Type 'undefined' is not assignable to type 'number | null'.", + "source": "nextLeafIndex" + }, + { + "file": "src/runtime/world.ts", + "code": 2322, + "message": "Type 'number | null | undefined' is not assignable to type 'number | null'.\n Type 'undefined' is not assignable to type 'number | null'.", + "source": "visibleLeafIndex" + }, + { + "file": "src/runtime/world.ts", + "code": 2322, + "message": "Type 'number | null | undefined' is not assignable to type 'number | null'.\n Type 'undefined' is not assignable to type 'number | null'.", + "source": "visibleLeafIndex" + }, + { + "file": "src/runtime/world.ts", + "code": 2322, + "message": "Type 'number | null | undefined' is not assignable to type 'number | null'.\n Type 'undefined' is not assignable to type 'number | null'.", + "source": "nextLeafIndex" + }, + { + "file": "src/runtime/world.ts", + "code": 2322, + "message": "Type 'number | null | undefined' is not assignable to type 'number | null'.\n Type 'undefined' is not assignable to type 'number | null'.", + "source": "visibleLeafIndex" + }, + { + "file": "src/runtime/world.ts", + "code": 2322, + "message": "Type 'number | null | undefined' is not assignable to type 'number | null'.\n Type 'undefined' is not assignable to type 'number | null'.", + "source": "nextLeafIndex" + }, + { + "file": "src/runtime/world.ts", + "code": 2322, + "message": "Type 'number | null | undefined' is not assignable to type 'number | null'.\n Type 'undefined' is not assignable to type 'number | null'.", + "source": "visibleLeafIndex" + }, + { + "file": "src/runtime/world.ts", + "code": 2322, + "message": "Type 'number | null | undefined' is not assignable to type 'number | null'.\n Type 'undefined' is not assignable to type 'number | null'.", + "source": "nextLeafIndex" + }, + { + "file": "src/runtime/world.ts", + "code": 2345, + "message": "Argument of type 'number | undefined' is not assignable to parameter of type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "currentLeafIndex" + }, + { + "file": "src/runtime/world.ts", + "code": 2345, + "message": "Argument of type 'number | undefined' is not assignable to parameter of type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "currentLeafIndex" + }, + { + "file": "src/runtime/world.ts", + "code": 2345, + "message": "Argument of type 'number | undefined' is not assignable to parameter of type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "currentLeafIndex" + }, + { + "file": "src/runtime/world.ts", + "code": 2322, + "message": "Type 'number | null | undefined' is not assignable to type 'number | null'.\n Type 'undefined' is not assignable to type 'number | null'.", + "source": "currentLeafIndex" + }, + { + "file": "src/runtime/world.ts", + "code": 2322, + "message": "Type 'number | null | undefined' is not assignable to type 'number | null'.\n Type 'undefined' is not assignable to type 'number | null'.", + "source": "currentLeafIndex" + }, + { + "file": "src/runtime/world.ts", + "code": 18048, + "message": "'pageIndex' is possibly 'undefined'.", + "source": "pageIndex" + }, + { + "file": "src/runtime/world.ts", + "code": 2322, + "message": "Type 'number | undefined' is not assignable to type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "pageIndex" + }, + { + "file": "src/runtime/world.ts", + "code": 2345, + "message": "Argument of type 'number | undefined' is not assignable to parameter of type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "pageIndex" + }, + { + "file": "src/runtime/world.ts", + "code": 2345, + "message": "Argument of type 'number | undefined' is not assignable to parameter of type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "pageIndex" + }, + { + "file": "src/runtime/world.ts", + "code": 2322, + "message": "Type 'number | undefined' is not assignable to type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "pageIndex" + }, + { + "file": "src/runtime/world.ts", + "code": 18048, + "message": "'atlasPageIndex' is possibly 'undefined'.", + "source": "atlasPageIndex" + }, + { + "file": "src/runtime/world.ts", + "code": 2345, + "message": "Argument of type 'QuakeRenderBundleLeafMetadata' is not assignable to parameter of type 'QuakeVisibilityLeafMetadata'.\n Type 'QuakeRenderBundleLeafMetadata' is missing the following properties from type 'QuakeVisibilityLeafMetadata': leafIndex, contents, bounds, faceIndexes, and 3 more.", + "source": "metadata" + }, + { + "file": "src/runtime/world.ts", + "code": 2345, + "message": "Argument of type 'number | undefined' is not assignable to parameter of type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "modelIndex" + }, + { + "file": "src/runtime/world.ts", + "code": 2345, + "message": "Argument of type 'number | undefined' is not assignable to parameter of type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "modelIndex" + }, + { + "file": "src/runtime/world.ts", + "code": 2339, + "message": "Property 'l' does not exist on type 'QuakeVisibilityLeafMetadata'.", + "source": "l" + }, + { + "file": "src/runtime/world.ts", + "code": 2339, + "message": "Property 'l' does not exist on type 'QuakeVisibilityLeafMetadata'.", + "source": "l" + }, + { + "file": "src/runtime/world.ts", + "code": 2345, + "message": "Argument of type 'number | undefined' is not assignable to parameter of type 'number'.\n Type 'undefined' is not assignable to type 'number'.", + "source": "styleId" + } + ] +} diff --git a/test/typescript/baseline.test.mjs b/test/typescript/baseline.test.mjs new file mode 100644 index 0000000..ea485e6 --- /dev/null +++ b/test/typescript/baseline.test.mjs @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { newDiagnostics } from "./check.mjs"; + +const error = { file: "src/example.ts", code: 2322, message: "Type mismatch", source: "assignment" }; +test("fixing an existing error cannot pay for a new error elsewhere", () => { + const replacement = { ...error, source: "anotherAssignment" }; + assert.deepEqual(newDiagnostics([replacement], [error]), [replacement]); +}); +test("another occurrence of the same error fails the baseline", () => { + assert.deepEqual(newDiagnostics([error, error], [error]), [error]); +}); +test("existing errors may move or be removed", () => { + assert.deepEqual(newDiagnostics([error], [error, error]), []); + assert.deepEqual(newDiagnostics([], [error]), []); +}); diff --git a/test/typescript/check.mjs b/test/typescript/check.mjs new file mode 100644 index 0000000..3c318d9 --- /dev/null +++ b/test/typescript/check.mjs @@ -0,0 +1,47 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +export function collectDiagnostics(root = projectRoot) { + const config = ts.readConfigFile(path.join(projectRoot, "tsconfig.json"), ts.sys.readFile); + if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, "\n")); + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root); + const program = ts.createProgram(parsed.fileNames, parsed.options); + return [...parsed.errors, ...ts.getPreEmitDiagnostics(program)].map(diagnostic => ({ + file: diagnostic.file ? path.relative(root, diagnostic.file.fileName).split(path.sep).join("/") : "", + code: diagnostic.code, + message: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n").split(root).join(""), + // Keep the source expression, not its line number: moving an error does not hide a new one. + source: diagnostic.file && diagnostic.start !== undefined + ? diagnostic.file.text.slice(diagnostic.start, diagnostic.start + diagnostic.length).trim() + : "", + })); +} + +export function newDiagnostics(current, baseline) { + const remaining = new Map(); + for (const diagnostic of baseline) { + const key = JSON.stringify(diagnostic); + remaining.set(key, (remaining.get(key) ?? 0) + 1); + } + return current.filter(diagnostic => { + const key = JSON.stringify(diagnostic); + const count = remaining.get(key) ?? 0; + if (!count) return true; + remaining.set(key, count - 1); + return false; + }); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const baseline = JSON.parse(readFileSync(new URL("./baseline.json", import.meta.url), "utf8")); + if (baseline.compilerVersion !== ts.version) throw new Error("TypeScript version changed; review the baseline with the compiler update."); + const current = collectDiagnostics(); + const added = newDiagnostics(current, baseline.diagnostics); + for (const diagnostic of added) console.error(`${diagnostic.file}: TS${diagnostic.code}: ${diagnostic.message}\n ${diagnostic.source}`); + console.log(`TypeScript baseline: ${current.length} existing diagnostics remain; ${added.length} new diagnostics (main had ${baseline.diagnostics.length}).`); + process.exitCode = added.length ? 1 : 0; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c639dcc --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client", "node"], + "allowJs": true, + "checkJs": false, + "strictNullChecks": true, + "noImplicitAny": false, + "skipLibCheck": true, + "noEmit": true + }, + "files": ["src/main.ts", "src/runtime/multiplayer/partyRoom.ts", "src/runtime/multiplayer/presenceRoom.ts"] +}