From af3a274361b529f17aabc1b52e1383061611b217 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Mon, 15 Jun 2026 19:51:36 -0300 Subject: [PATCH 01/18] Consolidate browser harness fixtures --- package.json | 9 +- src/App.ts | 12 +- src/runtime/app/debugApi.ts | 16 + src/runtime/debug/quakeDebug.ts | 62 ++ src/runtime/shootables.ts | 120 ++- src/runtime/shootables/enemyCombat.ts | 2 +- src/runtime/shootables/enemyProjectiles.ts | 46 +- src/runtime/shootables/state.ts | 2 + test/HARNESS.md | 58 ++ test/browserFixtureDefinitions.mjs | 917 +++++++++++++++++++++ test/browserFixtureProjectile.mjs | 866 +++++++++++++++++++ test/browserHarnessSupport.mjs | 233 ++++++ test/checkAssetState.mjs | 163 ++++ test/enemyProjectiles.test.mjs | 112 ++- test/runAssetIntegrity.mjs | 77 ++ test/runBrowserFixtures.mjs | 166 ++++ test/runBrowserSmoke.mjs | 161 ++++ test/runPerfPreflight.mjs | 43 + 18 files changed, 3044 insertions(+), 21 deletions(-) create mode 100644 test/HARNESS.md create mode 100644 test/browserFixtureDefinitions.mjs create mode 100644 test/browserFixtureProjectile.mjs create mode 100644 test/browserHarnessSupport.mjs create mode 100644 test/checkAssetState.mjs create mode 100644 test/runAssetIntegrity.mjs create mode 100644 test/runBrowserFixtures.mjs create mode 100644 test/runBrowserSmoke.mjs create mode 100644 test/runPerfPreflight.mjs diff --git a/package.json b/package.json index 589a28f..b12c619 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,14 @@ "build": "vite build", "build:full": "pnpm prepare:quake && vite build", "preview": "vite preview", - "test": "node scripts/testContracts.mjs" + "test": "node scripts/testContracts.mjs", + "test:asset-state": "node test/checkAssetState.mjs", + "test:assets": "node test/runAssetIntegrity.mjs", + "test:browser:smoke": "node test/runBrowserSmoke.mjs", + "test:browser": "node test/runBrowserFixtures.mjs", + "test:perf": "node test/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" }, "dependencies": { "@layoutit/polycss": "^0.2.6", diff --git a/src/App.ts b/src/App.ts index cd01d3b..4a940a1 100644 --- a/src/App.ts +++ b/src/App.ts @@ -2327,6 +2327,7 @@ quakePlayerLifecycle = createQuakePlayerLifecycleFlow({ viewmodel, }); let quakeDebugCollisionBypassUntil = 0; +let quakeDebugGameplaySyncActive = false; let quakeGamePaused = false; let quakeGamePausedAt = 0; let quakeMenuPauseActive = false; @@ -2369,7 +2370,7 @@ function isQuakeKey(value: string): value is QuakeKey { } function isQuakeGamePaused(): boolean { - return quakeGamePaused; + return !quakeDebugGameplaySyncActive && quakeGamePaused; } function setQuakeMenuPauseState(paused: boolean): void { @@ -4334,7 +4335,14 @@ function syncTouchedTriggers(origin: [number, number, number]): QuakeTouchedTrig } function syncQuakeDebugGameplay(origin: [number, number, number]): void { - quakeSceneMount.syncDebugGameplay(origin); + getPlayer().setDebugOrigin(origin); + const previousDebugGameplaySyncActive = quakeDebugGameplaySyncActive; + quakeDebugGameplaySyncActive = true; + try { + quakeSceneMount.syncDebugGameplay(origin); + } finally { + quakeDebugGameplaySyncActive = previousDebugGameplaySyncActive; + } } function applyQuakeUrlView(view: QuakeCssView): void { diff --git a/src/runtime/app/debugApi.ts b/src/runtime/app/debugApi.ts index 4c3932e..eb22915 100644 --- a/src/runtime/app/debugApi.ts +++ b/src/runtime/app/debugApi.ts @@ -93,10 +93,26 @@ function createQuakeAppDebugRuntime({ debugRecorder, enemyAcquisition: (entityIndex, playerSourceOrigin, monsterYaw) => runtime.controllers.shootables.debugEnemyAcquisition(entityIndex, playerSourceOrigin, { monsterYaw }), + enemyForceAttack: (entityIndex, targetOrigin) => + runtime.controllers.shootables.debugForceEnemyAttack( + entityIndex, + targetOrigin + ? pointToPoly({ x: targetOrigin[0], y: targetOrigin[1], z: targetOrigin[2] }) + : undefined, + ), + enemyForceAttackChain: (entityIndex, chain, targetOrigin) => + runtime.controllers.shootables.debugForceEnemyAttackChain( + entityIndex, + chain, + targetOrigin + ? pointToPoly({ x: targetOrigin[0], y: targetOrigin[1], z: targetOrigin[2] }) + : undefined, + ), enemyProjectileTraceCapture: () => runtime.controllers.shootables.debugEnemyProjectileCapture(), enemyProjectileTraceClear: () => runtime.controllers.shootables.debugClearEnemyProjectileCapture(), enemyProjectileTraceEnabled: (enabled) => runtime.controllers.shootables.debugSetEnemyProjectileCaptureEnabled(enabled), + enemyProjectileTraceStep: (dtMs) => runtime.controllers.shootables.debugStepEnemyProjectiles(dtMs), entities: runtime.session.entities, fireWeapon: () => runtime.controllers.weapons.fire(), fireWeaponDebug: (options) => runtime.controllers.weapons.debugFireProjectile(options), diff --git a/src/runtime/debug/quakeDebug.ts b/src/runtime/debug/quakeDebug.ts index 1544458..7dcf58c 100644 --- a/src/runtime/debug/quakeDebug.ts +++ b/src/runtime/debug/quakeDebug.ts @@ -51,9 +51,23 @@ export interface QuakeDebugHooks { playerZ: number, monsterYaw?: number, ): QuakeShootableEnemyAcquisitionDebugResult | null; + enemyForceAttack( + entityIndex: number, + targetX?: number, + targetY?: number, + targetZ?: number, + ): boolean; + enemyForceAttackChain( + entityIndex: number, + chain: string, + targetX?: number, + targetY?: number, + targetZ?: number, + ): boolean; enemyProjectileTraceCapture(): QuakeEnemyProjectileDebugCapture | null; enemyProjectileTraceClear(): boolean; enemyProjectileTraceEnabled(enabled: boolean): boolean; + enemyProjectileTraceStep(dtMs?: number): QuakeEnemyProjectileDebugCapture | null; entityIndexes(classname?: string): number[]; fire(): boolean; fireProjectileTrace( @@ -150,9 +164,12 @@ export interface QuakeDebugRuntime { playerSourceOrigin: { x: number; y: number; z: number }, monsterYaw?: number, ): QuakeShootableEnemyAcquisitionDebugResult | null; + enemyForceAttack(entityIndex: number, targetOrigin?: Vec3): boolean; + enemyForceAttackChain(entityIndex: number, chain: string, targetOrigin?: Vec3): boolean; enemyProjectileTraceCapture(): QuakeEnemyProjectileDebugCapture; enemyProjectileTraceClear(): void; enemyProjectileTraceEnabled(enabled: boolean): void; + enemyProjectileTraceStep(dtMs?: number): QuakeEnemyProjectileDebugCapture; entities(): ReadonlyMap; fireWeapon(): void; fireWeaponDebug(options?: QuakeWeaponProjectileDebugFireOptions): boolean; @@ -233,9 +250,14 @@ export function installQuakeDebugHooks(enabled: boolean, runtime: QuakeDebugRunt setEnemyTickFilter: (entityIndexes) => setQuakeDebugEnemyTickFilter(runtime, entityIndexes), enemyAcquisition: (entityIndex, playerX, playerY, playerZ, monsterYaw) => enemyAcquisitionQuakeDebugProbe(runtime, entityIndex, playerX, playerY, playerZ, monsterYaw), + enemyForceAttack: (entityIndex, targetX, targetY, targetZ) => + enemyForceAttackQuakeDebug(runtime, entityIndex, targetX, targetY, targetZ), + enemyForceAttackChain: (entityIndex, chain, targetX, targetY, targetZ) => + enemyForceAttackChainQuakeDebug(runtime, entityIndex, chain, targetX, targetY, targetZ), enemyProjectileTraceCapture: () => enemyProjectileTraceQuakeDebugCapture(runtime), enemyProjectileTraceClear: () => enemyProjectileTraceQuakeDebugClear(runtime), enemyProjectileTraceEnabled: (enabled) => enemyProjectileTraceQuakeDebugEnabled(runtime, enabled), + enemyProjectileTraceStep: (dtMs) => enemyProjectileTraceQuakeDebugStep(runtime, dtMs), entityIndexes: (classname) => quakeDebugEntityIndexes(runtime, classname), fire: () => fireQuakeDebugWeapon(runtime), fireProjectileTrace: (directDamage, timeoutMs) => @@ -418,6 +440,37 @@ function enemyAcquisitionQuakeDebugProbe( ); } +function enemyForceAttackQuakeDebug( + runtime: QuakeDebugRuntime, + entityIndex: number, + targetX?: number, + targetY?: number, + targetZ?: number, +): boolean { + if (runtime.isLoading() || !runtime.hasCurrentScene()) return false; + if (!Number.isFinite(entityIndex)) return false; + const hasTarget = targetX !== undefined || targetY !== undefined || targetZ !== undefined; + if (!hasTarget) return runtime.enemyForceAttack(Math.round(entityIndex)); + if (!Number.isFinite(targetX) || !Number.isFinite(targetY) || !Number.isFinite(targetZ)) return false; + return runtime.enemyForceAttack(Math.round(entityIndex), [targetX, targetY, targetZ]); +} + +function enemyForceAttackChainQuakeDebug( + runtime: QuakeDebugRuntime, + entityIndex: number, + chain: string, + targetX?: number, + targetY?: number, + targetZ?: number, +): boolean { + if (runtime.isLoading() || !runtime.hasCurrentScene()) return false; + if (!Number.isFinite(entityIndex) || typeof chain !== "string" || !chain) return false; + const hasTarget = targetX !== undefined || targetY !== undefined || targetZ !== undefined; + if (!hasTarget) return runtime.enemyForceAttackChain(Math.round(entityIndex), chain); + if (!Number.isFinite(targetX) || !Number.isFinite(targetY) || !Number.isFinite(targetZ)) return false; + return runtime.enemyForceAttackChain(Math.round(entityIndex), chain, [targetX, targetY, targetZ]); +} + function enemyProjectileTraceQuakeDebugCapture( runtime: QuakeDebugRuntime, ): QuakeEnemyProjectileDebugCapture | null { @@ -437,6 +490,15 @@ function enemyProjectileTraceQuakeDebugEnabled(runtime: QuakeDebugRuntime, enabl return true; } +function enemyProjectileTraceQuakeDebugStep( + runtime: QuakeDebugRuntime, + dtMs = 16, +): QuakeEnemyProjectileDebugCapture | null { + if (runtime.isLoading() || !runtime.hasCurrentScene()) return null; + if (!Number.isFinite(dtMs)) return null; + return runtime.enemyProjectileTraceStep(Math.max(1, Math.min(250, dtMs))); +} + function quakeDebugEntityIndexes(runtime: QuakeDebugRuntime, classname?: string): number[] { const selectedClassname = classname?.trim() ?? ""; return [...runtime.entities().values()] diff --git a/src/runtime/shootables.ts b/src/runtime/shootables.ts index 6703b73..4cdaeb8 100644 --- a/src/runtime/shootables.ts +++ b/src/runtime/shootables.ts @@ -217,7 +217,10 @@ export interface QuakeShootablesController { debugDamageWeaponTarget(entityIndex: number, amount: number): boolean; debugClearEnemyProjectileCapture(): void; debugEnemyProjectileCapture(): QuakeEnemyProjectileDebugCapture; + debugForceEnemyAttack(entityIndex: number, targetOrigin?: Vec3): boolean; + debugForceEnemyAttackChain(entityIndex: number, chain: string, targetOrigin?: Vec3): boolean; debugMountEntity(entityIndex: number): boolean; + debugStepEnemyProjectiles(dtMs?: number): QuakeEnemyProjectileDebugCapture; debugSetEnemyTickFilter(entityIndexes: readonly number[] | null): void; debugSetEnemyProjectileCaptureEnabled(enabled: boolean): void; debugSetOrigin(entityIndex: number, origin: Vec3): boolean; @@ -519,6 +522,7 @@ export function createQuakeShootablesController({ let monsterJumpTriggers: QuakeMonsterJumpTrigger[] = []; let visibilityChurn = createQuakeShootablesVisibilityChurnStats(); let debugEnemyTickFilter: Set | null = null; + let debugEnemyProjectileStepNow = 0; const combatBudget = createQuakeCombatBudgetRuntime(); const mountedEnemyAcquisitionVisibilityCache = createQuakeEnemyAcquisitionVisibilityCache(); let mountedEnemySightEntity: { entityIndex: number; seenAtSeconds: number } | null = null; @@ -605,6 +609,7 @@ export function createQuakeShootablesController({ consumePlayerPainRandom, currentModelLibrary: () => currentModelLibrary, damagePlayer, + floorAt, hasLineOfSight, markTrace: markQuakeTrace, offsetPoint: quakeEnemyProjectileOffsetPoint, @@ -2034,12 +2039,114 @@ export function createQuakeShootablesController({ return shootable.visible; } + function debugForceEnemyAttack(entityIndex: number, targetOrigin?: Vec3): boolean { + const shootable = shootables.get(entityIndex); + const enemy = shootable?.enemy; + if (!shootable || !enemy || shootable.dead || shootable.health <= 0) return false; + if (!debugMountEntity(entityIndex)) return false; + const profile = enemyCombatProfile(shootable); + if (!profile) return false; + const now = performance.now(); + const target = [...(targetOrigin ?? getPlayerOrigin())] as [number, number, number]; + clearEnemyAttackState(shootable); + enemy.awake = true; + enemy.currentTarget = targetOrigin ? { kind: "player" } : playerEnemyTargetReference(); + enemy.oldTarget = null; + enemy.nextAttackAt = now; + enemy.quakecIdealYaw = quakeYawToOrigin(shootable.origin, target); + syncShootableEnemyDatasets(shootable); + const started = tryStartEnemyAttack( + shootable, + enemy, + shootableEyeOrigin(shootable), + target, + profile, + now, + playerAttackTarget(target), + ); + if (started) startEnemyLoop(); + return started; + } + + function debugForceEnemyAttackChain(entityIndex: number, chain: string, targetOrigin?: Vec3): boolean { + const shootable = shootables.get(entityIndex); + const enemy = shootable?.enemy; + if (!shootable || !enemy || shootable.dead || shootable.health <= 0 || !chain) return false; + if (!debugMountEntity(entityIndex)) return false; + const profile = enemyCombatProfile(shootable); + if (!profile) return false; + const now = performance.now(); + const target = [...(targetOrigin ?? getPlayerOrigin())] as [number, number, number]; + clearEnemyAttackState(shootable); + enemy.awake = true; + enemy.currentTarget = playerEnemyTargetReference(); + enemy.oldTarget = null; + enemy.nextAttackAt = now; + enemy.pendingAttack = { + fireAt: Infinity, + forceAttackEvents: true, + quakecChain: chain, + target: [...target] as Vec3, + }; + enemy.quakecFiredEvents.clear(); + enemy.quakecIdealYaw = quakeYawToOrigin(shootable.origin, target); + syncShootableEnemyDatasets(shootable); + const context: QuakeEnemyAnimationContext = { + enemyEye: shootableEyeOrigin(shootable), + forceAttackEvents: true, + playerOrigin: target, + profile, + target: playerAttackTarget(target), + }; + if (!runDebugEnemyAttackChain(shootable, chain, now, context)) { + clearEnemyAttackState(shootable); + return false; + } + startEnemyLoop(); + return true; + } + + function runDebugEnemyAttackChain( + shootable: QuakeShootableState, + chain: string, + now: number, + context: QuakeEnemyAnimationContext, + ): boolean { + const enemy = shootable.enemy; + const runner = enemy?.quakecRunner; + if (!enemy || !runner || !runner.hasChain(chain)) return false; + let step = runner.enterChain(chain); + if (!step) return false; + enemy.quakecAnimationChain = chain; + enemy.animationMode = "attack"; + for (let index = 0; index < Math.max(1, runner.chainLength(chain)); index++) { + applyEnemyQuakecAnimationStep(shootable, step, "attack", now + index * QUAKE_MONSTER_QUAKEC_STATE_FRAME_MS, context); + if (step.chainCycleEnd) break; + step = runner.advance(); + if (step.chain !== chain) break; + } + return true; + } + function debugSetEnemyTickFilter(entityIndexes: readonly number[] | null): void { debugEnemyTickFilter = entityIndexes ? new Set(entityIndexes.filter((entityIndex) => Number.isInteger(entityIndex) && entityIndex > 0)) : null; } + function debugClearEnemyProjectileCapture(): void { + debugEnemyProjectileStepNow = 0; + enemyProjectiles.debugClearProjectileCapture(); + } + + function debugStepEnemyProjectiles(dtMs = QUAKE_ENEMY_TICK_MS): QuakeEnemyProjectileDebugCapture { + const boundedDtMs = Math.max(1, Math.min(250, Number.isFinite(dtMs) ? dtMs : QUAKE_ENEMY_TICK_MS)); + const now = Math.max(performance.now(), debugEnemyProjectileStepNow + boundedDtMs); + debugEnemyProjectileStepNow = now; + enemyProjectiles.update(getPlayerOrigin(), boundedDtMs / 1000, now); + return enemyProjectiles.debugProjectileCapture(); + } + function shootableVisibilitySnapshot(): QuakeShootablesVisibilitySnapshot { const mountedIndexes = new Set(); const visibleIndexes = new Set(); @@ -2732,6 +2839,7 @@ export function createQuakeShootablesController({ enemyMovement.faceShootableAtOrigin(shootable, attackTargetOrigin); updateEnemyAnimation(shootable, "attack", now, { enemyEye, + forceAttackEvents: enemy.pendingAttack.forceAttackEvents, playerOrigin: attackTargetOrigin, profile, target: attackTarget, @@ -2848,6 +2956,7 @@ export function createQuakeShootablesController({ if (quakeShootableUsesQuakecAttackEvents(shootable)) { updateEnemyAnimation(shootable, "attack", now, { enemyEye, + forceAttackEvents: enemy.pendingAttack.forceAttackEvents, playerOrigin: attackTargetOrigin, profile, target: attackTarget, @@ -3298,6 +3407,7 @@ export function createQuakeShootablesController({ enemy.quakecFiredEvents.clear(); updateEnemyAnimation(shootable, "attack", now, { enemyEye: shootableEyeOrigin(shootable), + forceAttackEvents: enemy.pendingAttack.forceAttackEvents, playerOrigin: targetOrigin, profile, target, @@ -3484,7 +3594,7 @@ export function createQuakeShootablesController({ next: step.next, state: step.stateName, }); - const runAttackEvents = mode !== "attack" || enemyAttackRuntimeEnabled(); + const runAttackEvents = mode !== "attack" || enemyAttackRuntimeEnabled() || context?.forceAttackEvents === true; if (runAttackEvents) { enemyCombat.runFrameSounds(shootable, step, mode, now); enemyCombat.runFrameEvents(shootable, step, mode, now, context); @@ -3754,6 +3864,7 @@ export function createQuakeShootablesController({ chain: string, mode: QuakeMonsterAnimationMode, now: number, + context?: QuakeEnemyAnimationContext, ): number | null { const enemy = shootable.enemy; const runner = enemy?.quakecRunner; @@ -3768,7 +3879,7 @@ export function createQuakeShootablesController({ enemy.quakecAnimationChain = chain; enemy.animationLockUntil = now + duration; enemy.nextAnimationFrameAt = now + QUAKE_MONSTER_QUAKEC_STATE_FRAME_MS; - applyEnemyQuakecAnimationStep(shootable, step, mode, now); + applyEnemyQuakecAnimationStep(shootable, step, mode, now, context); return duration; } @@ -4263,12 +4374,15 @@ export function createQuakeShootablesController({ debugStats, debugCanDamageTrace, debugEnemyAcquisition, - debugClearEnemyProjectileCapture: enemyProjectiles.debugClearProjectileCapture, + debugClearEnemyProjectileCapture, debugDamageWeaponTarget, debugEnemyProjectileCapture: enemyProjectiles.debugProjectileCapture, + debugForceEnemyAttackChain, + debugForceEnemyAttack, debugMountEntity, debugSetEnemyTickFilter, debugSetEnemyProjectileCaptureEnabled: enemyProjectiles.debugSetProjectileCaptureEnabled, + debugStepEnemyProjectiles, debugSetOrigin, debugSetYaw, setExpandedLogicalCombatEnabled, diff --git a/src/runtime/shootables/enemyCombat.ts b/src/runtime/shootables/enemyCombat.ts index 3643dd5..8337917 100644 --- a/src/runtime/shootables/enemyCombat.ts +++ b/src/runtime/shootables/enemyCombat.ts @@ -424,7 +424,7 @@ export function createQuakeEnemyCombatRuntime(options: QuakeEnemyCombatRuntimeOp type: event.type, }); }; - const delayMs = Math.max(0, event.delayMs ?? 0); + const delayMs = context.forceAttackEvents === true ? 0 : Math.max(0, event.delayMs ?? 0); if (delayMs <= 0) { fireProjectile(now, context.playerOrigin); return; diff --git a/src/runtime/shootables/enemyProjectiles.ts b/src/runtime/shootables/enemyProjectiles.ts index 5afd3a7..4e7715a 100644 --- a/src/runtime/shootables/enemyProjectiles.ts +++ b/src/runtime/shootables/enemyProjectiles.ts @@ -57,6 +57,7 @@ export interface QuakeEnemyProjectileRuntimeOptions { consumePlayerPainRandom?(details: QuakeEnemyProjectilePlayerPainRandomDetails): number | null; currentModelLibrary(): QuakePickupModelLibrary | null; damagePlayer(amount: number, context?: QuakePlayerDamageContext): boolean; + floorAt?(x: number, y: number, maxZ?: number, minZ?: number): number | null; hasLineOfSight(start: Vec3, end: Vec3): boolean; markTrace(kind: string, details?: QuakeEnemyProjectileTraceDetails): void; onExplosion?(event: QuakeEnemyProjectileExplosionEvent): void; @@ -99,7 +100,7 @@ export interface QuakeEnemyProjectileDebugCapture { export interface QuakeEnemyProjectileDebugEvent { seq: number; at: number; - type: "spawn" | "move" | "impact" | "expire" | "remove"; + type: "spawn" | "move" | "impact" | "expire" | "explode" | "remove"; damage?: number; expiresAt?: number; impactResult?: "keep" | "remove" | "stop"; @@ -118,6 +119,7 @@ export interface QuakeEnemyProjectileDebugEvent { fraction: number; }; velocity?: Vec3; + worldTouch?: "bounce" | "explode" | "stop"; } export interface QuakeEnemyProjectileRuntime { @@ -284,7 +286,7 @@ export function createQuakeEnemyProjectileRuntime( projectile.origin[2] + nextVelocity[2] * dt, ]; const hit = hitsPlayer(projectile, nextOrigin, playerOrigin); - const worldTrace = traceProjectileWorld(projectile.origin, nextOrigin); + const worldTrace = traceProjectileWorld(projectile, projectile.origin, nextOrigin); if (worldTrace && worldTouchPrecedesPlayerHit(projectile.origin, nextOrigin, worldTrace, hit)) { if (handleWorldTouch(projectile, worldTrace, nextVelocity, playerOrigin, now)) { active.push(projectile); @@ -326,10 +328,16 @@ export function createQuakeEnemyProjectileRuntime( } function traceProjectileWorld( + projectile: QuakeEnemyProjectile, start: Vec3, end: Vec3, ): QuakeEnemyProjectileWorldTrace | null { - if (options.traceLine) return options.traceLine(start, end); + const behavior = projectileWorldTouchBehavior(projectile.profile); + const lineTrace = options.traceLine?.(start, end) ?? null; + if (lineTrace?.planeNormal || (lineTrace && behavior !== "bounce")) return lineTrace; + const floorTrace = traceProjectileFloor(start, end); + if (floorTrace) return floorTrace; + if (lineTrace || behavior === "bounce") return null; return options.hasLineOfSight(start, end) ? null : { fraction: 0, end, @@ -337,6 +345,33 @@ export function createQuakeEnemyProjectileRuntime( }; } + function traceProjectileFloor( + start: Vec3, + end: Vec3, + ): QuakeEnemyProjectileWorldTrace | null { + if (!options.floorAt || end[2] >= start[2]) return null; + const floorZ = options.floorAt( + end[0], + end[1], + Math.max(start[2], end[2]), + Math.min(start[2], end[2]), + ); + if (floorZ === null || floorZ === undefined) return null; + if (floorZ > start[2] + COLLISION_EPSILON || floorZ < end[2] - COLLISION_EPSILON) return null; + const dz = end[2] - start[2]; + const fraction = Math.max(0, Math.min(1, dz === 0 ? 1 : (floorZ - start[2]) / dz)); + return { + classname: "worldspawn", + end: [ + start[0] + (end[0] - start[0]) * fraction, + start[1] + (end[1] - start[1]) * fraction, + floorZ, + ], + fraction, + planeNormal: [0, 0, 1], + }; + } + function worldTouchPrecedesPlayerHit( start: Vec3, end: Vec3, @@ -412,6 +447,10 @@ export function createQuakeEnemyProjectileRuntime( function emitProjectileExplosion(projectile: QuakeEnemyProjectile, origin: Vec3): void { if (!projectile.profile.projectileSplashDamage || !projectile.profile.projectileSplashRadius) return; const projectileClassname = projectile.profile.projectileClassname ?? "enemy_projectile_magic"; + recordProjectileDebugEvent("explode", { + ...projectileDebugEventPayload(projectile), + origin: [...origin] as Vec3, + }); options.onExplosion?.({ flavor: enemyProjectileExplosionFlavor(projectileClassname), origin: [...origin] as Vec3, @@ -643,6 +682,7 @@ export function createQuakeEnemyProjectileRuntime( splashDamage: projectile.profile.projectileSplashDamage, splashRadiusQuakeUnits: splashRadius === undefined ? undefined : splashRadius / QUAKE_COLLISION_UNIT_SCALE, velocity: [...projectile.velocity] as Vec3, + worldTouch: projectileWorldTouchBehavior(projectile.profile), }; } diff --git a/src/runtime/shootables/state.ts b/src/runtime/shootables/state.ts index 88e3ffd..9fa010a 100644 --- a/src/runtime/shootables/state.ts +++ b/src/runtime/shootables/state.ts @@ -125,6 +125,7 @@ export interface QuakeMoveGoalOptions { export interface QuakeEnemyPendingAttack { fireAt: number; + forceAttackEvents?: boolean; quakecChain?: string; target: Vec3; } @@ -163,6 +164,7 @@ export interface QuakeEnemyActiveTouchDamage { export interface QuakeEnemyAnimationContext { enemyEye: Vec3; + forceAttackEvents?: boolean; playerOrigin: [number, number, number]; profile: QuakeMonsterCombatProfile; target?: QuakeEnemyAttackTarget; diff --git a/test/HARNESS.md b/test/HARNESS.md new file mode 100644 index 0000000..b2b5493 --- /dev/null +++ b/test/HARNESS.md @@ -0,0 +1,58 @@ +# cssQuake Harness Guide + +Use `package.json` as the canonical command menu. Local files under ignored `scripts/` are exploratory unless the user names them or a committed runner points to them. + +| Situation | Command | Notes | +| --- | --- | --- | +| Code-only TS/CSS/runtime change | `pnpm test:dev && pnpm build` | no shared asset prepare | +| Generated asset or manifest concern | `pnpm test:assets` | requires ready prepared assets | +| Browser startup/link concern | `pnpm test:browser:smoke` | requires ready prepared assets | +| Browser gameplay fixture concern | `pnpm test:browser` | heavier; requires ready prepared assets | +| Perf claim or monster-render work | `pnpm test:perf`, then an explicit ignored local perf harness command if needed | read `notes/monster-render-spike.md` first when present; package gates do not run ignored scripts | +| Source/gameplay parity concern | use the named committed oracle runner | keep oracle scope narrow | + +## Gate Meanings + +- `pnpm test`: fast contract tests. +- `pnpm test:asset-state`: manifest/status/process preflight for prepared assets. +- `pnpm test:assets`: manifest and prepared scene integrity. +- `pnpm test:browser:smoke`: fast URL/API browser smoke. +- `pnpm test:browser`: explicit browser gameplay fixtures from committed fixture definitions. Use `pnpm test:browser -- --list` or `pnpm test:browser -- --fixture ` for focused runs. +- `pnpm test:perf`: no-asset preflight for the committed perf command surface and harness guidance. +- `pnpm test:dev`: normal no-asset confidence gate. +- `pnpm test:all`: all committed stable gates that require prepared assets, including browser fixtures. + +Prepared-asset gates must not run shared asset prepare. If assets are missing, regenerating, or a shared prepare is active, report the environment/prep issue and stop. + +Committed runners should print what they validate, prerequisites, whether they require prepared assets, artifact paths, and whether failures are likely product behavior, missing prepared assets, or local environment. + +## Browser Coverage + +`pnpm test:browser` is selective, not exhaustive. It currently covers committed DOM monster visibility, combat budget caps, logical weapon targetability, player rocket fire/touch behavior, forced enemy projectile chains for ogre/wizard/zombie, ogre grenade bounce and timeout lifecycle, zombie projectile world-stop, map trigger/target/mover logic, liquid damage, and pickup gameplay fixtures. + +Browser gameplay fixture definitions live in `test/browserFixtureDefinitions.mjs`; `test/runBrowserFixtures.mjs` is the only committed gameplay-fixture runner. + +Current fixture IDs: + +| Fixture ID | Covers | +| --- | --- | +| `monster-dom` | representative monster DOM visibility and mounted leaves | +| `combat-budget` | combat budget caps and event-bound weapon target counters | +| `logical-targetability` | logical weapon damage against an unmounted combat-interest target | +| `rocket-fire` | player rocket projectile fire path | +| `rocket-touch` | player rocket direct/splash touch path | +| `ogre-grenade-chain` | forced ogre grenade attack chain | +| `ogre-grenade-bounce` | ogre grenade bounce impact behavior | +| `ogre-grenade-lifecycle` | ogre grenade timeout, explosion, and removal | +| `wizard-spike-chain` | forced wizard spike attack chain | +| `zombie-projectile-chain` | forced zombie projectile attack chain | +| `zombie-projectile-stop` | zombie projectile world-stop behavior | +| `map-logic` | trigger_multiple target dispatch, cooldown, refire, and mover activation | +| `liquid-damage` | liquid contents damage through debug gameplay pose | +| `pickup` | pickup stat deltas, removal, and repeat prevention | + +Enemy projectile chain fixtures use debug-only hooks exposed through `window.__cssQuakeDebug` to force named QuakeC attack chains and step the enemy projectile runtime deterministically. Treat those hooks as harness surface, not normal gameplay API. + +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. diff --git a/test/browserFixtureDefinitions.mjs b/test/browserFixtureDefinitions.mjs new file mode 100644 index 0000000..436a5f5 --- /dev/null +++ b/test/browserFixtureDefinitions.mjs @@ -0,0 +1,917 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { + debugMapUrl, + openDebugMapPage, + waitForDebugMapReady, +} from "./browserHarnessSupport.mjs"; +import { + ogreGrenadeChainFixture, + ogreGrenadeBounceFixture, + ogreGrenadeLifecycleFixture, + rocketFireFixture, + rocketTouchFixture, + wizardSpikeChainFixture, + zombieProjectileChainFixture, + zombieProjectileStopFixture, +} from "./browserFixtureProjectile.mjs"; +import { projectRoot } from "./checkAssetState.mjs"; + +const REPRESENTATIVE_MONSTERS = [ + { map: "e1m1", classname: "monster_army", entity: 298 }, + { map: "e1m1", classname: "monster_dog", entity: 247 }, + { map: "e1m2", classname: "monster_knight", entity: 99 }, + { map: "e1m2", classname: "monster_ogre", entity: 80 }, + { map: "e1m5", classname: "monster_demon1", entity: 205 }, + { map: "e1m3", classname: "monster_wizard", entity: 294 }, + { map: "e1m6", classname: "monster_shambler", entity: 396 }, + { map: "e1m3", classname: "monster_zombie", entity: 272 }, + { map: "e1m7", classname: "monster_boss", entity: 28 }, +]; +const MONSTER_FOCUS_YAWS = [0, 45, 90, 135, 180, 225, 270, 315]; +const MONSTER_FOCUS_DISTANCES = [2.35, 3.5, 5, 8, 12]; + +const PICKUP_MAP = "e1m1"; +const PICKUP_CASES = [ + { classname: "item_armor1", entity: 20, label: "armor", stat: "playerArmor", delta: 100 }, + { classname: "item_spikes", entity: 226, label: "large nails", stat: "playerNails", delta: 50 }, +]; +const DISABLED_PICKUP = { classname: "weapon_rocketlauncher", entity: 201 }; +const PICKUP_FOCUS_YAWS = [0, 45, 90, 135, 180, 225, 270, 315]; + +const LIQUID_DAMAGE_MAP = "e1m1"; +const LIQUID_DAMAGE_CASE = { + contents: "slime", + contentsValue: -4, + expectedDamage: 8, + expectedWaterLevel: 2, + label: "E1M1 slime pool", + origin: { x: 0, y: 2688, z: -144 }, + sampleOffsets: [-23, 4, 32], +}; + +const MAP_LOGIC_MAP = "e1m1"; +const MAP_LOGIC_CASE = { + delayedRefireMs: 260, + doorEntity: 189, + expectedDoorClassname: "func_door_secret", + expectedDoorInitialMode: "closed", + expectedDoorTriggeredMode: "opening", + expectedTriggerClassname: "trigger_multiple", + inside: { x: 792, y: 512, z: 8 }, + label: "E1M1 trigger_multiple secret door", + outside: { x: 704, y: 512, z: 8 }, + targetname: "t8", + triggerEntity: 190, +}; + +const LOGICAL_MAP = "e1m1"; +const LOGICAL_ANCHOR_ENTITY = 21; +const LOGICAL_TARGET_ORIGIN = { x: 616, y: 72, z: 40 }; +const LOGICAL_VIEW_DISTANCE = 4.96; +const LOGICAL_VIEW_ROT_X = 90; +const LOGICAL_VIEW_ROT_Y = 90; +const LOGICAL_SOURCE_REFERENCE = { + engine: "Quake/vkQuake", + monsterClassname: "monster_army", + monsterHealth: 30, + weapon: "rocketlauncher", + directDamage: 100, + expectedKilled: true, + targetOrigin: LOGICAL_TARGET_ORIGIN, + playerOrigin: { x: 616, y: 320, z: 75 }, + playerAngles: { pitch: 0, yaw: 270, roll: 0 }, + comparison: "same map-space target path; cssQuake damage must pass through weaponTargets() while the target is unmounted", +}; +const LOGICAL_CANDIDATE_ENTITIES = [ + [21, 616, 72, 40], + [100, 248, 2392, 40], + [245, 0, 576, 24], + [246, 8, 1520, -200], + [247, 88, 1520, -200], + [248, 224, 1552, -200], + [249, -8, 936, -200], + [250, 648, 736, 104], + [255, 1312, 936, -248], + [256, 1336, 1784, -408], + [257, 1392, 928, -248], + [258, 1384, 1008, -248], + [259, 1240, 1008, -248], + [260, 1256, 1760, -408], + [261, 824, 1784, -408], + [262, 1128, 1760, -408], + [265, 1232, 2088, -216], + [266, 1232, 2448, -280], + [267, 832, 2464, -344], + [268, 832, 2072, -408], + [269, 840, 1960, -408], + [277, 416, 1912, -168], + [278, 432, 2120, -168], + [283, 80, 2024, -184], + [284, -16, 1888, -184], + [285, -248, 2144, -136], + [288, -432, 2352, 56], + [289, -544, 2584, 56], + [290, -344, 2656, -104], + [291, -72, 2896, -56], + [292, 432, 2920, -56], + [293, 424, 2832, -56], + [298, 424, 2672, -56], + [299, 424, 2880, -56], + [300, 424, 2760, -56], + [303, 848, 2584, -72], + [304, 824, 2008, -152], + [306, 248, 2352, 40], + [307, -72, 2464, 40], + [308, 904, 1024, -248], + [349, 288, 1536, -200], + [350, 968, 2432, -112], +].map(([entityIndex, x, y, z]) => ({ entityIndex, x, y, z })); + +const COMBAT_MAP = "e1m1"; +const COMBAT_FOCUS_ENTITY = 298; + +export const browserFixtures = [ + { + id: "monster-dom", + label: "DOM monster browser fixture", + artifact: "bench/results/quake/monster-dom-smoke-summary.json", + requirements: { requiredMaps: unique(REPRESENTATIVE_MONSTERS.map((monster) => monster.map)), requireRenderBundle: true }, + run: runMonsterDomFixture, + }, + { + id: "combat-budget", + label: "Combat budget browser fixture", + artifact: "bench/results/quake/combat-budget-harness-smoke-summary.json", + requirements: { requiredMaps: [COMBAT_MAP], requireRenderBundle: true }, + run: runCombatBudgetFixture, + }, + { + id: "logical-targetability", + label: "Logical targetability browser fixture", + artifact: "bench/results/quake/logical-targetability-smoke-summary.json", + requirements: { requiredMaps: [LOGICAL_MAP], requireRenderBundle: true }, + run: runLogicalTargetabilityFixture, + }, + rocketFireFixture, + rocketTouchFixture, + ogreGrenadeChainFixture, + ogreGrenadeBounceFixture, + ogreGrenadeLifecycleFixture, + wizardSpikeChainFixture, + zombieProjectileChainFixture, + zombieProjectileStopFixture, + { + id: "map-logic", + label: "Map logic browser fixture", + artifact: "bench/results/quake/map-logic-browser-smoke-summary.json", + requirements: { requiredMaps: [MAP_LOGIC_MAP], requireRenderBundle: true, requireGameLogic: true }, + run: runMapLogicFixture, + }, + { + id: "liquid-damage", + label: "Liquid damage browser fixture", + artifact: "bench/results/quake/liquid-damage-browser-smoke-summary.json", + requirements: { requiredMaps: [LIQUID_DAMAGE_MAP], requireRenderBundle: true, requireGameLogic: true }, + run: runLiquidDamageFixture, + }, + { + id: "pickup", + label: "Pickup browser fixture", + artifact: "bench/results/quake/pickup-browser-smoke-summary.json", + requirements: { requiredMaps: [PICKUP_MAP], requireRenderBundle: true, requireGameLogic: true }, + run: runPickupFixture, + }, +]; + +export function browserFixtureById(id) { + return browserFixtures.find((fixture) => fixture.id === id) ?? null; +} + +async function runMonsterDomFixture({ browser, baseUrl, options }) { + let page = null; + let pageErrors = []; + const results = []; + try { + let currentMap = ""; + for (const monster of REPRESENTATIVE_MONSTERS) { + if (monster.map !== currentMap) { + if (!page) { + ({ page, pageErrors } = await openDebugMapPage(browser, baseUrl, monster.map, options)); + } else { + await page.goto(debugMapUrl(baseUrl, monster.map), { waitUntil: "domcontentloaded", timeout: options.timeoutMs }); + await waitForDebugMapReady(page, { mapName: monster.map, timeoutMs: options.timeoutMs }); + } + currentMap = monster.map; + } + const result = await validateMonster(page, monster); + results.push(result); + const status = result.pass ? "PASS" : "FAIL"; + const attempt = result.attempt; + console.log(`${status} ${monster.map} ${monster.classname} #${monster.entity}` + + (attempt ? ` distance=${attempt.distance} yaw=${attempt.yaw} leaves=${attempt.leafCount}` : "")); + } + } finally { + await page?.close(); + } + const failed = results.filter((result) => !result.pass); + if (pageErrors.length || failed.length) { + throw new Error(`DOM monster browser fixture failed: ${results.length - failed.length}/${results.length} passed.\n${pageErrors.join("\n")}`); + } + return { + kind: "cssquake-monster-dom-smoke", + startedAt: new Date().toISOString(), + viewport: options.viewport, + total: results.length, + passed: results.length, + failed: 0, + results, + }; +} + +async function runCombatBudgetFixture({ browser, baseUrl, options }) { + const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, COMBAT_MAP, options); + try { + const result = await page.evaluate(async ({ entityIndex }) => { + const debug = window.__cssQuakeDebug; + if (!debug?.stats) return { hasDebug: false }; + const beforeStats = debug.stats(); + const before = beforeStats.shootables?.combatBudget ?? null; + const focusOk = Boolean(debug.focusEntity?.(entityIndex, 4.5, 90, 45)); + debug.setWeapon?.("shotgun"); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + const fired = Boolean(debug.fire?.()); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + const afterStats = debug.stats(); + return { + after: afterStats.shootables?.combatBudget ?? null, + before, + fired, + focusOk, + hasDebug: true, + mapName: afterStats.mapName ?? null, + }; + }, { entityIndex: COMBAT_FOCUS_ENTITY }); + result.pageErrors = pageErrors; + const failures = validateCombatBudgetResult(result); + if (failures.length) throw new Error(`Combat budget harness failed: ${failures.join("; ")}`); + console.log("PASS combat budget caps and event-bound weapon target counters"); + return { + generatedAt: new Date().toISOString(), + kind: "cssquake-combat-budget-browser-fixture", + mapName: COMBAT_MAP, + pass: true, + result, + failures, + }; + } finally { + await page.close(); + } +} + +async function runLogicalTargetabilityFixture({ browser, baseUrl, options }) { + const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, LOGICAL_MAP, options); + try { + const result = await page.evaluate(async ({ + anchorEntity, + candidateEntities, + sourceReference, + targetOrigin, + viewDistance, + viewRotX, + viewRotY, + }) => { + const debug = window.__cssQuakeDebug; + if (!debug?.stats) return { hasDebug: false }; + + debug.setExpandedLogicalCombat?.(false); + debug.setUnmountedAi?.(false); + const activeCandidates = []; + for (const candidate of candidateEntities) { + if (debug.setEntityOrigin?.(candidate.entityIndex, candidate.x, candidate.y, candidate.z)) { + activeCandidates.push(candidate); + } + } + const preferredBlockerIndexes = [246, 247, 255, 265, 298, 245, 248, 249, 250, 256, 257]; + const blockerFixtures = preferredBlockerIndexes + .map((entityIndex) => activeCandidates.find((candidate) => candidate.entityIndex === entityIndex)) + .filter(Boolean); + const targetFixture = activeCandidates.find((candidate) => !preferredBlockerIndexes.includes(candidate.entityIndex)) ?? null; + const fixtureCandidates = targetFixture ? [targetFixture, ...blockerFixtures] : []; + const blockerOffsets = [ + [-48, 0], [-32, 0], [-16, 0], [0, 0], [16, 0], [32, 0], + [-40, -16], [-20, -16], [0, -16], [20, -16], [40, -16], + ]; + const blockers = blockerFixtures.map((fixture, index) => { + const [xOffset, yOffset] = blockerOffsets[index] ?? [0, -32 - index * 8]; + return { + entityIndex: fixture.entityIndex, + x: 616 + xOffset, + y: 260 + yOffset, + z: 40, + }; + }); + const targetEntity = targetFixture?.entityIndex ?? null; + const originResults = targetEntity === null + ? [] + : [ + debug.setEntityOrigin?.(targetEntity, targetOrigin.x, targetOrigin.y, targetOrigin.z), + ...blockers.map((blocker) => + blocker.entityIndex !== undefined && + debug.setEntityOrigin?.(blocker.entityIndex, blocker.x, blocker.y, blocker.z) + ), + ]; + const enableExpandedOk = Boolean(debug.setExpandedLogicalCombat?.(true)); + const disableUnmountedAiOk = Boolean(debug.setUnmountedAi?.(false)); + const viewPoseOk = Boolean(debug.focusEntity?.(anchorEntity, viewDistance, viewRotX, viewRotY)); + debug.setWeapon?.("rocketlauncher"); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + + const beforeStats = debug.stats(); + const targetMountedBefore = activeEnemyElementsForEntity(targetEntity).length > 0; + const activeEnemyIndexesBefore = activeEnemyEntityIndexes(); + const beforeDeadShootables = beforeStats.shootables?.deadShootables ?? 0; + const beforeLiveShootables = beforeStats.shootables?.liveShootables ?? 0; + const beforeBudget = beforeStats.shootables?.combatBudget ?? null; + + const damageWeaponTargetOk = Boolean( + targetEntity !== null && + debug.damageWeaponTarget?.(targetEntity, sourceReference.directDamage) + ); + await sleepInPage(100); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + + const afterStats = debug.stats(); + const afterBudget = afterStats.shootables?.combatBudget ?? null; + return { + activeEnemyIndexesBefore, + activeCandidateEntityIndexes: activeCandidates.map((candidate) => candidate.entityIndex), + after: afterBudget, + afterDeadShootables: afterStats.shootables?.deadShootables ?? 0, + afterLiveShootables: afterStats.shootables?.liveShootables ?? 0, + before: beforeBudget, + beforeCameraRotX: beforeStats.cameraRotX ?? null, + beforeCameraRotY: beforeStats.cameraRotY ?? null, + beforeDeadShootables, + beforeLiveShootables, + beforeOrigin: beforeStats.origin ?? null, + damageWeaponTargetOk, + disableUnmountedAiOk, + enableExpandedOk, + hasDebug: true, + mapName: afterStats.mapName ?? null, + originResults, + selectedFixtureEntityIndexes: fixtureCandidates.map((candidate) => candidate.entityIndex), + sourceReference, + targetEntity, + targetMountedBefore, + viewPoseOk, + }; + + function activeEnemyElementsForEntity(entityIndex) { + return [...document.querySelectorAll(`.polycss-mesh.shootable.enemy[data-entity-index="${entityIndex}"]`)] + .filter((element) => + !element.classList.contains("quake-frame-hidden") && + !element.classList.contains("quake-shootable-prewarmed") && + !element.hidden + ); + } + + function activeEnemyEntityIndexes() { + return [...document.querySelectorAll(".polycss-mesh.shootable.enemy[data-entity-index]")] + .filter((element) => + !element.classList.contains("quake-frame-hidden") && + !element.classList.contains("quake-shootable-prewarmed") && + !element.hidden + ) + .map((element) => Number(element.dataset.entityIndex)) + .filter((entityIndex) => Number.isFinite(entityIndex)) + .sort((a, b) => a - b); + } + + function sleepInPage(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + }, { + anchorEntity: LOGICAL_ANCHOR_ENTITY, + candidateEntities: LOGICAL_CANDIDATE_ENTITIES, + sourceReference: LOGICAL_SOURCE_REFERENCE, + targetOrigin: LOGICAL_TARGET_ORIGIN, + viewDistance: LOGICAL_VIEW_DISTANCE, + viewRotX: LOGICAL_VIEW_ROT_X, + viewRotY: LOGICAL_VIEW_ROT_Y, + }); + result.pageErrors = pageErrors; + const failures = validateLogicalTargetabilityResult(result); + if (failures.length) throw new Error(`Logical targetability harness failed: ${failures.join("; ")}`); + console.log(`PASS target ${result.targetEntity} damaged while unmounted`); + return { + generatedAt: new Date().toISOString(), + kind: "cssquake-logical-targetability-browser-fixture", + mapName: LOGICAL_MAP, + pass: true, + result, + failures, + }; + } finally { + await page.close(); + } +} + +async function runPickupFixture({ browser, baseUrl, options }) { + const prepared = JSON.parse(readFileSync(path.join(projectRoot, `build/generated/public/q/${PICKUP_MAP}.json`), "utf8")); + const pickupCases = pickupCasesWithOrigins(prepared); + const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, PICKUP_MAP, options); + const results = []; + let disabled = null; + try { + disabled = await disabledPickupSnapshot(page); + if (disabled.mounted) throw new Error(`Skill-disabled pickup should not mount: ${JSON.stringify(disabled)}`); + for (const testCase of pickupCases) { + const result = await validatePickup(page, testCase); + assertPickupResult(testCase, result); + results.push({ ...testCase, result }); + console.log(`PASS ${PICKUP_MAP} ${testCase.classname} #${testCase.entity} ${testCase.stat} ${result.before[testCase.stat]} -> ${result.after[testCase.stat]}`); + } + } finally { + await page.close(); + } + if (pageErrors.length) throw new Error(`Browser emitted console/page errors:\n${pageErrors.join("\n")}`); + return { kind: "cssquake-pickup-browser-fixture", startedAt: new Date().toISOString(), map: PICKUP_MAP, disabled, results }; +} + +async function runLiquidDamageFixture({ browser, baseUrl, options }) { + const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, LIQUID_DAMAGE_MAP, options); + try { + const result = await validateLiquidDamage(page, LIQUID_DAMAGE_CASE); + assertLiquidDamageResult(LIQUID_DAMAGE_CASE, result); + if (pageErrors.length) throw new Error(`Browser emitted console/page errors:\n${pageErrors.join("\n")}`); + console.log( + `PASS ${LIQUID_DAMAGE_MAP} ${LIQUID_DAMAGE_CASE.contents} damage ${result.beforeHealth} -> ${result.afterHealth}`, + ); + return { + kind: "cssquake-liquid-damage-browser-fixture", + startedAt: new Date().toISOString(), + map: LIQUID_DAMAGE_MAP, + result, + }; + } finally { + await page.close(); + } +} + +async function runMapLogicFixture({ browser, baseUrl, options }) { + const prepared = JSON.parse(readFileSync(path.join(projectRoot, `build/generated/public/q/${MAP_LOGIC_MAP}.json`), "utf8")); + assertMapLogicFixturePrepared(prepared, MAP_LOGIC_CASE); + const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, MAP_LOGIC_MAP, options); + try { + const result = await validateMapLogic(page, MAP_LOGIC_CASE); + assertMapLogicResult(MAP_LOGIC_CASE, result); + if (pageErrors.length) throw new Error(`Browser emitted console/page errors:\n${pageErrors.join("\n")}`); + console.log( + `PASS ${MAP_LOGIC_MAP} trigger #${MAP_LOGIC_CASE.triggerEntity} count ${result.before.count} -> ${result.afterThird.count}, door #${MAP_LOGIC_CASE.doorEntity} ${result.before.mover.mode} -> ${result.afterFirst.mover.mode}`, + ); + return { + kind: "cssquake-map-logic-browser-fixture", + startedAt: new Date().toISOString(), + map: MAP_LOGIC_MAP, + result, + }; + } finally { + await page.close(); + } +} + +async function validateMonster(page, monster) { + let lastAttempt = null; + for (const distance of MONSTER_FOCUS_DISTANCES) { + for (const yaw of MONSTER_FOCUS_YAWS) { + const attempt = await page.evaluate(async ({ entity, expectedClassname, distance, yaw }) => { + const debug = window.__cssQuakeDebug; + const ok = debug.focusEntity(entity, distance, 90, yaw); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + await new Promise((resolve) => setTimeout(resolve, 120)); + const selector = `.polycss-mesh.shootable.enemy[data-entity-index="${entity}"]`; + const element = document.querySelector(selector); + const active = Boolean( + element && + element.getAttribute("aria-hidden") !== "true" && + !element.classList.contains("quake-shootable-prewarmed") && + !element.classList.contains("quake-frame-hidden") + ); + const stats = debug.stats(); + return { + distance, + yaw, + focusOk: ok, + mounted: Boolean(element), + active, + classname: element?.dataset.classname ?? null, + classnameOk: element?.dataset.classname === expectedClassname, + leafCount: element ? element.querySelectorAll("b,i,s,u").length : 0, + animationFrame: element?.dataset.animationFrame ?? null, + quakecState: element?.dataset.quakecState ?? null, + stats: { + activeEnemyMeshes: stats.activeEnemyMeshes, + mountedEnemyShootables: stats.shootables?.mountedEnemyShootables ?? null, + visibleEnemyShootables: stats.shootables?.visibleEnemyShootables ?? null, + }, + }; + }, { entity: monster.entity, expectedClassname: monster.classname, distance, yaw }); + lastAttempt = attempt; + if (attempt.active && attempt.classnameOk && attempt.leafCount > 0) { + return { ...monster, pass: true, naturalVisibility: true, attempt }; + } + } + } + return { ...monster, pass: false, naturalVisibility: false, attempt: lastAttempt }; +} + +function validateCombatBudgetResult(result) { + const failures = []; + if (!result.hasDebug) failures.push("debug hooks missing"); + if (!result.before) failures.push("missing before combat budget stats"); + if (!result.after) failures.push("missing after combat budget stats"); + if (!result.focusOk) failures.push("debug focusEntity failed"); + if (!result.fired) failures.push("debug fire failed"); + if (result.pageErrors?.length) failures.push(`page errors: ${result.pageErrors.join(" | ")}`); + if (!result.after || !result.before) return failures; + + const { after, before } = result; + const limits = after.limits ?? {}; + if (limits.ambientPathTicksPerFrame !== 1) failures.push(`ambientPathTicksPerFrame limit ${limits.ambientPathTicksPerFrame}`); + if (limits.ambientPathTicksPerSecond !== 30) failures.push(`ambientPathTicksPerSecond limit ${limits.ambientPathTicksPerSecond}`); + if (limits.ambientPathCadenceHz !== 5) failures.push(`ambientPathCadenceHz limit ${limits.ambientPathCadenceHz}`); + if (limits.combatInterestSet !== 12) failures.push(`combatInterestSet limit ${limits.combatInterestSet}`); + if (limits.unmountedAiActiveSet !== 4) failures.push(`unmountedAiActiveSet limit ${limits.unmountedAiActiveSet}`); + if (limits.unmountedAiCadenceHz !== 5) failures.push(`unmountedAiCadenceHz limit ${limits.unmountedAiCadenceHz}`); + if (limits.lineOfSightChecksPerFrame !== 8) failures.push(`lineOfSightChecksPerFrame limit ${limits.lineOfSightChecksPerFrame}`); + if (limits.lineOfSightChecksPerSecond !== 200) failures.push(`lineOfSightChecksPerSecond limit ${limits.lineOfSightChecksPerSecond}`); + if (limits.attackChainChecksPerFrame !== 8) failures.push(`attackChainChecksPerFrame limit ${limits.attackChainChecksPerFrame}`); + if (limits.domReads !== 0) failures.push(`domReads limit ${limits.domReads}`); + + if (after.expandedLogicalCombatEnabled !== false) failures.push("expanded logical combat should be disabled"); + if (after.unmountedAiEnabled !== false) failures.push("unmounted AI should be disabled"); + if (after.combatInterestSetSize > limits.combatInterestSet) failures.push(`combatInterestSetSize over cap, got ${after.combatInterestSetSize}`); + if (after.unmountedAiActiveSetSize !== 0) failures.push(`unmountedAiActiveSetSize should be 0, got ${after.unmountedAiActiveSetSize}`); + if ((after.maxFrame?.lineOfSightChecks ?? 0) > limits.lineOfSightChecksPerFrame) failures.push(`lineOfSightChecks max frame ${after.maxFrame.lineOfSightChecks}`); + if ((after.maxFrame?.attackChainChecks ?? 0) > limits.attackChainChecksPerFrame) failures.push(`attackChainChecks max frame ${after.maxFrame.attackChainChecks}`); + if ((after.maxFrame?.ambientPathTicks ?? 0) > limits.ambientPathTicksPerFrame) failures.push(`ambientPathTicks max frame ${after.maxFrame.ambientPathTicks}`); + if ((after.maxPerSecond?.ambientPathTicks ?? 0) > limits.ambientPathTicksPerSecond) failures.push(`ambientPathTicks max second ${after.maxPerSecond.ambientPathTicks}`); + if ((after.maxPerSecond?.lineOfSightChecks ?? 0) > limits.lineOfSightChecksPerSecond) failures.push(`lineOfSightChecks max second ${after.maxPerSecond.lineOfSightChecks}`); + + const counters = after.counters ?? {}; + const beforeCounters = before.counters ?? {}; + if (counters.unmountedAiTicksTotal !== 0) failures.push(`unmountedAiTicksTotal ${counters.unmountedAiTicksTotal}`); + if (counters.capDeferralsTotal !== 0) failures.push(`capDeferralsTotal ${counters.capDeferralsTotal}`); + if (counters.domReadsTotal !== 0) failures.push(`domReadsTotal ${counters.domReadsTotal}`); + if ((counters.weaponTargetQueriesTotal ?? 0) <= (beforeCounters.weaponTargetQueriesTotal ?? 0)) failures.push("weaponTargetQueriesTotal did not increase after event-bound fire"); + if ((counters.weaponTargetCandidatesTotal ?? 0) <= (beforeCounters.weaponTargetCandidatesTotal ?? 0)) failures.push("weaponTargetCandidatesTotal did not increase after event-bound fire"); + return failures; +} + +function validateLogicalTargetabilityResult(result) { + const failures = []; + if (!result.hasDebug) failures.push("debug hooks missing"); + if (result.pageErrors?.length) failures.push(`page errors: ${result.pageErrors.join(" | ")}`); + if (result.mapName !== LOGICAL_MAP) failures.push(`unexpected map ${result.mapName}`); + if (!result.originResults?.every(Boolean)) failures.push(`failed to place target fixtures: ${JSON.stringify(result.originResults)}`); + if (!result.enableExpandedOk) failures.push("failed to enable expanded logical combat"); + if (!result.disableUnmountedAiOk) failures.push("failed to disable unmounted AI"); + if ((result.selectedFixtureEntityIndexes?.length ?? 0) < 6) failures.push(`expected at least 6 active monster fixtures, got ${JSON.stringify(result.selectedFixtureEntityIndexes)}`); + if (!result.viewPoseOk) failures.push("debug focusEntity failed"); + if (result.targetMountedBefore) failures.push(`target ${result.targetEntity} should be over mount budget and unmounted`); + if (!result.damageWeaponTargetOk) failures.push("debug damageWeaponTarget failed"); + if (!result.before) failures.push("missing before combat budget stats"); + if (!result.after) failures.push("missing after combat budget stats"); + if (result.before && result.after) { + const beforeCounters = result.before.counters ?? {}; + const afterCounters = result.after.counters ?? {}; + const limits = result.after.limits ?? {}; + if (result.before.expandedLogicalCombatEnabled !== true) failures.push("expanded logical combat should be enabled before fire"); + if (result.before.unmountedAiEnabled !== false) failures.push("unmounted AI should stay disabled before fire"); + if (!result.before.combatInterestEntityIndexes?.includes?.(result.targetEntity)) failures.push(`combat interest set should include target ${result.targetEntity}`); + if ((result.before.combatInterestSetSize ?? 0) > limits.combatInterestSet) failures.push(`combat interest size over cap before fire: ${result.before.combatInterestSetSize}`); + if ((afterCounters.weaponTargetsYieldedTotal ?? 0) <= (beforeCounters.weaponTargetsYieldedTotal ?? 0)) failures.push("weaponTargetsYieldedTotal did not increase after logical weapon-target damage"); + if ((afterCounters.unmountedAiTicksTotal ?? 0) !== 0) failures.push(`unmountedAiTicksTotal should stay 0, got ${afterCounters.unmountedAiTicksTotal}`); + if ((afterCounters.domReadsTotal ?? 0) !== 0) failures.push(`domReadsTotal ${afterCounters.domReadsTotal}`); + if ((result.after.maxFrame?.lineOfSightChecks ?? 0) > limits.lineOfSightChecksPerFrame) failures.push(`lineOfSightChecks max frame ${result.after.maxFrame.lineOfSightChecks}`); + if ((result.after.maxFrame?.attackChainChecks ?? 0) > limits.attackChainChecksPerFrame) failures.push(`attackChainChecks max frame ${result.after.maxFrame.attackChainChecks}`); + if ((result.after.maxPerSecond?.lineOfSightChecks ?? 0) > limits.lineOfSightChecksPerSecond) failures.push(`lineOfSightChecks max second ${result.after.maxPerSecond.lineOfSightChecks}`); + } + if (!(result.afterLiveShootables < result.beforeLiveShootables)) failures.push(`live shootable count did not decrease: ${result.beforeLiveShootables} -> ${result.afterLiveShootables}`); + return failures; +} + +function pickupCasesWithOrigins(preparedScene) { + return PICKUP_CASES.map((testCase) => { + const entity = preparedScene.entities?.find((candidate) => candidate.index === testCase.entity); + if (!entity?.origin) throw new Error(`Missing E1M1 pickup entity ${testCase.entity}.`); + if (entity.classname !== testCase.classname) { + throw new Error(`Expected E1M1 entity ${testCase.entity} to be ${testCase.classname}, got ${entity.classname}.`); + } + return { ...testCase, origin: entity.origin }; + }); +} + +async function validatePickup(page, testCase) { + return await page.evaluate(async ({ testCase, yaws }) => { + const debug = window.__cssQuakeDebug; + if (!debug?.stats || !debug.focusEntity || !debug.setViewpos) return { pass: false, reason: "missing debug pickup hooks" }; + const settle = async (ms = 160) => { + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + await new Promise((resolve) => setTimeout(resolve, ms)); + }; + const pickupInfo = (entityIndex) => { + const element = document.querySelector(`.polycss-mesh.pickup[data-entity-index="${entityIndex}"]`); + if (!element) return { mounted: false }; + return { + mounted: true, + hidden: element.hidden, + classname: element.dataset.classname ?? null, + leafCount: element.querySelectorAll("b,i,s,u").length, + }; + }; + const statsSnapshot = () => { + const stats = debug.stats(); + return { + activePickupMeshes: stats.activePickupMeshes, + pickupMeshes: stats.pickupMeshes, + playerArmor: stats.playerArmor, + playerHealth: stats.playerHealth, + playerNails: stats.playerNails, + playerShells: stats.playerShells, + }; + }; + const before = statsSnapshot(); + let focused = null; + for (const yaw of yaws) { + const focusOk = debug.focusEntity(testCase.entity, 4, 90, yaw); + await settle(); + const info = pickupInfo(testCase.entity); + focused = { focusOk, yaw, ...info }; + if (focusOk && info.mounted && !info.hidden && info.classname === testCase.classname && info.leafCount > 0) break; + } + const beforePickup = statsSnapshot(); + const pickupOk = debug.setViewpos(testCase.origin.x, testCase.origin.y, testCase.origin.z, undefined, undefined, { gameplay: true }); + await settle(220); + const after = statsSnapshot(); + const afterInfo = pickupInfo(testCase.entity); + const repeatOk = debug.setViewpos(testCase.origin.x, testCase.origin.y, testCase.origin.z, undefined, undefined, { gameplay: true }); + await settle(120); + const afterRepeat = statsSnapshot(); + return { pass: true, before, beforePickup, focused, pickupOk, after, afterInfo, repeatOk, afterRepeat }; + }, { testCase, yaws: PICKUP_FOCUS_YAWS }); +} + +async function disabledPickupSnapshot(page) { + return await page.evaluate((pickup) => { + const element = document.querySelector(`.polycss-mesh.pickup[data-entity-index="${pickup.entity}"]`); + return { classname: pickup.classname, entity: pickup.entity, mounted: Boolean(element), elementClassname: element?.dataset.classname ?? null }; + }, DISABLED_PICKUP); +} + +function assertPickupResult(testCase, result) { + if (!result.pass) throw new Error(`${testCase.label} failed before validation: ${result.reason ?? "unknown"}`); + const focused = result.focused; + if (!focused?.focusOk || !focused.mounted || focused.hidden || focused.classname !== testCase.classname) { + throw new Error(`${testCase.label} pickup did not become visible: ${JSON.stringify(focused)}`); + } + if (!(focused.leafCount > 0)) throw new Error(`${testCase.label} pickup mounted without render leaves: ${JSON.stringify(focused)}`); + if (!result.pickupOk) throw new Error(`${testCase.label} pickup debug gameplay pose failed.`); + const expected = result.before[testCase.stat] + testCase.delta; + if (result.after[testCase.stat] !== expected) throw new Error(`${testCase.label} should change ${testCase.stat} to ${expected}, got ${result.after[testCase.stat]}.`); + if (result.afterInfo.mounted) throw new Error(`${testCase.label} pickup mesh should be removed after pickup: ${JSON.stringify(result.afterInfo)}`); + if (result.after.pickupMeshes !== result.beforePickup.pickupMeshes - 1) { + throw new Error(`${testCase.label} should remove exactly one pickup mesh, before=${result.beforePickup.pickupMeshes} after=${result.after.pickupMeshes}.`); + } + if (!result.repeatOk) throw new Error(`${testCase.label} repeat gameplay pose failed.`); + if (result.afterRepeat[testCase.stat] !== result.after[testCase.stat]) { + throw new Error(`${testCase.label} should not apply twice, after=${result.after[testCase.stat]} repeat=${result.afterRepeat[testCase.stat]}.`); + } +} + +async function validateLiquidDamage(page, testCase) { + return await page.evaluate(async ({ testCase, mapName }) => { + const debug = window.__cssQuakeDebug; + if (!debug?.stats || !debug.contentsAt || !debug.setViewpos) { + return { pass: false, reason: "missing debug liquid-damage hooks" }; + } + + const settle = async (ms = 80) => { + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + await new Promise((resolve) => setTimeout(resolve, ms)); + }; + const liquidContents = new Set([-3, -4, -5]); + const samples = testCase.sampleOffsets.map((offset) => { + const z = testCase.origin.z + offset; + return { + contents: debug.contentsAt(testCase.origin.x, testCase.origin.y, z), + offset, + z, + }; + }); + let waterLevel = 0; + for (const sample of samples) { + if (!liquidContents.has(sample.contents)) break; + waterLevel += 1; + } + const before = debug.stats(); + const setViewposOk = debug.setViewpos( + testCase.origin.x, + testCase.origin.y, + testCase.origin.z, + undefined, + undefined, + { gameplay: true }, + ); + const immediate = debug.stats(); + await settle(); + const after = debug.stats(); + return { + afterHealth: after.playerHealth, + beforeHealth: before.playerHealth, + bodyClass: document.body.className, + expectedMapName: mapName, + hasDebug: true, + immediateHealth: immediate.playerHealth, + mapName: after.mapName ?? null, + origin: testCase.origin, + playerMove: after.playerMove ?? null, + samples, + setViewposOk, + waterLevel, + }; + }, { mapName: LIQUID_DAMAGE_MAP, testCase }); +} + +async function validateMapLogic(page, testCase) { + return await page.evaluate(async ({ testCase, mapName }) => { + const debug = window.__cssQuakeDebug; + if (!debug?.stats || !debug.setViewpos) { + return { pass: false, reason: "missing debug map-logic hooks" }; + } + + const settle = async (ms = 80) => { + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + await new Promise((resolve) => setTimeout(resolve, ms)); + }; + const triggerCount = () => + debug.stats().triggers?.triggerMultipleActivationCounts + ?.find((entry) => entry.entityIndex === testCase.triggerEntity) + ?.count ?? 0; + const mover = () => + debug.stats().movers?.movers + ?.find((entry) => entry.entityIndex === testCase.doorEntity) ?? null; + const snapshot = (label) => { + const stats = debug.stats(); + return { + activeTriggerIndexes: stats.triggers?.activeTriggerIndexes ?? [], + cooldownTriggerIndexes: stats.triggers?.cooldownTriggerIndexes ?? [], + count: triggerCount(), + label, + mapName: stats.mapName ?? null, + mover: mover(), + origin: stats.origin ?? null, + }; + }; + const setPose = (pose) => debug.setViewpos( + pose.x, + pose.y, + pose.z, + undefined, + undefined, + { gameplay: true }, + ); + + const before = snapshot("before"); + const firstTouchOk = setPose(testCase.inside); + const afterFirst = snapshot("afterFirst"); + const leaveDuringCooldownOk = setPose(testCase.outside); + const afterLeaveDuringCooldown = snapshot("afterLeaveDuringCooldown"); + const blockedRetouchOk = setPose(testCase.inside); + const afterBlockedRetouch = snapshot("afterBlockedRetouch"); + const leaveForRefireOk = setPose(testCase.outside); + await settle(testCase.delayedRefireMs); + const afterCooldown = snapshot("afterCooldown"); + const refireTouchOk = setPose(testCase.inside); + const afterThird = snapshot("afterThird"); + await settle(); + const afterSettled = snapshot("afterSettled"); + + return { + afterBlockedRetouch, + afterCooldown, + afterFirst, + afterLeaveDuringCooldown, + afterSettled, + afterThird, + before, + blockedRetouchOk, + expectedMapName: mapName, + firstTouchOk, + hasDebug: true, + leaveDuringCooldownOk, + leaveForRefireOk, + refireTouchOk, + }; + }, { mapName: MAP_LOGIC_MAP, testCase }); +} + +function assertMapLogicFixturePrepared(preparedScene, testCase) { + const trigger = preparedScene.entities?.find((entity) => entity.index === testCase.triggerEntity); + const door = preparedScene.entities?.find((entity) => entity.index === testCase.doorEntity); + if (trigger?.classname !== testCase.expectedTriggerClassname) { + throw new Error(`${testCase.label} expected trigger #${testCase.triggerEntity} to be ${testCase.expectedTriggerClassname}, got ${trigger?.classname}.`); + } + if (door?.classname !== testCase.expectedDoorClassname) { + throw new Error(`${testCase.label} expected door #${testCase.doorEntity} to be ${testCase.expectedDoorClassname}, got ${door?.classname}.`); + } + if (trigger.properties?.target !== testCase.targetname) { + throw new Error(`${testCase.label} expected trigger target ${testCase.targetname}, got ${trigger.properties?.target}.`); + } + if (door.properties?.targetname !== testCase.targetname) { + throw new Error(`${testCase.label} expected door targetname ${testCase.targetname}, got ${door.properties?.targetname}.`); + } +} + +function assertMapLogicResult(testCase, result) { + if (!result.pass && result.reason) throw new Error(`${testCase.label} failed before validation: ${result.reason}`); + if (!result.hasDebug) throw new Error(`${testCase.label} debug hooks missing.`); + for (const [name, ok] of [ + ["firstTouchOk", result.firstTouchOk], + ["leaveDuringCooldownOk", result.leaveDuringCooldownOk], + ["blockedRetouchOk", result.blockedRetouchOk], + ["leaveForRefireOk", result.leaveForRefireOk], + ["refireTouchOk", result.refireTouchOk], + ]) { + if (!ok) throw new Error(`${testCase.label} ${name} failed.`); + } + if (result.before.mapName !== result.expectedMapName || result.afterSettled.mapName !== result.expectedMapName) { + throw new Error(`${testCase.label} unexpected map: before=${result.before.mapName} after=${result.afterSettled.mapName}.`); + } + if (result.before.mover?.mode !== testCase.expectedDoorInitialMode) { + throw new Error(`${testCase.label} expected initial door mode ${testCase.expectedDoorInitialMode}, got ${result.before.mover?.mode}.`); + } + if (result.afterFirst.mover?.mode !== testCase.expectedDoorTriggeredMode) { + throw new Error(`${testCase.label} expected triggered door mode ${testCase.expectedDoorTriggeredMode}, got ${result.afterFirst.mover?.mode}.`); + } + if (result.before.count !== 0) throw new Error(`${testCase.label} expected trigger count 0 before touch, got ${result.before.count}.`); + if (result.afterFirst.count !== 1) throw new Error(`${testCase.label} expected first touch count 1, got ${result.afterFirst.count}.`); + if (!result.afterFirst.activeTriggerIndexes.includes(testCase.triggerEntity)) { + throw new Error(`${testCase.label} trigger should be active after first touch: ${JSON.stringify(result.afterFirst.activeTriggerIndexes)}`); + } + if (!result.afterFirst.cooldownTriggerIndexes.includes(testCase.triggerEntity)) { + throw new Error(`${testCase.label} trigger should be cooling down after first touch: ${JSON.stringify(result.afterFirst.cooldownTriggerIndexes)}`); + } + if (result.afterLeaveDuringCooldown.activeTriggerIndexes.includes(testCase.triggerEntity)) { + throw new Error(`${testCase.label} trigger should clear active state after leaving.`); + } + if (result.afterBlockedRetouch.count !== 1) { + throw new Error(`${testCase.label} cooldown retouch should stay at count 1, got ${result.afterBlockedRetouch.count}.`); + } + if (result.afterCooldown.cooldownTriggerIndexes.includes(testCase.triggerEntity)) { + throw new Error(`${testCase.label} trigger should leave cooldown before delayed refire.`); + } + if (result.afterThird.count !== 2) { + throw new Error(`${testCase.label} delayed refire should increment count to 2, got ${result.afterThird.count}.`); + } +} + +function assertLiquidDamageResult(testCase, result) { + if (!result.pass && result.reason) throw new Error(`${testCase.label} failed before validation: ${result.reason}`); + if (!result.hasDebug) throw new Error(`${testCase.label} debug hooks missing.`); + if (result.mapName !== result.expectedMapName) { + throw new Error(`${testCase.label} expected map ${result.expectedMapName}, got ${result.mapName}.`); + } + if (!result.setViewposOk) throw new Error(`${testCase.label} debug gameplay pose failed.`); + if (result.waterLevel !== testCase.expectedWaterLevel) { + throw new Error(`${testCase.label} expected waterLevel ${testCase.expectedWaterLevel}, got ${result.waterLevel}.`); + } + for (let index = 0; index < testCase.expectedWaterLevel; index += 1) { + const sample = result.samples[index]; + if (sample?.contents !== testCase.contentsValue) { + throw new Error(`${testCase.label} sample ${index} expected ${testCase.contentsValue}, got ${sample?.contents}.`); + } + } + if (!Number.isFinite(result.beforeHealth) || !Number.isFinite(result.afterHealth)) { + throw new Error(`${testCase.label} missing health values: ${JSON.stringify(result)}`); + } + const actualDamage = result.beforeHealth - result.afterHealth; + if (actualDamage !== testCase.expectedDamage) { + throw new Error(`${testCase.label} expected ${testCase.expectedDamage} damage, got ${actualDamage}.`); + } +} + +function unique(values) { + return [...new Set(values)].sort(); +} diff --git a/test/browserFixtureProjectile.mjs b/test/browserFixtureProjectile.mjs new file mode 100644 index 0000000..def91c8 --- /dev/null +++ b/test/browserFixtureProjectile.mjs @@ -0,0 +1,866 @@ +import { openDebugMapPage } from "./browserHarnessSupport.mjs"; + +const ROCKET_TOUCH_MAP = "e1m1"; +const ROCKET_TOUCH_SCENARIO = { + id: "e1m1-soldier-rocket-touch", + map: ROCKET_TOUCH_MAP, + player: { + origin: [616, 160, 75], + angles: [0, 270, 0], + }, + edit: { + select: { classname: "monster_army", nth: 0 }, + origin: [616, 72, 40], + }, + action: { + type: "rocketTouch", + weapon: "rocketlauncher", + missileOrigin: [616, 72, 40], + expectedPlayerSplashDamageMin: 30, + expectedPlayerSplashDamageMax: 45, + }, +}; +const ROCKET_TOUCH_SOURCE = { + directDamage: 117, + playerSplashDamage: 36, + sourceEvent: "T_MissileTouch", +}; + +const ROCKET_FIRE_MAP = "e1m1"; +const ROCKET_FIRE_SCENARIO = { + id: "e1m1-soldier-rocket-fire", + map: ROCKET_FIRE_MAP, + player: { + origin: [616, 300, 40], + angles: [0, 270, 0], + }, + edit: { + select: { classname: "monster_army", nth: 0 }, + origin: [616, 72, 40], + }, + action: { + type: "rocketFire", + weapon: "rocketlauncher", + expectedPlayerSplashDamageMin: 0, + expectedPlayerSplashDamageMax: 0, + }, +}; +const ROCKET_FIRE_SOURCE = { + directDamage: 118, + missileOrigin: [616, 292, 56], + missileVelocity: [0.0000119249, -1000, 0], + playerSplashDamage: 0, + sourceEvent: "W_FireRocket", +}; + +const ENEMY_PROJECTILE_STEP_MS = 1000 / 72; +const OGRE_GRENADE_CHAIN_SCENARIO = { + id: "e1m2-ogre-grenade-chain", + map: "e1m2", + player: { origin: [1300, 156, 220], angles: [0, 225, 0] }, + edit: { + select: { classname: "monster_ogre", nth: 0 }, + origin: [1018, -126, 320], + yaw: 45, + }, + chain: "missile", + expected: { + damage: 40, + modelPath: "progs/grenade.mdl", + projectile: "enemy_projectile_grenade", + minMoveEvents: 8, + }, +}; +const OGRE_GRENADE_BOUNCE_SCENARIO = { + id: "e1m2-ogre-grenade-bounce", + map: "e1m2", + player: { origin: [2000, 1000, 500], angles: [0, 225, 0] }, + edit: { + select: { classname: "monster_ogre", nth: 0 }, + origin: [1018, -126, 320], + yaw: 45, + }, + chain: "missile", + targetOrigin: [1300, 156, 220], + expected: { + damage: 40, + impactResult: "keep", + impactTraceClassname: "worldspawn", + impactVelocityZ: "positive", + modelPath: "progs/grenade.mdl", + projectile: "enemy_projectile_grenade", + minMoveEvents: 60, + worldTouch: "bounce", + }, +}; +const OGRE_GRENADE_LIFECYCLE_SCENARIO = { + id: "e1m2-ogre-grenade-lifecycle", + map: "e1m2", + player: { origin: [2000, 1000, 500], angles: [0, 225, 0] }, + edit: { + select: { classname: "monster_ogre", nth: 0 }, + origin: [1018, -126, 320], + yaw: 45, + }, + chain: "missile", + targetOrigin: [1300, 156, 220], + expected: { + damage: 40, + expireEvents: 1, + explosionEvents: 1, + impactResult: "keep", + impactTraceClassname: "worldspawn", + impactVelocityZ: "positive", + modelPath: "progs/grenade.mdl", + projectile: "enemy_projectile_grenade", + minMoveEvents: 120, + removeEvents: 1, + splashDamage: 40, + splashRadiusQuakeUnits: 80, + worldTouch: "bounce", + }, +}; +const WIZARD_SPIKE_CHAIN_SCENARIO = { + id: "e1m4-wizard-spike-chain", + map: "e1m4", + player: { origin: [944, 980, 1016], angles: [0, 270, 0] }, + edit: { + select: { classname: "monster_wizard", entityIndex: 317, nth: 0 }, + origin: [944, 840, 956], + yaw: 270, + }, + chain: "attack", + expected: { + damage: 9, + modelPath: "progs/w_spike.mdl", + projectile: "enemy_projectile_spike", + spawnEvents: 2, + minMoveEvents: 2, + }, +}; +const ZOMBIE_PROJECTILE_CHAIN_SCENARIO = { + id: "e1m7-zombie-projectile-chain", + map: "e1m7", + player: { origin: [1760, -160, 100], angles: [0, 90, 0] }, + edit: { + select: { classname: "monster_zombie", nth: 0 }, + origin: [1760, 128, 24], + yaw: 270, + }, + chain: "attack", + expected: { + damage: 10, + modelPath: "progs/zom_gib.mdl", + projectile: "enemy_projectile_zombie_grenade", + minMoveEvents: 1, + }, +}; +const ZOMBIE_PROJECTILE_STOP_SCENARIO = { + id: "e1m7-zombie-projectile-stop", + map: "e1m7", + player: { origin: [2400, -900, 300], angles: [0, 90, 0] }, + edit: { + select: { classname: "monster_zombie", nth: 0 }, + origin: [1760, 128, 24], + yaw: 270, + }, + chain: "attack", + targetOrigin: [1760, -160, 24], + expected: { + damage: 10, + impactResult: "stop", + impactVelocity: [0, 0, 0], + modelPath: "progs/zom_gib.mdl", + projectile: "enemy_projectile_zombie_grenade", + minMoveEvents: 4, + worldTouch: "stop", + }, +}; + +export const rocketTouchFixture = { + id: "rocket-touch", + label: "Rocket touch browser fixture", + artifact: "bench/results/quake/oracle/e1m1-soldier-rocket-touch.cssquake.json", + requirements: { requiredMaps: [ROCKET_TOUCH_MAP], requireRenderBundle: true }, + run: runRocketTouchFixture, +}; + +export const rocketFireFixture = { + id: "rocket-fire", + label: "Rocket fire browser fixture", + artifact: "bench/results/quake/oracle/e1m1-soldier-rocket-fire.cssquake.json", + requirements: { requiredMaps: [ROCKET_FIRE_MAP], requireRenderBundle: true }, + run: runRocketFireFixture, +}; + +export const ogreGrenadeChainFixture = enemyProjectileChainFixture({ + artifact: "bench/results/quake/oracle/e1m2-ogre-grenade-chain.cssquake.json", + id: "ogre-grenade-chain", + label: "Ogre grenade chain browser fixture", + scenario: OGRE_GRENADE_CHAIN_SCENARIO, +}); + +export const ogreGrenadeBounceFixture = enemyProjectileChainFixture({ + artifact: "bench/results/quake/oracle/e1m2-ogre-grenade-bounce.cssquake.json", + id: "ogre-grenade-bounce", + label: "Ogre grenade bounce browser fixture", + scenario: OGRE_GRENADE_BOUNCE_SCENARIO, +}); + +export const ogreGrenadeLifecycleFixture = enemyProjectileChainFixture({ + artifact: "bench/results/quake/oracle/e1m2-ogre-grenade-lifecycle.cssquake.json", + id: "ogre-grenade-lifecycle", + label: "Ogre grenade lifecycle browser fixture", + scenario: OGRE_GRENADE_LIFECYCLE_SCENARIO, +}); + +export const wizardSpikeChainFixture = enemyProjectileChainFixture({ + artifact: "bench/results/quake/oracle/e1m4-wizard-spike-chain.cssquake.json", + id: "wizard-spike-chain", + label: "Wizard spike chain browser fixture", + scenario: WIZARD_SPIKE_CHAIN_SCENARIO, +}); + +export const zombieProjectileChainFixture = enemyProjectileChainFixture({ + artifact: "bench/results/quake/oracle/e1m7-zombie-projectile-chain.cssquake.json", + id: "zombie-projectile-chain", + label: "Zombie projectile chain browser fixture", + scenario: ZOMBIE_PROJECTILE_CHAIN_SCENARIO, +}); + +export const zombieProjectileStopFixture = enemyProjectileChainFixture({ + artifact: "bench/results/quake/oracle/e1m7-zombie-projectile-stop.cssquake.json", + id: "zombie-projectile-stop", + label: "Zombie projectile stop browser fixture", + scenario: ZOMBIE_PROJECTILE_STOP_SCENARIO, +}); + +async function runRocketTouchFixture({ browser, baseUrl, options }) { + const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, ROCKET_TOUCH_MAP, options); + try { + const result = await page.evaluate(async ({ scenario, sourceReference }) => { + const debug = window.__cssQuakeDebug; + const [playerX, playerY, playerZ] = scenario.player.origin; + const [pitch, yaw, roll] = scenario.player.angles; + const [targetX, targetY, targetZ] = scenario.edit.origin; + const [missileX, missileY, missileZ] = scenario.action.missileOrigin; + if (!debug?.stats) return { hasDebug: false }; + + debug.setExpandedLogicalCombat?.(false); + debug.setUnmountedAi?.(false); + const setPlayerOk = Boolean(debug.setViewpos?.(playerX, playerY, playerZ, pitch, yaw, roll, { + gameplay: true, + })); + const setWeaponOk = Boolean(debug.setWeapon?.(scenario.action.weapon)); + + const targetEntityIndexes = debug.entityIndexes?.(scenario.edit.select.classname) ?? []; + const requestedTargetEntity = targetEntityIndexes[scenario.edit.select.nth ?? 0] ?? null; + let targetEntity = null; + let setTargetOriginOk = false; + let mountTargetOk = false; + for (const entityIndex of targetEntityIndexes) { + if (!debug.setEntityOrigin?.(entityIndex, targetX, targetY, targetZ)) continue; + targetEntity = entityIndex; + setTargetOriginOk = true; + mountTargetOk = Boolean(debug.debugMountEntity?.(entityIndex)); + break; + } + + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + + const beforeStats = debug.stats(); + const beforeBudget = beforeStats.shootables?.combatBudget ?? null; + const beforeInventory = { + armor: beforeStats.playerArmor ?? null, + health: beforeStats.playerHealth ?? null, + }; + const beforeShootables = { + dead: beforeStats.shootables?.deadShootables ?? null, + live: beforeStats.shootables?.liveShootables ?? null, + }; + const impact = targetEntity === null + ? null + : debug.projectileImpact?.( + scenario.action.weapon, + targetEntity, + missileX, + missileY, + missileZ, + sourceReference.directDamage, + ) ?? null; + + await new Promise(requestAnimationFrame); + const afterStats = debug.stats(); + const afterBudget = afterStats.shootables?.combatBudget ?? null; + const afterInventory = { + armor: afterStats.playerArmor ?? null, + health: afterStats.playerHealth ?? null, + }; + const afterShootables = { + dead: afterStats.shootables?.deadShootables ?? null, + live: afterStats.shootables?.liveShootables ?? null, + }; + + return { + action: scenario.action, + after: afterBudget, + afterInventory, + afterShootables, + before: beforeBudget, + beforeInventory, + beforeShootables, + hasDebug: true, + impact, + mapName: afterStats.mapName ?? null, + mountTargetOk, + scenarioId: scenario.id, + setPlayerOk, + setTargetOriginOk, + setWeaponOk, + sourceReference: { + directDamage: sourceReference.directDamage, + expectedSourcePlayerSplashDamage: sourceReference.playerSplashDamage, + missileOrigin: { x: missileX, y: missileY, z: missileZ }, + playerAngles: { pitch, roll, yaw }, + playerOrigin: { x: playerX, y: playerY, z: playerZ }, + requestedTargetEntity, + sourceEvent: sourceReference.sourceEvent, + targetClassname: scenario.edit.select.classname, + targetEntity, + targetEntityIndexes, + targetSelection: targetEntity === requestedTargetEntity ? "requested-nth" : "first-active-shootable", + targetOrigin: { x: targetX, y: targetY, z: targetZ }, + weapon: scenario.action.weapon, + }, + targetEntity, + }; + }, { scenario: ROCKET_TOUCH_SCENARIO, sourceReference: ROCKET_TOUCH_SOURCE }); + result.pageErrors = pageErrors; + const failures = validateRocketTouchResult(result); + if (failures.length) throw new Error(`Rocket touch fixture failed: ${failures.join("; ")}`); + const playerDamage = result.beforeInventory.health - result.afterInventory.health; + const losDelta = budgetCounterDelta(result.before, result.after, "lineOfSightChecksTotal"); + console.log(`PASS rocket touch direct ${ROCKET_TOUCH_SOURCE.directDamage}, player splash ${playerDamage}, LOS cost ${losDelta}`); + return { + failures, + generatedAt: new Date().toISOString(), + kind: "cssquake-rocket-touch-browser-fixture", + mapName: ROCKET_TOUCH_MAP, + pass: true, + result, + scenarioId: ROCKET_TOUCH_SCENARIO.id, + sourceReference: ROCKET_TOUCH_SOURCE, + }; + } finally { + await page.close(); + } +} + +function validateRocketTouchResult(result) { + const failures = []; + const action = ROCKET_TOUCH_SCENARIO.action; + const expectedSplash = ROCKET_TOUCH_SOURCE.playerSplashDamage; + if (!result.hasDebug) failures.push("debug hooks missing"); + if (result.pageErrors?.length) failures.push(`page errors: ${result.pageErrors.join(" | ")}`); + if (result.mapName !== ROCKET_TOUCH_MAP) failures.push(`unexpected map ${result.mapName}`); + if (result.scenarioId !== ROCKET_TOUCH_SCENARIO.id) failures.push(`scenario id mismatch: ${result.scenarioId}`); + if (!result.targetEntity) failures.push("target entity was not selected"); + if (!result.setPlayerOk) failures.push("failed to place player fixture"); + if (!result.setTargetOriginOk) failures.push("failed to place target fixture"); + if (!result.mountTargetOk) failures.push("failed to mount target fixture"); + if (!result.setWeaponOk) failures.push("failed to set rocketlauncher"); + if (!result.impact) failures.push("debug projectileImpact returned null"); + if (!result.before) failures.push("missing before combat budget stats"); + if (!result.after) failures.push("missing after combat budget stats"); + if (result.impact) { + if (result.impact.weapon !== action.weapon) failures.push(`impact weapon ${result.impact.weapon}, expected ${action.weapon}`); + if (result.impact.impactResult !== "remove") failures.push(`impact result ${result.impact.impactResult}, expected remove`); + if (result.impact.directDamage !== ROCKET_TOUCH_SOURCE.directDamage) { + failures.push(`impact direct damage ${result.impact.directDamage}, expected ${ROCKET_TOUCH_SOURCE.directDamage}`); + } + if (result.impact.directEntityIndex !== result.targetEntity) { + failures.push(`impact direct entity ${result.impact.directEntityIndex}, expected ${result.targetEntity}`); + } + if (result.impact.splashDamage !== 120) failures.push(`rocket splash damage ${result.impact.splashDamage}, expected 120`); + if (result.impact.splashRadiusQuakeUnits !== 160) { + failures.push(`rocket splash radius ${result.impact.splashRadiusQuakeUnits}, expected 160 Quake units`); + } + if (result.impact.splashRequiresCanDamage !== true) failures.push("rocket splash should require CanDamage"); + if (result.impact.splashIgnoresDirectHit !== true) failures.push("rocket splash should ignore direct-hit target"); + } + if (result.beforeInventory && result.afterInventory) { + const healthDelta = result.beforeInventory.health - result.afterInventory.health; + if (healthDelta !== expectedSplash) { + failures.push(`player splash damage ${healthDelta}, expected source ${expectedSplash}`); + } + } + if (result.beforeShootables && result.afterShootables) { + const liveDelta = result.afterShootables.live - result.beforeShootables.live; + if (liveDelta !== -1) failures.push(`live shootable delta ${liveDelta}, expected -1`); + } + if (result.before && result.after) { + const losDelta = budgetCounterDelta(result.before, result.after, "lineOfSightChecksTotal"); + const unmountedAiDelta = budgetCounterDelta(result.before, result.after, "unmountedAiTicksTotal"); + const domReadDelta = budgetCounterDelta(result.before, result.after, "domReadsTotal"); + const limits = result.after.limits ?? {}; + if (losDelta < 1 || losDelta > 5) failures.push(`rocket touch LOS cost ${losDelta}, expected 1-5`); + if (unmountedAiDelta !== 0) failures.push(`unmounted AI ticks changed by ${unmountedAiDelta}`); + if (domReadDelta !== 0) failures.push(`DOM reads changed by ${domReadDelta}`); + if (result.after.expandedLogicalCombatEnabled !== false) failures.push("expanded logical combat should stay disabled"); + if (result.after.unmountedAiEnabled !== false) failures.push("unmounted AI should stay disabled"); + if ((result.after.currentFrame?.lineOfSightChecks ?? 0) > (limits.lineOfSightChecksPerFrame ?? Infinity)) { + failures.push(`current-frame LOS over cap: ${result.after.currentFrame.lineOfSightChecks}`); + } + if ((result.after.maxFrame?.lineOfSightChecks ?? 0) > (limits.lineOfSightChecksPerFrame ?? Infinity)) { + failures.push(`max-frame LOS over cap: ${result.after.maxFrame.lineOfSightChecks}`); + } + if ((result.after.maxPerSecond?.lineOfSightChecks ?? 0) > (limits.lineOfSightChecksPerSecond ?? Infinity)) { + failures.push(`per-second LOS over cap: ${result.after.maxPerSecond.lineOfSightChecks}`); + } + } + return failures; +} + +async function runRocketFireFixture({ browser, baseUrl, options }) { + const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, ROCKET_FIRE_MAP, options); + try { + const result = await page.evaluate(async ({ scenario, sourceReference, timeoutMs }) => { + const debug = window.__cssQuakeDebug; + const [playerX, playerY, playerZ] = scenario.player.origin; + const [pitch, yaw, roll] = scenario.player.angles; + const [targetX, targetY, targetZ] = scenario.edit.origin; + if (!debug?.stats) return { hasDebug: false }; + + debug.setExpandedLogicalCombat?.(false); + debug.setUnmountedAi?.(false); + const setPlayerOk = Boolean(debug.setViewpos?.(playerX, playerY, playerZ, pitch, yaw, roll, { + gameplay: true, + stableViewmodel: true, + })); + const setWeaponOk = Boolean(debug.setWeapon?.(scenario.action.weapon)); + + const targetEntityIndexes = debug.entityIndexes?.(scenario.edit.select.classname) ?? []; + const requestedTargetEntity = targetEntityIndexes[scenario.edit.select.nth ?? 0] ?? null; + const targetSearchOrder = [ + ...targetEntityIndexes.slice(scenario.edit.select.nth ?? 0), + ...targetEntityIndexes.slice(0, scenario.edit.select.nth ?? 0), + ]; + let targetEntity = null; + let setTargetOriginOk = false; + let mountTargetOk = false; + for (const entityIndex of targetSearchOrder) { + if (!debug.setEntityOrigin?.(entityIndex, targetX, targetY, targetZ)) continue; + targetEntity = entityIndex; + setTargetOriginOk = true; + mountTargetOk = Boolean(debug.debugMountEntity?.(entityIndex)); + break; + } + + await nextFrame(); + await nextFrame(); + + const beforeStats = debug.stats(); + const beforeInventory = { + armor: beforeStats.playerArmor ?? null, + health: beforeStats.playerHealth ?? null, + }; + const beforeShootables = { + dead: beforeStats.shootables?.deadShootables ?? null, + live: beforeStats.shootables?.liveShootables ?? null, + }; + const fireTrace = await debug.fireProjectileTrace?.( + sourceReference.directDamage, + Math.min(timeoutMs, 5000), + ) ?? null; + await nextFrame(); + const afterStats = debug.stats(); + const afterInventory = { + armor: afterStats.playerArmor ?? null, + health: afterStats.playerHealth ?? null, + }; + const afterShootables = { + dead: afterStats.shootables?.deadShootables ?? null, + live: afterStats.shootables?.liveShootables ?? null, + }; + + return { + action: scenario.action, + after: afterStats.shootables?.combatBudget ?? null, + afterInventory, + afterShootables, + before: beforeStats.shootables?.combatBudget ?? null, + beforeInventory, + beforeShootables, + fireTrace, + hasDebug: true, + mapName: afterStats.mapName ?? null, + mountTargetOk, + scenarioId: scenario.id, + setPlayerOk, + setTargetOriginOk, + setWeaponOk, + sourceReference: { + directDamage: sourceReference.directDamage, + expectedSourcePlayerSplashDamage: sourceReference.playerSplashDamage, + playerAngles: { pitch, roll, yaw }, + playerOrigin: { x: playerX, y: playerY, z: playerZ }, + requestedTargetEntity, + sourceEvent: sourceReference.sourceEvent, + sourceMissileOrigin: sourceReference.missileOrigin, + sourceMissileVelocity: sourceReference.missileVelocity, + targetClassname: scenario.edit.select.classname, + targetEntity, + targetEntityIndexes, + targetSelection: targetEntity === requestedTargetEntity ? "requested-nth" : "first-active-shootable", + targetOrigin: { x: targetX, y: targetY, z: targetZ }, + weapon: scenario.action.weapon, + }, + targetEntity, + }; + + function nextFrame() { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); + } + }, { scenario: ROCKET_FIRE_SCENARIO, sourceReference: ROCKET_FIRE_SOURCE, timeoutMs: options.timeoutMs }); + result.pageErrors = pageErrors; + const failures = validateRocketFireResult(result); + if (failures.length) throw new Error(`Rocket fire fixture failed: ${failures.join("; ")}`); + const playerDamage = result.beforeInventory.health - result.afterInventory.health; + const moveCount = captureRocketFireEvents(result, "move").length; + console.log(`PASS rocket fire direct ${ROCKET_FIRE_SOURCE.directDamage}, player splash ${playerDamage}, projectile moves ${moveCount}`); + return { + failures, + generatedAt: new Date().toISOString(), + kind: "cssquake-rocket-fire-browser-fixture", + mapName: ROCKET_FIRE_MAP, + pass: true, + result, + scenarioId: ROCKET_FIRE_SCENARIO.id, + sourceReference: ROCKET_FIRE_SOURCE, + }; + } finally { + await page.close(); + } +} + +function validateRocketFireResult(result) { + const failures = []; + const action = ROCKET_FIRE_SCENARIO.action; + const expectedSplash = ROCKET_FIRE_SOURCE.playerSplashDamage; + const fireEvents = captureRocketFireEvents(result, "fire"); + const spawnEvents = captureRocketFireEvents(result, "spawn"); + const moveEvents = captureRocketFireEvents(result, "move"); + const impactEvents = captureRocketFireEvents(result, "impact"); + const removeEvents = captureRocketFireEvents(result, "remove"); + const finalImpact = impactEvents.find((event) => event.impactResult === "remove") ?? null; + if (!result.hasDebug) failures.push("debug hooks missing"); + if (result.pageErrors?.length) failures.push(`page errors: ${result.pageErrors.join(" | ")}`); + if (result.mapName !== ROCKET_FIRE_MAP) failures.push(`unexpected map ${result.mapName}`); + if (result.scenarioId !== ROCKET_FIRE_SCENARIO.id) failures.push(`scenario id mismatch: ${result.scenarioId}`); + if (!result.targetEntity) failures.push("target entity was not selected"); + if (!result.setPlayerOk) failures.push("failed to place player fixture"); + if (!result.setTargetOriginOk) failures.push("failed to place target fixture"); + if (!result.mountTargetOk) failures.push("failed to mount target fixture"); + if (!result.setWeaponOk) failures.push("failed to set rocketlauncher"); + if (!result.fireTrace) failures.push("debug fireProjectileTrace returned null"); + if (result.fireTrace && result.fireTrace.fired !== true) failures.push("debug fireProjectileTrace did not fire"); + if (!fireEvents.length) failures.push("missing projectile fire event"); + if (!spawnEvents.length) failures.push("missing projectile spawn event"); + if (!moveEvents.length) failures.push("missing projectile move event"); + if (!impactEvents.length) failures.push("missing projectile impact event"); + if (!removeEvents.length) failures.push("missing projectile remove event"); + if (!finalImpact) failures.push("missing terminal projectile impact event"); + if (finalImpact) { + if (finalImpact.weapon !== action.weapon) failures.push(`impact weapon ${finalImpact.weapon}, expected ${action.weapon}`); + if (finalImpact.target?.entityIndex !== result.targetEntity) { + failures.push(`impact target ${finalImpact.target?.entityIndex}, expected ${result.targetEntity}`); + } + if (finalImpact.damage !== ROCKET_FIRE_SOURCE.directDamage) { + failures.push(`impact direct damage ${finalImpact.damage}, expected ${ROCKET_FIRE_SOURCE.directDamage}`); + } + if (finalImpact.splashDamage !== 120) failures.push(`rocket splash damage ${finalImpact.splashDamage}, expected 120`); + if (finalImpact.splashRadiusQuakeUnits !== 160) { + failures.push(`rocket splash radius ${finalImpact.splashRadiusQuakeUnits}, expected 160 Quake units`); + } + if (finalImpact.splashIgnoresDirectHit !== true) failures.push("rocket splash should ignore direct-hit target"); + } + if (result.beforeInventory && result.afterInventory) { + const healthDelta = result.beforeInventory.health - result.afterInventory.health; + if (healthDelta !== expectedSplash) failures.push(`player splash damage ${healthDelta}, expected source ${expectedSplash}`); + } + if (result.beforeShootables && result.afterShootables) { + const liveDelta = result.afterShootables.live - result.beforeShootables.live; + if (liveDelta !== -1) failures.push(`live shootable delta ${liveDelta}, expected -1`); + } + if (result.before && result.after) { + const unmountedAiDelta = budgetCounterDelta(result.before, result.after, "unmountedAiTicksTotal"); + const domReadDelta = budgetCounterDelta(result.before, result.after, "domReadsTotal"); + const limits = result.after.limits ?? {}; + if (unmountedAiDelta !== 0) failures.push(`unmounted AI ticks changed by ${unmountedAiDelta}`); + if (domReadDelta !== 0) failures.push(`DOM reads changed by ${domReadDelta}`); + if (result.after.expandedLogicalCombatEnabled !== false) failures.push("expanded logical combat should stay disabled"); + if (result.after.unmountedAiEnabled !== false) failures.push("unmounted AI should stay disabled"); + if ((result.after.maxFrame?.lineOfSightChecks ?? 0) > (limits.lineOfSightChecksPerFrame ?? Infinity)) { + failures.push(`max-frame LOS over cap: ${result.after.maxFrame.lineOfSightChecks}`); + } + if ((result.after.maxPerSecond?.lineOfSightChecks ?? 0) > (limits.lineOfSightChecksPerSecond ?? Infinity)) { + failures.push(`per-second LOS over cap: ${result.after.maxPerSecond.lineOfSightChecks}`); + } + } else { + failures.push("missing combat budget stats"); + } + return failures; +} + +function captureRocketFireEvents(result, type) { + return result.fireTrace?.capture?.events?.filter((event) => event.type === type) ?? []; +} + +function enemyProjectileChainFixture({ artifact, id, label, scenario }) { + return { + id, + label, + artifact, + requirements: { requiredMaps: [scenario.map], requireRenderBundle: true }, + run: (context) => runEnemyProjectileChainFixture(context, scenario), + }; +} + +async function runEnemyProjectileChainFixture({ browser, baseUrl, options }, scenario) { + const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, scenario.map, options); + try { + const result = await page.evaluate(async ({ scenario, stepMs }) => { + const debug = window.__cssQuakeDebug; + const [playerX, playerY, playerZ] = scenario.player.origin; + const [pitch, yaw, roll] = scenario.player.angles; + const [targetX, targetY, targetZ] = scenario.edit.origin; + if (!debug?.stats) return { hasDebug: false }; + + debug.setExpandedLogicalCombat?.(false); + debug.setUnmountedAi?.(false); + debug.setMountedEnemyAcquisition?.(true); + const setPlayerOk = Boolean(debug.setViewpos?.(playerX, playerY, playerZ, pitch, yaw, roll, { + gameplay: true, + stableViewmodel: true, + })); + const targetEntityIndexes = debug.entityIndexes?.(scenario.edit.select.classname) ?? []; + const targetEntity = Number.isFinite(scenario.edit.select.entityIndex) + ? scenario.edit.select.entityIndex + : targetEntityIndexes[scenario.edit.select.nth ?? 0] ?? null; + const setTargetOriginOk = targetEntity !== null && + Boolean(debug.setEntityOrigin?.(targetEntity, targetX, targetY, targetZ)); + const setTargetYawOk = targetEntity !== null && Boolean(debug.setEntityYaw?.(targetEntity, scenario.edit.yaw)); + const mountTargetOk = targetEntity !== null && Boolean(debug.debugMountEntity?.(targetEntity)); + const setFilterOk = targetEntity !== null && Boolean(debug.setEnemyTickFilter?.([targetEntity])); + + await nextFrame(); + await nextFrame(); + const clearCaptureOk = Boolean(debug.enemyProjectileTraceClear?.()); + const enableCaptureOk = Boolean(debug.enemyProjectileTraceEnabled?.(true)); + const beforeStats = debug.stats(); + const [aimX, aimY, aimZ] = scenario.targetOrigin ?? scenario.player.origin; + const forceAttackOk = targetEntity !== null && + Boolean(debug.enemyForceAttackChain?.(targetEntity, scenario.chain, aimX, aimY, aimZ)); + let capture = debug.enemyProjectileTraceCapture?.() ?? null; + for (let step = 0; step < 700; step++) { + if (enemyProjectileChainDone(capture, scenario.expected)) break; + capture = debug.enemyProjectileTraceStep?.(stepMs) ?? debug.enemyProjectileTraceCapture?.() ?? null; + } + const afterStats = debug.stats(); + const disableCaptureOk = Boolean(debug.enemyProjectileTraceEnabled?.(false)); + debug.setEnemyTickFilter?.(null); + + return { + after: afterStats.shootables?.combatBudget ?? null, + before: beforeStats.shootables?.combatBudget ?? null, + capture, + clearCaptureOk, + disableCaptureOk, + enableCaptureOk, + forceAttackOk, + hasDebug: true, + mapName: afterStats.mapName ?? null, + mountTargetOk, + scenarioId: scenario.id, + setFilterOk, + setPlayerOk, + setTargetOriginOk, + setTargetYawOk, + sourceReference: { + chain: scenario.chain, + expected: scenario.expected, + playerAngles: { pitch, roll, yaw }, + playerOrigin: { x: playerX, y: playerY, z: playerZ }, + targetOrigin: { x: aimX, y: aimY, z: aimZ }, + targetClassname: scenario.edit.select.classname, + targetEntity, + targetEntityIndexes, + targetOrigin: { x: targetX, y: targetY, z: targetZ }, + targetYaw: scenario.edit.yaw, + }, + targetEntity, + }; + + function enemyProjectileChainDone(captureValue, expected) { + const events = captureValue?.events ?? []; + const projectileEvents = events.filter((event) => event.projectile === expected.projectile); + const spawnCount = projectileEvents.filter((event) => event.type === "spawn").length; + const moveCount = projectileEvents.filter((event) => event.type === "move").length; + const expectedImpact = expected.impactResult + ? projectileEvents.some((event) => event.type === "impact" && event.impactResult === expected.impactResult) + : true; + const expectedExpire = expected.expireEvents + ? projectileEvents.filter((event) => event.type === "expire").length >= expected.expireEvents + : true; + const expectedExplosion = expected.explosionEvents + ? projectileEvents.filter((event) => event.type === "explode").length >= expected.explosionEvents + : true; + const expectedRemove = expected.removeEvents + ? projectileEvents.filter((event) => event.type === "remove").length >= expected.removeEvents + : true; + return spawnCount >= (expected.spawnEvents ?? 1) && + moveCount >= expected.minMoveEvents && + expectedImpact && + expectedExpire && + expectedExplosion && + expectedRemove; + } + + function nextFrame() { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); + } + }, { scenario, stepMs: ENEMY_PROJECTILE_STEP_MS }); + result.pageErrors = pageErrors; + const failures = validateEnemyProjectileChainResult(result, scenario); + if (failures.length) throw new Error(`${scenario.id} fixture failed: ${failures.join("; ")}`); + const projectileEvents = enemyProjectileEvents(result, scenario.expected.projectile); + const spawnCount = projectileEvents.filter((event) => event.type === "spawn").length; + const moveCount = projectileEvents.filter((event) => event.type === "move").length; + const impact = projectileEvents.find((event) => + event.type === "impact" && + (!scenario.expected.impactResult || event.impactResult === scenario.expected.impactResult) + ); + const expireCount = projectileEvents.filter((event) => event.type === "expire").length; + const explosionCount = projectileEvents.filter((event) => event.type === "explode").length; + const removeCount = projectileEvents.filter((event) => event.type === "remove").length; + console.log(`PASS ${scenario.id} ${scenario.expected.projectile} spawns ${spawnCount}, moves ${moveCount}` + + (impact ? `, impact ${impact.impactResult}` : "") + + (expireCount ? `, expires ${expireCount}` : "") + + (explosionCount ? `, explodes ${explosionCount}` : "") + + (removeCount ? `, removes ${removeCount}` : "")); + return { + failures, + generatedAt: new Date().toISOString(), + kind: "cssquake-enemy-projectile-chain-browser-fixture", + mapName: scenario.map, + pass: true, + result, + scenario, + scenarioId: scenario.id, + }; + } finally { + await page.close(); + } +} + +function validateEnemyProjectileChainResult(result, scenario) { + const failures = []; + const expected = scenario.expected; + const projectileEvents = enemyProjectileEvents(result, expected.projectile); + const spawnEvents = projectileEvents.filter((event) => event.type === "spawn"); + const moveEvents = projectileEvents.filter((event) => event.type === "move"); + const impactEvents = projectileEvents.filter((event) => event.type === "impact"); + const expireEvents = projectileEvents.filter((event) => event.type === "expire"); + const explosionEvents = projectileEvents.filter((event) => event.type === "explode"); + const removeEvents = projectileEvents.filter((event) => event.type === "remove"); + const expectedSpawns = expected.spawnEvents ?? 1; + if (!result.hasDebug) failures.push("debug hooks missing"); + if (result.pageErrors?.length) failures.push(`page errors: ${result.pageErrors.join(" | ")}`); + if (result.mapName !== scenario.map) failures.push(`unexpected map ${result.mapName}`); + if (result.scenarioId !== scenario.id) failures.push(`scenario id mismatch: ${result.scenarioId}`); + if (!result.targetEntity) failures.push("target entity was not selected"); + if (!result.setPlayerOk) failures.push("failed to place player fixture"); + if (!result.setTargetOriginOk) failures.push("failed to place enemy fixture"); + if (!result.setTargetYawOk) failures.push("failed to set enemy yaw"); + if (!result.mountTargetOk) failures.push("failed to mount enemy fixture"); + if (!result.setFilterOk) failures.push("failed to focus enemy tick filter"); + if (!result.clearCaptureOk) failures.push("failed to clear enemy projectile capture"); + if (!result.enableCaptureOk) failures.push("failed to enable enemy projectile capture"); + if (!result.disableCaptureOk) failures.push("failed to disable enemy projectile capture"); + if (!result.forceAttackOk) failures.push(`failed to force enemy attack chain ${scenario.chain}`); + if (!result.before || !result.after) failures.push("missing combat budget stats"); + if (spawnEvents.length < expectedSpawns) failures.push(`projectile spawns ${spawnEvents.length}, expected at least ${expectedSpawns}`); + if (moveEvents.length < expected.minMoveEvents) failures.push(`projectile moves ${moveEvents.length}, expected at least ${expected.minMoveEvents}`); + if (expected.expireEvents && expireEvents.length < expected.expireEvents) { + failures.push(`projectile expires ${expireEvents.length}, expected at least ${expected.expireEvents}`); + } + if (expected.explosionEvents && explosionEvents.length < expected.explosionEvents) { + failures.push(`projectile explosions ${explosionEvents.length}, expected at least ${expected.explosionEvents}`); + } + if (expected.removeEvents && removeEvents.length < expected.removeEvents) { + failures.push(`projectile removes ${removeEvents.length}, expected at least ${expected.removeEvents}`); + } + for (const event of spawnEvents) { + if (event.modelPath !== expected.modelPath) failures.push(`spawn model ${event.modelPath}, expected ${expected.modelPath}`); + if (event.damage !== expected.damage) failures.push(`spawn damage ${event.damage}, expected ${expected.damage}`); + if (expected.splashDamage !== undefined && event.splashDamage !== expected.splashDamage) { + failures.push(`spawn splash damage ${event.splashDamage}, expected ${expected.splashDamage}`); + } + if (expected.splashRadiusQuakeUnits !== undefined && event.splashRadiusQuakeUnits !== expected.splashRadiusQuakeUnits) { + failures.push(`spawn splash radius ${event.splashRadiusQuakeUnits}, expected ${expected.splashRadiusQuakeUnits}`); + } + if (expected.worldTouch && event.worldTouch !== expected.worldTouch) { + failures.push(`spawn worldTouch ${event.worldTouch}, expected ${expected.worldTouch}`); + } + if (event.sourceEntityIndex !== result.targetEntity) { + failures.push(`spawn source ${event.sourceEntityIndex}, expected ${result.targetEntity}`); + } + } + if (expected.impactResult) { + const impact = impactEvents.find((event) => event.impactResult === expected.impactResult) ?? null; + if (!impact) failures.push(`missing projectile impact ${expected.impactResult}`); + else { + if (expected.worldTouch && impact.worldTouch !== expected.worldTouch) { + failures.push(`impact worldTouch ${impact.worldTouch}, expected ${expected.worldTouch}`); + } + if (expected.impactTraceClassname && impact.trace?.classname !== expected.impactTraceClassname) { + failures.push(`impact trace classname ${impact.trace?.classname}, expected ${expected.impactTraceClassname}`); + } + if (expected.impactVelocity && !vec3Equals(impact.velocity, expected.impactVelocity)) { + failures.push(`impact velocity ${JSON.stringify(impact.velocity)}, expected ${JSON.stringify(expected.impactVelocity)}`); + } + if (expected.impactVelocityZ === "positive" && !(impact.velocity?.[2] > 0)) { + failures.push(`impact velocity z ${impact.velocity?.[2]}, expected positive`); + } + } + } + if (expected.explosionEvents) { + for (const event of explosionEvents) { + if (expected.splashDamage !== undefined && event.splashDamage !== expected.splashDamage) { + failures.push(`explosion splash damage ${event.splashDamage}, expected ${expected.splashDamage}`); + } + if (expected.splashRadiusQuakeUnits !== undefined && event.splashRadiusQuakeUnits !== expected.splashRadiusQuakeUnits) { + failures.push(`explosion splash radius ${event.splashRadiusQuakeUnits}, expected ${expected.splashRadiusQuakeUnits}`); + } + if (expected.worldTouch && event.worldTouch !== expected.worldTouch) { + failures.push(`explosion worldTouch ${event.worldTouch}, expected ${expected.worldTouch}`); + } + } + } + return failures; +} + +function enemyProjectileEvents(result, projectile) { + return result.capture?.events?.filter((event) => event.projectile === projectile) ?? []; +} + +function budgetCounterDelta(before, after, name) { + return (after?.counters?.[name] ?? 0) - (before?.counters?.[name] ?? 0); +} + +function vec3Equals(actual, expected) { + return Array.isArray(actual) && + actual.length === expected.length && + actual.every((value, index) => value === expected[index]); +} diff --git a/test/browserHarnessSupport.mjs b/test/browserHarnessSupport.mjs new file mode 100644 index 0000000..271747b --- /dev/null +++ b/test/browserHarnessSupport.mjs @@ -0,0 +1,233 @@ +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdir } from "node:fs/promises"; +import { existsSync, readdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; + +import { projectRoot } from "./checkAssetState.mjs"; + +export function hasFlag(args, name) { + return args.includes(`--${name}`); +} + +export function optionValue(args, name, fallback = "") { + const flag = `--${name}`; + const index = args.indexOf(flag); + if (index >= 0 && args[index + 1] && !args[index + 1].startsWith("--")) return args[index + 1]; + const prefixed = args.find((arg) => arg.startsWith(`${flag}=`)); + return prefixed ? prefixed.slice(flag.length + 1) : fallback; +} + +export function numberOption(args, name, fallback) { + const raw = optionValue(args, name); + if (!raw) return fallback; + const value = Number(raw); + return Number.isFinite(value) ? value : fallback; +} + +export function parseCommonBrowserArgs(args, defaults = {}) { + return { + explicitUrl: optionValue(args, "url", process.env.CSSQUAKE_SMOKE_URL ?? ""), + headed: hasFlag(args, "headed"), + host: optionValue(args, "host", defaults.host ?? "127.0.0.1"), + jsonOut: optionValue(args, "json-out", defaults.jsonOut ?? ""), + port: Math.max(1, Math.round(numberOption(args, "port", defaults.port ?? 5177))), + timeoutMs: Math.max(1_000, Math.round(numberOption(args, "timeout-ms", defaults.timeoutMs ?? 90_000))), + viewport: viewportFromOption(optionValue(args, "viewport", defaults.viewport ?? "1280x800")), + }; +} + +export function viewportFromOption(raw) { + const match = String(raw).match(/^(\d+)x(\d+)$/i); + if (!match) throw new Error(`Invalid viewport "${raw}". Expected WIDTHxHEIGHT.`); + return { width: Number(match[1]), height: Number(match[2]) }; +} + +export async function loadChromium() { + const require = createRequire(import.meta.url); + try { + return (await import("playwright")).chromium; + } catch (error) { + const roots = [ + ...splitPathList(process.env.PLAYWRIGHT_NODE_MODULES), + ...splitPathList(process.env.NODE_PATH), + projectRoot, + path.join(projectRoot, "node_modules"), + ]; + for (const root of roots) { + try { + return require(require.resolve("playwright", { paths: [root] })).chromium; + } catch { + // Try the next module root. + } + } + for (const packageDir of pnpmPackageDirs("playwright")) { + try { + return require(packageDir).chromium; + } catch { + // Try the next pnpm package directory. + } + } + throw new Error( + `Could not load Playwright. Install it for this workspace or set PLAYWRIGHT_NODE_MODULES=/path/to/node_modules.\n${ + error instanceof Error ? error.message : String(error) + }`, + ); + } +} + +function splitPathList(value) { + return (value ?? "") + .split(path.delimiter) + .map((item) => item.trim()) + .filter(Boolean); +} + +function pnpmPackageDirs(packageName) { + const pnpmDir = path.join(projectRoot, "node_modules", ".pnpm"); + if (!existsSync(pnpmDir)) return []; + return readdirSync(pnpmDir) + .filter((entry) => entry.startsWith(`${packageName}@`)) + .map((entry) => path.join(pnpmDir, entry, "node_modules", packageName)) + .filter((packageDir) => existsSync(packageDir)); +} + +export async function startViteServer({ port, host = "127.0.0.1", strictPort = false, forceDeps = false } = {}) { + const args = ["exec", "vite", "--host", host, "--port", String(port)]; + if (strictPort) args.push("--strictPort"); + if (forceDeps) args.push("--force"); + const child = spawn("pnpm", args, { + cwd: projectRoot, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + output += chunk.toString(); + }); + + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const match = output.match(/Local:\s+(http:\/\/127\.0\.0\.1:\d+\/)/) ?? + output.match(/Local:\s+(http:\/\/localhost:\d+\/)/); + if (match) { + return { + url: match[1], + close: async () => { + child.kill("SIGTERM"); + await sleep(250); + if (!child.killed) child.kill("SIGKILL"); + }, + }; + } + if (child.exitCode !== null) { + throw new Error(`Vite exited before becoming ready.\n${output}`); + } + await sleep(100); + } + child.kill("SIGTERM"); + throw new Error(`Timed out waiting for Vite.\n${output}`); +} + +export async function resolveBrowserTarget({ explicitUrl, port, host = "127.0.0.1", envName = "CSSQUAKE_SMOKE_URL", ...serverOptions } = {}) { + const url = explicitUrl || process.env[envName] || ""; + if (url) return { url, close: async () => {} }; + return await startViteServer({ port, host, ...serverOptions }); +} + +export function debugMapUrl(baseUrl, mapName, extraParams = {}) { + const url = new URL(baseUrl); + url.searchParams.set("debug", "1"); + if (mapName) url.searchParams.set("map", mapName); + for (const [key, value] of Object.entries(extraParams)) { + if (value === undefined || value === null) continue; + if (value === true) url.searchParams.set(key, ""); + else url.searchParams.set(key, String(value)); + } + return url.toString(); +} + +export async function waitForDebugMapReady(page, { mapName = "", timeoutMs = 90_000, minMeshes = 1 } = {}) { + const started = Date.now(); + let last = null; + while (Date.now() - started < timeoutMs) { + try { + last = await page.evaluate((expected) => { + const debug = window.__cssQuakeDebug; + const stats = debug?.stats?.(); + const meshCount = document.querySelectorAll(".polycss-mesh").length; + return { + href: window.location.href, + hasDebug: Boolean(debug), + loading: stats?.loading ?? null, + mapName: stats?.mapName ?? null, + meshCount, + ready: Boolean( + stats && + !stats.loading && + meshCount >= expected.minMeshes && + (!expected.mapName || stats.mapName === expected.mapName) + ), + text: document.body.innerText?.slice(0, 300) ?? "", + }; + }, { mapName, minMeshes }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!/Execution context was destroyed|Cannot find context|Target closed/.test(message)) throw error; + last = { navigation: "reloading", message }; + } + if (last.ready) return last; + await page.waitForTimeout(250); + } + throw new Error(`Timed out waiting for ${mapName || "debug map"} readiness: ${JSON.stringify(last)}`); +} + +export function collectPageErrors(page, options = {}) { + const pageErrors = []; + page.on("pageerror", (error) => { + pageErrors.push(String(error?.message ?? error)); + }); + page.on("console", (message) => { + const text = message.text(); + if (message.type() !== "error") return; + if (options.ignoreConsoleError?.(text)) return; + pageErrors.push(text); + }); + return pageErrors; +} + +export async function openDebugMapPage(browser, baseUrl, mapName, options = {}) { + const page = await browser.newPage({ viewport: options.viewport }); + if (options.domMetadata !== false) { + await page.addInitScript(() => { + window.__cssQuakeDebugDomMetadata = true; + }); + } + const pageErrors = collectPageErrors(page, { + ignoreConsoleError: (text) => text.startsWith("[vite]"), + }); + try { + await page.goto(debugMapUrl(baseUrl, mapName), { + waitUntil: "domcontentloaded", + timeout: options.timeoutMs, + }); + await waitForDebugMapReady(page, { + mapName, + timeoutMs: options.timeoutMs, + minMeshes: options.minMeshes ?? 1, + }); + return { page, pageErrors }; + } catch (error) { + await page.close(); + throw error; + } +} + +export async function writeJsonArtifact(file, value) { + if (!file) return; + await mkdir(path.dirname(path.resolve(projectRoot, file)), { recursive: true }); + writeFileSync(path.resolve(projectRoot, file), `${JSON.stringify(value, null, 2)}\n`); +} diff --git a/test/checkAssetState.mjs b/test/checkAssetState.mjs new file mode 100644 index 0000000..43d3998 --- /dev/null +++ b/test/checkAssetState.mjs @@ -0,0 +1,163 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +export const manifestPath = path.join(projectRoot, "build/generated/public/q/manifest.json"); + +function option(args, name, fallback = "") { + const flag = `--${name}`; + const index = args.indexOf(flag); + if (index >= 0 && args[index + 1] && !args[index + 1].startsWith("--")) return args[index + 1]; + const prefixed = args.find((arg) => arg.startsWith(`${flag}=`)); + return prefixed ? prefixed.slice(flag.length + 1) : fallback; +} + +function hasFlag(args, name) { + return args.includes(`--${name}`); +} + +export function parseMapList(value) { + return String(value ?? "") + .split(",") + .map((item) => item.trim().toLowerCase()) + .filter(Boolean); +} + +export function activePrepareProcesses() { + const ps = spawnSync("ps", ["-axo", "pid,command"], { + cwd: projectRoot, + encoding: "utf8", + }); + if (ps.error || ps.status !== 0) { + return { + error: ps.error?.message ?? ps.stderr?.trim() ?? `ps exited with status ${ps.status}`, + processes: [], + }; + } + const selfPid = String(process.pid); + return { + error: "", + processes: ps.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => + line && + !line.startsWith("PID ") && + !line.startsWith(`${selfPid} `) && + /pnpm prepare:quake|node src\/prepare\/assets\.mjs/.test(line) + ), + }; +} + +export function readAssetManifest() { + if (!existsSync(manifestPath)) return null; + return JSON.parse(readFileSync(manifestPath, "utf8")); +} + +export function inspectAssetState(options = {}) { + const requiredMaps = options.requiredMaps ?? []; + const prepare = activePrepareProcesses(); + const manifest = readAssetManifest(); + const problems = []; + const warnings = []; + + if (prepare.error) warnings.push(`could not inspect active prepare processes: ${prepare.error}`); + if (prepare.processes.length) { + problems.push(`active shared prepare process detected:\n${prepare.processes.join("\n")}`); + } + + if (!manifest) { + problems.push("missing build/generated/public/q/manifest.json"); + } else { + if (manifest.status && manifest.status !== "ready") { + problems.push(`manifest status is ${JSON.stringify(manifest.status)}, expected ready or absent`); + } + if (!Array.isArray(manifest.maps) || manifest.maps.length === 0) { + problems.push("manifest maps must be a non-empty array"); + } + const mapNames = new Set((manifest.maps ?? []).map((entry) => entry?.mapName).filter(Boolean)); + for (const mapName of requiredMaps) { + if (!mapNames.has(mapName)) problems.push(`manifest is missing required map ${mapName}`); + const scenePath = path.join(projectRoot, `build/generated/public/q/${mapName}.json`); + if (!existsSync(scenePath)) { + problems.push(`missing prepared scene build/generated/public/q/${mapName}.json`); + continue; + } + try { + const scene = JSON.parse(readFileSync(scenePath, "utf8")); + if (options.requireRenderBundle && !scene.renderBundle) { + problems.push(`${mapName} prepared scene is missing renderBundle`); + } + if (options.requireGameLogic && !scene.gameLogic) { + problems.push(`${mapName} prepared scene is missing gameLogic`); + } + } catch (error) { + problems.push(`could not read prepared scene ${mapName}: ${error instanceof Error ? error.message : String(error)}`); + } + } + } + + return { + ok: problems.length === 0, + manifestPath, + manifestStatus: manifest?.status ?? (manifest ? "ready" : "missing"), + mapCount: Array.isArray(manifest?.maps) ? manifest.maps.length : 0, + requiredMaps, + activePrepareProcesses: prepare.processes, + problems, + warnings, + }; +} + +export function assertAssetState(options = {}) { + const state = inspectAssetState(options); + if (!state.ok) { + const next = [ + "Prepared Quake assets are not ready for this gate.", + "Do not start a shared prepare automatically from a test gate.", + "Run the explicit prepare command requested by the task, then rerun this gate.", + ].join("\n"); + throw new Error(`${state.problems.join("\n")}\n\n${next}`); + } + return state; +} + +function printState(state, json) { + if (json) { + console.log(JSON.stringify(state, null, 2)); + return; + } + console.log(`Asset state: ${state.ok ? "ready" : "not ready"}`); + console.log(`manifest: ${state.manifestPath}`); + console.log(`manifestStatus: ${state.manifestStatus}`); + console.log(`maps: ${state.mapCount}`); + if (state.requiredMaps.length) console.log(`requiredMaps: ${state.requiredMaps.join(",")}`); + if (state.activePrepareProcesses.length) { + console.log("activePrepareProcesses:"); + for (const processLine of state.activePrepareProcesses) console.log(` ${processLine}`); + } + for (const warning of state.warnings) console.warn(`warning: ${warning}`); + for (const problem of state.problems) console.error(`problem: ${problem}`); +} + +async function main() { + const args = process.argv.slice(2); + const requiredMaps = parseMapList(option(args, "maps", "")); + const state = inspectAssetState({ + requiredMaps, + requireRenderBundle: hasFlag(args, "require-render-bundle"), + requireGameLogic: hasFlag(args, "require-game-logic"), + }); + printState(state, hasFlag(args, "json")); + process.exitCode = state.ok ? 0 : 1; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((error) => { + console.error(error instanceof Error ? error.stack : error); + process.exitCode = 1; + }); +} diff --git a/test/enemyProjectiles.test.mjs b/test/enemyProjectiles.test.mjs index f74b4d5..61a31fe 100644 --- a/test/enemyProjectiles.test.mjs +++ b/test/enemyProjectiles.test.mjs @@ -10,6 +10,8 @@ const { function createRuntime({ consumePlayerPainRandom = () => null, damagePlayer = () => true, + floorAt, + hasLineOfSight = () => true, traceLine = true, traceNormal = [-1, 0, 0], } = {}) { @@ -30,7 +32,8 @@ function createRuntime({ currentModelLibrary: () => null, consumePlayerPainRandom, damagePlayer, - hasLineOfSight: () => true, + ...(floorAt ? { floorAt } : {}), + hasLineOfSight, markTrace: () => undefined, onExplosion: (event) => { explosions.push(event); }, offsetPoint: (origin) => [...origin], @@ -46,16 +49,18 @@ function createRuntime({ }, randomRange: () => 0, schedulePresentationResync: () => undefined, - traceLine: traceLine ? (_start, _end) => { - traceCount += 1; - if (traceCount > 1) return null; - return { - classname: "worldspawn", - end: [0.5, 0, 0], - fraction: 0.5, - planeNormal: traceNormal, - }; - } : undefined, + traceLine: typeof traceLine === "function" + ? traceLine + : traceLine ? (_start, _end) => { + traceCount += 1; + if (traceCount > 1) return null; + return { + classname: "worldspawn", + end: [0.5, 0, 0], + fraction: 0.5, + planeNormal: traceNormal, + }; + } : undefined, }); return { explosions, runtime, sounds }; @@ -144,6 +149,25 @@ test("ogre grenades play source explosion sound on timeout", () => { assert.equal(explosions[0].projectile, "enemy_projectile_grenade"); }); +test("ogre grenade timeout records expire, explode, and remove debug events", () => { + const { runtime } = createRuntime({ traceLine: false }); + + spawnProjectile(runtime, { + projectileClassname: "enemy_projectile_grenade", + projectileLifetimeMs: 50, + projectileSplashDamage: 40, + projectileSplashOnExpire: true, + projectileSplashRadius: 40, + }); + runtime.debugSetProjectileCaptureEnabled(true); + runtime.update([100, 100, 100], 0.1, 100); + + assert.deepEqual( + runtime.debugProjectileCapture().events.map((event) => event.type), + ["expire", "explode", "remove"], + ); +}); + test("zombie projectiles play source launch and miss sounds on world stop", () => { const { runtime, sounds } = createRuntime(); @@ -159,6 +183,72 @@ test("zombie projectiles play source launch and miss sounds on world stop", () = ]); }); +test("zombie projectiles stop on floor fallback when line trace misses", () => { + const { runtime } = createRuntime({ + floorAt: (_x, _y, maxZ, minZ) => (maxZ >= 0 && minZ <= 0 ? 0 : null), + traceLine: (_start, _end) => null, + }); + + spawnProjectile(runtime, { + projectileClassname: "enemy_projectile_zombie_grenade", + projectileGravity: 800, + projectileVerticalVelocity: -10, + projectileWorldTouch: "stop", + }); + runtime.debugSetProjectileCaptureEnabled(true); + runtime.update([100, 100, 100], 0.1, 100); + + const impact = runtime.debugProjectileCapture().events.find((event) => event.type === "impact"); + assert.equal(impact?.impactResult, "stop"); + assert.deepEqual(impact?.velocity, [0, 0, 0]); + assert.equal(impact?.trace?.classname, "worldspawn"); +}); + +test("ogre grenades bounce on floor fallback when line trace misses", () => { + const { runtime } = createRuntime({ + floorAt: (_x, _y, maxZ, minZ) => (maxZ >= 0 && minZ <= 0 ? 0 : null), + traceLine: (_start, _end) => null, + }); + + spawnProjectile(runtime, { + projectileClassname: "enemy_projectile_grenade", + projectileGravity: 800, + projectileVerticalVelocity: -10, + projectileWorldTouch: "bounce", + }); + runtime.debugSetProjectileCaptureEnabled(true); + runtime.update([100, 100, 100], 0.1, 100); + + const impact = runtime.debugProjectileCapture().events.find((event) => event.type === "impact"); + assert.equal(impact?.impactResult, "keep"); + assert.equal(impact?.trace?.classname, "worldspawn"); + assert.equal((impact?.velocity?.[2] ?? 0) > 0, true); +}); + +test("ogre grenades ignore no-normal obstruction traces instead of exploding", () => { + const { runtime } = createRuntime({ + hasLineOfSight: () => false, + traceLine: (_start, end) => ({ + classname: null, + end, + fraction: 0, + planeNormal: null, + }), + }); + + spawnProjectile(runtime, { + projectileClassname: "enemy_projectile_grenade", + projectileWorldTouch: "bounce", + }); + runtime.debugSetProjectileCaptureEnabled(true); + runtime.update([100, 100, 100], 0.1, 100); + + const capture = runtime.debugProjectileCapture(); + assert.equal(capture.events.some((event) => event.type === "impact"), false); + assert.equal(capture.events.some((event) => event.type === "remove"), false); + assert.equal(capture.activeCount, 1); +}); + test("wizard spikes play the source launch sound when fired", () => { const { runtime, sounds } = createRuntime(); diff --git a/test/runAssetIntegrity.mjs b/test/runAssetIntegrity.mjs new file mode 100644 index 0000000..09d6fba --- /dev/null +++ b/test/runAssetIntegrity.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +import { projectRoot, assertAssetState } from "./checkAssetState.mjs"; + +console.log("Asset integrity gate"); +console.log("validates: prepared manifest, scene URLs, renderBundle, gameLogic, collision, model/sound path arrays"); +console.log("requires prepared assets: yes"); +console.log("classification: acceptance"); + +const state = assertAssetState({ requireRenderBundle: false }); +const generatedRoot = path.join(projectRoot, "build/generated/public"); +const manifest = JSON.parse(readFileSync(path.join(generatedRoot, "q/manifest.json"), "utf8")); +const errors = []; + +if (!Number.isFinite(manifest.version)) errors.push("manifest version must be finite"); +if (typeof manifest.assetRoot !== "string" || !manifest.assetRoot.startsWith("/q")) { + errors.push(`manifest assetRoot should start with /q, got ${JSON.stringify(manifest.assetRoot)}`); +} + +const mapNames = new Set(); +for (const mapEntry of Array.isArray(manifest.maps) ? manifest.maps : []) { + validateMapEntry(mapEntry, mapNames); +} +if (typeof manifest.startMap !== "string" || !mapNames.has(manifest.startMap)) { + errors.push(`manifest startMap ${JSON.stringify(manifest.startMap)} must exist in maps`); +} + +if (errors.length) { + throw new Error(`Asset integrity failed:\n${errors.map((error) => `- ${error}`).join("\n")}`); +} + +console.log(`Asset integrity passed: ${state.mapCount} maps, startMap=${manifest.startMap}.`); + +function readJsonFile(relativeUrl) { + const normalized = relativeUrl.replace(/^\/+/, ""); + const fullPath = path.join(generatedRoot, normalized); + try { + return JSON.parse(readFileSync(fullPath, "utf8")); + } catch (error) { + errors.push(`could not read ${relativeUrl}: ${error instanceof Error ? error.message : String(error)}`); + return null; + } +} + +function validateSceneUrl(sceneUrl, mapName) { + const scene = readJsonFile(sceneUrl); + if (!scene) return; + if (!Number.isFinite(scene.version)) errors.push(`${mapName} scene version must be finite`); + if (!Array.isArray(scene.entities)) errors.push(`${mapName} scene must include entities`); + if (!scene.entityManifest || typeof scene.entityManifest !== "object") errors.push(`${mapName} scene must include entityManifest`); + if (!scene.gameLogic || typeof scene.gameLogic !== "object") errors.push(`${mapName} scene must include gameLogic facts`); + if (!scene.collision || typeof scene.collision !== "object") errors.push(`${mapName} scene must include collision data`); + if (!scene.renderBundle || typeof scene.renderBundle !== "object") errors.push(`${mapName} scene must include a renderBundle`); +} + +function validateMapEntry(mapEntry, mapNames) { + if (!mapEntry || typeof mapEntry !== "object") { + errors.push("manifest map entry must be an object"); + return; + } + const mapName = mapEntry.mapName; + if (typeof mapName !== "string" || !mapName) { + errors.push(`manifest map entry has invalid mapName ${JSON.stringify(mapName)}`); + return; + } + if (mapNames.has(mapName)) errors.push(`manifest has duplicate map ${mapName}`); + mapNames.add(mapName); + if (typeof mapEntry.sceneUrl !== "string" || !mapEntry.sceneUrl.startsWith("/q/")) { + errors.push(`${mapName} sceneUrl should start with /q/, got ${JSON.stringify(mapEntry.sceneUrl)}`); + } else { + validateSceneUrl(mapEntry.sceneUrl, mapName); + } + if (!Array.isArray(mapEntry.modelPaths)) errors.push(`${mapName} modelPaths must be an array`); + if (!Array.isArray(mapEntry.soundPaths)) errors.push(`${mapName} soundPaths must be an array`); +} diff --git a/test/runBrowserFixtures.mjs b/test/runBrowserFixtures.mjs new file mode 100644 index 0000000..d240a09 --- /dev/null +++ b/test/runBrowserFixtures.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node +import { + hasFlag, + loadChromium, + parseCommonBrowserArgs, + resolveBrowserTarget, + writeJsonArtifact, +} from "./browserHarnessSupport.mjs"; +import { assertAssetState } from "./checkAssetState.mjs"; +import { browserFixtureById, browserFixtures } from "./browserFixtureDefinitions.mjs"; + +const DEFAULT_PORT = 5184; +const DEFAULT_TIMEOUT_MS = 120_000; +const DEFAULT_VIEWPORT = "1280x800"; + +const args = process.argv.slice(2); +validateFixtureDefinitions(browserFixtures); +if (hasFlag(args, "help") || hasFlag(args, "h")) { + printHelp(); + process.exit(0); +} +if (hasFlag(args, "list")) { + printFixtureList(); + process.exit(0); +} + +const common = parseCommonBrowserArgs(args, { + port: DEFAULT_PORT, + timeoutMs: DEFAULT_TIMEOUT_MS, + viewport: DEFAULT_VIEWPORT, +}); +const selectedFixtures = selectFixtures(args); + +console.log("Browser gameplay fixture gate"); +console.log("validates: committed browser gameplay fixtures"); +console.log("requires prepared assets: yes"); +console.log("classification: acceptance"); +console.log(`fixtures: ${selectedFixtures.map((fixture) => fixture.id).join(", ")}`); +console.log(`focused rerun: pnpm test:browser -- --fixture ${selectedFixtures.map((fixture) => fixture.id).join(",")}`); + +for (const fixture of selectedFixtures) assertAssetState(fixture.requirements); + +const chromium = await loadChromium(); +const target = await resolveBrowserTarget({ ...common, forceDeps: hasFlag(args, "force-deps") }); +const summaries = []; +let browser = await chromium.launch({ headless: !common.headed }); +try { + for (const fixture of selectedFixtures) { + const summary = await runFixtureWithRetry({ + fixture, + browser, + baseUrl: target.url, + options: common, + restartBrowser: async () => { + await browser.close(); + browser = await chromium.launch({ headless: !common.headed }); + return browser; + }, + }); + await writeJsonArtifact(fixture.artifact, summary); + summaries.push({ fixture: fixture.id, artifact: fixture.artifact, pass: true, summary }); + } +} finally { + await browser.close(); + await target.close(); +} + +const aggregate = { + generatedAt: new Date().toISOString(), + kind: "cssquake-browser-fixture-aggregate", + fixtures: summaries.map(({ fixture, artifact, pass }) => ({ fixture, artifact, pass })), +}; +await writeJsonArtifact(common.jsonOut, aggregate); +console.log(`Browser gameplay fixtures passed: ${summaries.length}.`); + +function printHelp() { + console.log(`Usage: + node test/runBrowserFixtures.mjs [options] + +Options: + --fixture Run only selected fixture ids. Repeatable. + --list Print fixture ids. + --url Use an already-running cssQuake dev server. + --port Port for temporary Vite. Default: ${DEFAULT_PORT} + --force-deps Start Vite with --force. + --headed Run Chromium headed. + --viewport Browser viewport. Default: ${DEFAULT_VIEWPORT} + --timeout-ms Per-fixture readiness timeout. Default: ${DEFAULT_TIMEOUT_MS} + --json-out Write aggregate result JSON. Per-fixture JSON uses fixture defaults.`); +} + +function printFixtureList() { + console.log("Browser gameplay fixtures"); + console.log("focused run: pnpm test:browser -- --fixture "); + for (const fixture of browserFixtures) { + const maps = fixture.requirements?.requiredMaps?.join(",") || "-"; + console.log(`${fixture.id}\t${fixture.label}\tmaps=${maps}\tartifact=${fixture.artifact}`); + } +} + +function validateFixtureDefinitions(fixtures) { + const seenIds = new Set(); + const seenArtifacts = new Set(); + for (const fixture of fixtures) { + if (!fixture?.id) throw new Error("Browser fixture is missing id."); + if (seenIds.has(fixture.id)) throw new Error(`Duplicate browser fixture id "${fixture.id}".`); + seenIds.add(fixture.id); + if (!fixture.label) throw new Error(`Browser fixture "${fixture.id}" is missing label.`); + if (!fixture.artifact) throw new Error(`Browser fixture "${fixture.id}" is missing artifact.`); + if (seenArtifacts.has(fixture.artifact)) throw new Error(`Duplicate browser fixture artifact "${fixture.artifact}".`); + seenArtifacts.add(fixture.artifact); + if (!fixture.requirements) throw new Error(`Browser fixture "${fixture.id}" is missing requirements.`); + if (typeof fixture.run !== "function") throw new Error(`Browser fixture "${fixture.id}" is missing run function.`); + } +} + +function selectFixtures(argv) { + const rawSelections = []; + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] === "--fixture" && argv[index + 1] && !argv[index + 1].startsWith("--")) { + rawSelections.push(argv[index + 1]); + index += 1; + } + } + const prefixed = argv + .filter((arg) => arg.startsWith("--fixture=")) + .map((arg) => arg.slice("--fixture=".length)); + const selectedIds = [...rawSelections, ...prefixed] + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter(Boolean); + if (!selectedIds.length) return browserFixtures; + + const fixtures = selectedIds.map((id) => { + const fixture = browserFixtureById(id); + if (!fixture) { + throw new Error(`Unknown browser fixture "${id}". Known fixtures: ${browserFixtures.map((candidate) => candidate.id).join(", ")}`); + } + return fixture; + }); + return [...new Map(fixtures.map((fixture) => [fixture.id, fixture])).values()]; +} + +async function runFixtureWithRetry({ fixture, browser, baseUrl, options, restartBrowser }) { + try { + return await runFixtureOnce(fixture, browser, baseUrl, options); + } catch (error) { + console.warn(`${fixture.label} failed once; retrying with a fresh browser process.`); + console.warn(error instanceof Error ? error.message : String(error)); + console.warn(`Focused rerun: pnpm test:browser -- --fixture ${fixture.id}`); + const freshBrowser = await restartBrowser(); + try { + return await runFixtureOnce(fixture, freshBrowser, baseUrl, options); + } catch (retryError) { + console.error(`${fixture.label} failed after retry.`); + console.error(`Focused rerun: pnpm test:browser -- --fixture ${fixture.id}`); + throw retryError; + } + } +} + +async function runFixtureOnce(fixture, browser, baseUrl, options) { + console.log(`\n> ${fixture.label}`); + console.log(` id=${fixture.id} artifact=${fixture.artifact}`); + return await fixture.run({ browser, baseUrl, options }); +} diff --git a/test/runBrowserSmoke.mjs b/test/runBrowserSmoke.mjs new file mode 100644 index 0000000..9ce00c1 --- /dev/null +++ b/test/runBrowserSmoke.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node +import { + collectPageErrors, + debugMapUrl, + hasFlag, + loadChromium, + optionValue, + parseCommonBrowserArgs, + resolveBrowserTarget, +} from "./browserHarnessSupport.mjs"; +import { assertAssetState } from "./checkAssetState.mjs"; + +const DEFAULT_PORT = 5188; +const DEFAULT_TIMEOUT_MS = 45_000; +const DEFAULT_VIEWPORT = "1280x720"; +const TEST_VIEW = "-576,192,184,0,90,0"; +const TEST_VIEW_FIVE = "-576,192,184,0,90"; + +if (hasFlag(process.argv.slice(2), "help") || hasFlag(process.argv.slice(2), "h")) { + printHelp(); + process.exit(0); +} + +const args = process.argv.slice(2); +const common = parseCommonBrowserArgs(args, { + port: DEFAULT_PORT, + timeoutMs: DEFAULT_TIMEOUT_MS, + viewport: DEFAULT_VIEWPORT, +}); + +console.log("Browser URL/API smoke gate"); +console.log("validates: public URL map/view links, invalid views, debug roll rejection"); +console.log("requires prepared assets: yes, maps e1m1 and e1m5"); +console.log("classification: acceptance"); +assertAssetState({ requiredMaps: ["e1m1", "e1m5"], requireRenderBundle: true }); + +const server = await resolveBrowserTarget({ ...common, forceDeps: hasFlag(args, "force-deps") }); +let browser = null; +try { + const chromium = await loadChromium(); + browser = await chromium.launch({ headless: !common.headed }); + const page = await browser.newPage({ viewport: common.viewport }); + const pageErrors = collectPageErrors(page, { + ignoreConsoleError: (text) => text.includes("the server responded with a status of 409 (Conflict)"), + }); + + const cases = [ + { name: "nativeSix", params: { map: "e1m5", view: TEST_VIEW, debug: true }, assert: assertCanonicalView }, + { name: "fivePartRejected", params: { map: "e1m5", view: TEST_VIEW_FIVE, debug: true }, assert: (state) => assertNoView(state, "fivePartRejected") }, + { name: "nonZeroRollRejected", params: { map: "e1m5", view: "-576,192,184,0,90,3", debug: true }, assert: (state) => assertNoView(state, "nonZeroRollRejected") }, + { name: "underscoreRejected", params: { map: "e1m5", view: "-576_192_184_0_90_0", debug: true }, assert: (state) => assertNoView(state, "underscoreRejected") }, + { + name: "invalidMapWithViewIgnored", + params: { map: "badmap", view: TEST_VIEW, debug: true }, + assert: (state) => { + assert(state.mapName === "e1m1", `invalid map should load fallback e1m1, got ${state.mapName}`); + assert(state.menuOpen === true && state.paused === true, "invalid map should keep menu open"); + assert(new URL(state.href).searchParams.get("map") === "badmap", `invalid map should not publish fake canonical URL: ${state.href}`); + }, + }, + ]; + + for (const testCase of cases) await runRouteCase(page, server.url, testCase, common.timeoutMs); + const debugRoll = await page.evaluate(() => ({ + zeroRoll: window.__cssQuakeDebug?.setViewpos?.(-576, 192, 184, 0, 90, 0), + nonZeroRoll: window.__cssQuakeDebug?.setViewpos?.(-576, 192, 184, 0, 90, 3), + })); + assert(debugRoll.zeroRoll === true, `debug zero roll should succeed: ${JSON.stringify(debugRoll)}`); + assert(debugRoll.nonZeroRoll === false, `debug non-zero roll should fail: ${JSON.stringify(debugRoll)}`); + console.log("ok debugRoll"); + if (pageErrors.length) throw new Error(`Page errors:\n${pageErrors.join("\n")}`); + console.log("Browser URL/API smoke passed."); +} finally { + await browser?.close(); + await server.close(); +} + +function printHelp() { + console.log(`Usage: + node test/runBrowserSmoke.mjs [options] + +Options: + --url Use an already-running cssQuake dev server. + --port Port for temporary Vite. Default: ${DEFAULT_PORT} + --force-deps Start Vite with --force. + --headed Run Chromium headed. + --viewport Browser viewport. Default: ${DEFAULT_VIEWPORT} + --timeout-ms Per-route readiness timeout. Default: ${DEFAULT_TIMEOUT_MS}`); +} + +function routeUrl(baseUrl, params) { + const url = new URL(debugMapUrl(baseUrl, "", params)); + if (params.view) url.search = url.search.replace(/([?&]view=)[^&]*/, `$1${params.view}`); + return url.toString(); +} + +async function waitForState(page, name, timeoutMs) { + const started = Date.now(); + let last = null; + while (Date.now() - started < timeoutMs) { + try { + last = await page.evaluate(() => { + const debug = window.__cssQuakeDebug; + if (!debug) return { href: window.location.href, hasDebug: false, bodyClass: document.body.className }; + const stats = debug.stats(); + return { + href: window.location.href, + hasDebug: true, + bodyClass: document.body.className, + loading: stats.loading, + mapName: stats.mapName, + origin: stats.origin, + cameraRotX: stats.cameraRotX, + cameraRotY: stats.cameraRotY, + menuOpen: document.body.classList.contains("quake-menu-open"), + paused: document.body.classList.contains("quake-game-paused"), + viewUrl: debug.viewUrl(), + }; + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!/Execution context was destroyed|Cannot find context|Target closed/.test(message)) throw error; + last = { navigation: "reloading", message }; + } + if (last.hasDebug && last.loading === false) return last; + await page.waitForTimeout(250); + } + throw new Error(`${name} timed out: ${JSON.stringify(last)}`); +} + +async function runRouteCase(page, baseUrl, testCase, timeoutMs) { + await page.goto(routeUrl(baseUrl, testCase.params), { waitUntil: "domcontentloaded", timeout: timeoutMs }); + const state = await waitForState(page, testCase.name, timeoutMs); + testCase.assert(state); + console.log(`ok ${testCase.name}`); +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function near(actual, expected, epsilon = 0.001) { + return Math.abs(actual - expected) <= epsilon; +} + +function assertCanonicalView(state) { + const url = new URL(state.href); + const viewUrl = new URL(state.viewUrl); + assert(url.searchParams.get("map") === "e1m5", `expected canonical map=e1m5, got ${state.href}`); + assert(url.searchParams.get("view") === TEST_VIEW, `expected canonical view=${TEST_VIEW}, got ${state.href}`); + assert(viewUrl.searchParams.get("view") === TEST_VIEW, `expected copied view=${TEST_VIEW}, got ${state.viewUrl}`); + assert(state.mapName === "e1m5", `expected e1m5, got ${state.mapName}`); + assert(near(state.origin[0], 0) && near(state.origin[1], 0) && near(state.origin[2], 0.92), `unexpected origin ${JSON.stringify(state.origin)}`); + assert(state.cameraRotX === 90 && state.cameraRotY === 270, `unexpected rotation ${state.cameraRotX}/${state.cameraRotY}`); +} + +function assertNoView(state, name) { + const url = new URL(state.href); + assert(!url.searchParams.has("view"), `${name} should strip view, got ${state.href}`); + assert(state.mapName === "e1m5", `${name} expected e1m5, got ${state.mapName}`); +} diff --git a/test/runPerfPreflight.mjs b/test/runPerfPreflight.mjs new file mode 100644 index 0000000..0cf01da --- /dev/null +++ b/test/runPerfPreflight.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { projectRoot } from "./checkAssetState.mjs"; + +const packagePath = path.join(projectRoot, "package.json"); +const harnessDocPath = path.join(projectRoot, "test/HARNESS.md"); +const requiredScripts = [ + "test", + "test:asset-state", + "test:assets", + "test:browser:smoke", + "test:browser", + "test:perf", + "test:dev", + "test:all", +]; +const requiredDocPhrases = [ + "Perf claim or monster-render work", + "pnpm test:perf", + "notes/monster-render-spike.md", + "Committed runners should print what they validate", +]; + +console.log("Perf preflight gate"); +console.log("validates: committed no-asset perf command surface and harness guidance"); +console.log("requires prepared assets: no"); +console.log("classification: diagnostic-only"); + +const packageJson = JSON.parse(readFileSync(packagePath, "utf8")); +for (const scriptName of requiredScripts) { + if (!packageJson.scripts?.[scriptName]) throw new Error(`package.json is missing scripts.${scriptName}`); + console.log(`ok package script ${scriptName}`); +} + +const harnessDoc = readFileSync(harnessDocPath, "utf8"); +for (const phrase of requiredDocPhrases) { + if (!harnessDoc.includes(phrase)) throw new Error(`test/HARNESS.md is missing required guidance: ${phrase}`); + console.log(`ok harness doc phrase ${phrase}`); +} + +console.log("Perf preflight passed. For local perf claims, read notes/monster-render-spike.md when present and run the ignored perf harness explicitly."); From b218cb446b521197223b6d01522f744ef87213c8 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Mon, 15 Jun 2026 19:59:25 -0300 Subject: [PATCH 02/18] Add browser fixture family selection --- test/HARNESS.md | 13 +- test/browserFixtureCombat.mjs | 354 ++++++++++++ test/browserFixtureDefinitions.mjs | 901 +---------------------------- test/browserFixtureMapLogic.mjs | 433 ++++++++++++++ test/browserFixtureMonster.mjs | 119 ++++ test/browserFixtureProjectile.mjs | 3 + test/runBrowserFixtures.mjs | 34 +- 7 files changed, 962 insertions(+), 895 deletions(-) create mode 100644 test/browserFixtureCombat.mjs create mode 100644 test/browserFixtureMapLogic.mjs create mode 100644 test/browserFixtureMonster.mjs diff --git a/test/HARNESS.md b/test/HARNESS.md index b2b5493..45f8fab 100644 --- a/test/HARNESS.md +++ b/test/HARNESS.md @@ -17,7 +17,7 @@ Use `package.json` as the canonical command menu. Local files under ignored `scr - `pnpm test:asset-state`: manifest/status/process preflight for prepared assets. - `pnpm test:assets`: manifest and prepared scene integrity. - `pnpm test:browser:smoke`: fast URL/API browser smoke. -- `pnpm test:browser`: explicit browser gameplay fixtures from committed fixture definitions. Use `pnpm test:browser -- --list` or `pnpm test:browser -- --fixture ` for focused runs. +- `pnpm test:browser`: explicit browser gameplay fixtures from committed fixture definitions. Use `pnpm test:browser -- --list`, `pnpm test:browser -- --family `, or `pnpm test:browser -- --fixture ` for focused runs. - `pnpm test:perf`: no-asset preflight for the committed perf command surface and harness guidance. - `pnpm test:dev`: normal no-asset confidence gate. - `pnpm test:all`: all committed stable gates that require prepared assets, including browser fixtures. @@ -30,7 +30,16 @@ Committed runners should print what they validate, prerequisites, whether they r `pnpm test:browser` is selective, not exhaustive. It currently covers committed DOM monster visibility, combat budget caps, logical weapon targetability, player rocket fire/touch behavior, forced enemy projectile chains for ogre/wizard/zombie, ogre grenade bounce and timeout lifecycle, zombie projectile world-stop, map trigger/target/mover logic, liquid damage, and pickup gameplay fixtures. -Browser gameplay fixture definitions live in `test/browserFixtureDefinitions.mjs`; `test/runBrowserFixtures.mjs` is the only committed gameplay-fixture runner. +Browser gameplay fixtures are assembled in `test/browserFixtureDefinitions.mjs`; family implementations live beside it as `test/browserFixture*.mjs`. `test/runBrowserFixtures.mjs` is the only committed gameplay-fixture runner. + +Current fixture families: + +| Family | Command | Covers | +| --- | --- | --- | +| `monster` | `pnpm test:browser -- --family monster` | representative monster DOM visibility | +| `combat` | `pnpm test:browser -- --family combat` | combat budget and logical targetability | +| `projectile` | `pnpm test:browser -- --family projectile` | player and enemy projectile fixture paths | +| `map-logic` | `pnpm test:browser -- --family map-logic` | trigger/mover, liquid, and pickup gameplay | Current fixture IDs: diff --git a/test/browserFixtureCombat.mjs b/test/browserFixtureCombat.mjs new file mode 100644 index 0000000..e11f55b --- /dev/null +++ b/test/browserFixtureCombat.mjs @@ -0,0 +1,354 @@ +import { openDebugMapPage } from "./browserHarnessSupport.mjs"; + +const LOGICAL_MAP = "e1m1"; +const LOGICAL_ANCHOR_ENTITY = 21; +const LOGICAL_TARGET_ORIGIN = { x: 616, y: 72, z: 40 }; +const LOGICAL_VIEW_DISTANCE = 4.96; +const LOGICAL_VIEW_ROT_X = 90; +const LOGICAL_VIEW_ROT_Y = 90; +const LOGICAL_SOURCE_REFERENCE = { + engine: "Quake/vkQuake", + monsterClassname: "monster_army", + monsterHealth: 30, + weapon: "rocketlauncher", + directDamage: 100, + expectedKilled: true, + targetOrigin: LOGICAL_TARGET_ORIGIN, + playerOrigin: { x: 616, y: 320, z: 75 }, + playerAngles: { pitch: 0, yaw: 270, roll: 0 }, + comparison: "same map-space target path; cssQuake damage must pass through weaponTargets() while the target is unmounted", +}; +const LOGICAL_CANDIDATE_ENTITIES = [ + [21, 616, 72, 40], + [100, 248, 2392, 40], + [245, 0, 576, 24], + [246, 8, 1520, -200], + [247, 88, 1520, -200], + [248, 224, 1552, -200], + [249, -8, 936, -200], + [250, 648, 736, 104], + [255, 1312, 936, -248], + [256, 1336, 1784, -408], + [257, 1392, 928, -248], + [258, 1384, 1008, -248], + [259, 1240, 1008, -248], + [260, 1256, 1760, -408], + [261, 824, 1784, -408], + [262, 1128, 1760, -408], + [265, 1232, 2088, -216], + [266, 1232, 2448, -280], + [267, 832, 2464, -344], + [268, 832, 2072, -408], + [269, 840, 1960, -408], + [277, 416, 1912, -168], + [278, 432, 2120, -168], + [283, 80, 2024, -184], + [284, -16, 1888, -184], + [285, -248, 2144, -136], + [288, -432, 2352, 56], + [289, -544, 2584, 56], + [290, -344, 2656, -104], + [291, -72, 2896, -56], + [292, 432, 2920, -56], + [293, 424, 2832, -56], + [298, 424, 2672, -56], + [299, 424, 2880, -56], + [300, 424, 2760, -56], + [303, 848, 2584, -72], + [304, 824, 2008, -152], + [306, 248, 2352, 40], + [307, -72, 2464, 40], + [308, 904, 1024, -248], + [349, 288, 1536, -200], + [350, 968, 2432, -112], +].map(([entityIndex, x, y, z]) => ({ entityIndex, x, y, z })); + +const COMBAT_MAP = "e1m1"; +const COMBAT_FOCUS_ENTITY = 298; + +export const combatBudgetFixture = { + id: "combat-budget", + label: "Combat budget browser fixture", + artifact: "bench/results/quake/combat-budget-harness-smoke-summary.json", + family: "combat", + requirements: { requiredMaps: [COMBAT_MAP], requireRenderBundle: true }, + run: runCombatBudgetFixture, +}; + +export const logicalTargetabilityFixture = { + id: "logical-targetability", + label: "Logical targetability browser fixture", + artifact: "bench/results/quake/logical-targetability-smoke-summary.json", + family: "combat", + requirements: { requiredMaps: [LOGICAL_MAP], requireRenderBundle: true }, + run: runLogicalTargetabilityFixture, +}; + +async function runCombatBudgetFixture({ browser, baseUrl, options }) { + const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, COMBAT_MAP, options); + try { + const result = await page.evaluate(async ({ entityIndex }) => { + const debug = window.__cssQuakeDebug; + if (!debug?.stats) return { hasDebug: false }; + const beforeStats = debug.stats(); + const before = beforeStats.shootables?.combatBudget ?? null; + const focusOk = Boolean(debug.focusEntity?.(entityIndex, 4.5, 90, 45)); + debug.setWeapon?.("shotgun"); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + const fired = Boolean(debug.fire?.()); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + const afterStats = debug.stats(); + return { + after: afterStats.shootables?.combatBudget ?? null, + before, + fired, + focusOk, + hasDebug: true, + mapName: afterStats.mapName ?? null, + }; + }, { entityIndex: COMBAT_FOCUS_ENTITY }); + result.pageErrors = pageErrors; + const failures = validateCombatBudgetResult(result); + if (failures.length) throw new Error(`Combat budget harness failed: ${failures.join("; ")}`); + console.log("PASS combat budget caps and event-bound weapon target counters"); + return { + generatedAt: new Date().toISOString(), + kind: "cssquake-combat-budget-browser-fixture", + mapName: COMBAT_MAP, + pass: true, + result, + failures, + }; + } finally { + await page.close(); + } +} + +async function runLogicalTargetabilityFixture({ browser, baseUrl, options }) { + const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, LOGICAL_MAP, options); + try { + const result = await page.evaluate(async ({ + anchorEntity, + candidateEntities, + sourceReference, + targetOrigin, + viewDistance, + viewRotX, + viewRotY, + }) => { + const debug = window.__cssQuakeDebug; + if (!debug?.stats) return { hasDebug: false }; + + debug.setExpandedLogicalCombat?.(false); + debug.setUnmountedAi?.(false); + const activeCandidates = []; + for (const candidate of candidateEntities) { + if (debug.setEntityOrigin?.(candidate.entityIndex, candidate.x, candidate.y, candidate.z)) { + activeCandidates.push(candidate); + } + } + const preferredBlockerIndexes = [246, 247, 255, 265, 298, 245, 248, 249, 250, 256, 257]; + const blockerFixtures = preferredBlockerIndexes + .map((entityIndex) => activeCandidates.find((candidate) => candidate.entityIndex === entityIndex)) + .filter(Boolean); + const targetFixture = activeCandidates.find((candidate) => !preferredBlockerIndexes.includes(candidate.entityIndex)) ?? null; + const fixtureCandidates = targetFixture ? [targetFixture, ...blockerFixtures] : []; + const blockerOffsets = [ + [-48, 0], [-32, 0], [-16, 0], [0, 0], [16, 0], [32, 0], + [-40, -16], [-20, -16], [0, -16], [20, -16], [40, -16], + ]; + const blockers = blockerFixtures.map((fixture, index) => { + const [xOffset, yOffset] = blockerOffsets[index] ?? [0, -32 - index * 8]; + return { + entityIndex: fixture.entityIndex, + x: 616 + xOffset, + y: 260 + yOffset, + z: 40, + }; + }); + const targetEntity = targetFixture?.entityIndex ?? null; + const originResults = targetEntity === null + ? [] + : [ + debug.setEntityOrigin?.(targetEntity, targetOrigin.x, targetOrigin.y, targetOrigin.z), + ...blockers.map((blocker) => + blocker.entityIndex !== undefined && + debug.setEntityOrigin?.(blocker.entityIndex, blocker.x, blocker.y, blocker.z) + ), + ]; + const enableExpandedOk = Boolean(debug.setExpandedLogicalCombat?.(true)); + const disableUnmountedAiOk = Boolean(debug.setUnmountedAi?.(false)); + const viewPoseOk = Boolean(debug.focusEntity?.(anchorEntity, viewDistance, viewRotX, viewRotY)); + debug.setWeapon?.("rocketlauncher"); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + + const beforeStats = debug.stats(); + const targetMountedBefore = activeEnemyElementsForEntity(targetEntity).length > 0; + const activeEnemyIndexesBefore = activeEnemyEntityIndexes(); + const beforeDeadShootables = beforeStats.shootables?.deadShootables ?? 0; + const beforeLiveShootables = beforeStats.shootables?.liveShootables ?? 0; + const beforeBudget = beforeStats.shootables?.combatBudget ?? null; + + const damageWeaponTargetOk = Boolean( + targetEntity !== null && + debug.damageWeaponTarget?.(targetEntity, sourceReference.directDamage) + ); + await sleepInPage(100); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + + const afterStats = debug.stats(); + const afterBudget = afterStats.shootables?.combatBudget ?? null; + return { + activeEnemyIndexesBefore, + activeCandidateEntityIndexes: activeCandidates.map((candidate) => candidate.entityIndex), + after: afterBudget, + afterDeadShootables: afterStats.shootables?.deadShootables ?? 0, + afterLiveShootables: afterStats.shootables?.liveShootables ?? 0, + before: beforeBudget, + beforeCameraRotX: beforeStats.cameraRotX ?? null, + beforeCameraRotY: beforeStats.cameraRotY ?? null, + beforeDeadShootables, + beforeLiveShootables, + beforeOrigin: beforeStats.origin ?? null, + damageWeaponTargetOk, + disableUnmountedAiOk, + enableExpandedOk, + hasDebug: true, + mapName: afterStats.mapName ?? null, + originResults, + selectedFixtureEntityIndexes: fixtureCandidates.map((candidate) => candidate.entityIndex), + sourceReference, + targetEntity, + targetMountedBefore, + viewPoseOk, + }; + + function activeEnemyElementsForEntity(entityIndex) { + return [...document.querySelectorAll(`.polycss-mesh.shootable.enemy[data-entity-index="${entityIndex}"]`)] + .filter((element) => + !element.classList.contains("quake-frame-hidden") && + !element.classList.contains("quake-shootable-prewarmed") && + !element.hidden + ); + } + + function activeEnemyEntityIndexes() { + return [...document.querySelectorAll(".polycss-mesh.shootable.enemy[data-entity-index]")] + .filter((element) => + !element.classList.contains("quake-frame-hidden") && + !element.classList.contains("quake-shootable-prewarmed") && + !element.hidden + ) + .map((element) => Number(element.dataset.entityIndex)) + .filter((entityIndex) => Number.isFinite(entityIndex)) + .sort((a, b) => a - b); + } + + function sleepInPage(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + }, { + anchorEntity: LOGICAL_ANCHOR_ENTITY, + candidateEntities: LOGICAL_CANDIDATE_ENTITIES, + sourceReference: LOGICAL_SOURCE_REFERENCE, + targetOrigin: LOGICAL_TARGET_ORIGIN, + viewDistance: LOGICAL_VIEW_DISTANCE, + viewRotX: LOGICAL_VIEW_ROT_X, + viewRotY: LOGICAL_VIEW_ROT_Y, + }); + result.pageErrors = pageErrors; + const failures = validateLogicalTargetabilityResult(result); + if (failures.length) throw new Error(`Logical targetability harness failed: ${failures.join("; ")}`); + console.log(`PASS target ${result.targetEntity} damaged while unmounted`); + return { + generatedAt: new Date().toISOString(), + kind: "cssquake-logical-targetability-browser-fixture", + mapName: LOGICAL_MAP, + pass: true, + result, + failures, + }; + } finally { + await page.close(); + } +} + +function validateCombatBudgetResult(result) { + const failures = []; + if (!result.hasDebug) failures.push("debug hooks missing"); + if (!result.before) failures.push("missing before combat budget stats"); + if (!result.after) failures.push("missing after combat budget stats"); + if (!result.focusOk) failures.push("debug focusEntity failed"); + if (!result.fired) failures.push("debug fire failed"); + if (result.pageErrors?.length) failures.push(`page errors: ${result.pageErrors.join(" | ")}`); + if (!result.after || !result.before) return failures; + + const { after, before } = result; + const limits = after.limits ?? {}; + if (limits.ambientPathTicksPerFrame !== 1) failures.push(`ambientPathTicksPerFrame limit ${limits.ambientPathTicksPerFrame}`); + if (limits.ambientPathTicksPerSecond !== 30) failures.push(`ambientPathTicksPerSecond limit ${limits.ambientPathTicksPerSecond}`); + if (limits.ambientPathCadenceHz !== 5) failures.push(`ambientPathCadenceHz limit ${limits.ambientPathCadenceHz}`); + if (limits.combatInterestSet !== 12) failures.push(`combatInterestSet limit ${limits.combatInterestSet}`); + if (limits.unmountedAiActiveSet !== 4) failures.push(`unmountedAiActiveSet limit ${limits.unmountedAiActiveSet}`); + if (limits.unmountedAiCadenceHz !== 5) failures.push(`unmountedAiCadenceHz limit ${limits.unmountedAiCadenceHz}`); + if (limits.lineOfSightChecksPerFrame !== 8) failures.push(`lineOfSightChecksPerFrame limit ${limits.lineOfSightChecksPerFrame}`); + if (limits.lineOfSightChecksPerSecond !== 200) failures.push(`lineOfSightChecksPerSecond limit ${limits.lineOfSightChecksPerSecond}`); + if (limits.attackChainChecksPerFrame !== 8) failures.push(`attackChainChecksPerFrame limit ${limits.attackChainChecksPerFrame}`); + if (limits.domReads !== 0) failures.push(`domReads limit ${limits.domReads}`); + + if (after.expandedLogicalCombatEnabled !== false) failures.push("expanded logical combat should be disabled"); + if (after.unmountedAiEnabled !== false) failures.push("unmounted AI should be disabled"); + if (after.combatInterestSetSize > limits.combatInterestSet) failures.push(`combatInterestSetSize over cap, got ${after.combatInterestSetSize}`); + if (after.unmountedAiActiveSetSize !== 0) failures.push(`unmountedAiActiveSetSize should be 0, got ${after.unmountedAiActiveSetSize}`); + if ((after.maxFrame?.lineOfSightChecks ?? 0) > limits.lineOfSightChecksPerFrame) failures.push(`lineOfSightChecks max frame ${after.maxFrame.lineOfSightChecks}`); + if ((after.maxFrame?.attackChainChecks ?? 0) > limits.attackChainChecksPerFrame) failures.push(`attackChainChecks max frame ${after.maxFrame.attackChainChecks}`); + if ((after.maxFrame?.ambientPathTicks ?? 0) > limits.ambientPathTicksPerFrame) failures.push(`ambientPathTicks max frame ${after.maxFrame.ambientPathTicks}`); + if ((after.maxPerSecond?.ambientPathTicks ?? 0) > limits.ambientPathTicksPerSecond) failures.push(`ambientPathTicks max second ${after.maxPerSecond.ambientPathTicks}`); + if ((after.maxPerSecond?.lineOfSightChecks ?? 0) > limits.lineOfSightChecksPerSecond) failures.push(`lineOfSightChecks max second ${after.maxPerSecond.lineOfSightChecks}`); + + const counters = after.counters ?? {}; + const beforeCounters = before.counters ?? {}; + if (counters.unmountedAiTicksTotal !== 0) failures.push(`unmountedAiTicksTotal ${counters.unmountedAiTicksTotal}`); + if (counters.capDeferralsTotal !== 0) failures.push(`capDeferralsTotal ${counters.capDeferralsTotal}`); + if (counters.domReadsTotal !== 0) failures.push(`domReadsTotal ${counters.domReadsTotal}`); + if ((counters.weaponTargetQueriesTotal ?? 0) <= (beforeCounters.weaponTargetQueriesTotal ?? 0)) failures.push("weaponTargetQueriesTotal did not increase after event-bound fire"); + if ((counters.weaponTargetCandidatesTotal ?? 0) <= (beforeCounters.weaponTargetCandidatesTotal ?? 0)) failures.push("weaponTargetCandidatesTotal did not increase after event-bound fire"); + return failures; +} + +function validateLogicalTargetabilityResult(result) { + const failures = []; + if (!result.hasDebug) failures.push("debug hooks missing"); + if (result.pageErrors?.length) failures.push(`page errors: ${result.pageErrors.join(" | ")}`); + if (result.mapName !== LOGICAL_MAP) failures.push(`unexpected map ${result.mapName}`); + if (!result.originResults?.every(Boolean)) failures.push(`failed to place target fixtures: ${JSON.stringify(result.originResults)}`); + if (!result.enableExpandedOk) failures.push("failed to enable expanded logical combat"); + if (!result.disableUnmountedAiOk) failures.push("failed to disable unmounted AI"); + if ((result.selectedFixtureEntityIndexes?.length ?? 0) < 6) failures.push(`expected at least 6 active monster fixtures, got ${JSON.stringify(result.selectedFixtureEntityIndexes)}`); + if (!result.viewPoseOk) failures.push("debug focusEntity failed"); + if (result.targetMountedBefore) failures.push(`target ${result.targetEntity} should be over mount budget and unmounted`); + if (!result.damageWeaponTargetOk) failures.push("debug damageWeaponTarget failed"); + if (!result.before) failures.push("missing before combat budget stats"); + if (!result.after) failures.push("missing after combat budget stats"); + if (result.before && result.after) { + const beforeCounters = result.before.counters ?? {}; + const afterCounters = result.after.counters ?? {}; + const limits = result.after.limits ?? {}; + if (result.before.expandedLogicalCombatEnabled !== true) failures.push("expanded logical combat should be enabled before fire"); + if (result.before.unmountedAiEnabled !== false) failures.push("unmounted AI should stay disabled before fire"); + if (!result.before.combatInterestEntityIndexes?.includes?.(result.targetEntity)) failures.push(`combat interest set should include target ${result.targetEntity}`); + if ((result.before.combatInterestSetSize ?? 0) > limits.combatInterestSet) failures.push(`combat interest size over cap before fire: ${result.before.combatInterestSetSize}`); + if ((afterCounters.weaponTargetsYieldedTotal ?? 0) <= (beforeCounters.weaponTargetsYieldedTotal ?? 0)) failures.push("weaponTargetsYieldedTotal did not increase after logical weapon-target damage"); + if ((afterCounters.unmountedAiTicksTotal ?? 0) !== 0) failures.push(`unmountedAiTicksTotal should stay 0, got ${afterCounters.unmountedAiTicksTotal}`); + if ((afterCounters.domReadsTotal ?? 0) !== 0) failures.push(`domReadsTotal ${afterCounters.domReadsTotal}`); + if ((result.after.maxFrame?.lineOfSightChecks ?? 0) > limits.lineOfSightChecksPerFrame) failures.push(`lineOfSightChecks max frame ${result.after.maxFrame.lineOfSightChecks}`); + if ((result.after.maxFrame?.attackChainChecks ?? 0) > limits.attackChainChecksPerFrame) failures.push(`attackChainChecks max frame ${result.after.maxFrame.attackChainChecks}`); + if ((result.after.maxPerSecond?.lineOfSightChecks ?? 0) > limits.lineOfSightChecksPerSecond) failures.push(`lineOfSightChecks max second ${result.after.maxPerSecond.lineOfSightChecks}`); + } + if (!(result.afterLiveShootables < result.beforeLiveShootables)) failures.push(`live shootable count did not decrease: ${result.beforeLiveShootables} -> ${result.afterLiveShootables}`); + return failures; +} diff --git a/test/browserFixtureDefinitions.mjs b/test/browserFixtureDefinitions.mjs index 436a5f5..28eabd8 100644 --- a/test/browserFixtureDefinitions.mjs +++ b/test/browserFixtureDefinitions.mjs @@ -1,11 +1,6 @@ -import { readFileSync } from "node:fs"; -import path from "node:path"; - -import { - debugMapUrl, - openDebugMapPage, - waitForDebugMapReady, -} from "./browserHarnessSupport.mjs"; +import { combatBudgetFixture, logicalTargetabilityFixture } from "./browserFixtureCombat.mjs"; +import { liquidDamageFixture, mapLogicFixture, pickupFixture } from "./browserFixtureMapLogic.mjs"; +import { monsterDomFixture } from "./browserFixtureMonster.mjs"; import { ogreGrenadeChainFixture, ogreGrenadeBounceFixture, @@ -16,144 +11,11 @@ import { zombieProjectileChainFixture, zombieProjectileStopFixture, } from "./browserFixtureProjectile.mjs"; -import { projectRoot } from "./checkAssetState.mjs"; - -const REPRESENTATIVE_MONSTERS = [ - { map: "e1m1", classname: "monster_army", entity: 298 }, - { map: "e1m1", classname: "monster_dog", entity: 247 }, - { map: "e1m2", classname: "monster_knight", entity: 99 }, - { map: "e1m2", classname: "monster_ogre", entity: 80 }, - { map: "e1m5", classname: "monster_demon1", entity: 205 }, - { map: "e1m3", classname: "monster_wizard", entity: 294 }, - { map: "e1m6", classname: "monster_shambler", entity: 396 }, - { map: "e1m3", classname: "monster_zombie", entity: 272 }, - { map: "e1m7", classname: "monster_boss", entity: 28 }, -]; -const MONSTER_FOCUS_YAWS = [0, 45, 90, 135, 180, 225, 270, 315]; -const MONSTER_FOCUS_DISTANCES = [2.35, 3.5, 5, 8, 12]; - -const PICKUP_MAP = "e1m1"; -const PICKUP_CASES = [ - { classname: "item_armor1", entity: 20, label: "armor", stat: "playerArmor", delta: 100 }, - { classname: "item_spikes", entity: 226, label: "large nails", stat: "playerNails", delta: 50 }, -]; -const DISABLED_PICKUP = { classname: "weapon_rocketlauncher", entity: 201 }; -const PICKUP_FOCUS_YAWS = [0, 45, 90, 135, 180, 225, 270, 315]; - -const LIQUID_DAMAGE_MAP = "e1m1"; -const LIQUID_DAMAGE_CASE = { - contents: "slime", - contentsValue: -4, - expectedDamage: 8, - expectedWaterLevel: 2, - label: "E1M1 slime pool", - origin: { x: 0, y: 2688, z: -144 }, - sampleOffsets: [-23, 4, 32], -}; - -const MAP_LOGIC_MAP = "e1m1"; -const MAP_LOGIC_CASE = { - delayedRefireMs: 260, - doorEntity: 189, - expectedDoorClassname: "func_door_secret", - expectedDoorInitialMode: "closed", - expectedDoorTriggeredMode: "opening", - expectedTriggerClassname: "trigger_multiple", - inside: { x: 792, y: 512, z: 8 }, - label: "E1M1 trigger_multiple secret door", - outside: { x: 704, y: 512, z: 8 }, - targetname: "t8", - triggerEntity: 190, -}; - -const LOGICAL_MAP = "e1m1"; -const LOGICAL_ANCHOR_ENTITY = 21; -const LOGICAL_TARGET_ORIGIN = { x: 616, y: 72, z: 40 }; -const LOGICAL_VIEW_DISTANCE = 4.96; -const LOGICAL_VIEW_ROT_X = 90; -const LOGICAL_VIEW_ROT_Y = 90; -const LOGICAL_SOURCE_REFERENCE = { - engine: "Quake/vkQuake", - monsterClassname: "monster_army", - monsterHealth: 30, - weapon: "rocketlauncher", - directDamage: 100, - expectedKilled: true, - targetOrigin: LOGICAL_TARGET_ORIGIN, - playerOrigin: { x: 616, y: 320, z: 75 }, - playerAngles: { pitch: 0, yaw: 270, roll: 0 }, - comparison: "same map-space target path; cssQuake damage must pass through weaponTargets() while the target is unmounted", -}; -const LOGICAL_CANDIDATE_ENTITIES = [ - [21, 616, 72, 40], - [100, 248, 2392, 40], - [245, 0, 576, 24], - [246, 8, 1520, -200], - [247, 88, 1520, -200], - [248, 224, 1552, -200], - [249, -8, 936, -200], - [250, 648, 736, 104], - [255, 1312, 936, -248], - [256, 1336, 1784, -408], - [257, 1392, 928, -248], - [258, 1384, 1008, -248], - [259, 1240, 1008, -248], - [260, 1256, 1760, -408], - [261, 824, 1784, -408], - [262, 1128, 1760, -408], - [265, 1232, 2088, -216], - [266, 1232, 2448, -280], - [267, 832, 2464, -344], - [268, 832, 2072, -408], - [269, 840, 1960, -408], - [277, 416, 1912, -168], - [278, 432, 2120, -168], - [283, 80, 2024, -184], - [284, -16, 1888, -184], - [285, -248, 2144, -136], - [288, -432, 2352, 56], - [289, -544, 2584, 56], - [290, -344, 2656, -104], - [291, -72, 2896, -56], - [292, 432, 2920, -56], - [293, 424, 2832, -56], - [298, 424, 2672, -56], - [299, 424, 2880, -56], - [300, 424, 2760, -56], - [303, 848, 2584, -72], - [304, 824, 2008, -152], - [306, 248, 2352, 40], - [307, -72, 2464, 40], - [308, 904, 1024, -248], - [349, 288, 1536, -200], - [350, 968, 2432, -112], -].map(([entityIndex, x, y, z]) => ({ entityIndex, x, y, z })); - -const COMBAT_MAP = "e1m1"; -const COMBAT_FOCUS_ENTITY = 298; export const browserFixtures = [ - { - id: "monster-dom", - label: "DOM monster browser fixture", - artifact: "bench/results/quake/monster-dom-smoke-summary.json", - requirements: { requiredMaps: unique(REPRESENTATIVE_MONSTERS.map((monster) => monster.map)), requireRenderBundle: true }, - run: runMonsterDomFixture, - }, - { - id: "combat-budget", - label: "Combat budget browser fixture", - artifact: "bench/results/quake/combat-budget-harness-smoke-summary.json", - requirements: { requiredMaps: [COMBAT_MAP], requireRenderBundle: true }, - run: runCombatBudgetFixture, - }, - { - id: "logical-targetability", - label: "Logical targetability browser fixture", - artifact: "bench/results/quake/logical-targetability-smoke-summary.json", - requirements: { requiredMaps: [LOGICAL_MAP], requireRenderBundle: true }, - run: runLogicalTargetabilityFixture, - }, + monsterDomFixture, + combatBudgetFixture, + logicalTargetabilityFixture, rocketFireFixture, rocketTouchFixture, ogreGrenadeChainFixture, @@ -162,756 +24,15 @@ export const browserFixtures = [ wizardSpikeChainFixture, zombieProjectileChainFixture, zombieProjectileStopFixture, - { - id: "map-logic", - label: "Map logic browser fixture", - artifact: "bench/results/quake/map-logic-browser-smoke-summary.json", - requirements: { requiredMaps: [MAP_LOGIC_MAP], requireRenderBundle: true, requireGameLogic: true }, - run: runMapLogicFixture, - }, - { - id: "liquid-damage", - label: "Liquid damage browser fixture", - artifact: "bench/results/quake/liquid-damage-browser-smoke-summary.json", - requirements: { requiredMaps: [LIQUID_DAMAGE_MAP], requireRenderBundle: true, requireGameLogic: true }, - run: runLiquidDamageFixture, - }, - { - id: "pickup", - label: "Pickup browser fixture", - artifact: "bench/results/quake/pickup-browser-smoke-summary.json", - requirements: { requiredMaps: [PICKUP_MAP], requireRenderBundle: true, requireGameLogic: true }, - run: runPickupFixture, - }, + mapLogicFixture, + liquidDamageFixture, + pickupFixture, ]; export function browserFixtureById(id) { return browserFixtures.find((fixture) => fixture.id === id) ?? null; } -async function runMonsterDomFixture({ browser, baseUrl, options }) { - let page = null; - let pageErrors = []; - const results = []; - try { - let currentMap = ""; - for (const monster of REPRESENTATIVE_MONSTERS) { - if (monster.map !== currentMap) { - if (!page) { - ({ page, pageErrors } = await openDebugMapPage(browser, baseUrl, monster.map, options)); - } else { - await page.goto(debugMapUrl(baseUrl, monster.map), { waitUntil: "domcontentloaded", timeout: options.timeoutMs }); - await waitForDebugMapReady(page, { mapName: monster.map, timeoutMs: options.timeoutMs }); - } - currentMap = monster.map; - } - const result = await validateMonster(page, monster); - results.push(result); - const status = result.pass ? "PASS" : "FAIL"; - const attempt = result.attempt; - console.log(`${status} ${monster.map} ${monster.classname} #${monster.entity}` + - (attempt ? ` distance=${attempt.distance} yaw=${attempt.yaw} leaves=${attempt.leafCount}` : "")); - } - } finally { - await page?.close(); - } - const failed = results.filter((result) => !result.pass); - if (pageErrors.length || failed.length) { - throw new Error(`DOM monster browser fixture failed: ${results.length - failed.length}/${results.length} passed.\n${pageErrors.join("\n")}`); - } - return { - kind: "cssquake-monster-dom-smoke", - startedAt: new Date().toISOString(), - viewport: options.viewport, - total: results.length, - passed: results.length, - failed: 0, - results, - }; -} - -async function runCombatBudgetFixture({ browser, baseUrl, options }) { - const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, COMBAT_MAP, options); - try { - const result = await page.evaluate(async ({ entityIndex }) => { - const debug = window.__cssQuakeDebug; - if (!debug?.stats) return { hasDebug: false }; - const beforeStats = debug.stats(); - const before = beforeStats.shootables?.combatBudget ?? null; - const focusOk = Boolean(debug.focusEntity?.(entityIndex, 4.5, 90, 45)); - debug.setWeapon?.("shotgun"); - await new Promise(requestAnimationFrame); - await new Promise(requestAnimationFrame); - const fired = Boolean(debug.fire?.()); - await new Promise(requestAnimationFrame); - await new Promise(requestAnimationFrame); - const afterStats = debug.stats(); - return { - after: afterStats.shootables?.combatBudget ?? null, - before, - fired, - focusOk, - hasDebug: true, - mapName: afterStats.mapName ?? null, - }; - }, { entityIndex: COMBAT_FOCUS_ENTITY }); - result.pageErrors = pageErrors; - const failures = validateCombatBudgetResult(result); - if (failures.length) throw new Error(`Combat budget harness failed: ${failures.join("; ")}`); - console.log("PASS combat budget caps and event-bound weapon target counters"); - return { - generatedAt: new Date().toISOString(), - kind: "cssquake-combat-budget-browser-fixture", - mapName: COMBAT_MAP, - pass: true, - result, - failures, - }; - } finally { - await page.close(); - } -} - -async function runLogicalTargetabilityFixture({ browser, baseUrl, options }) { - const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, LOGICAL_MAP, options); - try { - const result = await page.evaluate(async ({ - anchorEntity, - candidateEntities, - sourceReference, - targetOrigin, - viewDistance, - viewRotX, - viewRotY, - }) => { - const debug = window.__cssQuakeDebug; - if (!debug?.stats) return { hasDebug: false }; - - debug.setExpandedLogicalCombat?.(false); - debug.setUnmountedAi?.(false); - const activeCandidates = []; - for (const candidate of candidateEntities) { - if (debug.setEntityOrigin?.(candidate.entityIndex, candidate.x, candidate.y, candidate.z)) { - activeCandidates.push(candidate); - } - } - const preferredBlockerIndexes = [246, 247, 255, 265, 298, 245, 248, 249, 250, 256, 257]; - const blockerFixtures = preferredBlockerIndexes - .map((entityIndex) => activeCandidates.find((candidate) => candidate.entityIndex === entityIndex)) - .filter(Boolean); - const targetFixture = activeCandidates.find((candidate) => !preferredBlockerIndexes.includes(candidate.entityIndex)) ?? null; - const fixtureCandidates = targetFixture ? [targetFixture, ...blockerFixtures] : []; - const blockerOffsets = [ - [-48, 0], [-32, 0], [-16, 0], [0, 0], [16, 0], [32, 0], - [-40, -16], [-20, -16], [0, -16], [20, -16], [40, -16], - ]; - const blockers = blockerFixtures.map((fixture, index) => { - const [xOffset, yOffset] = blockerOffsets[index] ?? [0, -32 - index * 8]; - return { - entityIndex: fixture.entityIndex, - x: 616 + xOffset, - y: 260 + yOffset, - z: 40, - }; - }); - const targetEntity = targetFixture?.entityIndex ?? null; - const originResults = targetEntity === null - ? [] - : [ - debug.setEntityOrigin?.(targetEntity, targetOrigin.x, targetOrigin.y, targetOrigin.z), - ...blockers.map((blocker) => - blocker.entityIndex !== undefined && - debug.setEntityOrigin?.(blocker.entityIndex, blocker.x, blocker.y, blocker.z) - ), - ]; - const enableExpandedOk = Boolean(debug.setExpandedLogicalCombat?.(true)); - const disableUnmountedAiOk = Boolean(debug.setUnmountedAi?.(false)); - const viewPoseOk = Boolean(debug.focusEntity?.(anchorEntity, viewDistance, viewRotX, viewRotY)); - debug.setWeapon?.("rocketlauncher"); - await new Promise(requestAnimationFrame); - await new Promise(requestAnimationFrame); - - const beforeStats = debug.stats(); - const targetMountedBefore = activeEnemyElementsForEntity(targetEntity).length > 0; - const activeEnemyIndexesBefore = activeEnemyEntityIndexes(); - const beforeDeadShootables = beforeStats.shootables?.deadShootables ?? 0; - const beforeLiveShootables = beforeStats.shootables?.liveShootables ?? 0; - const beforeBudget = beforeStats.shootables?.combatBudget ?? null; - - const damageWeaponTargetOk = Boolean( - targetEntity !== null && - debug.damageWeaponTarget?.(targetEntity, sourceReference.directDamage) - ); - await sleepInPage(100); - await new Promise(requestAnimationFrame); - await new Promise(requestAnimationFrame); - - const afterStats = debug.stats(); - const afterBudget = afterStats.shootables?.combatBudget ?? null; - return { - activeEnemyIndexesBefore, - activeCandidateEntityIndexes: activeCandidates.map((candidate) => candidate.entityIndex), - after: afterBudget, - afterDeadShootables: afterStats.shootables?.deadShootables ?? 0, - afterLiveShootables: afterStats.shootables?.liveShootables ?? 0, - before: beforeBudget, - beforeCameraRotX: beforeStats.cameraRotX ?? null, - beforeCameraRotY: beforeStats.cameraRotY ?? null, - beforeDeadShootables, - beforeLiveShootables, - beforeOrigin: beforeStats.origin ?? null, - damageWeaponTargetOk, - disableUnmountedAiOk, - enableExpandedOk, - hasDebug: true, - mapName: afterStats.mapName ?? null, - originResults, - selectedFixtureEntityIndexes: fixtureCandidates.map((candidate) => candidate.entityIndex), - sourceReference, - targetEntity, - targetMountedBefore, - viewPoseOk, - }; - - function activeEnemyElementsForEntity(entityIndex) { - return [...document.querySelectorAll(`.polycss-mesh.shootable.enemy[data-entity-index="${entityIndex}"]`)] - .filter((element) => - !element.classList.contains("quake-frame-hidden") && - !element.classList.contains("quake-shootable-prewarmed") && - !element.hidden - ); - } - - function activeEnemyEntityIndexes() { - return [...document.querySelectorAll(".polycss-mesh.shootable.enemy[data-entity-index]")] - .filter((element) => - !element.classList.contains("quake-frame-hidden") && - !element.classList.contains("quake-shootable-prewarmed") && - !element.hidden - ) - .map((element) => Number(element.dataset.entityIndex)) - .filter((entityIndex) => Number.isFinite(entityIndex)) - .sort((a, b) => a - b); - } - - function sleepInPage(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - }, { - anchorEntity: LOGICAL_ANCHOR_ENTITY, - candidateEntities: LOGICAL_CANDIDATE_ENTITIES, - sourceReference: LOGICAL_SOURCE_REFERENCE, - targetOrigin: LOGICAL_TARGET_ORIGIN, - viewDistance: LOGICAL_VIEW_DISTANCE, - viewRotX: LOGICAL_VIEW_ROT_X, - viewRotY: LOGICAL_VIEW_ROT_Y, - }); - result.pageErrors = pageErrors; - const failures = validateLogicalTargetabilityResult(result); - if (failures.length) throw new Error(`Logical targetability harness failed: ${failures.join("; ")}`); - console.log(`PASS target ${result.targetEntity} damaged while unmounted`); - return { - generatedAt: new Date().toISOString(), - kind: "cssquake-logical-targetability-browser-fixture", - mapName: LOGICAL_MAP, - pass: true, - result, - failures, - }; - } finally { - await page.close(); - } -} - -async function runPickupFixture({ browser, baseUrl, options }) { - const prepared = JSON.parse(readFileSync(path.join(projectRoot, `build/generated/public/q/${PICKUP_MAP}.json`), "utf8")); - const pickupCases = pickupCasesWithOrigins(prepared); - const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, PICKUP_MAP, options); - const results = []; - let disabled = null; - try { - disabled = await disabledPickupSnapshot(page); - if (disabled.mounted) throw new Error(`Skill-disabled pickup should not mount: ${JSON.stringify(disabled)}`); - for (const testCase of pickupCases) { - const result = await validatePickup(page, testCase); - assertPickupResult(testCase, result); - results.push({ ...testCase, result }); - console.log(`PASS ${PICKUP_MAP} ${testCase.classname} #${testCase.entity} ${testCase.stat} ${result.before[testCase.stat]} -> ${result.after[testCase.stat]}`); - } - } finally { - await page.close(); - } - if (pageErrors.length) throw new Error(`Browser emitted console/page errors:\n${pageErrors.join("\n")}`); - return { kind: "cssquake-pickup-browser-fixture", startedAt: new Date().toISOString(), map: PICKUP_MAP, disabled, results }; -} - -async function runLiquidDamageFixture({ browser, baseUrl, options }) { - const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, LIQUID_DAMAGE_MAP, options); - try { - const result = await validateLiquidDamage(page, LIQUID_DAMAGE_CASE); - assertLiquidDamageResult(LIQUID_DAMAGE_CASE, result); - if (pageErrors.length) throw new Error(`Browser emitted console/page errors:\n${pageErrors.join("\n")}`); - console.log( - `PASS ${LIQUID_DAMAGE_MAP} ${LIQUID_DAMAGE_CASE.contents} damage ${result.beforeHealth} -> ${result.afterHealth}`, - ); - return { - kind: "cssquake-liquid-damage-browser-fixture", - startedAt: new Date().toISOString(), - map: LIQUID_DAMAGE_MAP, - result, - }; - } finally { - await page.close(); - } -} - -async function runMapLogicFixture({ browser, baseUrl, options }) { - const prepared = JSON.parse(readFileSync(path.join(projectRoot, `build/generated/public/q/${MAP_LOGIC_MAP}.json`), "utf8")); - assertMapLogicFixturePrepared(prepared, MAP_LOGIC_CASE); - const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, MAP_LOGIC_MAP, options); - try { - const result = await validateMapLogic(page, MAP_LOGIC_CASE); - assertMapLogicResult(MAP_LOGIC_CASE, result); - if (pageErrors.length) throw new Error(`Browser emitted console/page errors:\n${pageErrors.join("\n")}`); - console.log( - `PASS ${MAP_LOGIC_MAP} trigger #${MAP_LOGIC_CASE.triggerEntity} count ${result.before.count} -> ${result.afterThird.count}, door #${MAP_LOGIC_CASE.doorEntity} ${result.before.mover.mode} -> ${result.afterFirst.mover.mode}`, - ); - return { - kind: "cssquake-map-logic-browser-fixture", - startedAt: new Date().toISOString(), - map: MAP_LOGIC_MAP, - result, - }; - } finally { - await page.close(); - } -} - -async function validateMonster(page, monster) { - let lastAttempt = null; - for (const distance of MONSTER_FOCUS_DISTANCES) { - for (const yaw of MONSTER_FOCUS_YAWS) { - const attempt = await page.evaluate(async ({ entity, expectedClassname, distance, yaw }) => { - const debug = window.__cssQuakeDebug; - const ok = debug.focusEntity(entity, distance, 90, yaw); - await new Promise(requestAnimationFrame); - await new Promise(requestAnimationFrame); - await new Promise((resolve) => setTimeout(resolve, 120)); - const selector = `.polycss-mesh.shootable.enemy[data-entity-index="${entity}"]`; - const element = document.querySelector(selector); - const active = Boolean( - element && - element.getAttribute("aria-hidden") !== "true" && - !element.classList.contains("quake-shootable-prewarmed") && - !element.classList.contains("quake-frame-hidden") - ); - const stats = debug.stats(); - return { - distance, - yaw, - focusOk: ok, - mounted: Boolean(element), - active, - classname: element?.dataset.classname ?? null, - classnameOk: element?.dataset.classname === expectedClassname, - leafCount: element ? element.querySelectorAll("b,i,s,u").length : 0, - animationFrame: element?.dataset.animationFrame ?? null, - quakecState: element?.dataset.quakecState ?? null, - stats: { - activeEnemyMeshes: stats.activeEnemyMeshes, - mountedEnemyShootables: stats.shootables?.mountedEnemyShootables ?? null, - visibleEnemyShootables: stats.shootables?.visibleEnemyShootables ?? null, - }, - }; - }, { entity: monster.entity, expectedClassname: monster.classname, distance, yaw }); - lastAttempt = attempt; - if (attempt.active && attempt.classnameOk && attempt.leafCount > 0) { - return { ...monster, pass: true, naturalVisibility: true, attempt }; - } - } - } - return { ...monster, pass: false, naturalVisibility: false, attempt: lastAttempt }; -} - -function validateCombatBudgetResult(result) { - const failures = []; - if (!result.hasDebug) failures.push("debug hooks missing"); - if (!result.before) failures.push("missing before combat budget stats"); - if (!result.after) failures.push("missing after combat budget stats"); - if (!result.focusOk) failures.push("debug focusEntity failed"); - if (!result.fired) failures.push("debug fire failed"); - if (result.pageErrors?.length) failures.push(`page errors: ${result.pageErrors.join(" | ")}`); - if (!result.after || !result.before) return failures; - - const { after, before } = result; - const limits = after.limits ?? {}; - if (limits.ambientPathTicksPerFrame !== 1) failures.push(`ambientPathTicksPerFrame limit ${limits.ambientPathTicksPerFrame}`); - if (limits.ambientPathTicksPerSecond !== 30) failures.push(`ambientPathTicksPerSecond limit ${limits.ambientPathTicksPerSecond}`); - if (limits.ambientPathCadenceHz !== 5) failures.push(`ambientPathCadenceHz limit ${limits.ambientPathCadenceHz}`); - if (limits.combatInterestSet !== 12) failures.push(`combatInterestSet limit ${limits.combatInterestSet}`); - if (limits.unmountedAiActiveSet !== 4) failures.push(`unmountedAiActiveSet limit ${limits.unmountedAiActiveSet}`); - if (limits.unmountedAiCadenceHz !== 5) failures.push(`unmountedAiCadenceHz limit ${limits.unmountedAiCadenceHz}`); - if (limits.lineOfSightChecksPerFrame !== 8) failures.push(`lineOfSightChecksPerFrame limit ${limits.lineOfSightChecksPerFrame}`); - if (limits.lineOfSightChecksPerSecond !== 200) failures.push(`lineOfSightChecksPerSecond limit ${limits.lineOfSightChecksPerSecond}`); - if (limits.attackChainChecksPerFrame !== 8) failures.push(`attackChainChecksPerFrame limit ${limits.attackChainChecksPerFrame}`); - if (limits.domReads !== 0) failures.push(`domReads limit ${limits.domReads}`); - - if (after.expandedLogicalCombatEnabled !== false) failures.push("expanded logical combat should be disabled"); - if (after.unmountedAiEnabled !== false) failures.push("unmounted AI should be disabled"); - if (after.combatInterestSetSize > limits.combatInterestSet) failures.push(`combatInterestSetSize over cap, got ${after.combatInterestSetSize}`); - if (after.unmountedAiActiveSetSize !== 0) failures.push(`unmountedAiActiveSetSize should be 0, got ${after.unmountedAiActiveSetSize}`); - if ((after.maxFrame?.lineOfSightChecks ?? 0) > limits.lineOfSightChecksPerFrame) failures.push(`lineOfSightChecks max frame ${after.maxFrame.lineOfSightChecks}`); - if ((after.maxFrame?.attackChainChecks ?? 0) > limits.attackChainChecksPerFrame) failures.push(`attackChainChecks max frame ${after.maxFrame.attackChainChecks}`); - if ((after.maxFrame?.ambientPathTicks ?? 0) > limits.ambientPathTicksPerFrame) failures.push(`ambientPathTicks max frame ${after.maxFrame.ambientPathTicks}`); - if ((after.maxPerSecond?.ambientPathTicks ?? 0) > limits.ambientPathTicksPerSecond) failures.push(`ambientPathTicks max second ${after.maxPerSecond.ambientPathTicks}`); - if ((after.maxPerSecond?.lineOfSightChecks ?? 0) > limits.lineOfSightChecksPerSecond) failures.push(`lineOfSightChecks max second ${after.maxPerSecond.lineOfSightChecks}`); - - const counters = after.counters ?? {}; - const beforeCounters = before.counters ?? {}; - if (counters.unmountedAiTicksTotal !== 0) failures.push(`unmountedAiTicksTotal ${counters.unmountedAiTicksTotal}`); - if (counters.capDeferralsTotal !== 0) failures.push(`capDeferralsTotal ${counters.capDeferralsTotal}`); - if (counters.domReadsTotal !== 0) failures.push(`domReadsTotal ${counters.domReadsTotal}`); - if ((counters.weaponTargetQueriesTotal ?? 0) <= (beforeCounters.weaponTargetQueriesTotal ?? 0)) failures.push("weaponTargetQueriesTotal did not increase after event-bound fire"); - if ((counters.weaponTargetCandidatesTotal ?? 0) <= (beforeCounters.weaponTargetCandidatesTotal ?? 0)) failures.push("weaponTargetCandidatesTotal did not increase after event-bound fire"); - return failures; -} - -function validateLogicalTargetabilityResult(result) { - const failures = []; - if (!result.hasDebug) failures.push("debug hooks missing"); - if (result.pageErrors?.length) failures.push(`page errors: ${result.pageErrors.join(" | ")}`); - if (result.mapName !== LOGICAL_MAP) failures.push(`unexpected map ${result.mapName}`); - if (!result.originResults?.every(Boolean)) failures.push(`failed to place target fixtures: ${JSON.stringify(result.originResults)}`); - if (!result.enableExpandedOk) failures.push("failed to enable expanded logical combat"); - if (!result.disableUnmountedAiOk) failures.push("failed to disable unmounted AI"); - if ((result.selectedFixtureEntityIndexes?.length ?? 0) < 6) failures.push(`expected at least 6 active monster fixtures, got ${JSON.stringify(result.selectedFixtureEntityIndexes)}`); - if (!result.viewPoseOk) failures.push("debug focusEntity failed"); - if (result.targetMountedBefore) failures.push(`target ${result.targetEntity} should be over mount budget and unmounted`); - if (!result.damageWeaponTargetOk) failures.push("debug damageWeaponTarget failed"); - if (!result.before) failures.push("missing before combat budget stats"); - if (!result.after) failures.push("missing after combat budget stats"); - if (result.before && result.after) { - const beforeCounters = result.before.counters ?? {}; - const afterCounters = result.after.counters ?? {}; - const limits = result.after.limits ?? {}; - if (result.before.expandedLogicalCombatEnabled !== true) failures.push("expanded logical combat should be enabled before fire"); - if (result.before.unmountedAiEnabled !== false) failures.push("unmounted AI should stay disabled before fire"); - if (!result.before.combatInterestEntityIndexes?.includes?.(result.targetEntity)) failures.push(`combat interest set should include target ${result.targetEntity}`); - if ((result.before.combatInterestSetSize ?? 0) > limits.combatInterestSet) failures.push(`combat interest size over cap before fire: ${result.before.combatInterestSetSize}`); - if ((afterCounters.weaponTargetsYieldedTotal ?? 0) <= (beforeCounters.weaponTargetsYieldedTotal ?? 0)) failures.push("weaponTargetsYieldedTotal did not increase after logical weapon-target damage"); - if ((afterCounters.unmountedAiTicksTotal ?? 0) !== 0) failures.push(`unmountedAiTicksTotal should stay 0, got ${afterCounters.unmountedAiTicksTotal}`); - if ((afterCounters.domReadsTotal ?? 0) !== 0) failures.push(`domReadsTotal ${afterCounters.domReadsTotal}`); - if ((result.after.maxFrame?.lineOfSightChecks ?? 0) > limits.lineOfSightChecksPerFrame) failures.push(`lineOfSightChecks max frame ${result.after.maxFrame.lineOfSightChecks}`); - if ((result.after.maxFrame?.attackChainChecks ?? 0) > limits.attackChainChecksPerFrame) failures.push(`attackChainChecks max frame ${result.after.maxFrame.attackChainChecks}`); - if ((result.after.maxPerSecond?.lineOfSightChecks ?? 0) > limits.lineOfSightChecksPerSecond) failures.push(`lineOfSightChecks max second ${result.after.maxPerSecond.lineOfSightChecks}`); - } - if (!(result.afterLiveShootables < result.beforeLiveShootables)) failures.push(`live shootable count did not decrease: ${result.beforeLiveShootables} -> ${result.afterLiveShootables}`); - return failures; -} - -function pickupCasesWithOrigins(preparedScene) { - return PICKUP_CASES.map((testCase) => { - const entity = preparedScene.entities?.find((candidate) => candidate.index === testCase.entity); - if (!entity?.origin) throw new Error(`Missing E1M1 pickup entity ${testCase.entity}.`); - if (entity.classname !== testCase.classname) { - throw new Error(`Expected E1M1 entity ${testCase.entity} to be ${testCase.classname}, got ${entity.classname}.`); - } - return { ...testCase, origin: entity.origin }; - }); -} - -async function validatePickup(page, testCase) { - return await page.evaluate(async ({ testCase, yaws }) => { - const debug = window.__cssQuakeDebug; - if (!debug?.stats || !debug.focusEntity || !debug.setViewpos) return { pass: false, reason: "missing debug pickup hooks" }; - const settle = async (ms = 160) => { - await new Promise(requestAnimationFrame); - await new Promise(requestAnimationFrame); - await new Promise((resolve) => setTimeout(resolve, ms)); - }; - const pickupInfo = (entityIndex) => { - const element = document.querySelector(`.polycss-mesh.pickup[data-entity-index="${entityIndex}"]`); - if (!element) return { mounted: false }; - return { - mounted: true, - hidden: element.hidden, - classname: element.dataset.classname ?? null, - leafCount: element.querySelectorAll("b,i,s,u").length, - }; - }; - const statsSnapshot = () => { - const stats = debug.stats(); - return { - activePickupMeshes: stats.activePickupMeshes, - pickupMeshes: stats.pickupMeshes, - playerArmor: stats.playerArmor, - playerHealth: stats.playerHealth, - playerNails: stats.playerNails, - playerShells: stats.playerShells, - }; - }; - const before = statsSnapshot(); - let focused = null; - for (const yaw of yaws) { - const focusOk = debug.focusEntity(testCase.entity, 4, 90, yaw); - await settle(); - const info = pickupInfo(testCase.entity); - focused = { focusOk, yaw, ...info }; - if (focusOk && info.mounted && !info.hidden && info.classname === testCase.classname && info.leafCount > 0) break; - } - const beforePickup = statsSnapshot(); - const pickupOk = debug.setViewpos(testCase.origin.x, testCase.origin.y, testCase.origin.z, undefined, undefined, { gameplay: true }); - await settle(220); - const after = statsSnapshot(); - const afterInfo = pickupInfo(testCase.entity); - const repeatOk = debug.setViewpos(testCase.origin.x, testCase.origin.y, testCase.origin.z, undefined, undefined, { gameplay: true }); - await settle(120); - const afterRepeat = statsSnapshot(); - return { pass: true, before, beforePickup, focused, pickupOk, after, afterInfo, repeatOk, afterRepeat }; - }, { testCase, yaws: PICKUP_FOCUS_YAWS }); -} - -async function disabledPickupSnapshot(page) { - return await page.evaluate((pickup) => { - const element = document.querySelector(`.polycss-mesh.pickup[data-entity-index="${pickup.entity}"]`); - return { classname: pickup.classname, entity: pickup.entity, mounted: Boolean(element), elementClassname: element?.dataset.classname ?? null }; - }, DISABLED_PICKUP); -} - -function assertPickupResult(testCase, result) { - if (!result.pass) throw new Error(`${testCase.label} failed before validation: ${result.reason ?? "unknown"}`); - const focused = result.focused; - if (!focused?.focusOk || !focused.mounted || focused.hidden || focused.classname !== testCase.classname) { - throw new Error(`${testCase.label} pickup did not become visible: ${JSON.stringify(focused)}`); - } - if (!(focused.leafCount > 0)) throw new Error(`${testCase.label} pickup mounted without render leaves: ${JSON.stringify(focused)}`); - if (!result.pickupOk) throw new Error(`${testCase.label} pickup debug gameplay pose failed.`); - const expected = result.before[testCase.stat] + testCase.delta; - if (result.after[testCase.stat] !== expected) throw new Error(`${testCase.label} should change ${testCase.stat} to ${expected}, got ${result.after[testCase.stat]}.`); - if (result.afterInfo.mounted) throw new Error(`${testCase.label} pickup mesh should be removed after pickup: ${JSON.stringify(result.afterInfo)}`); - if (result.after.pickupMeshes !== result.beforePickup.pickupMeshes - 1) { - throw new Error(`${testCase.label} should remove exactly one pickup mesh, before=${result.beforePickup.pickupMeshes} after=${result.after.pickupMeshes}.`); - } - if (!result.repeatOk) throw new Error(`${testCase.label} repeat gameplay pose failed.`); - if (result.afterRepeat[testCase.stat] !== result.after[testCase.stat]) { - throw new Error(`${testCase.label} should not apply twice, after=${result.after[testCase.stat]} repeat=${result.afterRepeat[testCase.stat]}.`); - } -} - -async function validateLiquidDamage(page, testCase) { - return await page.evaluate(async ({ testCase, mapName }) => { - const debug = window.__cssQuakeDebug; - if (!debug?.stats || !debug.contentsAt || !debug.setViewpos) { - return { pass: false, reason: "missing debug liquid-damage hooks" }; - } - - const settle = async (ms = 80) => { - await new Promise(requestAnimationFrame); - await new Promise(requestAnimationFrame); - await new Promise((resolve) => setTimeout(resolve, ms)); - }; - const liquidContents = new Set([-3, -4, -5]); - const samples = testCase.sampleOffsets.map((offset) => { - const z = testCase.origin.z + offset; - return { - contents: debug.contentsAt(testCase.origin.x, testCase.origin.y, z), - offset, - z, - }; - }); - let waterLevel = 0; - for (const sample of samples) { - if (!liquidContents.has(sample.contents)) break; - waterLevel += 1; - } - const before = debug.stats(); - const setViewposOk = debug.setViewpos( - testCase.origin.x, - testCase.origin.y, - testCase.origin.z, - undefined, - undefined, - { gameplay: true }, - ); - const immediate = debug.stats(); - await settle(); - const after = debug.stats(); - return { - afterHealth: after.playerHealth, - beforeHealth: before.playerHealth, - bodyClass: document.body.className, - expectedMapName: mapName, - hasDebug: true, - immediateHealth: immediate.playerHealth, - mapName: after.mapName ?? null, - origin: testCase.origin, - playerMove: after.playerMove ?? null, - samples, - setViewposOk, - waterLevel, - }; - }, { mapName: LIQUID_DAMAGE_MAP, testCase }); -} - -async function validateMapLogic(page, testCase) { - return await page.evaluate(async ({ testCase, mapName }) => { - const debug = window.__cssQuakeDebug; - if (!debug?.stats || !debug.setViewpos) { - return { pass: false, reason: "missing debug map-logic hooks" }; - } - - const settle = async (ms = 80) => { - await new Promise(requestAnimationFrame); - await new Promise(requestAnimationFrame); - await new Promise((resolve) => setTimeout(resolve, ms)); - }; - const triggerCount = () => - debug.stats().triggers?.triggerMultipleActivationCounts - ?.find((entry) => entry.entityIndex === testCase.triggerEntity) - ?.count ?? 0; - const mover = () => - debug.stats().movers?.movers - ?.find((entry) => entry.entityIndex === testCase.doorEntity) ?? null; - const snapshot = (label) => { - const stats = debug.stats(); - return { - activeTriggerIndexes: stats.triggers?.activeTriggerIndexes ?? [], - cooldownTriggerIndexes: stats.triggers?.cooldownTriggerIndexes ?? [], - count: triggerCount(), - label, - mapName: stats.mapName ?? null, - mover: mover(), - origin: stats.origin ?? null, - }; - }; - const setPose = (pose) => debug.setViewpos( - pose.x, - pose.y, - pose.z, - undefined, - undefined, - { gameplay: true }, - ); - - const before = snapshot("before"); - const firstTouchOk = setPose(testCase.inside); - const afterFirst = snapshot("afterFirst"); - const leaveDuringCooldownOk = setPose(testCase.outside); - const afterLeaveDuringCooldown = snapshot("afterLeaveDuringCooldown"); - const blockedRetouchOk = setPose(testCase.inside); - const afterBlockedRetouch = snapshot("afterBlockedRetouch"); - const leaveForRefireOk = setPose(testCase.outside); - await settle(testCase.delayedRefireMs); - const afterCooldown = snapshot("afterCooldown"); - const refireTouchOk = setPose(testCase.inside); - const afterThird = snapshot("afterThird"); - await settle(); - const afterSettled = snapshot("afterSettled"); - - return { - afterBlockedRetouch, - afterCooldown, - afterFirst, - afterLeaveDuringCooldown, - afterSettled, - afterThird, - before, - blockedRetouchOk, - expectedMapName: mapName, - firstTouchOk, - hasDebug: true, - leaveDuringCooldownOk, - leaveForRefireOk, - refireTouchOk, - }; - }, { mapName: MAP_LOGIC_MAP, testCase }); -} - -function assertMapLogicFixturePrepared(preparedScene, testCase) { - const trigger = preparedScene.entities?.find((entity) => entity.index === testCase.triggerEntity); - const door = preparedScene.entities?.find((entity) => entity.index === testCase.doorEntity); - if (trigger?.classname !== testCase.expectedTriggerClassname) { - throw new Error(`${testCase.label} expected trigger #${testCase.triggerEntity} to be ${testCase.expectedTriggerClassname}, got ${trigger?.classname}.`); - } - if (door?.classname !== testCase.expectedDoorClassname) { - throw new Error(`${testCase.label} expected door #${testCase.doorEntity} to be ${testCase.expectedDoorClassname}, got ${door?.classname}.`); - } - if (trigger.properties?.target !== testCase.targetname) { - throw new Error(`${testCase.label} expected trigger target ${testCase.targetname}, got ${trigger.properties?.target}.`); - } - if (door.properties?.targetname !== testCase.targetname) { - throw new Error(`${testCase.label} expected door targetname ${testCase.targetname}, got ${door.properties?.targetname}.`); - } -} - -function assertMapLogicResult(testCase, result) { - if (!result.pass && result.reason) throw new Error(`${testCase.label} failed before validation: ${result.reason}`); - if (!result.hasDebug) throw new Error(`${testCase.label} debug hooks missing.`); - for (const [name, ok] of [ - ["firstTouchOk", result.firstTouchOk], - ["leaveDuringCooldownOk", result.leaveDuringCooldownOk], - ["blockedRetouchOk", result.blockedRetouchOk], - ["leaveForRefireOk", result.leaveForRefireOk], - ["refireTouchOk", result.refireTouchOk], - ]) { - if (!ok) throw new Error(`${testCase.label} ${name} failed.`); - } - if (result.before.mapName !== result.expectedMapName || result.afterSettled.mapName !== result.expectedMapName) { - throw new Error(`${testCase.label} unexpected map: before=${result.before.mapName} after=${result.afterSettled.mapName}.`); - } - if (result.before.mover?.mode !== testCase.expectedDoorInitialMode) { - throw new Error(`${testCase.label} expected initial door mode ${testCase.expectedDoorInitialMode}, got ${result.before.mover?.mode}.`); - } - if (result.afterFirst.mover?.mode !== testCase.expectedDoorTriggeredMode) { - throw new Error(`${testCase.label} expected triggered door mode ${testCase.expectedDoorTriggeredMode}, got ${result.afterFirst.mover?.mode}.`); - } - if (result.before.count !== 0) throw new Error(`${testCase.label} expected trigger count 0 before touch, got ${result.before.count}.`); - if (result.afterFirst.count !== 1) throw new Error(`${testCase.label} expected first touch count 1, got ${result.afterFirst.count}.`); - if (!result.afterFirst.activeTriggerIndexes.includes(testCase.triggerEntity)) { - throw new Error(`${testCase.label} trigger should be active after first touch: ${JSON.stringify(result.afterFirst.activeTriggerIndexes)}`); - } - if (!result.afterFirst.cooldownTriggerIndexes.includes(testCase.triggerEntity)) { - throw new Error(`${testCase.label} trigger should be cooling down after first touch: ${JSON.stringify(result.afterFirst.cooldownTriggerIndexes)}`); - } - if (result.afterLeaveDuringCooldown.activeTriggerIndexes.includes(testCase.triggerEntity)) { - throw new Error(`${testCase.label} trigger should clear active state after leaving.`); - } - if (result.afterBlockedRetouch.count !== 1) { - throw new Error(`${testCase.label} cooldown retouch should stay at count 1, got ${result.afterBlockedRetouch.count}.`); - } - if (result.afterCooldown.cooldownTriggerIndexes.includes(testCase.triggerEntity)) { - throw new Error(`${testCase.label} trigger should leave cooldown before delayed refire.`); - } - if (result.afterThird.count !== 2) { - throw new Error(`${testCase.label} delayed refire should increment count to 2, got ${result.afterThird.count}.`); - } -} - -function assertLiquidDamageResult(testCase, result) { - if (!result.pass && result.reason) throw new Error(`${testCase.label} failed before validation: ${result.reason}`); - if (!result.hasDebug) throw new Error(`${testCase.label} debug hooks missing.`); - if (result.mapName !== result.expectedMapName) { - throw new Error(`${testCase.label} expected map ${result.expectedMapName}, got ${result.mapName}.`); - } - if (!result.setViewposOk) throw new Error(`${testCase.label} debug gameplay pose failed.`); - if (result.waterLevel !== testCase.expectedWaterLevel) { - throw new Error(`${testCase.label} expected waterLevel ${testCase.expectedWaterLevel}, got ${result.waterLevel}.`); - } - for (let index = 0; index < testCase.expectedWaterLevel; index += 1) { - const sample = result.samples[index]; - if (sample?.contents !== testCase.contentsValue) { - throw new Error(`${testCase.label} sample ${index} expected ${testCase.contentsValue}, got ${sample?.contents}.`); - } - } - if (!Number.isFinite(result.beforeHealth) || !Number.isFinite(result.afterHealth)) { - throw new Error(`${testCase.label} missing health values: ${JSON.stringify(result)}`); - } - const actualDamage = result.beforeHealth - result.afterHealth; - if (actualDamage !== testCase.expectedDamage) { - throw new Error(`${testCase.label} expected ${testCase.expectedDamage} damage, got ${actualDamage}.`); - } -} - -function unique(values) { - return [...new Set(values)].sort(); +export function browserFixtureFamilies() { + return [...new Set(browserFixtures.map((fixture) => fixture.family))].sort(); } diff --git a/test/browserFixtureMapLogic.mjs b/test/browserFixtureMapLogic.mjs new file mode 100644 index 0000000..d7db515 --- /dev/null +++ b/test/browserFixtureMapLogic.mjs @@ -0,0 +1,433 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { openDebugMapPage } from "./browserHarnessSupport.mjs"; +import { projectRoot } from "./checkAssetState.mjs"; + +const PICKUP_MAP = "e1m1"; +const PICKUP_CASES = [ + { classname: "item_armor1", entity: 20, label: "armor", stat: "playerArmor", delta: 100 }, + { classname: "item_spikes", entity: 226, label: "large nails", stat: "playerNails", delta: 50 }, +]; +const DISABLED_PICKUP = { classname: "weapon_rocketlauncher", entity: 201 }; +const PICKUP_FOCUS_YAWS = [0, 45, 90, 135, 180, 225, 270, 315]; + +const LIQUID_DAMAGE_MAP = "e1m1"; +const LIQUID_DAMAGE_CASE = { + contents: "slime", + contentsValue: -4, + expectedDamage: 8, + expectedWaterLevel: 2, + label: "E1M1 slime pool", + origin: { x: 0, y: 2688, z: -144 }, + sampleOffsets: [-23, 4, 32], +}; + +const MAP_LOGIC_MAP = "e1m1"; +const MAP_LOGIC_CASE = { + delayedRefireMs: 260, + doorEntity: 189, + expectedDoorClassname: "func_door_secret", + expectedDoorInitialMode: "closed", + expectedDoorTriggeredMode: "opening", + expectedTriggerClassname: "trigger_multiple", + inside: { x: 792, y: 512, z: 8 }, + label: "E1M1 trigger_multiple secret door", + outside: { x: 704, y: 512, z: 8 }, + targetname: "t8", + triggerEntity: 190, +}; + +export const mapLogicFixture = { + id: "map-logic", + label: "Map logic browser fixture", + artifact: "bench/results/quake/map-logic-browser-smoke-summary.json", + family: "map-logic", + requirements: { requiredMaps: [MAP_LOGIC_MAP], requireRenderBundle: true, requireGameLogic: true }, + run: runMapLogicFixture, +}; + +export const liquidDamageFixture = { + id: "liquid-damage", + label: "Liquid damage browser fixture", + artifact: "bench/results/quake/liquid-damage-browser-smoke-summary.json", + family: "map-logic", + requirements: { requiredMaps: [LIQUID_DAMAGE_MAP], requireRenderBundle: true, requireGameLogic: true }, + run: runLiquidDamageFixture, +}; + +export const pickupFixture = { + id: "pickup", + label: "Pickup browser fixture", + artifact: "bench/results/quake/pickup-browser-smoke-summary.json", + family: "map-logic", + requirements: { requiredMaps: [PICKUP_MAP], requireRenderBundle: true, requireGameLogic: true }, + run: runPickupFixture, +}; + +async function runPickupFixture({ browser, baseUrl, options }) { + const prepared = JSON.parse(readFileSync(path.join(projectRoot, `build/generated/public/q/${PICKUP_MAP}.json`), "utf8")); + const pickupCases = pickupCasesWithOrigins(prepared); + const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, PICKUP_MAP, options); + const results = []; + let disabled = null; + try { + disabled = await disabledPickupSnapshot(page); + if (disabled.mounted) throw new Error(`Skill-disabled pickup should not mount: ${JSON.stringify(disabled)}`); + for (const testCase of pickupCases) { + const result = await validatePickup(page, testCase); + assertPickupResult(testCase, result); + results.push({ ...testCase, result }); + console.log(`PASS ${PICKUP_MAP} ${testCase.classname} #${testCase.entity} ${testCase.stat} ${result.before[testCase.stat]} -> ${result.after[testCase.stat]}`); + } + } finally { + await page.close(); + } + if (pageErrors.length) throw new Error(`Browser emitted console/page errors:\n${pageErrors.join("\n")}`); + return { kind: "cssquake-pickup-browser-fixture", startedAt: new Date().toISOString(), map: PICKUP_MAP, disabled, results }; +} + +async function runLiquidDamageFixture({ browser, baseUrl, options }) { + const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, LIQUID_DAMAGE_MAP, options); + try { + const result = await validateLiquidDamage(page, LIQUID_DAMAGE_CASE); + assertLiquidDamageResult(LIQUID_DAMAGE_CASE, result); + if (pageErrors.length) throw new Error(`Browser emitted console/page errors:\n${pageErrors.join("\n")}`); + console.log( + `PASS ${LIQUID_DAMAGE_MAP} ${LIQUID_DAMAGE_CASE.contents} damage ${result.beforeHealth} -> ${result.afterHealth}`, + ); + return { + kind: "cssquake-liquid-damage-browser-fixture", + startedAt: new Date().toISOString(), + map: LIQUID_DAMAGE_MAP, + result, + }; + } finally { + await page.close(); + } +} + +async function runMapLogicFixture({ browser, baseUrl, options }) { + const prepared = JSON.parse(readFileSync(path.join(projectRoot, `build/generated/public/q/${MAP_LOGIC_MAP}.json`), "utf8")); + assertMapLogicFixturePrepared(prepared, MAP_LOGIC_CASE); + const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, MAP_LOGIC_MAP, options); + try { + const result = await validateMapLogic(page, MAP_LOGIC_CASE); + assertMapLogicResult(MAP_LOGIC_CASE, result); + if (pageErrors.length) throw new Error(`Browser emitted console/page errors:\n${pageErrors.join("\n")}`); + console.log( + `PASS ${MAP_LOGIC_MAP} trigger #${MAP_LOGIC_CASE.triggerEntity} count ${result.before.count} -> ${result.afterThird.count}, door #${MAP_LOGIC_CASE.doorEntity} ${result.before.mover.mode} -> ${result.afterFirst.mover.mode}`, + ); + return { + kind: "cssquake-map-logic-browser-fixture", + startedAt: new Date().toISOString(), + map: MAP_LOGIC_MAP, + result, + }; + } finally { + await page.close(); + } +} + +function pickupCasesWithOrigins(preparedScene) { + return PICKUP_CASES.map((testCase) => { + const entity = preparedScene.entities?.find((candidate) => candidate.index === testCase.entity); + if (!entity?.origin) throw new Error(`Missing E1M1 pickup entity ${testCase.entity}.`); + if (entity.classname !== testCase.classname) { + throw new Error(`Expected E1M1 entity ${testCase.entity} to be ${testCase.classname}, got ${entity.classname}.`); + } + return { ...testCase, origin: entity.origin }; + }); +} + +async function validatePickup(page, testCase) { + return await page.evaluate(async ({ testCase, yaws }) => { + const debug = window.__cssQuakeDebug; + if (!debug?.stats || !debug.focusEntity || !debug.setViewpos) return { pass: false, reason: "missing debug pickup hooks" }; + const settle = async (ms = 160) => { + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + await new Promise((resolve) => setTimeout(resolve, ms)); + }; + const pickupInfo = (entityIndex) => { + const element = document.querySelector(`.polycss-mesh.pickup[data-entity-index="${entityIndex}"]`); + if (!element) return { mounted: false }; + return { + mounted: true, + hidden: element.hidden, + classname: element.dataset.classname ?? null, + leafCount: element.querySelectorAll("b,i,s,u").length, + }; + }; + const statsSnapshot = () => { + const stats = debug.stats(); + return { + activePickupMeshes: stats.activePickupMeshes, + pickupMeshes: stats.pickupMeshes, + playerArmor: stats.playerArmor, + playerHealth: stats.playerHealth, + playerNails: stats.playerNails, + playerShells: stats.playerShells, + }; + }; + const before = statsSnapshot(); + let focused = null; + for (const yaw of yaws) { + const focusOk = debug.focusEntity(testCase.entity, 4, 90, yaw); + await settle(); + const info = pickupInfo(testCase.entity); + focused = { focusOk, yaw, ...info }; + if (focusOk && info.mounted && !info.hidden && info.classname === testCase.classname && info.leafCount > 0) break; + } + const beforePickup = statsSnapshot(); + const pickupOk = debug.setViewpos(testCase.origin.x, testCase.origin.y, testCase.origin.z, undefined, undefined, { gameplay: true }); + await settle(220); + const after = statsSnapshot(); + const afterInfo = pickupInfo(testCase.entity); + const repeatOk = debug.setViewpos(testCase.origin.x, testCase.origin.y, testCase.origin.z, undefined, undefined, { gameplay: true }); + await settle(120); + const afterRepeat = statsSnapshot(); + return { pass: true, before, beforePickup, focused, pickupOk, after, afterInfo, repeatOk, afterRepeat }; + }, { testCase, yaws: PICKUP_FOCUS_YAWS }); +} + +async function disabledPickupSnapshot(page) { + return await page.evaluate((pickup) => { + const element = document.querySelector(`.polycss-mesh.pickup[data-entity-index="${pickup.entity}"]`); + return { classname: pickup.classname, entity: pickup.entity, mounted: Boolean(element), elementClassname: element?.dataset.classname ?? null }; + }, DISABLED_PICKUP); +} + +function assertPickupResult(testCase, result) { + if (!result.pass) throw new Error(`${testCase.label} failed before validation: ${result.reason ?? "unknown"}`); + const focused = result.focused; + if (!focused?.focusOk || !focused.mounted || focused.hidden || focused.classname !== testCase.classname) { + throw new Error(`${testCase.label} pickup did not become visible: ${JSON.stringify(focused)}`); + } + if (!(focused.leafCount > 0)) throw new Error(`${testCase.label} pickup mounted without render leaves: ${JSON.stringify(focused)}`); + if (!result.pickupOk) throw new Error(`${testCase.label} pickup debug gameplay pose failed.`); + const expected = result.before[testCase.stat] + testCase.delta; + if (result.after[testCase.stat] !== expected) throw new Error(`${testCase.label} should change ${testCase.stat} to ${expected}, got ${result.after[testCase.stat]}.`); + if (result.afterInfo.mounted) throw new Error(`${testCase.label} pickup mesh should be removed after pickup: ${JSON.stringify(result.afterInfo)}`); + if (result.after.pickupMeshes !== result.beforePickup.pickupMeshes - 1) { + throw new Error(`${testCase.label} should remove exactly one pickup mesh, before=${result.beforePickup.pickupMeshes} after=${result.after.pickupMeshes}.`); + } + if (!result.repeatOk) throw new Error(`${testCase.label} repeat gameplay pose failed.`); + if (result.afterRepeat[testCase.stat] !== result.after[testCase.stat]) { + throw new Error(`${testCase.label} should not apply twice, after=${result.after[testCase.stat]} repeat=${result.afterRepeat[testCase.stat]}.`); + } +} + +async function validateLiquidDamage(page, testCase) { + return await page.evaluate(async ({ testCase, mapName }) => { + const debug = window.__cssQuakeDebug; + if (!debug?.stats || !debug.contentsAt || !debug.setViewpos) { + return { pass: false, reason: "missing debug liquid-damage hooks" }; + } + + const settle = async (ms = 80) => { + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + await new Promise((resolve) => setTimeout(resolve, ms)); + }; + const liquidContents = new Set([-3, -4, -5]); + const samples = testCase.sampleOffsets.map((offset) => { + const z = testCase.origin.z + offset; + return { + contents: debug.contentsAt(testCase.origin.x, testCase.origin.y, z), + offset, + z, + }; + }); + let waterLevel = 0; + for (const sample of samples) { + if (!liquidContents.has(sample.contents)) break; + waterLevel += 1; + } + const before = debug.stats(); + const setViewposOk = debug.setViewpos( + testCase.origin.x, + testCase.origin.y, + testCase.origin.z, + undefined, + undefined, + { gameplay: true }, + ); + const immediate = debug.stats(); + await settle(); + const after = debug.stats(); + return { + afterHealth: after.playerHealth, + beforeHealth: before.playerHealth, + bodyClass: document.body.className, + expectedMapName: mapName, + hasDebug: true, + immediateHealth: immediate.playerHealth, + mapName: after.mapName ?? null, + origin: testCase.origin, + playerMove: after.playerMove ?? null, + samples, + setViewposOk, + waterLevel, + }; + }, { mapName: LIQUID_DAMAGE_MAP, testCase }); +} + +async function validateMapLogic(page, testCase) { + return await page.evaluate(async ({ testCase, mapName }) => { + const debug = window.__cssQuakeDebug; + if (!debug?.stats || !debug.setViewpos) { + return { pass: false, reason: "missing debug map-logic hooks" }; + } + + const settle = async (ms = 80) => { + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + await new Promise((resolve) => setTimeout(resolve, ms)); + }; + const triggerCount = () => + debug.stats().triggers?.triggerMultipleActivationCounts + ?.find((entry) => entry.entityIndex === testCase.triggerEntity) + ?.count ?? 0; + const mover = () => + debug.stats().movers?.movers + ?.find((entry) => entry.entityIndex === testCase.doorEntity) ?? null; + const snapshot = (label) => { + const stats = debug.stats(); + return { + activeTriggerIndexes: stats.triggers?.activeTriggerIndexes ?? [], + cooldownTriggerIndexes: stats.triggers?.cooldownTriggerIndexes ?? [], + count: triggerCount(), + label, + mapName: stats.mapName ?? null, + mover: mover(), + origin: stats.origin ?? null, + }; + }; + const setPose = (pose) => debug.setViewpos( + pose.x, + pose.y, + pose.z, + undefined, + undefined, + { gameplay: true }, + ); + + const before = snapshot("before"); + const firstTouchOk = setPose(testCase.inside); + const afterFirst = snapshot("afterFirst"); + const leaveDuringCooldownOk = setPose(testCase.outside); + const afterLeaveDuringCooldown = snapshot("afterLeaveDuringCooldown"); + const blockedRetouchOk = setPose(testCase.inside); + const afterBlockedRetouch = snapshot("afterBlockedRetouch"); + const leaveForRefireOk = setPose(testCase.outside); + await settle(testCase.delayedRefireMs); + const afterCooldown = snapshot("afterCooldown"); + const refireTouchOk = setPose(testCase.inside); + const afterThird = snapshot("afterThird"); + await settle(); + const afterSettled = snapshot("afterSettled"); + + return { + afterBlockedRetouch, + afterCooldown, + afterFirst, + afterLeaveDuringCooldown, + afterSettled, + afterThird, + before, + blockedRetouchOk, + expectedMapName: mapName, + firstTouchOk, + hasDebug: true, + leaveDuringCooldownOk, + leaveForRefireOk, + refireTouchOk, + }; + }, { mapName: MAP_LOGIC_MAP, testCase }); +} + +function assertMapLogicFixturePrepared(preparedScene, testCase) { + const trigger = preparedScene.entities?.find((entity) => entity.index === testCase.triggerEntity); + const door = preparedScene.entities?.find((entity) => entity.index === testCase.doorEntity); + if (trigger?.classname !== testCase.expectedTriggerClassname) { + throw new Error(`${testCase.label} expected trigger #${testCase.triggerEntity} to be ${testCase.expectedTriggerClassname}, got ${trigger?.classname}.`); + } + if (door?.classname !== testCase.expectedDoorClassname) { + throw new Error(`${testCase.label} expected door #${testCase.doorEntity} to be ${testCase.expectedDoorClassname}, got ${door?.classname}.`); + } + if (trigger.properties?.target !== testCase.targetname) { + throw new Error(`${testCase.label} expected trigger target ${testCase.targetname}, got ${trigger.properties?.target}.`); + } + if (door.properties?.targetname !== testCase.targetname) { + throw new Error(`${testCase.label} expected door targetname ${testCase.targetname}, got ${door.properties?.targetname}.`); + } +} + +function assertMapLogicResult(testCase, result) { + if (!result.pass && result.reason) throw new Error(`${testCase.label} failed before validation: ${result.reason}`); + if (!result.hasDebug) throw new Error(`${testCase.label} debug hooks missing.`); + for (const [name, ok] of [ + ["firstTouchOk", result.firstTouchOk], + ["leaveDuringCooldownOk", result.leaveDuringCooldownOk], + ["blockedRetouchOk", result.blockedRetouchOk], + ["leaveForRefireOk", result.leaveForRefireOk], + ["refireTouchOk", result.refireTouchOk], + ]) { + if (!ok) throw new Error(`${testCase.label} ${name} failed.`); + } + if (result.before.mapName !== result.expectedMapName || result.afterSettled.mapName !== result.expectedMapName) { + throw new Error(`${testCase.label} unexpected map: before=${result.before.mapName} after=${result.afterSettled.mapName}.`); + } + if (result.before.mover?.mode !== testCase.expectedDoorInitialMode) { + throw new Error(`${testCase.label} expected initial door mode ${testCase.expectedDoorInitialMode}, got ${result.before.mover?.mode}.`); + } + if (result.afterFirst.mover?.mode !== testCase.expectedDoorTriggeredMode) { + throw new Error(`${testCase.label} expected triggered door mode ${testCase.expectedDoorTriggeredMode}, got ${result.afterFirst.mover?.mode}.`); + } + if (result.before.count !== 0) throw new Error(`${testCase.label} expected trigger count 0 before touch, got ${result.before.count}.`); + if (result.afterFirst.count !== 1) throw new Error(`${testCase.label} expected first touch count 1, got ${result.afterFirst.count}.`); + if (!result.afterFirst.activeTriggerIndexes.includes(testCase.triggerEntity)) { + throw new Error(`${testCase.label} trigger should be active after first touch: ${JSON.stringify(result.afterFirst.activeTriggerIndexes)}`); + } + if (!result.afterFirst.cooldownTriggerIndexes.includes(testCase.triggerEntity)) { + throw new Error(`${testCase.label} trigger should be cooling down after first touch: ${JSON.stringify(result.afterFirst.cooldownTriggerIndexes)}`); + } + if (result.afterLeaveDuringCooldown.activeTriggerIndexes.includes(testCase.triggerEntity)) { + throw new Error(`${testCase.label} trigger should clear active state after leaving.`); + } + if (result.afterBlockedRetouch.count !== 1) { + throw new Error(`${testCase.label} cooldown retouch should stay at count 1, got ${result.afterBlockedRetouch.count}.`); + } + if (result.afterCooldown.cooldownTriggerIndexes.includes(testCase.triggerEntity)) { + throw new Error(`${testCase.label} trigger should leave cooldown before delayed refire.`); + } + if (result.afterThird.count !== 2) { + throw new Error(`${testCase.label} delayed refire should increment count to 2, got ${result.afterThird.count}.`); + } +} + +function assertLiquidDamageResult(testCase, result) { + if (!result.pass && result.reason) throw new Error(`${testCase.label} failed before validation: ${result.reason}`); + if (!result.hasDebug) throw new Error(`${testCase.label} debug hooks missing.`); + if (result.mapName !== result.expectedMapName) { + throw new Error(`${testCase.label} expected map ${result.expectedMapName}, got ${result.mapName}.`); + } + if (!result.setViewposOk) throw new Error(`${testCase.label} debug gameplay pose failed.`); + if (result.waterLevel !== testCase.expectedWaterLevel) { + throw new Error(`${testCase.label} expected waterLevel ${testCase.expectedWaterLevel}, got ${result.waterLevel}.`); + } + for (let index = 0; index < testCase.expectedWaterLevel; index += 1) { + const sample = result.samples[index]; + if (sample?.contents !== testCase.contentsValue) { + throw new Error(`${testCase.label} sample ${index} expected ${testCase.contentsValue}, got ${sample?.contents}.`); + } + } + if (!Number.isFinite(result.beforeHealth) || !Number.isFinite(result.afterHealth)) { + throw new Error(`${testCase.label} missing health values: ${JSON.stringify(result)}`); + } + const actualDamage = result.beforeHealth - result.afterHealth; + if (actualDamage !== testCase.expectedDamage) { + throw new Error(`${testCase.label} expected ${testCase.expectedDamage} damage, got ${actualDamage}.`); + } +} diff --git a/test/browserFixtureMonster.mjs b/test/browserFixtureMonster.mjs new file mode 100644 index 0000000..b9b0a22 --- /dev/null +++ b/test/browserFixtureMonster.mjs @@ -0,0 +1,119 @@ +import { + debugMapUrl, + openDebugMapPage, + waitForDebugMapReady, +} from "./browserHarnessSupport.mjs"; + +const REPRESENTATIVE_MONSTERS = [ + { map: "e1m1", classname: "monster_army", entity: 298 }, + { map: "e1m1", classname: "monster_dog", entity: 247 }, + { map: "e1m2", classname: "monster_knight", entity: 99 }, + { map: "e1m2", classname: "monster_ogre", entity: 80 }, + { map: "e1m5", classname: "monster_demon1", entity: 205 }, + { map: "e1m3", classname: "monster_wizard", entity: 294 }, + { map: "e1m6", classname: "monster_shambler", entity: 396 }, + { map: "e1m3", classname: "monster_zombie", entity: 272 }, + { map: "e1m7", classname: "monster_boss", entity: 28 }, +]; +const MONSTER_FOCUS_YAWS = [0, 45, 90, 135, 180, 225, 270, 315]; +const MONSTER_FOCUS_DISTANCES = [2.35, 3.5, 5, 8, 12]; + +export const monsterDomFixture = { + id: "monster-dom", + label: "DOM monster browser fixture", + artifact: "bench/results/quake/monster-dom-smoke-summary.json", + family: "monster", + requirements: { requiredMaps: unique(REPRESENTATIVE_MONSTERS.map((monster) => monster.map)), requireRenderBundle: true }, + run: runMonsterDomFixture, +}; + +async function runMonsterDomFixture({ browser, baseUrl, options }) { + let page = null; + let pageErrors = []; + const results = []; + try { + let currentMap = ""; + for (const monster of REPRESENTATIVE_MONSTERS) { + if (monster.map !== currentMap) { + if (!page) { + ({ page, pageErrors } = await openDebugMapPage(browser, baseUrl, monster.map, options)); + } else { + await page.goto(debugMapUrl(baseUrl, monster.map), { waitUntil: "domcontentloaded", timeout: options.timeoutMs }); + await waitForDebugMapReady(page, { mapName: monster.map, timeoutMs: options.timeoutMs }); + } + currentMap = monster.map; + } + const result = await validateMonster(page, monster); + results.push(result); + const status = result.pass ? "PASS" : "FAIL"; + const attempt = result.attempt; + console.log(`${status} ${monster.map} ${monster.classname} #${monster.entity}` + + (attempt ? ` distance=${attempt.distance} yaw=${attempt.yaw} leaves=${attempt.leafCount}` : "")); + } + } finally { + await page?.close(); + } + const failed = results.filter((result) => !result.pass); + if (pageErrors.length || failed.length) { + throw new Error(`DOM monster browser fixture failed: ${results.length - failed.length}/${results.length} passed.\n${pageErrors.join("\n")}`); + } + return { + kind: "cssquake-monster-dom-smoke", + startedAt: new Date().toISOString(), + viewport: options.viewport, + total: results.length, + passed: results.length, + failed: 0, + results, + }; +} + +async function validateMonster(page, monster) { + let lastAttempt = null; + for (const distance of MONSTER_FOCUS_DISTANCES) { + for (const yaw of MONSTER_FOCUS_YAWS) { + const attempt = await page.evaluate(async ({ entity, expectedClassname, distance, yaw }) => { + const debug = window.__cssQuakeDebug; + const ok = debug.focusEntity(entity, distance, 90, yaw); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + await new Promise((resolve) => setTimeout(resolve, 120)); + const selector = `.polycss-mesh.shootable.enemy[data-entity-index="${entity}"]`; + const element = document.querySelector(selector); + const active = Boolean( + element && + element.getAttribute("aria-hidden") !== "true" && + !element.classList.contains("quake-shootable-prewarmed") && + !element.classList.contains("quake-frame-hidden") + ); + const stats = debug.stats(); + return { + distance, + yaw, + focusOk: ok, + mounted: Boolean(element), + active, + classname: element?.dataset.classname ?? null, + classnameOk: element?.dataset.classname === expectedClassname, + leafCount: element ? element.querySelectorAll("b,i,s,u").length : 0, + animationFrame: element?.dataset.animationFrame ?? null, + quakecState: element?.dataset.quakecState ?? null, + stats: { + activeEnemyMeshes: stats.activeEnemyMeshes, + mountedEnemyShootables: stats.shootables?.mountedEnemyShootables ?? null, + visibleEnemyShootables: stats.shootables?.visibleEnemyShootables ?? null, + }, + }; + }, { entity: monster.entity, expectedClassname: monster.classname, distance, yaw }); + lastAttempt = attempt; + if (attempt.active && attempt.classnameOk && attempt.leafCount > 0) { + return { ...monster, pass: true, naturalVisibility: true, attempt }; + } + } + } + return { ...monster, pass: false, naturalVisibility: false, attempt: lastAttempt }; +} + +function unique(values) { + return [...new Set(values)].sort(); +} diff --git a/test/browserFixtureProjectile.mjs b/test/browserFixtureProjectile.mjs index def91c8..0eab523 100644 --- a/test/browserFixtureProjectile.mjs +++ b/test/browserFixtureProjectile.mjs @@ -181,6 +181,7 @@ export const rocketTouchFixture = { id: "rocket-touch", label: "Rocket touch browser fixture", artifact: "bench/results/quake/oracle/e1m1-soldier-rocket-touch.cssquake.json", + family: "projectile", requirements: { requiredMaps: [ROCKET_TOUCH_MAP], requireRenderBundle: true }, run: runRocketTouchFixture, }; @@ -189,6 +190,7 @@ export const rocketFireFixture = { id: "rocket-fire", label: "Rocket fire browser fixture", artifact: "bench/results/quake/oracle/e1m1-soldier-rocket-fire.cssquake.json", + family: "projectile", requirements: { requiredMaps: [ROCKET_FIRE_MAP], requireRenderBundle: true }, run: runRocketFireFixture, }; @@ -622,6 +624,7 @@ function enemyProjectileChainFixture({ artifact, id, label, scenario }) { id, label, artifact, + family: "projectile", requirements: { requiredMaps: [scenario.map], requireRenderBundle: true }, run: (context) => runEnemyProjectileChainFixture(context, scenario), }; diff --git a/test/runBrowserFixtures.mjs b/test/runBrowserFixtures.mjs index d240a09..43f6d77 100644 --- a/test/runBrowserFixtures.mjs +++ b/test/runBrowserFixtures.mjs @@ -7,7 +7,7 @@ import { writeJsonArtifact, } from "./browserHarnessSupport.mjs"; import { assertAssetState } from "./checkAssetState.mjs"; -import { browserFixtureById, browserFixtures } from "./browserFixtureDefinitions.mjs"; +import { browserFixtureById, browserFixtureFamilies, browserFixtures } from "./browserFixtureDefinitions.mjs"; const DEFAULT_PORT = 5184; const DEFAULT_TIMEOUT_MS = 120_000; @@ -79,6 +79,7 @@ function printHelp() { Options: --fixture Run only selected fixture ids. Repeatable. + --family Run fixture families. Known: ${browserFixtureFamilies().join(", ")} --list Print fixture ids. --url Use an already-running cssQuake dev server. --port Port for temporary Vite. Default: ${DEFAULT_PORT} @@ -92,9 +93,10 @@ Options: function printFixtureList() { console.log("Browser gameplay fixtures"); console.log("focused run: pnpm test:browser -- --fixture "); + console.log("family run: pnpm test:browser -- --family "); for (const fixture of browserFixtures) { const maps = fixture.requirements?.requiredMaps?.join(",") || "-"; - console.log(`${fixture.id}\t${fixture.label}\tmaps=${maps}\tartifact=${fixture.artifact}`); + console.log(`${fixture.id}\t${fixture.family}\t${fixture.label}\tmaps=${maps}\tartifact=${fixture.artifact}`); } } @@ -106,6 +108,7 @@ function validateFixtureDefinitions(fixtures) { if (seenIds.has(fixture.id)) throw new Error(`Duplicate browser fixture id "${fixture.id}".`); seenIds.add(fixture.id); if (!fixture.label) throw new Error(`Browser fixture "${fixture.id}" is missing label.`); + if (!fixture.family) throw new Error(`Browser fixture "${fixture.id}" is missing family.`); if (!fixture.artifact) throw new Error(`Browser fixture "${fixture.id}" is missing artifact.`); if (seenArtifacts.has(fixture.artifact)) throw new Error(`Duplicate browser fixture artifact "${fixture.artifact}".`); seenArtifacts.add(fixture.artifact); @@ -129,7 +132,8 @@ function selectFixtures(argv) { .flatMap((value) => value.split(",")) .map((value) => value.trim()) .filter(Boolean); - if (!selectedIds.length) return browserFixtures; + const selectedFamilies = selectedFixtureFamilies(argv); + if (!selectedIds.length && !selectedFamilies.length) return browserFixtures; const fixtures = selectedIds.map((id) => { const fixture = browserFixtureById(id); @@ -138,9 +142,33 @@ function selectFixtures(argv) { } return fixture; }); + for (const family of selectedFamilies) { + const familyFixtures = browserFixtures.filter((fixture) => fixture.family === family); + if (!familyFixtures.length) { + throw new Error(`Unknown browser fixture family "${family}". Known families: ${browserFixtureFamilies().join(", ")}`); + } + fixtures.push(...familyFixtures); + } return [...new Map(fixtures.map((fixture) => [fixture.id, fixture])).values()]; } +function selectedFixtureFamilies(argv) { + const rawSelections = []; + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] === "--family" && argv[index + 1] && !argv[index + 1].startsWith("--")) { + rawSelections.push(argv[index + 1]); + index += 1; + } + } + const prefixed = argv + .filter((arg) => arg.startsWith("--family=")) + .map((arg) => arg.slice("--family=".length)); + return [...rawSelections, ...prefixed] + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter(Boolean); +} + async function runFixtureWithRetry({ fixture, browser, baseUrl, options, restartBrowser }) { try { return await runFixtureOnce(fixture, browser, baseUrl, options); From 8632ce5bcaceb21eab5520266fb8ddeb95d90569 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Mon, 15 Jun 2026 20:06:25 -0300 Subject: [PATCH 03/18] Add harness gate planner --- package.json | 1 + test/HARNESS.md | 2 + test/runHarnessPlan.mjs | 195 ++++++++++++++++++++++++++++++++++++++ test/runPerfPreflight.mjs | 2 + 4 files changed, 200 insertions(+) create mode 100644 test/runHarnessPlan.mjs diff --git a/package.json b/package.json index b12c619..7dcb3ed 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "test:assets": "node test/runAssetIntegrity.mjs", "test:browser:smoke": "node test/runBrowserSmoke.mjs", "test:browser": "node test/runBrowserFixtures.mjs", + "test:harness": "node test/runHarnessPlan.mjs", "test:perf": "node test/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" diff --git a/test/HARNESS.md b/test/HARNESS.md index 45f8fab..6bd837a 100644 --- a/test/HARNESS.md +++ b/test/HARNESS.md @@ -8,6 +8,7 @@ Use `package.json` as the canonical command menu. Local files under ignored `scr | Generated asset or manifest concern | `pnpm test:assets` | requires ready prepared assets | | Browser startup/link concern | `pnpm test:browser:smoke` | requires ready prepared assets | | Browser gameplay fixture concern | `pnpm test:browser` | heavier; requires ready prepared assets | +| Unsure which gate applies | `pnpm test:harness` | dry-run planner from current changed files | | Perf claim or monster-render work | `pnpm test:perf`, then an explicit ignored local perf harness command if needed | read `notes/monster-render-spike.md` first when present; package gates do not run ignored scripts | | Source/gameplay parity concern | use the named committed oracle runner | keep oracle scope narrow | @@ -18,6 +19,7 @@ Use `package.json` as the canonical command menu. Local files under ignored `scr - `pnpm test:assets`: manifest and prepared scene integrity. - `pnpm test:browser:smoke`: fast URL/API browser smoke. - `pnpm test:browser`: explicit browser gameplay fixtures from committed fixture definitions. Use `pnpm test:browser -- --list`, `pnpm test:browser -- --family `, or `pnpm test:browser -- --fixture ` for focused runs. +- `pnpm test:harness`: dry-run changed-file planner that prints exact recommended committed gates. - `pnpm test:perf`: no-asset preflight for the committed perf command surface and harness guidance. - `pnpm test:dev`: normal no-asset confidence gate. - `pnpm test:all`: all committed stable gates that require prepared assets, including browser fixtures. diff --git a/test/runHarnessPlan.mjs b/test/runHarnessPlan.mjs new file mode 100644 index 0000000..e46400c --- /dev/null +++ b/test/runHarnessPlan.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import path from "node:path"; + +import { projectRoot } from "./checkAssetState.mjs"; + +const args = process.argv.slice(2); +const files = selectedFiles(args); +const plan = planHarnessCommands(files); + +if (hasFlag(args, "json")) { + console.log(JSON.stringify(plan, null, 2)); +} else { + printPlan(plan); +} + +function selectedFiles(argv) { + const explicit = optionValues(argv, "file") + .flatMap((value) => value.split(",")) + .map(normalizePath) + .filter(Boolean); + if (explicit.length) return [...new Set(explicit)].sort(); + + const status = spawnSync("git", ["status", "--porcelain=v1", "--untracked-files=all"], { + cwd: projectRoot, + encoding: "utf8", + }); + if (status.error || status.status !== 0) { + throw new Error(status.error?.message ?? status.stderr?.trim() ?? `git status exited with ${status.status}`); + } + return [...new Set(status.stdout + .split("\n") + .map(statusPath) + .filter(Boolean) + .map(normalizePath))].sort(); +} + +function statusPath(line) { + if (!line.trim()) return ""; + const rawPath = line.slice(3).trim(); + const renamed = rawPath.split(" -> ").pop() ?? rawPath; + return renamed.replace(/^"|"$/g, ""); +} + +function planHarnessCommands(files) { + const reasons = []; + const browserFamilies = new Set(); + const commands = []; + let needsDev = false; + let needsBuild = false; + let needsAssetIntegrity = false; + let needsBrowserSmoke = false; + let needsPerfPreflight = false; + + for (const file of files) { + const route = routeFile(file); + if (route.reason) reasons.push({ file, ...route }); + for (const family of route.browserFamilies) browserFamilies.add(family); + needsDev ||= route.needsDev; + needsBuild ||= route.needsBuild; + needsAssetIntegrity ||= route.needsAssetIntegrity; + needsBrowserSmoke ||= route.needsBrowserSmoke; + needsPerfPreflight ||= route.needsPerfPreflight; + } + + if (needsDev) commands.push("pnpm test:dev"); + if (needsAssetIntegrity) commands.push("pnpm test:assets"); + if (needsBrowserSmoke) commands.push("pnpm test:browser:smoke"); + if (browserFamilies.size) { + commands.push(`pnpm test:browser -- --family ${[...browserFamilies].sort().join(",")}`); + } + if (needsPerfPreflight && !commands.includes("pnpm test:dev")) commands.push("pnpm test:perf"); + if (needsBuild) commands.push("pnpm build"); + if (!commands.length) commands.push("pnpm test:dev && pnpm build"); + + return { + changedFiles: files, + commands, + browserFamilies: [...browserFamilies].sort(), + reasons, + }; +} + +function routeFile(file) { + const route = { + browserFamilies: [], + needsAssetIntegrity: false, + needsBrowserSmoke: false, + needsBuild: false, + needsDev: false, + needsPerfPreflight: false, + reason: "", + }; + + if (file === "package.json" || file === "test/HARNESS.md" || file.startsWith("test/run")) { + route.needsDev = true; + route.needsPerfPreflight = true; + route.reason = "harness command surface"; + } + if (file.startsWith("src/") || file === "package.json") { + route.needsDev = true; + route.needsBuild = true; + } + if (file.startsWith("src/App") || file.includes("/app/") || file.includes("/debug/")) { + route.needsBrowserSmoke = true; + route.browserFamilies.push("combat", "map-logic", "projectile"); + route.reason ||= "debug/app browser surface"; + } + if (file.includes("/shootables") || file.includes("/weapons") || file.includes("/player")) { + route.browserFamilies.push("combat", "projectile"); + route.reason ||= "combat/projectile runtime"; + } + if (file.includes("/world") || file === "src/quake.css" || file.includes("/visibility")) { + route.browserFamilies.push("monster"); + route.reason ||= "world rendering or visibility"; + } + if (file.includes("/triggers") || file.includes("/movers") || file.includes("/pickups") || file.includes("/liquid")) { + route.browserFamilies.push("map-logic"); + route.reason ||= "map gameplay logic"; + } + if (file.startsWith("src/prepare/")) { + route.needsAssetIntegrity = true; + route.needsBuild = true; + route.browserFamilies.push("monster"); + route.reason ||= "prepared asset pipeline"; + } + if (file === "test/browserFixtureDefinitions.mjs" || file === "test/runBrowserFixtures.mjs") { + route.browserFamilies.push("combat", "map-logic", "monster", "projectile"); + route.reason ||= "browser fixture router"; + } + if (file === "test/browserFixtureCombat.mjs") { + route.browserFamilies.push("combat"); + route.reason ||= "combat browser fixture family"; + } + if (file === "test/browserFixtureMapLogic.mjs") { + route.browserFamilies.push("map-logic"); + route.reason ||= "map-logic browser fixture family"; + } + if (file === "test/browserFixtureMonster.mjs") { + route.browserFamilies.push("monster"); + route.reason ||= "monster browser fixture family"; + } + if (file === "test/browserFixtureProjectile.mjs") { + route.browserFamilies.push("projectile"); + route.reason ||= "projectile browser fixture family"; + } + if (file.endsWith(".test.mjs")) { + route.needsDev = true; + route.reason ||= "contract test"; + } + + route.browserFamilies = [...new Set(route.browserFamilies)]; + return route; +} + +function printPlan(plan) { + console.log("Harness plan"); + console.log("validates: changed-file routing to committed gates"); + console.log("requires prepared assets: only for commands that say browser/assets"); + console.log("classification: diagnostic-only"); + console.log(`changed files: ${plan.changedFiles.length ? plan.changedFiles.join(", ") : "(none)"}`); + console.log("commands:"); + for (const command of plan.commands) console.log(` ${command}`); + if (plan.browserFamilies.length) console.log(`browser families: ${plan.browserFamilies.join(", ")}`); + if (plan.reasons.length) { + console.log("routing:"); + for (const reason of plan.reasons) { + const families = reason.browserFamilies.length ? ` families=${reason.browserFamilies.join(",")}` : ""; + console.log(` ${reason.file}: ${reason.reason}${families}`); + } + } +} + +function optionValues(argv, name) { + const values = []; + const flag = `--${name}`; + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] === flag && argv[index + 1] && !argv[index + 1].startsWith("--")) { + values.push(argv[index + 1]); + index += 1; + } else if (argv[index].startsWith(`${flag}=`)) { + values.push(argv[index].slice(flag.length + 1)); + } + } + return values; +} + +function hasFlag(argv, name) { + return argv.includes(`--${name}`); +} + +function normalizePath(file) { + const normalized = path.relative(projectRoot, path.resolve(projectRoot, file)); + return normalized && !normalized.startsWith("..") ? normalized : file.replaceAll("\\", "/"); +} diff --git a/test/runPerfPreflight.mjs b/test/runPerfPreflight.mjs index 0cf01da..5250d95 100644 --- a/test/runPerfPreflight.mjs +++ b/test/runPerfPreflight.mjs @@ -12,6 +12,7 @@ const requiredScripts = [ "test:assets", "test:browser:smoke", "test:browser", + "test:harness", "test:perf", "test:dev", "test:all", @@ -19,6 +20,7 @@ const requiredScripts = [ const requiredDocPhrases = [ "Perf claim or monster-render work", "pnpm test:perf", + "pnpm test:harness", "notes/monster-render-spike.md", "Committed runners should print what they validate", ]; From c6699af88ce7f84265d819a5753d58b0b4efe942 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Mon, 15 Jun 2026 20:53:23 -0300 Subject: [PATCH 04/18] Refactor test harness layout --- package.json | 10 +- scripts/testContracts.mjs | 21 +- src/runtime/multiplayer/reconciliation.ts | 3 +- src/runtime/multiplayer/simulation.ts | 10 +- test/HARNESS.md | 10 +- test/{ => assets}/checkAssetState.mjs | 2 +- test/assets/preparedAssets.mjs | 33 +++ test/{ => assets}/runAssetIntegrity.mjs | 22 +- test/{ => browser}/browserFixtureCombat.mjs | 43 +-- .../browserFixtureDefinitions.mjs | 0 test/{ => browser}/browserFixtureMapLogic.mjs | 101 +++---- test/{ => browser}/browserFixtureMonster.mjs | 14 +- .../browserFixtureProjectile.mjs | 62 +++-- test/{ => browser}/browserHarnessSupport.mjs | 2 +- test/browser/fixtureHarness.mjs | 45 ++++ test/{ => browser}/runBrowserFixtures.mjs | 4 +- test/{ => browser}/runBrowserSmoke.mjs | 4 +- test/{ => gameplay}/combatBudget.test.mjs | 2 +- test/{ => gameplay}/damageContext.test.mjs | 2 +- test/{ => gameplay}/enemyAcquisition.test.mjs | 2 +- test/{ => gameplay}/enemyCombat.test.mjs | 2 +- test/{ => gameplay}/enemyProjectiles.test.mjs | 2 +- test/{ => gameplay}/intermissionFlow.test.mjs | 2 +- test/{ => gameplay}/playerDeath.test.mjs | 2 +- test/{ => gameplay}/playerFallDamage.test.mjs | 2 +- .../shootableExplosionParticles.test.mjs | 2 +- .../weaponImpactParticles.test.mjs | 2 +- test/multiplayer/authorityFlow.test.mjs | 100 +++++++ test/multiplayer/harness.mjs | 246 +++++++++++++++++ test/multiplayer/movement.test.mjs | 79 ++++++ test/multiplayer/protocol.test.mjs | 252 ++++++++++++++++++ test/multiplayer/reconciliation.test.mjs | 97 +++++++ test/{ => perf}/runPerfPreflight.mjs | 2 +- test/runHarnessPlan.mjs | 119 ++++++--- .../{ => runtime}/cameraFeedbackFlow.test.mjs | 2 +- .../{ => runtime}/impactParticleFlow.test.mjs | 2 +- test/{ => runtime}/mobileControls.test.mjs | 6 +- test/{ => runtime}/orientation.test.mjs | 2 +- .../renderBundlePreloadUrls.test.mjs | 2 +- test/{ => runtime}/shootablePrewarm.test.mjs | 2 +- 40 files changed, 1141 insertions(+), 176 deletions(-) rename test/{ => assets}/checkAssetState.mjs (99%) create mode 100644 test/assets/preparedAssets.mjs rename test/{ => assets}/runAssetIntegrity.mjs (82%) rename test/{ => browser}/browserFixtureCombat.mjs (96%) rename test/{ => browser}/browserFixtureDefinitions.mjs (100%) rename test/{ => browser}/browserFixtureMapLogic.mjs (86%) rename test/{ => browser}/browserFixtureMonster.mjs (95%) rename test/{ => browser}/browserFixtureProjectile.mjs (97%) rename test/{ => browser}/browserHarnessSupport.mjs (99%) create mode 100644 test/browser/fixtureHarness.mjs rename test/{ => browser}/runBrowserFixtures.mjs (98%) rename test/{ => browser}/runBrowserSmoke.mjs (98%) rename test/{ => gameplay}/combatBudget.test.mjs (99%) rename test/{ => gameplay}/damageContext.test.mjs (97%) rename test/{ => gameplay}/enemyAcquisition.test.mjs (99%) rename test/{ => gameplay}/enemyCombat.test.mjs (99%) rename test/{ => gameplay}/enemyProjectiles.test.mjs (99%) rename test/{ => gameplay}/intermissionFlow.test.mjs (99%) rename test/{ => gameplay}/playerDeath.test.mjs (99%) rename test/{ => gameplay}/playerFallDamage.test.mjs (98%) rename test/{ => gameplay}/shootableExplosionParticles.test.mjs (99%) rename test/{ => gameplay}/weaponImpactParticles.test.mjs (99%) create mode 100644 test/multiplayer/authorityFlow.test.mjs create mode 100644 test/multiplayer/harness.mjs create mode 100644 test/multiplayer/movement.test.mjs create mode 100644 test/multiplayer/protocol.test.mjs create mode 100644 test/multiplayer/reconciliation.test.mjs rename test/{ => perf}/runPerfPreflight.mjs (96%) rename test/{ => runtime}/cameraFeedbackFlow.test.mjs (98%) rename test/{ => runtime}/impactParticleFlow.test.mjs (99%) rename test/{ => runtime}/mobileControls.test.mjs (98%) rename test/{ => runtime}/orientation.test.mjs (98%) rename test/{ => runtime}/renderBundlePreloadUrls.test.mjs (96%) rename test/{ => runtime}/shootablePrewarm.test.mjs (98%) diff --git a/package.json b/package.json index 7dcb3ed..46b75dc 100644 --- a/package.json +++ b/package.json @@ -18,12 +18,12 @@ "build:full": "pnpm prepare:quake && vite build", "preview": "vite preview", "test": "node scripts/testContracts.mjs", - "test:asset-state": "node test/checkAssetState.mjs", - "test:assets": "node test/runAssetIntegrity.mjs", - "test:browser:smoke": "node test/runBrowserSmoke.mjs", - "test:browser": "node test/runBrowserFixtures.mjs", + "test:asset-state": "node test/assets/checkAssetState.mjs", + "test:assets": "node test/assets/runAssetIntegrity.mjs", + "test:browser:smoke": "node test/browser/runBrowserSmoke.mjs", + "test:browser": "node test/browser/runBrowserFixtures.mjs", "test:harness": "node test/runHarnessPlan.mjs", - "test:perf": "node test/runPerfPreflight.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" }, diff --git a/scripts/testContracts.mjs b/scripts/testContracts.mjs index a005df2..bc1e2e3 100644 --- a/scripts/testContracts.mjs +++ b/scripts/testContracts.mjs @@ -7,13 +7,10 @@ import { fileURLToPath } from "node:url"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(scriptDir, ".."); const testDir = path.join(projectRoot, "test"); -const testFiles = (await readdir(testDir)) - .filter((entry) => entry.endsWith(".test.mjs")) - .sort() - .map((entry) => path.join(testDir, entry)); +const testFiles = (await collectContractTestFiles(testDir)).sort(); if (!testFiles.length) { - throw new Error("No contract test files found in test/*.test.mjs."); + throw new Error("No contract test files found in test/**/*.test.mjs."); } const child = spawn(process.execPath, ["--test", ...testFiles], { @@ -35,3 +32,17 @@ if (exitCode !== 0) { } console.log("\nContract tests passed."); + +async function collectContractTestFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const testFiles = []; + for (const entry of entries) { + const resolved = path.join(directory, entry.name); + if (entry.isDirectory()) { + testFiles.push(...await collectContractTestFiles(resolved)); + } else if (entry.isFile() && entry.name.endsWith(".test.mjs")) { + testFiles.push(resolved); + } + } + return testFiles; +} diff --git a/src/runtime/multiplayer/reconciliation.ts b/src/runtime/multiplayer/reconciliation.ts index 431b5a3..212aca4 100644 --- a/src/runtime/multiplayer/reconciliation.ts +++ b/src/runtime/multiplayer/reconciliation.ts @@ -51,7 +51,8 @@ export function decideQuakeMultiplayerLocalCorrection( return { action: "none", reason: "already-handled", drift, inputSequence }; } const hardSnapDistance = Math.max(0, options.hardSnapDistance); - const softCorrectionDistance = Math.max(0, options.softCorrectionDistance ?? hardSnapDistance); + const requestedSoftCorrectionDistance = Math.max(0, options.softCorrectionDistance ?? hardSnapDistance); + const softCorrectionDistance = Math.min(requestedSoftCorrectionDistance, hardSnapDistance); if (drift < softCorrectionDistance) { return { action: "none", reason: "within-threshold", drift, inputSequence }; } diff --git a/src/runtime/multiplayer/simulation.ts b/src/runtime/multiplayer/simulation.ts index 76e35df..c494d16 100644 --- a/src/runtime/multiplayer/simulation.ts +++ b/src/runtime/multiplayer/simulation.ts @@ -214,7 +214,7 @@ export function advanceQuakeMultiplayerRoomPlayerSimulation( } else { fallVelocityZ = nextPlayer.velocity[2] < 0 ? nextPlayer.velocity[2] : undefined; } - if (selected.input) { + if (selected.consumesInput) { lastAcceptedInput = selected.input; lastAcceptedInputSequence = selected.input.inputSequence; if (!consumedInputSequences.includes(selected.input.inputSequence)) { @@ -306,17 +306,19 @@ function selectInputForSimulationTick( simulatedAt: number, maxInputHoldMs: number, ): { + consumesInput: boolean; input: QuakeMultiplayerLocalInputIntent | undefined; pendingInputs: readonly QuakeMultiplayerLocalInputIntent[]; } { const freshPendingInputs = state.pendingInputs.filter((input) => input.inputSequence > state.lastAcceptedInputSequence ); - const queuedInput = freshPendingInputs.at(-1); + const queuedInput = freshPendingInputs[0]; if (queuedInput) { return { + consumesInput: true, input: queuedInput, - pendingInputs: [], + pendingInputs: freshPendingInputs.slice(1), }; } if ( @@ -325,11 +327,13 @@ function selectInputForSimulationTick( simulatedAt - state.lastAcceptedInput.sampledAt <= maxInputHoldMs ) { return { + consumesInput: false, input: state.lastAcceptedInput, pendingInputs: [], }; } return { + consumesInput: false, input: undefined, pendingInputs: [], }; diff --git a/test/HARNESS.md b/test/HARNESS.md index 6bd837a..70db5cb 100644 --- a/test/HARNESS.md +++ b/test/HARNESS.md @@ -28,11 +28,19 @@ Prepared-asset gates must not run shared asset prepare. If assets are missing, r Committed runners should print what they validate, prerequisites, whether they require prepared assets, artifact paths, and whether failures are likely product behavior, missing prepared assets, or local environment. +## Contract Test Layout + +`pnpm test` discovers `test/**/*.test.mjs`. Keep standalone contract tests under broad domain folders: `test/gameplay/` for Quake/game rules and combat behavior, and `test/runtime/` for app/runtime presentation, input, preload, and scheduling behavior. Use a feature folder when related tests need shared builders or protocol fixtures. + +Multiplayer contract tests live under `test/multiplayer/`. Use `test/multiplayer/harness.mjs` for room keys, protocol envelopes, loopback sessions, authoritative player fixtures, input fixtures, correction defaults, and message lookup helpers. Do not duplicate those builders in individual multiplayer tests. + ## Browser Coverage `pnpm test:browser` is selective, not exhaustive. It currently covers committed DOM monster visibility, combat budget caps, logical weapon targetability, player rocket fire/touch behavior, forced enemy projectile chains for ogre/wizard/zombie, ogre grenade bounce and timeout lifecycle, zombie projectile world-stop, map trigger/target/mover logic, liquid damage, and pickup gameplay fixtures. -Browser gameplay fixtures are assembled in `test/browserFixtureDefinitions.mjs`; family implementations live beside it as `test/browserFixture*.mjs`. `test/runBrowserFixtures.mjs` is the only committed gameplay-fixture runner. +Browser gameplay fixtures are assembled in `test/browser/browserFixtureDefinitions.mjs`; family implementations live beside it as `test/browser/browserFixture*.mjs`. `test/browser/runBrowserFixtures.mjs` is the only committed gameplay-fixture runner. + +Browser fixture files should use `test/browser/fixtureHarness.mjs` for fixture metadata, debug-page open/close lifecycle, page-error assertions, and small shared fixture utilities. Prepared asset readers for tests live in `test/assets/preparedAssets.mjs`; keep generated asset path knowledge there instead of duplicating `build/generated/public` paths in browser fixtures. Current fixture families: diff --git a/test/checkAssetState.mjs b/test/assets/checkAssetState.mjs similarity index 99% rename from test/checkAssetState.mjs rename to test/assets/checkAssetState.mjs index 43d3998..06349fb 100644 --- a/test/checkAssetState.mjs +++ b/test/assets/checkAssetState.mjs @@ -4,7 +4,7 @@ import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -export const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +export const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); export const manifestPath = path.join(projectRoot, "build/generated/public/q/manifest.json"); function option(args, name, fallback = "") { diff --git a/test/assets/preparedAssets.mjs b/test/assets/preparedAssets.mjs new file mode 100644 index 0000000..26ec20a --- /dev/null +++ b/test/assets/preparedAssets.mjs @@ -0,0 +1,33 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { projectRoot, readAssetManifest } from "./checkAssetState.mjs"; + +export const generatedPublicRoot = path.join(projectRoot, "build/generated/public"); + +export function readGeneratedJson(relativeUrl) { + const normalized = relativeUrl.replace(/^\/+/, ""); + return JSON.parse(readFileSync(path.join(generatedPublicRoot, normalized), "utf8")); +} + +export function readPreparedScene(mapName) { + return readGeneratedJson(`/q/${mapName}.json`); +} + +export function readPreparedManifest() { + const manifest = readAssetManifest(); + if (!manifest) throw new Error("missing build/generated/public/q/manifest.json"); + return manifest; +} + +export function preparedEntity(preparedScene, entityIndex) { + return preparedScene.entities?.find((entity) => entity.index === entityIndex) ?? null; +} + +export function assertPreparedEntity(preparedScene, entityIndex, expectedClassname) { + const entity = preparedEntity(preparedScene, entityIndex); + if (entity?.classname !== expectedClassname) { + throw new Error(`Expected prepared entity ${entityIndex} to be ${expectedClassname}, got ${entity?.classname}.`); + } + return entity; +} diff --git a/test/runAssetIntegrity.mjs b/test/assets/runAssetIntegrity.mjs similarity index 82% rename from test/runAssetIntegrity.mjs rename to test/assets/runAssetIntegrity.mjs index 09d6fba..b59bdc9 100644 --- a/test/runAssetIntegrity.mjs +++ b/test/assets/runAssetIntegrity.mjs @@ -1,17 +1,17 @@ #!/usr/bin/env node -import { existsSync, readFileSync } from "node:fs"; -import path from "node:path"; - -import { projectRoot, assertAssetState } from "./checkAssetState.mjs"; +import { assertAssetState } from "./checkAssetState.mjs"; +import { + readGeneratedJson, + readPreparedManifest, +} from "./preparedAssets.mjs"; console.log("Asset integrity gate"); -console.log("validates: prepared manifest, scene URLs, renderBundle, gameLogic, collision, model/sound path arrays"); +console.log("validates: prepared manifest, scene URLs, renderBundle, gameLogic, collision, and preload path arrays"); console.log("requires prepared assets: yes"); console.log("classification: acceptance"); const state = assertAssetState({ requireRenderBundle: false }); -const generatedRoot = path.join(projectRoot, "build/generated/public"); -const manifest = JSON.parse(readFileSync(path.join(generatedRoot, "q/manifest.json"), "utf8")); +const manifest = readPreparedManifest(); const errors = []; if (!Number.isFinite(manifest.version)) errors.push("manifest version must be finite"); @@ -34,10 +34,8 @@ if (errors.length) { console.log(`Asset integrity passed: ${state.mapCount} maps, startMap=${manifest.startMap}.`); function readJsonFile(relativeUrl) { - const normalized = relativeUrl.replace(/^\/+/, ""); - const fullPath = path.join(generatedRoot, normalized); try { - return JSON.parse(readFileSync(fullPath, "utf8")); + return readGeneratedJson(relativeUrl); } catch (error) { errors.push(`could not read ${relativeUrl}: ${error instanceof Error ? error.message : String(error)}`); return null; @@ -73,5 +71,7 @@ function validateMapEntry(mapEntry, mapNames) { validateSceneUrl(mapEntry.sceneUrl, mapName); } if (!Array.isArray(mapEntry.modelPaths)) errors.push(`${mapName} modelPaths must be an array`); - if (!Array.isArray(mapEntry.soundPaths)) errors.push(`${mapName} soundPaths must be an array`); + if (mapEntry.soundPaths !== undefined && !Array.isArray(mapEntry.soundPaths)) { + errors.push(`${mapName} soundPaths must be an array when present`); + } } diff --git a/test/browserFixtureCombat.mjs b/test/browser/browserFixtureCombat.mjs similarity index 96% rename from test/browserFixtureCombat.mjs rename to test/browser/browserFixtureCombat.mjs index e11f55b..4898c9a 100644 --- a/test/browserFixtureCombat.mjs +++ b/test/browser/browserFixtureCombat.mjs @@ -1,4 +1,7 @@ -import { openDebugMapPage } from "./browserHarnessSupport.mjs"; +import { + defineBrowserFixture, + runDebugMapFixture, +} from "./fixtureHarness.mjs"; const LOGICAL_MAP = "e1m1"; const LOGICAL_ANCHOR_ENTITY = 21; @@ -66,27 +69,31 @@ const LOGICAL_CANDIDATE_ENTITIES = [ const COMBAT_MAP = "e1m1"; const COMBAT_FOCUS_ENTITY = 298; -export const combatBudgetFixture = { +export const combatBudgetFixture = defineBrowserFixture({ id: "combat-budget", label: "Combat budget browser fixture", artifact: "bench/results/quake/combat-budget-harness-smoke-summary.json", family: "combat", - requirements: { requiredMaps: [COMBAT_MAP], requireRenderBundle: true }, + mapName: COMBAT_MAP, run: runCombatBudgetFixture, -}; +}); -export const logicalTargetabilityFixture = { +export const logicalTargetabilityFixture = defineBrowserFixture({ id: "logical-targetability", label: "Logical targetability browser fixture", artifact: "bench/results/quake/logical-targetability-smoke-summary.json", family: "combat", - requirements: { requiredMaps: [LOGICAL_MAP], requireRenderBundle: true }, + mapName: LOGICAL_MAP, run: runLogicalTargetabilityFixture, -}; +}); async function runCombatBudgetFixture({ browser, baseUrl, options }) { - const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, COMBAT_MAP, options); - try { + return await runDebugMapFixture({ + browser, + baseUrl, + options, + mapName: COMBAT_MAP, + run: async ({ page, pageErrors }) => { const result = await page.evaluate(async ({ entityIndex }) => { const debug = window.__cssQuakeDebug; if (!debug?.stats) return { hasDebug: false }; @@ -121,14 +128,17 @@ async function runCombatBudgetFixture({ browser, baseUrl, options }) { result, failures, }; - } finally { - await page.close(); - } + }, + }); } async function runLogicalTargetabilityFixture({ browser, baseUrl, options }) { - const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, LOGICAL_MAP, options); - try { + return await runDebugMapFixture({ + browser, + baseUrl, + options, + mapName: LOGICAL_MAP, + run: async ({ page, pageErrors }) => { const result = await page.evaluate(async ({ anchorEntity, candidateEntities, @@ -272,9 +282,8 @@ async function runLogicalTargetabilityFixture({ browser, baseUrl, options }) { result, failures, }; - } finally { - await page.close(); - } + }, + }); } function validateCombatBudgetResult(result) { diff --git a/test/browserFixtureDefinitions.mjs b/test/browser/browserFixtureDefinitions.mjs similarity index 100% rename from test/browserFixtureDefinitions.mjs rename to test/browser/browserFixtureDefinitions.mjs diff --git a/test/browserFixtureMapLogic.mjs b/test/browser/browserFixtureMapLogic.mjs similarity index 86% rename from test/browserFixtureMapLogic.mjs rename to test/browser/browserFixtureMapLogic.mjs index d7db515..9140275 100644 --- a/test/browserFixtureMapLogic.mjs +++ b/test/browser/browserFixtureMapLogic.mjs @@ -1,8 +1,12 @@ -import { readFileSync } from "node:fs"; -import path from "node:path"; - -import { openDebugMapPage } from "./browserHarnessSupport.mjs"; -import { projectRoot } from "./checkAssetState.mjs"; +import { + assertNoPageErrors, + defineBrowserFixture, + runDebugMapFixture, +} from "./fixtureHarness.mjs"; +import { + assertPreparedEntity, + readPreparedScene, +} from "../assets/preparedAssets.mjs"; const PICKUP_MAP = "e1m1"; const PICKUP_CASES = [ @@ -38,40 +42,47 @@ const MAP_LOGIC_CASE = { triggerEntity: 190, }; -export const mapLogicFixture = { +export const mapLogicFixture = defineBrowserFixture({ id: "map-logic", label: "Map logic browser fixture", artifact: "bench/results/quake/map-logic-browser-smoke-summary.json", family: "map-logic", - requirements: { requiredMaps: [MAP_LOGIC_MAP], requireRenderBundle: true, requireGameLogic: true }, + mapName: MAP_LOGIC_MAP, + requirements: { requireGameLogic: true }, run: runMapLogicFixture, -}; +}); -export const liquidDamageFixture = { +export const liquidDamageFixture = defineBrowserFixture({ id: "liquid-damage", label: "Liquid damage browser fixture", artifact: "bench/results/quake/liquid-damage-browser-smoke-summary.json", family: "map-logic", - requirements: { requiredMaps: [LIQUID_DAMAGE_MAP], requireRenderBundle: true, requireGameLogic: true }, + mapName: LIQUID_DAMAGE_MAP, + requirements: { requireGameLogic: true }, run: runLiquidDamageFixture, -}; +}); -export const pickupFixture = { +export const pickupFixture = defineBrowserFixture({ id: "pickup", label: "Pickup browser fixture", artifact: "bench/results/quake/pickup-browser-smoke-summary.json", family: "map-logic", - requirements: { requiredMaps: [PICKUP_MAP], requireRenderBundle: true, requireGameLogic: true }, + mapName: PICKUP_MAP, + requirements: { requireGameLogic: true }, run: runPickupFixture, -}; +}); async function runPickupFixture({ browser, baseUrl, options }) { - const prepared = JSON.parse(readFileSync(path.join(projectRoot, `build/generated/public/q/${PICKUP_MAP}.json`), "utf8")); + const prepared = readPreparedScene(PICKUP_MAP); const pickupCases = pickupCasesWithOrigins(prepared); - const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, PICKUP_MAP, options); + return await runDebugMapFixture({ + browser, + baseUrl, + options, + mapName: PICKUP_MAP, + run: async ({ page, pageErrors }) => { const results = []; let disabled = null; - try { disabled = await disabledPickupSnapshot(page); if (disabled.mounted) throw new Error(`Skill-disabled pickup should not mount: ${JSON.stringify(disabled)}`); for (const testCase of pickupCases) { @@ -80,19 +91,22 @@ async function runPickupFixture({ browser, baseUrl, options }) { results.push({ ...testCase, result }); console.log(`PASS ${PICKUP_MAP} ${testCase.classname} #${testCase.entity} ${testCase.stat} ${result.before[testCase.stat]} -> ${result.after[testCase.stat]}`); } - } finally { - await page.close(); - } - if (pageErrors.length) throw new Error(`Browser emitted console/page errors:\n${pageErrors.join("\n")}`); + assertNoPageErrors(pageErrors); return { kind: "cssquake-pickup-browser-fixture", startedAt: new Date().toISOString(), map: PICKUP_MAP, disabled, results }; + }, + }); } async function runLiquidDamageFixture({ browser, baseUrl, options }) { - const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, LIQUID_DAMAGE_MAP, options); - try { + return await runDebugMapFixture({ + browser, + baseUrl, + options, + mapName: LIQUID_DAMAGE_MAP, + run: async ({ page, pageErrors }) => { const result = await validateLiquidDamage(page, LIQUID_DAMAGE_CASE); assertLiquidDamageResult(LIQUID_DAMAGE_CASE, result); - if (pageErrors.length) throw new Error(`Browser emitted console/page errors:\n${pageErrors.join("\n")}`); + assertNoPageErrors(pageErrors); console.log( `PASS ${LIQUID_DAMAGE_MAP} ${LIQUID_DAMAGE_CASE.contents} damage ${result.beforeHealth} -> ${result.afterHealth}`, ); @@ -102,19 +116,22 @@ async function runLiquidDamageFixture({ browser, baseUrl, options }) { map: LIQUID_DAMAGE_MAP, result, }; - } finally { - await page.close(); - } + }, + }); } async function runMapLogicFixture({ browser, baseUrl, options }) { - const prepared = JSON.parse(readFileSync(path.join(projectRoot, `build/generated/public/q/${MAP_LOGIC_MAP}.json`), "utf8")); + const prepared = readPreparedScene(MAP_LOGIC_MAP); assertMapLogicFixturePrepared(prepared, MAP_LOGIC_CASE); - const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, MAP_LOGIC_MAP, options); - try { + return await runDebugMapFixture({ + browser, + baseUrl, + options, + mapName: MAP_LOGIC_MAP, + run: async ({ page, pageErrors }) => { const result = await validateMapLogic(page, MAP_LOGIC_CASE); assertMapLogicResult(MAP_LOGIC_CASE, result); - if (pageErrors.length) throw new Error(`Browser emitted console/page errors:\n${pageErrors.join("\n")}`); + assertNoPageErrors(pageErrors); console.log( `PASS ${MAP_LOGIC_MAP} trigger #${MAP_LOGIC_CASE.triggerEntity} count ${result.before.count} -> ${result.afterThird.count}, door #${MAP_LOGIC_CASE.doorEntity} ${result.before.mover.mode} -> ${result.afterFirst.mover.mode}`, ); @@ -124,18 +141,14 @@ async function runMapLogicFixture({ browser, baseUrl, options }) { map: MAP_LOGIC_MAP, result, }; - } finally { - await page.close(); - } + }, + }); } function pickupCasesWithOrigins(preparedScene) { return PICKUP_CASES.map((testCase) => { - const entity = preparedScene.entities?.find((candidate) => candidate.index === testCase.entity); - if (!entity?.origin) throw new Error(`Missing E1M1 pickup entity ${testCase.entity}.`); - if (entity.classname !== testCase.classname) { - throw new Error(`Expected E1M1 entity ${testCase.entity} to be ${testCase.classname}, got ${entity.classname}.`); - } + const entity = assertPreparedEntity(preparedScene, testCase.entity, testCase.classname); + if (!entity.origin) throw new Error(`Missing E1M1 pickup entity ${testCase.entity}.`); return { ...testCase, origin: entity.origin }; }); } @@ -348,14 +361,8 @@ async function validateMapLogic(page, testCase) { } function assertMapLogicFixturePrepared(preparedScene, testCase) { - const trigger = preparedScene.entities?.find((entity) => entity.index === testCase.triggerEntity); - const door = preparedScene.entities?.find((entity) => entity.index === testCase.doorEntity); - if (trigger?.classname !== testCase.expectedTriggerClassname) { - throw new Error(`${testCase.label} expected trigger #${testCase.triggerEntity} to be ${testCase.expectedTriggerClassname}, got ${trigger?.classname}.`); - } - if (door?.classname !== testCase.expectedDoorClassname) { - throw new Error(`${testCase.label} expected door #${testCase.doorEntity} to be ${testCase.expectedDoorClassname}, got ${door?.classname}.`); - } + const trigger = assertPreparedEntity(preparedScene, testCase.triggerEntity, testCase.expectedTriggerClassname); + const door = assertPreparedEntity(preparedScene, testCase.doorEntity, testCase.expectedDoorClassname); if (trigger.properties?.target !== testCase.targetname) { throw new Error(`${testCase.label} expected trigger target ${testCase.targetname}, got ${trigger.properties?.target}.`); } diff --git a/test/browserFixtureMonster.mjs b/test/browser/browserFixtureMonster.mjs similarity index 95% rename from test/browserFixtureMonster.mjs rename to test/browser/browserFixtureMonster.mjs index b9b0a22..b4d22a1 100644 --- a/test/browserFixtureMonster.mjs +++ b/test/browser/browserFixtureMonster.mjs @@ -3,6 +3,10 @@ import { openDebugMapPage, waitForDebugMapReady, } from "./browserHarnessSupport.mjs"; +import { + defineBrowserFixture, + unique, +} from "./fixtureHarness.mjs"; const REPRESENTATIVE_MONSTERS = [ { map: "e1m1", classname: "monster_army", entity: 298 }, @@ -18,14 +22,14 @@ const REPRESENTATIVE_MONSTERS = [ const MONSTER_FOCUS_YAWS = [0, 45, 90, 135, 180, 225, 270, 315]; const MONSTER_FOCUS_DISTANCES = [2.35, 3.5, 5, 8, 12]; -export const monsterDomFixture = { +export const monsterDomFixture = defineBrowserFixture({ id: "monster-dom", label: "DOM monster browser fixture", artifact: "bench/results/quake/monster-dom-smoke-summary.json", family: "monster", - requirements: { requiredMaps: unique(REPRESENTATIVE_MONSTERS.map((monster) => monster.map)), requireRenderBundle: true }, + maps: unique(REPRESENTATIVE_MONSTERS.map((monster) => monster.map)), run: runMonsterDomFixture, -}; +}); async function runMonsterDomFixture({ browser, baseUrl, options }) { let page = null; @@ -113,7 +117,3 @@ async function validateMonster(page, monster) { } return { ...monster, pass: false, naturalVisibility: false, attempt: lastAttempt }; } - -function unique(values) { - return [...new Set(values)].sort(); -} diff --git a/test/browserFixtureProjectile.mjs b/test/browser/browserFixtureProjectile.mjs similarity index 97% rename from test/browserFixtureProjectile.mjs rename to test/browser/browserFixtureProjectile.mjs index 0eab523..5975773 100644 --- a/test/browserFixtureProjectile.mjs +++ b/test/browser/browserFixtureProjectile.mjs @@ -1,4 +1,7 @@ -import { openDebugMapPage } from "./browserHarnessSupport.mjs"; +import { + defineBrowserFixture, + runDebugMapFixture, +} from "./fixtureHarness.mjs"; const ROCKET_TOUCH_MAP = "e1m1"; const ROCKET_TOUCH_SCENARIO = { @@ -177,23 +180,23 @@ const ZOMBIE_PROJECTILE_STOP_SCENARIO = { }, }; -export const rocketTouchFixture = { +export const rocketTouchFixture = defineBrowserFixture({ id: "rocket-touch", label: "Rocket touch browser fixture", artifact: "bench/results/quake/oracle/e1m1-soldier-rocket-touch.cssquake.json", family: "projectile", - requirements: { requiredMaps: [ROCKET_TOUCH_MAP], requireRenderBundle: true }, + mapName: ROCKET_TOUCH_MAP, run: runRocketTouchFixture, -}; +}); -export const rocketFireFixture = { +export const rocketFireFixture = defineBrowserFixture({ id: "rocket-fire", label: "Rocket fire browser fixture", artifact: "bench/results/quake/oracle/e1m1-soldier-rocket-fire.cssquake.json", family: "projectile", - requirements: { requiredMaps: [ROCKET_FIRE_MAP], requireRenderBundle: true }, + mapName: ROCKET_FIRE_MAP, run: runRocketFireFixture, -}; +}); export const ogreGrenadeChainFixture = enemyProjectileChainFixture({ artifact: "bench/results/quake/oracle/e1m2-ogre-grenade-chain.cssquake.json", @@ -238,8 +241,12 @@ export const zombieProjectileStopFixture = enemyProjectileChainFixture({ }); async function runRocketTouchFixture({ browser, baseUrl, options }) { - const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, ROCKET_TOUCH_MAP, options); - try { + return await runDebugMapFixture({ + browser, + baseUrl, + options, + mapName: ROCKET_TOUCH_MAP, + run: async ({ page, pageErrors }) => { const result = await page.evaluate(async ({ scenario, sourceReference }) => { const debug = window.__cssQuakeDebug; const [playerX, playerY, playerZ] = scenario.player.origin; @@ -354,9 +361,8 @@ async function runRocketTouchFixture({ browser, baseUrl, options }) { scenarioId: ROCKET_TOUCH_SCENARIO.id, sourceReference: ROCKET_TOUCH_SOURCE, }; - } finally { - await page.close(); - } + }, + }); } function validateRocketTouchResult(result) { @@ -425,8 +431,12 @@ function validateRocketTouchResult(result) { } async function runRocketFireFixture({ browser, baseUrl, options }) { - const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, ROCKET_FIRE_MAP, options); - try { + return await runDebugMapFixture({ + browser, + baseUrl, + options, + mapName: ROCKET_FIRE_MAP, + run: async ({ page, pageErrors }) => { const result = await page.evaluate(async ({ scenario, sourceReference, timeoutMs }) => { const debug = window.__cssQuakeDebug; const [playerX, playerY, playerZ] = scenario.player.origin; @@ -541,9 +551,8 @@ async function runRocketFireFixture({ browser, baseUrl, options }) { scenarioId: ROCKET_FIRE_SCENARIO.id, sourceReference: ROCKET_FIRE_SOURCE, }; - } finally { - await page.close(); - } + }, + }); } function validateRocketFireResult(result) { @@ -620,19 +629,23 @@ function captureRocketFireEvents(result, type) { } function enemyProjectileChainFixture({ artifact, id, label, scenario }) { - return { + return defineBrowserFixture({ id, label, artifact, family: "projectile", - requirements: { requiredMaps: [scenario.map], requireRenderBundle: true }, + mapName: scenario.map, run: (context) => runEnemyProjectileChainFixture(context, scenario), - }; + }); } async function runEnemyProjectileChainFixture({ browser, baseUrl, options }, scenario) { - const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, scenario.map, options); - try { + return await runDebugMapFixture({ + browser, + baseUrl, + options, + mapName: scenario.map, + run: async ({ page, pageErrors }) => { const result = await page.evaluate(async ({ scenario, stepMs }) => { const debug = window.__cssQuakeDebug; const [playerX, playerY, playerZ] = scenario.player.origin; @@ -762,9 +775,8 @@ async function runEnemyProjectileChainFixture({ browser, baseUrl, options }, sce scenario, scenarioId: scenario.id, }; - } finally { - await page.close(); - } + }, + }); } function validateEnemyProjectileChainResult(result, scenario) { diff --git a/test/browserHarnessSupport.mjs b/test/browser/browserHarnessSupport.mjs similarity index 99% rename from test/browserHarnessSupport.mjs rename to test/browser/browserHarnessSupport.mjs index 271747b..f08877f 100644 --- a/test/browserHarnessSupport.mjs +++ b/test/browser/browserHarnessSupport.mjs @@ -5,7 +5,7 @@ import { existsSync, readdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; -import { projectRoot } from "./checkAssetState.mjs"; +import { projectRoot } from "../assets/checkAssetState.mjs"; export function hasFlag(args, name) { return args.includes(`--${name}`); diff --git a/test/browser/fixtureHarness.mjs b/test/browser/fixtureHarness.mjs new file mode 100644 index 0000000..e219b4c --- /dev/null +++ b/test/browser/fixtureHarness.mjs @@ -0,0 +1,45 @@ +import { openDebugMapPage } from "./browserHarnessSupport.mjs"; + +export function defineBrowserFixture({ + artifact, + family, + id, + label, + mapName, + maps, + requirements = {}, + run, +}) { + const requiredMaps = maps ?? (mapName ? [mapName] : []); + return { + id, + label, + artifact, + family, + requirements: { + requiredMaps, + requireRenderBundle: true, + ...requirements, + }, + run, + }; +} + +export async function runDebugMapFixture({ browser, baseUrl, options, mapName, run }) { + const { page, pageErrors } = await openDebugMapPage(browser, baseUrl, mapName, options); + try { + return await run({ page, pageErrors }); + } finally { + await page.close(); + } +} + +export function assertNoPageErrors(pageErrors, label = "Browser fixture") { + if (pageErrors.length) { + throw new Error(`${label} emitted console/page errors:\n${pageErrors.join("\n")}`); + } +} + +export function unique(values) { + return [...new Set(values)].sort(); +} diff --git a/test/runBrowserFixtures.mjs b/test/browser/runBrowserFixtures.mjs similarity index 98% rename from test/runBrowserFixtures.mjs rename to test/browser/runBrowserFixtures.mjs index 43f6d77..655cea8 100644 --- a/test/runBrowserFixtures.mjs +++ b/test/browser/runBrowserFixtures.mjs @@ -6,7 +6,7 @@ import { resolveBrowserTarget, writeJsonArtifact, } from "./browserHarnessSupport.mjs"; -import { assertAssetState } from "./checkAssetState.mjs"; +import { assertAssetState } from "../assets/checkAssetState.mjs"; import { browserFixtureById, browserFixtureFamilies, browserFixtures } from "./browserFixtureDefinitions.mjs"; const DEFAULT_PORT = 5184; @@ -75,7 +75,7 @@ console.log(`Browser gameplay fixtures passed: ${summaries.length}.`); function printHelp() { console.log(`Usage: - node test/runBrowserFixtures.mjs [options] + node test/browser/runBrowserFixtures.mjs [options] Options: --fixture Run only selected fixture ids. Repeatable. diff --git a/test/runBrowserSmoke.mjs b/test/browser/runBrowserSmoke.mjs similarity index 98% rename from test/runBrowserSmoke.mjs rename to test/browser/runBrowserSmoke.mjs index 9ce00c1..2ff4e12 100644 --- a/test/runBrowserSmoke.mjs +++ b/test/browser/runBrowserSmoke.mjs @@ -8,7 +8,7 @@ import { parseCommonBrowserArgs, resolveBrowserTarget, } from "./browserHarnessSupport.mjs"; -import { assertAssetState } from "./checkAssetState.mjs"; +import { assertAssetState } from "../assets/checkAssetState.mjs"; const DEFAULT_PORT = 5188; const DEFAULT_TIMEOUT_MS = 45_000; @@ -77,7 +77,7 @@ try { function printHelp() { console.log(`Usage: - node test/runBrowserSmoke.mjs [options] + node test/browser/runBrowserSmoke.mjs [options] Options: --url Use an already-running cssQuake dev server. diff --git a/test/combatBudget.test.mjs b/test/gameplay/combatBudget.test.mjs similarity index 99% rename from test/combatBudget.test.mjs rename to test/gameplay/combatBudget.test.mjs index 402b2d3..a0cb6a8 100644 --- a/test/combatBudget.test.mjs +++ b/test/gameplay/combatBudget.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { importTsModule } from "./importTsModule.mjs"; +import { importTsModule } from "../importTsModule.mjs"; const { QUAKE_COMBAT_BUDGET_LIMITS, diff --git a/test/damageContext.test.mjs b/test/gameplay/damageContext.test.mjs similarity index 97% rename from test/damageContext.test.mjs rename to test/gameplay/damageContext.test.mjs index 3cd851d..c3ea72c 100644 --- a/test/damageContext.test.mjs +++ b/test/gameplay/damageContext.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { importTsModule } from "./importTsModule.mjs"; +import { importTsModule } from "../importTsModule.mjs"; const { quakeDamageRetargetDecision, diff --git a/test/enemyAcquisition.test.mjs b/test/gameplay/enemyAcquisition.test.mjs similarity index 99% rename from test/enemyAcquisition.test.mjs rename to test/gameplay/enemyAcquisition.test.mjs index ce42bb6..b480331 100644 --- a/test/enemyAcquisition.test.mjs +++ b/test/gameplay/enemyAcquisition.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { importTsModule } from "./importTsModule.mjs"; +import { importTsModule } from "../importTsModule.mjs"; const { createQuakeEnemyAcquisitionVisibilityCache, diff --git a/test/enemyCombat.test.mjs b/test/gameplay/enemyCombat.test.mjs similarity index 99% rename from test/enemyCombat.test.mjs rename to test/gameplay/enemyCombat.test.mjs index ecf90cc..14933e6 100644 --- a/test/enemyCombat.test.mjs +++ b/test/gameplay/enemyCombat.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { importTsModule } from "./importTsModule.mjs"; +import { importTsModule } from "../importTsModule.mjs"; const { createQuakeEnemyCombatRuntime, diff --git a/test/enemyProjectiles.test.mjs b/test/gameplay/enemyProjectiles.test.mjs similarity index 99% rename from test/enemyProjectiles.test.mjs rename to test/gameplay/enemyProjectiles.test.mjs index 61a31fe..304502c 100644 --- a/test/enemyProjectiles.test.mjs +++ b/test/gameplay/enemyProjectiles.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { importTsModule } from "./importTsModule.mjs"; +import { importTsModule } from "../importTsModule.mjs"; const { createQuakeEnemyProjectileRuntime, diff --git a/test/intermissionFlow.test.mjs b/test/gameplay/intermissionFlow.test.mjs similarity index 99% rename from test/intermissionFlow.test.mjs rename to test/gameplay/intermissionFlow.test.mjs index fcafbbf..df57f42 100644 --- a/test/intermissionFlow.test.mjs +++ b/test/gameplay/intermissionFlow.test.mjs @@ -3,7 +3,7 @@ import test from "node:test"; import { Window } from "happy-dom"; -import { importTsModule } from "./importTsModule.mjs"; +import { importTsModule } from "../importTsModule.mjs"; const { createQuakeIntermissionFlow, diff --git a/test/playerDeath.test.mjs b/test/gameplay/playerDeath.test.mjs similarity index 99% rename from test/playerDeath.test.mjs rename to test/gameplay/playerDeath.test.mjs index b21dc31..56157d7 100644 --- a/test/playerDeath.test.mjs +++ b/test/gameplay/playerDeath.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { importTsModule } from "./importTsModule.mjs"; +import { importTsModule } from "../importTsModule.mjs"; const player = await importTsModule("src/runtime/player.ts"); const constants = await importTsModule("src/runtime/constants.ts"); diff --git a/test/playerFallDamage.test.mjs b/test/gameplay/playerFallDamage.test.mjs similarity index 98% rename from test/playerFallDamage.test.mjs rename to test/gameplay/playerFallDamage.test.mjs index febc1ac..84d5cca 100644 --- a/test/playerFallDamage.test.mjs +++ b/test/gameplay/playerFallDamage.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { importTsModule } from "./importTsModule.mjs"; +import { importTsModule } from "../importTsModule.mjs"; const constants = await importTsModule("src/runtime/constants.ts"); const hazards = await importTsModule("src/runtime/hazards.ts"); diff --git a/test/shootableExplosionParticles.test.mjs b/test/gameplay/shootableExplosionParticles.test.mjs similarity index 99% rename from test/shootableExplosionParticles.test.mjs rename to test/gameplay/shootableExplosionParticles.test.mjs index 06dc1d6..7bfaf68 100644 --- a/test/shootableExplosionParticles.test.mjs +++ b/test/gameplay/shootableExplosionParticles.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { importTsModule } from "./importTsModule.mjs"; +import { importTsModule } from "../importTsModule.mjs"; const { createQuakeShootablesController, diff --git a/test/weaponImpactParticles.test.mjs b/test/gameplay/weaponImpactParticles.test.mjs similarity index 99% rename from test/weaponImpactParticles.test.mjs rename to test/gameplay/weaponImpactParticles.test.mjs index 24d9704..3f689a7 100644 --- a/test/weaponImpactParticles.test.mjs +++ b/test/gameplay/weaponImpactParticles.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { importTsModule } from "./importTsModule.mjs"; +import { importTsModule } from "../importTsModule.mjs"; const { createQuakeWeaponsController, diff --git a/test/multiplayer/authorityFlow.test.mjs b/test/multiplayer/authorityFlow.test.mjs new file mode 100644 index 0000000..e040a17 --- /dev/null +++ b/test/multiplayer/authorityFlow.test.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; + +import { + NORMALIZED_ROOM_KEY, + correctionOptions, + helloEnvelope, + inputEnvelope, + latestMessage, + latestSnapshotPlayer, + loopback, + presenceEnvelope, + reconciliation, + waitForMessage, +} from "./harness.mjs"; + +test("loopback authority consumes ordered input, snapshots it, reconciles it, and ignores paused input", async () => { + const messages = []; + const session = loopback.createQuakeLoopbackMultiplayerSession({ + now: () => Date.now(), + asyncDispatch: false, + heartbeatIntervalMs: false, + simulationTickMs: 1, + snapshotIntervalMs: false, + }); + const unsubscribe = session.subscribe((message) => messages.push(message)); + try { + const connected = await session.connect({ + roomKey: NORMALIZED_ROOM_KEY, + clientId: "client-a", + displayName: "Alice", + color: "#00ffaa", + }); + assert.equal(connected.state, "connected"); + + session.send(helloEnvelope({ + color: "#00ffaa", + messageId: "flow-hello", + sequence: 1, + sentAt: Date.now(), + })); + assert.equal(latestSnapshotPlayer(messages).lastInputSequence, 0); + + for (const inputSequence of [1, 2, 3]) { + session.send(inputEnvelope({ + messageId: `flow-input-${inputSequence}`, + sequence: inputSequence + 1, + inputSequence, + sentAt: Date.now(), + })); + await delay(12); + } + + await waitForMessage( + messages, + (message) => + message.type === "room.snapshot" && + message.payload.players.some((player) => + player.playerId === "loopback:client-a" && player.lastInputSequence >= 3 + ), + { message: "timed out waiting for authoritative input snapshot" }, + ); + const authoritative = latestSnapshotPlayer(messages); + assert.equal(authoritative.lastInputSequence, 3); + + const correction = reconciliation.decideQuakeMultiplayerLocalCorrection( + authoritative.origin, + authoritative, + 2, + correctionOptions(), + ); + assert.equal(correction.action, "none"); + assert.equal(correction.reason, "within-threshold"); + assert.equal(correction.inputSequence, 3); + + session.send(presenceEnvelope("backgrounded", { + messageId: "flow-backgrounded", + sequence: 5, + sentAt: Date.now(), + })); + assert.equal(latestMessage(messages, "room.event").payload.event.status, "backgrounded"); + assert.equal(latestSnapshotPlayer(messages).lastInputSequence, 3); + + const messageCountBeforePausedInput = messages.length; + await delay(12); + session.send(inputEnvelope({ + messageId: "flow-paused-input", + sequence: 6, + inputSequence: 4, + sentAt: Date.now(), + })); + await delay(20); + assert.equal(messages.length, messageCountBeforePausedInput); + assert.equal(latestSnapshotPlayer(messages).lastInputSequence, 3); + } finally { + unsubscribe(); + session.disconnect("test-complete"); + } +}); diff --git a/test/multiplayer/harness.mjs b/test/multiplayer/harness.mjs new file mode 100644 index 0000000..31387e8 --- /dev/null +++ b/test/multiplayer/harness.mjs @@ -0,0 +1,246 @@ +import assert from "node:assert/strict"; +import { setTimeout as delay } from "node:timers/promises"; + +import { importTsModule } from "../importTsModule.mjs"; + +export const authority = await importTsModule("src/runtime/multiplayer/authority.ts"); +export const loopback = await importTsModule("src/runtime/multiplayer/loopback.ts"); +export const protocol = await importTsModule("src/runtime/multiplayer/protocol.ts"); +export const reconciliation = await importTsModule("src/runtime/multiplayer/reconciliation.ts"); +export const simulation = await importTsModule("src/runtime/multiplayer/simulation.ts"); +export const validation = await importTsModule("src/runtime/multiplayer/validation.ts"); + +export const ROOM_KEY = { + mapName: "E1M1", + assetManifestVersion: 1, + assetRoot: "/q", + sceneUrl: "/q/e1m1.json", + preparedSceneVersion: 2, + gameLogicVersion: 3, +}; + +export const NORMALIZED_ROOM_KEY = { + ...ROOM_KEY, + mapName: "e1m1", +}; + +export function clientEnvelope(type, payload, options = {}) { + return protocol.createQuakeMultiplayerEnvelope({ + direction: "client", + type, + roomKey: options.roomKey ?? NORMALIZED_ROOM_KEY, + messageId: options.messageId, + sequence: options.sequence ?? 0, + sentAt: options.sentAt ?? 100, + payload, + }); +} + +export function helloEnvelope(options = {}) { + const clientId = options.clientId ?? "client-a"; + return clientEnvelope("client.hello", { + clientId, + displayName: options.displayName ?? "Alice", + ...(options.color ? { color: options.color } : {}), + ...(options.capabilities ? { capabilities: options.capabilities } : {}), + ...(options.matchSettings ? { matchSettings: options.matchSettings } : {}), + ...(options.deathmatchSpawns ? { deathmatchSpawns: options.deathmatchSpawns } : {}), + ...(options.pickupDefinitions ? { pickupDefinitions: options.pickupDefinitions } : {}), + ...(options.gameplayFacts ? { gameplayFacts: options.gameplayFacts } : {}), + }, options); +} + +export function presenceEnvelope(status, options = {}) { + return clientEnvelope("client.presence", { + clientId: options.clientId ?? "client-a", + status, + }, options); +} + +export function inputEnvelope(options = {}) { + const clientId = options.clientId ?? "client-a"; + return clientEnvelope("client.input", { + clientId, + input: createInput(options.inputSequence ?? 1, { + sampledAt: options.sampledAt ?? options.sentAt ?? 100, + ...(options.input ?? {}), + }), + }, options); +} + +export function fireEnvelope(options = {}) { + return clientEnvelope("client.fire", { + clientId: options.clientId ?? "client-a", + fire: { + fireSequence: options.fireSequence ?? 1, + firedAt: options.sentAt ?? 100, + weapon: "shotgun", + fireKind: "hitscan", + origin: [0, 0, 0], + direction: [1, 0, 0], + range: 1024, + ...(options.fire ?? {}), + }, + }, { ...options, messageId: options.messageId ?? "paused-fire" }); +} + +export function pickupEnvelope(options = {}) { + return clientEnvelope("client.pickup", { + clientId: options.clientId ?? "client-a", + pickup: { + pickupSequence: options.pickupSequence ?? 1, + requestedAt: options.sentAt ?? 100, + entityIndex: 20, + ...(options.pickup ?? {}), + }, + }, { ...options, messageId: options.messageId ?? "paused-pickup" }); +} + +export function worldEnvelope(options = {}) { + return clientEnvelope("client.world", { + clientId: options.clientId ?? "client-a", + intent: { + intentType: "touch", + worldSequence: options.worldSequence ?? 1, + requestedAt: options.sentAt ?? 100, + entityIndex: 178, + origin: [0, 0, 0], + ...(options.intent ?? {}), + }, + }, { ...options, messageId: options.messageId ?? "paused-world" }); +} + +export function matchEnvelope(options = {}) { + return clientEnvelope("client.match", { + clientId: options.clientId ?? "client-a", + match: { + matchSequence: options.matchSequence ?? 1, + requestedAt: options.sentAt ?? 100, + action: "restart", + ...(options.match ?? {}), + }, + }, { ...options, messageId: options.messageId ?? "paused-match" }); +} + +export function createInput(inputSequence, overrides = {}) { + return { + inputSequence, + sampledAt: overrides.sampledAt ?? inputSequence * 10, + dt: overrides.dt ?? 0.05, + move: { forward: 320, side: 0, up: 0, ...(overrides.move ?? {}) }, + buttons: { attack: false, jump: false, use: false, ...(overrides.buttons ?? {}) }, + rotX: 0, + rotY: 0, + ...overrides, + }; +} + +export function createPlayer(overrides = {}) { + return { + playerId: "player-1", + clientId: "client-1", + displayName: "Player", + mapName: "e1m1", + origin: [0, 0, 0], + velocity: [0, 0, 0], + rotX: 0, + rotY: 0, + health: 100, + armor: 0, + activeWeapon: "shotgun", + inventory: { + health: 100, + armor: 0, + armorType: 0, + activeWeapon: "shotgun", + itemFlags: 0, + weapons: ["axe", "shotgun"], + shells: 25, + nails: 0, + rockets: 0, + cells: 0, + keys: [], + powerups: [], + }, + alive: true, + frags: 0, + deaths: 0, + lastInputSequence: 0, + updatedAt: 0, + ...overrides, + }; +} + +export function correctionOptions(overrides = {}) { + return { + hardSnapDistance: 32, + softCorrectionDistance: 8, + blendFraction: 0.35, + maxBlendDistance: 64, + ...overrides, + }; +} + +export async function createLoopbackHarness(options = {}) { + let now = options.now ?? 1000; + const messages = []; + const session = loopback.createQuakeLoopbackMultiplayerSession({ + now: options.nowProvider ?? (() => now), + asyncDispatch: false, + heartbeatIntervalMs: false, + simulationTickMs: false, + snapshotIntervalMs: false, + ...(options.sessionOptions ?? {}), + }); + const unsubscribe = session.subscribe((message) => messages.push(message)); + const status = await session.connect({ + roomKey: options.roomKey ?? NORMALIZED_ROOM_KEY, + clientId: options.clientId ?? "client-a", + displayName: options.displayName ?? "Alice", + ...(options.color ? { color: options.color } : {}), + }); + + return { + messages, + session, + status, + now: () => now, + setNow: (value) => { + now = value; + return now; + }, + advanceNow: (ms) => { + now += ms; + return now; + }, + disconnect: (reason = "test-complete") => { + unsubscribe(); + session.disconnect(reason); + }, + }; +} + +export function latestMessage(messages, type) { + const message = messages.findLast((candidate) => candidate.type === type); + assert.ok(message, `expected ${type} message`); + return message; +} + +export function latestSnapshotPlayer(messages, playerId = "loopback:client-a") { + const snapshot = latestMessage(messages, "room.snapshot"); + const player = snapshot.payload.players.find((candidate) => candidate.playerId === playerId); + assert.ok(player, `expected snapshot player ${playerId}`); + return player; +} + +export async function waitForMessage(messages, predicate, options = {}) { + const timeoutMs = options.timeoutMs ?? 250; + const intervalMs = options.intervalMs ?? 5; + const startedAt = Date.now(); + while (Date.now() - startedAt <= timeoutMs) { + const message = messages.findLast(predicate); + if (message) return message; + await delay(intervalMs); + } + assert.fail(options.message ?? "timed out waiting for multiplayer message"); +} diff --git a/test/multiplayer/movement.test.mjs b/test/multiplayer/movement.test.mjs new file mode 100644 index 0000000..1078a3e --- /dev/null +++ b/test/multiplayer/movement.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createInput, + createPlayer, + simulation, +} from "./harness.mjs"; + +test("room simulation consumes queued inputs in sequence order across fixed ticks", () => { + const player = createPlayer(); + let state = simulation.createQuakeMultiplayerRoomPlayerSimulationState({ + playerId: player.playerId, + now: 0, + }); + for (const inputSequence of [1, 2, 3]) { + const result = simulation.queueQuakeMultiplayerRoomInput(state, createInput(inputSequence)); + assert.equal(result.accepted, true); + state = result.state; + } + + const first = simulation.advanceQuakeMultiplayerRoomPlayerSimulation(player, state, { + now: 50, + tickMs: 50, + maxCatchupTicks: 1, + }); + assert.deepEqual(first.consumedInputSequences, [1]); + assert.equal(first.state.lastAcceptedInputSequence, 1); + assert.deepEqual(first.state.pendingInputs.map((input) => input.inputSequence), [2, 3]); + + const second = simulation.advanceQuakeMultiplayerRoomPlayerSimulation(first.player, first.state, { + now: 100, + tickMs: 50, + maxCatchupTicks: 1, + }); + assert.deepEqual(second.consumedInputSequences, [2]); + assert.equal(second.state.lastAcceptedInputSequence, 2); + assert.deepEqual(second.state.pendingInputs.map((input) => input.inputSequence), [3]); + + const third = simulation.advanceQuakeMultiplayerRoomPlayerSimulation(second.player, second.state, { + now: 150, + tickMs: 50, + maxCatchupTicks: 1, + }); + assert.deepEqual(third.consumedInputSequences, [3]); + assert.equal(third.state.lastAcceptedInputSequence, 3); + assert.deepEqual(third.state.pendingInputs, []); +}); + +test("room simulation still holds the last accepted input after the queue drains", () => { + const player = createPlayer(); + let state = simulation.createQuakeMultiplayerRoomPlayerSimulationState({ + playerId: player.playerId, + now: 0, + }); + state = simulation.queueQuakeMultiplayerRoomInput(state, createInput(1, { sampledAt: 0 })).state; + + const first = simulation.advanceQuakeMultiplayerRoomPlayerSimulation(player, state, { + now: 50, + tickMs: 50, + maxCatchupTicks: 1, + }); + const held = simulation.advanceQuakeMultiplayerRoomPlayerSimulation(first.player, first.state, { + now: 100, + tickMs: 50, + maxCatchupTicks: 1, + maxInputHoldMs: 250, + }); + + assert.deepEqual(held.consumedInputSequences, []); + assert.equal(held.state.lastAcceptedInputSequence, 1); + assert.equal(held.state.lastAcceptedInput?.inputSequence, 1); + assert.equal(held.player.lastInputSequence, 1); + assert.ok(horizontalDistance(held.player.origin) > horizontalDistance(first.player.origin)); +}); + +function horizontalDistance(origin) { + return Math.hypot(origin[0], origin[1]); +} diff --git a/test/multiplayer/protocol.test.mjs b/test/multiplayer/protocol.test.mjs new file mode 100644 index 0000000..a69d992 --- /dev/null +++ b/test/multiplayer/protocol.test.mjs @@ -0,0 +1,252 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + NORMALIZED_ROOM_KEY, + ROOM_KEY, + authority, + clientEnvelope, + createLoopbackHarness, + fireEnvelope, + helloEnvelope, + inputEnvelope, + latestMessage, + matchEnvelope, + pickupEnvelope, + presenceEnvelope, + protocol, + validation, + worldEnvelope, +} from "./harness.mjs"; + +test("multiplayer room compatibility keys normalize map names and compare full asset identity", () => { + const normalized = protocol.createQuakeMultiplayerRoomCompatibilityKey(ROOM_KEY); + assert.deepEqual(normalized, NORMALIZED_ROOM_KEY); + assert.equal(protocol.sameQuakeMultiplayerRoomCompatibilityKey(ROOM_KEY, NORMALIZED_ROOM_KEY), true); + assert.equal( + protocol.sameQuakeMultiplayerRoomCompatibilityKey(ROOM_KEY, { + ...NORMALIZED_ROOM_KEY, + sceneUrl: "/q/e1m2.json", + }), + false, + ); +}); + +test("client hello validates and establishes authority state before other client messages", () => { + const hello = helloEnvelope({ + color: "#00ffaa", + capabilities: ["input", "snapshots"], + matchSettings: { fragLimit: 5, maxPlayers: 4 }, + sequence: 1, + sentAt: 100, + }); + + const validationResult = validation.validateQuakeMultiplayerClientEnvelope(hello, { + roomKey: NORMALIZED_ROOM_KEY, + now: 100, + }); + assert.equal(validationResult.ok, true); + + const authorityResult = authority.validateQuakeMultiplayerClientAuthority(hello, null, { now: 100 }); + assert.equal(authorityResult.ok, true); + assert.equal(authorityResult.state.clientId, "client-a"); + assert.equal(authorityResult.state.lastEnvelopeSequence, 1); +}); + +test("client authority rejects non-hello first messages and client id swaps", () => { + const input = inputEnvelope({ sequence: 1, inputSequence: 1, sentAt: 100 }); + const firstResult = authority.validateQuakeMultiplayerClientAuthority(input, null, { now: 100 }); + assert.equal(firstResult.ok, false); + assert.equal(firstResult.reject.code, "not-authorized"); + assert.equal(firstResult.reject.recoverable, false); + + const helloResult = authority.validateQuakeMultiplayerClientAuthority( + helloEnvelope({ sequence: 1, sentAt: 100 }), + null, + { now: 100 }, + ); + assert.equal(helloResult.ok, true); + + const swappedClient = inputEnvelope({ + clientId: "client-b", + sequence: 2, + inputSequence: 1, + sentAt: 130, + }); + const swappedResult = authority.validateQuakeMultiplayerClientAuthority(swappedClient, helloResult.state, { + now: 130, + }); + assert.equal(swappedResult.ok, false); + assert.equal(swappedResult.reject.code, "not-authorized"); + assert.equal(swappedResult.reject.recoverable, false); +}); + +test("client authority rejects replayed envelope and intent sequences independently", () => { + const helloResult = authority.validateQuakeMultiplayerClientAuthority( + helloEnvelope({ sequence: 1, sentAt: 100 }), + null, + { now: 100 }, + ); + assert.equal(helloResult.ok, true); + + const inputOne = inputEnvelope({ sequence: 2, inputSequence: 1, sentAt: 120 }); + const inputOneResult = authority.validateQuakeMultiplayerClientAuthority(inputOne, helloResult.state, { now: 120 }); + assert.equal(inputOneResult.ok, true); + + const replayedEnvelope = inputEnvelope({ sequence: 2, inputSequence: 2, sentAt: 140 }); + const replayedEnvelopeResult = authority.validateQuakeMultiplayerClientAuthority( + replayedEnvelope, + inputOneResult.state, + { now: 140 }, + ); + assert.equal(replayedEnvelopeResult.ok, false); + assert.equal(replayedEnvelopeResult.reject.code, "stale"); + + const replayedIntent = inputEnvelope({ sequence: 3, inputSequence: 1, sentAt: 150 }); + const replayedIntentResult = authority.validateQuakeMultiplayerClientAuthority( + replayedIntent, + inputOneResult.state, + { now: 150 }, + ); + assert.equal(replayedIntentResult.ok, false); + assert.equal(replayedIntentResult.reject.code, "stale"); + assert.match(replayedIntentResult.reject.message, /input sequence/); +}); + +test("room wrong-map rejects validate even when their room key differs", () => { + const reject = protocol.createQuakeMultiplayerEnvelope({ + direction: "room", + type: "room.reject", + roomKey: { + ...NORMALIZED_ROOM_KEY, + mapName: "e1m2", + sceneUrl: "/q/e1m2.json", + }, + sequence: 1, + sentAt: 100, + payload: { + code: "wrong-map", + message: "Room is running a different map.", + recoverable: false, + rejectedMessageId: "client-hello-1", + }, + }); + + const result = validation.validateQuakeMultiplayerRoomEnvelope(reject, { + roomKey: NORMALIZED_ROOM_KEY, + now: 100, + }); + assert.equal(result.ok, true); +}); + +test("loopback session emits hello snapshot, presence event, and suppresses paused input", async () => { + const harness = await createLoopbackHarness({ color: "#00ffaa" }); + const { messages, session, status } = harness; + try { + assert.equal(status.state, "connected"); + assert.equal(status.mode, "loopback"); + assert.equal(messages.length, 0); + + session.send(helloEnvelope({ + color: "#00ffaa", + messageId: "hello-1", + sequence: 1, + sentAt: harness.now(), + })); + + const helloSnapshot = latestMessage(messages, "room.snapshot"); + assert.equal(helloSnapshot.payload.players.length, 1); + assert.equal(helloSnapshot.payload.players[0].playerId, "loopback:client-a"); + assert.equal(helloSnapshot.payload.players[0].displayName, "Alice"); + assert.equal(helloSnapshot.payload.players[0].lastInputSequence, 0); + + harness.advanceNow(120); + session.send(presenceEnvelope("input-paused", { + messageId: "presence-1", + sequence: 2, + sentAt: harness.now(), + })); + + const presenceEvent = latestMessage(messages, "room.event"); + assert.equal(presenceEvent.payload.event.eventType, "player.presence"); + assert.equal(presenceEvent.payload.event.playerId, "loopback:client-a"); + assert.equal(presenceEvent.payload.event.status, "input-paused"); + + const pausedSnapshot = latestMessage(messages, "room.snapshot"); + assert.equal(pausedSnapshot.payload.players[0].lastInputSequence, 0); + const messageCountBeforePausedInput = messages.length; + + harness.advanceNow(20); + session.send(inputEnvelope({ sequence: 3, inputSequence: 1, sentAt: harness.now() })); + assert.equal(messages.length, messageCountBeforePausedInput); + + harness.advanceNow(120); + session.send(presenceEnvelope("active", { + messageId: "presence-2", + sequence: 4, + sentAt: harness.now(), + })); + + const activeEvent = latestMessage(messages, "room.event"); + assert.equal(activeEvent.payload.event.status, "active"); + } finally { + harness.disconnect(); + } +}); + +test("loopback session rejects paused mutation intents", async () => { + const harness = await createLoopbackHarness({ now: 2000 }); + const { messages, session } = harness; + try { + session.send(helloEnvelope({ messageId: "hello-paused", sequence: 1, sentAt: harness.now() })); + + harness.advanceNow(120); + session.send(presenceEnvelope("backgrounded", { + messageId: "presence-backgrounded", + sequence: 2, + sentAt: harness.now(), + })); + assert.equal(latestMessage(messages, "room.event").payload.event.status, "backgrounded"); + + const mutationCases = [ + { + messageId: "paused-fire", + envelope: () => fireEnvelope({ sequence: 3, fireSequence: 1, sentAt: harness.now() }), + advanceMs: 30, + }, + { + messageId: "paused-pickup", + envelope: () => pickupEnvelope({ sequence: 4, pickupSequence: 1, sentAt: harness.now() }), + advanceMs: 160, + }, + { + messageId: "paused-world", + envelope: () => worldEnvelope({ sequence: 5, worldSequence: 1, sentAt: harness.now() }), + advanceMs: 1, + }, + { + messageId: "paused-match", + envelope: () => matchEnvelope({ sequence: 6, matchSequence: 1, sentAt: harness.now() }), + advanceMs: 250, + }, + ]; + + const firstMutationMessageCount = messages.length; + for (const testCase of mutationCases) { + harness.advanceNow(testCase.advanceMs); + session.send(testCase.envelope()); + const reject = latestMessage(messages, "room.reject"); + assert.equal(reject.payload.rejectedMessageId, testCase.messageId); + assert.equal(reject.payload.code, "unsupported"); + assert.equal(reject.payload.recoverable, true); + assert.match(reject.payload.message, /input is paused/); + } + assert.equal(messages.filter((message) => message.type === "room.reject").length, mutationCases.length); + assert.equal( + messages.slice(firstMutationMessageCount).filter((message) => message.type === "room.event").length, + 0, + ); + } finally { + harness.disconnect(); + } +}); diff --git a/test/multiplayer/reconciliation.test.mjs b/test/multiplayer/reconciliation.test.mjs new file mode 100644 index 0000000..3396142 --- /dev/null +++ b/test/multiplayer/reconciliation.test.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + correctionOptions, + createPlayer, + reconciliation, +} from "./harness.mjs"; + +test("multiplayer correction ignores snapshots that should not move the local player", () => { + assertDecision( + reconciliation.decideQuakeMultiplayerLocalCorrection( + [0, 0, 0], + createPlayer({ lastInputSequence: 0, origin: [100, 0, 0] }), + 0, + correctionOptions(), + ), + { action: "none", reason: "no-authoritative-input", inputSequence: 0 }, + ); + assertDecision( + reconciliation.decideQuakeMultiplayerLocalCorrection( + [0, 0, 0], + createPlayer({ alive: false, lastInputSequence: 10, origin: [100, 0, 0] }), + 0, + correctionOptions(), + ), + { action: "none", reason: "not-alive", inputSequence: 10 }, + ); + assertDecision( + reconciliation.decideQuakeMultiplayerLocalCorrection( + [0, 0, 0], + createPlayer({ lastInputSequence: 10, origin: [100, 0, 0] }), + 10, + correctionOptions(), + ), + { action: "none", reason: "already-handled", inputSequence: 10 }, + ); + assertDecision( + reconciliation.decideQuakeMultiplayerLocalCorrection( + [0, 0, 0], + createPlayer({ lastInputSequence: 11, origin: [7.99, 0, 0] }), + 10, + correctionOptions(), + ), + { action: "none", reason: "within-threshold", inputSequence: 11 }, + ); +}); + +test("multiplayer correction blends medium drift and caps blend distance", () => { + const decision = reconciliation.decideQuakeMultiplayerLocalCorrection( + [0, 0, 0], + createPlayer({ lastInputSequence: 12, origin: [20, 0, 0] }), + 11, + correctionOptions({ blendFraction: 0.5, maxBlendDistance: 6 }), + ); + + assert.equal(decision.action, "blend"); + assert.equal(decision.reason, "drift"); + assert.equal(decision.inputSequence, 12); + assert.equal(decision.drift, 20); + assert.deepEqual(decision.authoritativeOrigin, [20, 0, 0]); + assert.deepEqual(decision.origin, [6, 0, 0]); +}); + +test("multiplayer correction snaps large drift to the authoritative origin", () => { + const decision = reconciliation.decideQuakeMultiplayerLocalCorrection( + [0, 0, 0], + createPlayer({ lastInputSequence: 13, origin: [32, 4, 0] }), + 12, + correctionOptions(), + ); + + assert.equal(decision.action, "snap"); + assert.equal(decision.reason, "drift"); + assert.equal(decision.inputSequence, 13); + assert.deepEqual(decision.origin, [32, 4, 0]); +}); + +test("multiplayer correction clamps an inverted soft threshold to the hard snap threshold", () => { + const decision = reconciliation.decideQuakeMultiplayerLocalCorrection( + [0, 0, 0], + createPlayer({ lastInputSequence: 14, origin: [6, 0, 0] }), + 13, + correctionOptions({ hardSnapDistance: 5, softCorrectionDistance: 10 }), + ); + + assert.equal(decision.action, "snap"); + assert.equal(decision.reason, "drift"); + assert.equal(decision.inputSequence, 14); + assert.deepEqual(decision.origin, [6, 0, 0]); +}); + +function assertDecision(actual, expected) { + assert.equal(actual.action, expected.action); + assert.equal(actual.reason, expected.reason); + assert.equal(actual.inputSequence, expected.inputSequence); +} diff --git a/test/runPerfPreflight.mjs b/test/perf/runPerfPreflight.mjs similarity index 96% rename from test/runPerfPreflight.mjs rename to test/perf/runPerfPreflight.mjs index 5250d95..6ec4b6f 100644 --- a/test/runPerfPreflight.mjs +++ b/test/perf/runPerfPreflight.mjs @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; -import { projectRoot } from "./checkAssetState.mjs"; +import { projectRoot } from "../assets/checkAssetState.mjs"; const packagePath = path.join(projectRoot, "package.json"); const harnessDocPath = path.join(projectRoot, "test/HARNESS.md"); diff --git a/test/runHarnessPlan.mjs b/test/runHarnessPlan.mjs index e46400c..90f0bec 100644 --- a/test/runHarnessPlan.mjs +++ b/test/runHarnessPlan.mjs @@ -2,8 +2,39 @@ import { spawnSync } from "node:child_process"; import path from "node:path"; -import { projectRoot } from "./checkAssetState.mjs"; - +import { projectRoot } from "./assets/checkAssetState.mjs"; + +const ALL_BROWSER_FAMILIES = ["combat", "map-logic", "monster", "projectile"]; +const HARNESS_RUNNER_PREFIXES = [ + "test/assets/run", + "test/browser/run", + "test/perf/run", +]; +const HARNESS_COMMAND_SURFACE_FILES = new Set([ + "package.json", + "test/HARNESS.md", + "test/runHarnessPlan.mjs", +]); +const SHARED_ASSET_GATE_FILES = new Set([ + "test/assets/checkAssetState.mjs", + "test/assets/preparedAssets.mjs", +]); +const SHARED_BROWSER_FIXTURE_FILES = new Set([ + "test/browser/browserFixtureDefinitions.mjs", + "test/browser/browserHarnessSupport.mjs", + "test/browser/fixtureHarness.mjs", + "test/browser/runBrowserFixtures.mjs", +]); +const BROWSER_SMOKE_FILES = new Set([ + "test/browser/browserHarnessSupport.mjs", + "test/browser/runBrowserSmoke.mjs", +]); +const BROWSER_FIXTURE_FAMILY_BY_FILE = new Map([ + ["test/browser/browserFixtureCombat.mjs", "combat"], + ["test/browser/browserFixtureMapLogic.mjs", "map-logic"], + ["test/browser/browserFixtureMonster.mjs", "monster"], + ["test/browser/browserFixtureProjectile.mjs", "projectile"], +]); const args = process.argv.slice(2); const files = selectedFiles(args); const plan = planHarnessCommands(files); @@ -91,68 +122,94 @@ function routeFile(file) { needsPerfPreflight: false, reason: "", }; + const sourceFile = file.startsWith("src/"); - if (file === "package.json" || file === "test/HARNESS.md" || file.startsWith("test/run")) { + if (BROWSER_SMOKE_FILES.has(file)) { + route.needsBrowserSmoke = true; + route.reason = file === "test/browser/runBrowserSmoke.mjs" + ? "browser smoke runner" + : "shared browser harness support"; + } + if (isHarnessCommandSurfaceFile(file)) { route.needsDev = true; route.needsPerfPreflight = true; - route.reason = "harness command surface"; + route.reason ||= "harness command surface"; } - if (file.startsWith("src/") || file === "package.json") { + if (sourceFile || file === "package.json") { route.needsDev = true; route.needsBuild = true; } - if (file.startsWith("src/App") || file.includes("/app/") || file.includes("/debug/")) { + if (sourceFile && (file.startsWith("src/App") || file.includes("/app/") || file.includes("/debug/"))) { route.needsBrowserSmoke = true; - route.browserFamilies.push("combat", "map-logic", "projectile"); + addBrowserFamilies(route, ["combat", "map-logic", "projectile"]); route.reason ||= "debug/app browser surface"; } - if (file.includes("/shootables") || file.includes("/weapons") || file.includes("/player")) { - route.browserFamilies.push("combat", "projectile"); + if (sourceFile && (file.includes("/shootables") || file.includes("/weapons") || file.includes("/player"))) { + addBrowserFamilies(route, ["combat", "projectile"]); route.reason ||= "combat/projectile runtime"; } - if (file.includes("/world") || file === "src/quake.css" || file.includes("/visibility")) { - route.browserFamilies.push("monster"); + if (sourceFile && (file.includes("/world") || file === "src/quake.css" || file.includes("/visibility"))) { + addBrowserFamilies(route, ["monster"]); route.reason ||= "world rendering or visibility"; } - if (file.includes("/triggers") || file.includes("/movers") || file.includes("/pickups") || file.includes("/liquid")) { - route.browserFamilies.push("map-logic"); + if (sourceFile && ( + file.includes("/triggers") || + file.includes("/movers") || + file.includes("/pickups") || + file.includes("/liquid") + )) { + addBrowserFamilies(route, ["map-logic"]); route.reason ||= "map gameplay logic"; } if (file.startsWith("src/prepare/")) { route.needsAssetIntegrity = true; route.needsBuild = true; - route.browserFamilies.push("monster"); + addBrowserFamilies(route, ["monster"]); route.reason ||= "prepared asset pipeline"; } - if (file === "test/browserFixtureDefinitions.mjs" || file === "test/runBrowserFixtures.mjs") { - route.browserFamilies.push("combat", "map-logic", "monster", "projectile"); - route.reason ||= "browser fixture router"; - } - if (file === "test/browserFixtureCombat.mjs") { - route.browserFamilies.push("combat"); - route.reason ||= "combat browser fixture family"; + if (SHARED_ASSET_GATE_FILES.has(file)) { + route.needsAssetIntegrity = true; + route.needsBrowserSmoke = true; + addBrowserFamilies(route, ALL_BROWSER_FAMILIES); + route.reason ||= "shared asset gate helper"; } - if (file === "test/browserFixtureMapLogic.mjs") { - route.browserFamilies.push("map-logic"); - route.reason ||= "map-logic browser fixture family"; + if (file === "test/assets/runAssetIntegrity.mjs") { + route.needsAssetIntegrity = true; + route.reason ||= "asset integrity runner"; } - if (file === "test/browserFixtureMonster.mjs") { - route.browserFamilies.push("monster"); - route.reason ||= "monster browser fixture family"; + if (SHARED_BROWSER_FIXTURE_FILES.has(file)) { + addBrowserFamilies(route, ALL_BROWSER_FAMILIES); + route.reason ||= "browser fixture router"; } - if (file === "test/browserFixtureProjectile.mjs") { - route.browserFamilies.push("projectile"); - route.reason ||= "projectile browser fixture family"; + const fixtureFamily = BROWSER_FIXTURE_FAMILY_BY_FILE.get(file); + if (fixtureFamily) { + addBrowserFamilies(route, [fixtureFamily]); + route.reason ||= `${fixtureFamily} browser fixture family`; } if (file.endsWith(".test.mjs")) { route.needsDev = true; - route.reason ||= "contract test"; + route.reason ||= contractTestReason(file); } route.browserFamilies = [...new Set(route.browserFamilies)]; return route; } +function addBrowserFamilies(route, families) { + route.browserFamilies.push(...families); +} + +function isHarnessCommandSurfaceFile(file) { + return HARNESS_COMMAND_SURFACE_FILES.has(file) || + HARNESS_RUNNER_PREFIXES.some((prefix) => file.startsWith(prefix)); +} + +function contractTestReason(file) { + if (file.startsWith("test/gameplay/")) return "gameplay contract test"; + if (file.startsWith("test/runtime/")) return "runtime contract test"; + return "contract test"; +} + function printPlan(plan) { console.log("Harness plan"); console.log("validates: changed-file routing to committed gates"); diff --git a/test/cameraFeedbackFlow.test.mjs b/test/runtime/cameraFeedbackFlow.test.mjs similarity index 98% rename from test/cameraFeedbackFlow.test.mjs rename to test/runtime/cameraFeedbackFlow.test.mjs index 3d4c7a9..9d6cf21 100644 --- a/test/cameraFeedbackFlow.test.mjs +++ b/test/runtime/cameraFeedbackFlow.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { importTsModule } from "./importTsModule.mjs"; +import { importTsModule } from "../importTsModule.mjs"; const { createQuakeCameraFeedbackFlow, diff --git a/test/impactParticleFlow.test.mjs b/test/runtime/impactParticleFlow.test.mjs similarity index 99% rename from test/impactParticleFlow.test.mjs rename to test/runtime/impactParticleFlow.test.mjs index 1de570e..0c9e7e6 100644 --- a/test/impactParticleFlow.test.mjs +++ b/test/runtime/impactParticleFlow.test.mjs @@ -3,7 +3,7 @@ import test from "node:test"; import { Window } from "happy-dom"; -import { importTsModule } from "./importTsModule.mjs"; +import { importTsModule } from "../importTsModule.mjs"; const { createQuakeImpactParticleFlow, diff --git a/test/mobileControls.test.mjs b/test/runtime/mobileControls.test.mjs similarity index 98% rename from test/mobileControls.test.mjs rename to test/runtime/mobileControls.test.mjs index 38c142f..69ef804 100644 --- a/test/mobileControls.test.mjs +++ b/test/runtime/mobileControls.test.mjs @@ -3,7 +3,7 @@ import test from "node:test"; import { Window } from "happy-dom"; -import { importTsModule } from "./importTsModule.mjs"; +import { importTsModule } from "../importTsModule.mjs"; const { QUAKE_MOBILE_CONTROLS_QUERY, @@ -243,6 +243,10 @@ function pointer(window, type, clientX, clientY, pointerId, buttons) { } function restoreGlobal(name, value) { + if (value === undefined) { + delete globalThis[name]; + return; + } Object.defineProperty(globalThis, name, { configurable: true, value, diff --git a/test/orientation.test.mjs b/test/runtime/orientation.test.mjs similarity index 98% rename from test/orientation.test.mjs rename to test/runtime/orientation.test.mjs index 58ead0b..7d895e9 100644 --- a/test/orientation.test.mjs +++ b/test/runtime/orientation.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { importTsModule } from "./importTsModule.mjs"; +import { importTsModule } from "../importTsModule.mjs"; const { requestQuakeLandscapeOnMobile, diff --git a/test/renderBundlePreloadUrls.test.mjs b/test/runtime/renderBundlePreloadUrls.test.mjs similarity index 96% rename from test/renderBundlePreloadUrls.test.mjs rename to test/runtime/renderBundlePreloadUrls.test.mjs index 183d3a7..d9d2c3c 100644 --- a/test/renderBundlePreloadUrls.test.mjs +++ b/test/runtime/renderBundlePreloadUrls.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { importTsModule } from "./importTsModule.mjs"; +import { importTsModule } from "../importTsModule.mjs"; const { quakeRenderBundlePreloadAssetUrls, diff --git a/test/shootablePrewarm.test.mjs b/test/runtime/shootablePrewarm.test.mjs similarity index 98% rename from test/shootablePrewarm.test.mjs rename to test/runtime/shootablePrewarm.test.mjs index 769e2b8..448c716 100644 --- a/test/shootablePrewarm.test.mjs +++ b/test/runtime/shootablePrewarm.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { importTsModule } from "./importTsModule.mjs"; +import { importTsModule } from "../importTsModule.mjs"; const { createQuakeShootablePrewarmQueues, From 05ea2b52db5b1a444ae9b432d27dba1be40b0b7c Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Mon, 15 Jun 2026 22:29:37 -0300 Subject: [PATCH 05/18] Block movement input while paused --- src/App.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.ts b/src/App.ts index 4a940a1..697dc1c 100644 --- a/src/App.ts +++ b/src/App.ts @@ -2729,7 +2729,7 @@ function isQuakeLevelTransitionActive(): boolean { } function canUseQuakeGameplayInput(): boolean { - return quakePlayerLifecycle.canUseGameplayInput(); + return !isQuakeGamePaused() && quakePlayerLifecycle.canUseGameplayInput(); } function shouldResumeQuakeMainMenuOnEscape(): boolean { From e1536dafc0ee084ac82890131538444918960f4d Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Mon, 15 Jun 2026 22:30:26 -0300 Subject: [PATCH 06/18] Remove unused startup image preloads --- index.html | 2 -- 1 file changed, 2 deletions(-) diff --git a/index.html b/index.html index 67fbe1a..a1f8b12 100644 --- a/index.html +++ b/index.html @@ -11,10 +11,8 @@ - -