Блог компании Яндекс
Спортивное программирование
Занимательные задачки
JavaScript
9 сентября

Телефон для коня и оркестр без пианиста. Как придумать спортивные задачи по фронтенду

Привет! Меня зовут Дмитрий Андриянов, я работаю разработчиком интерфейсов в Яндексе. В прошлом году я участвовал в подготовке нашего онлайн-соревнования по фронтенду.



Пару дней назад мне пришло письмо от организаторов с вопросом, не хочу ли я поучаствовать снова — придумать задачи по фронтенду для второго чемпионата по программированию. Я согласился — и подумал, что это интересная тема для статьи. Налейте кофе, усаживайтесь поудобнее. Я расскажу, как мы готовили задачи год назад.




Нас было около десяти человек, почти все — фронтенд-разработчики из разных сервисов Яндекса. Нам предстояло сделать подборку задач, которые проверялись бы автотестами.


Для соревнований по программированию есть специальный сервис — Яндекс.Контест. Там можно публиковать задания, а участники регистрируются и решают их. Проверка заданий происходит автоматически, результаты участников публикуются в специальной таблице. Таким образом, инфраструктура уже была готова. Требовалось только придумать задачи. Но оказалось, что есть один нюанс. Раньше Яндекс проводил соревнования по алгоритмам, машинному обучению и другим темам, а по фронтенду — ни разу. Ни у кого не было понимания, из чего должно состоять соревнование и как автоматизировать проверку.



Мы решили, что для фронтендеров подойдут задачи, в которых нужны верстка, JavaScript и знание API браузера. Верстку можно проверять сравнением скриншотов. Алгоритмические задачи можно запускать в Node.js и проверять, сравнивая результат с правильным ответом. Программы, работающие с API браузера, можно запускать через Puppeteer и скриптом проверять состояние страницы после выполнения.


Соревнования состоят из двух раундов — квалификации и финала, по 6 задач в каждом раунде. Квалификационные задачи должны быть вариативными, чтобы разным участникам достались разные варианты. Мы выбрали количество и тип задач для каждого раунда, поделились на команды по два человека и распределили задачи между командами. Каждой группе нужно было придумать две вариативные задачи для квалификации и две невариативные для финала.



Давайте кликать по DOM-элементам...


Появилась идея — в качестве одного из вариативных заданий дать браузерную игру, в которой нужно кликать по DOM-элементам. Задачей участника было написать программу, которая играет в эту игру и выигрывает. Придумали 4 варианта:



Если хотите, можете перейти по ссылкам и поиграть. Если будете играть в «телефон» или «пианино», не забудьте включить звук.


Написали общую часть для всех вариантов. В ней содержалась логика отображения кликабельных элементов, а также элементов с информацией о том, куда нужно кликнуть (нот, рукописных цифр, карточек с картинками и цветами). Наборы информационных и кликабельных элементов задаются через параметры.


// все элементы — это блоки div с определенными классами
// targetClasses — набор классов для информационных элементов
// keyClasses — набор классов для элементов, по которым нужно кликать
function initGame(targetClasses, keyClasses) {

    // рендерим информационные элементы
    for(let i = 0; i < targetClasses.length; i++) {
        document.body.insertAdjacentHTML('afterbegin', `<div class="${targetClasses[i]}" />`);
    }

    // рендерим кликабельные элементы
    for(let i = 0; i < keyClasses.length; i++) {
        document.body.insertAdjacentHTML('beforeend', 
            // data-index пригодится для обработки кликов
            `<div class="key ${keyClasses[i]}" data-index="${i}" />`);
    }

    // на самом деле код был немного другой, но суть та же
}

Внешним видом управляли через CSS. Получилось очень похоже на csszengarden.com — одна верстка с разными стилями выглядит по-разному.






Результат работы программы участника — это лог кликов по элементам. Добавили обработчик, который записывает информацию о кликнутых элементах в глобальную переменную. Чтобы участник вместо честных кликов не мог сразу записать результат в эту переменную, мы передаем её название снаружи.


function initGame(targetClasses, keyClasses, resultName) {
    // ...
    const log = [];
    document.body.addEventListener('click', (e) => {
        if (e.target.classList.contains('key')) {
            // если кликнули по кликабельному элементу, 
            // то записываем в лог его номер
            log.push(e.target.data.index);

            // если кликнули столько раз, сколько информационных элементов
            // на странице, то кладем лог в глобальную переменную
            if (log.length === targetClasses.length) {
                window[resultName] = log;
            }
        }
    });
}

Cкрипт запуска программы участника был примерно таким:


// В отличие от кода игры, который работает в браузере,
// этот скрипт запускается в Node.js.
// Он запускает Chrome в headless-режиме, открывает в нем 
// страницу с игрой и подключает скрипт участника.

const puppeteer = require('puppeteer');
const { writeFileSync } = require('fs');

const htmlFilePath = require.resolve('./game.html'); // файл с игрой
const solutionJsPath = resolve(process.argv[2]);     // программа участника

const data = require('input.json');                  // входные данные теста
const resName = `RESULT${Date.now()}`;               // случайное название глобальной переменной

(async () => {
    const browser = await puppeteer.launch();          // запускаем браузер
    const page = await browser.newPage();              // открываем новую вкладку

    await page.goto(`file://${htmlFilePath}`);         // открываем в ней файл с игрой

    await page.evaluate(resName => initGame(           // инициализируем игру
        data.target, data.keys, resName), resName);

    await page.addScriptTag({ path: solutionJsPath }); // подключаем решение участника
    await page.waitForFunction(`!!window[${resName}]`) // ждем, пока не появится переменная resName

    const result = await page.evaluate(`window[${resName}]`);   // получаем результат
    writeFileSync('output.json', JSON.stringify(result));       // и записываем его в выходной файл

    await browser.close();
})();

Добавим звук


Мы решили, что нужно немного оживить игру с телефоном и добавить звук нажатий клавиш. Такие звуки называются DTMF-тонами. Нашли статью о том, как их генерировать. Если кратко, необходимо одновременно проигрывать два звука с разной частотой. Звуки заданной частоты можно проигрывать с помощью Web Audio API. Получился примерно такой код:


function playSound(num) {

    // создаем audioContext
    const context = this.audioContext;
    const g = context.createGain()

    // настраиваем первый генератор звука
    const o = context.createOscillator();
    o.connect(g);
    o.type='sine';
    o.frequency.value = [697, 697, 697, 770, 770, 770, 852, 852, 852, 941, 941][num];
    g.connect(context.destination);

    // настраиваем второй генератор звука
    const o2 = context.createOscillator();
    o2.connect(g);
    o2.type='sine';
    o2.frequency.value = [1209, 1336, 1477, 1209, 1336, 1477, 1209, 1336, 1477, 1209, 1336][num];
    g.connect(context.destination);

    // магические числа — это значения частоты из таблицы
    // см. статью по ссылке

    // включаем звук
    o.start(0);
    o2.start(0);

    // выключаем через 240 мс
    g.gain.value = 1;
    setTimeout(() => g.gain.value = 0, 240);
}

Для игры с пианино тоже добавили звуки. Если бы кто-нибудь из участников попробовал сыграть написанные на странице ноты, он услышал бы имперский марш из Star Wars.



Усложним задание


Мы радовались тому, какое классное задание со звуками у нас получилось, но радость длилась недолго. Во время тестирования игры оказалось, что программа очень быстро кликает по кнопкам и все наши классные звуки сливаются в общую кашу. Мы решили добавить задержку 50 мс между нажатиями клавиш, чтобы звуки проигрывались по очереди. Заодно это немного усложнило задание.


function initGame(targetClasses, keyClasses, resultName) {
    // в этой переменной будем запоминать 
    // время последнего клика
    let lastClick = 0;
    // ...
    document.body.addEventListener('click', (e) => {
        const now = Date.now();

        // если с момента последнего клика 
        // еще не прошло 50 мс, то игнорируем клик
        if (lastClick + 50 < now) {
            // ...

            // запоминаем новое время клика
            lastClick = now;
        }
    });
}

Но и это не всё. Мы подумали, что участники могут легко посмотреть исходный код и сразу увидеть задержку. Чтобы усложнить им задачу, мы минифицировали весь JS-код на странице с помощью UglifyJS. Но эта библиотека не меняет публичный API классов. Поэтому те части, которые UglifyJS оставил прежними (а именно названия методов и полей классов), мы заменили через replace.


Скрипт для обфускации игры выглядел примерно так:


const minified = uglifyjs.minify(lines.join('\n'));

const replaced = minified.code
    .replaceAll('this.window', 'this.шахматы')
    .replaceAll('this.document', 'this.цапля')
    .replaceAll('this.log', 'this.мыш')
    .replaceAll('this.lastClick', 'this.табло')
    .replaceAll('this.target', 'this.библиотека')
    .replaceAll('this.resName', 'this.глинтвейн')
    .replaceAll('this.audioContext', 'this.зоопарк')
    .replaceAll('this.keyCount', 'this.лось')
    .replaceAll('this.classMap', 'this.конь')
    .replaceAll('_createDiv', 'подоить_козу')
    .replaceAll('_renderTarget', 'поесть_сена')
    .replaceAll('_renderKeys', 'помыть_слона')
    .replaceAll('_updateLog', 'погладить_кота')
    .replaceAll('_generateAnswer', 'бобры')
    .replaceAll('_createKeyElement', 'гироскутер')
    .replaceAll('_getMessage', 'выхухоль')
    .replaceAll('_next', 'сделать_вступительные_задания_школы_разработки_интерфейсов')
    .replaceAll('_pos', 'мычать_как_корова')
    .replaceAll('PhoneGame', 'кавалерия')
    .replaceAll('MusicGame', 'паяльник')
    .replaceAll('BaseGame', 'xyz');

Напишем креативное условие


Техническую часть игры мы подготовили, но нужен был креативный текст условия — не только с требованиями, которые нужно выполнить, а с какой-то историей.


Мой любимый вид юмора — абсурд. Это когда с серьезным видом говоришь какую-то нелепую чушь. Чушь обычно звучит неожиданно и вызывает смех. Я хотел сделать условия задач абсурдными, чтобы порадовать участников. Так появилась история про коня Адольфа, который не может позвонить другу, потому что не попадает своими большими копытами по клавишам телефона.



Потом появилась история про девочку, которая занимается на пианино и хочет это автоматизировать, чтобы вместо занятий пойти гулять. Там была фраза «Если девочка перестает играть, из комнаты приходит мама и дает подзатыльник». Нам сказали, что это пропаганда детского насилия и нужно написать другой текст. Тогда мы придумали историю про оркестр, в котором перед концертом заболел пианист, а один из музыкантов пишет программу на JS, которая исполнит его партию.


В целом нам удалось добиться нужного эффекта от текстов. Если хотите, можете прочитать их по ссылке.


Настройка задач в Контесте


Итак, у нас были готовы условия задач, скрипты для проверки решений и эталонные решения. Дальше нужно было настроить это всё в Контесте. Для любой задачи есть несколько тестов, в каждом из которых содержится набор входных данных и правильный ответ. На схеме ниже показаны этапы работы Контеста. Первый этап — выполнение программы, второй — проверка результата:



На вход первого этапа поступает набор тестовых данных и программа участника. Внутри работает скрипт run.js, код которого мы написали выше. Он отвечает за то, чтобы запустить программу участника, получить и записать в файл результат её работы. Выполнение программы происходит в отдельной виртуальной машине, которая поднимается из Docker-образа перед запуском. Эта виртуальная машина ограничена в ресурсах, у неё нет доступа в сеть.


Второй этап (проверка результата) выполняется в другой виртуальной машине. Таким образом, у программы участника физически нет доступа в окружение, где происходит проверка. На вход второго этапа подается результат работы программы участника (полученный на первом этапе) и файл с правильным ответом. На выходе — exit code проверяющего скрипта, по которому Контест понимает, чем закончилась проверка:


OK = 0,
PE (presentation error — неправильный формат результата) = 4
WA (wrong answer) = 5
CF (ошибка при проверке) = 6


Контест был плохо приспособлен к задачам по фронтенду, в том числе нельзя было использовать Node.js. Мы решили проблему, упаковав скрипты проверки в бинарный файл при помощи pkg вместе с Node.js и node_modules. Теперь мы обладаем тайными знаниями о Контесте и при подготовке нынешнего чемпионата испытываем гораздо меньше сложностей.




Итак, мы подготовили задачи. После этого было ещё много всего: тестирование на людях, чтобы откалибровать сложность, публикация задач, дежурство в техподдержке во время соревнования и награждение победителей в офисе Яндекса. Но это совсем другие истории.


Сейчас вместо соревнований по отдельным направлениям мы проводим единые чемпионаты по программированию, где просто есть параллельные треки, включая фронтенд.


Я ни капельки не жалею о времени, потраченном на подготовку задач. Было интересно и весело, нешаблонно. В одном из комментариев на Хабре написали, что условия были придуманы энтузиастами своего дела. Во время соревнования классно было осознавать, что участники решают задачи, которые придумывал ты.


Ссылки:
Разбор прошлогоднего задания по фронтенду, которое мы подготовили
Разбор трека по фронтенду в первом чемпионате этого года


+23
5,7k 38
Комментарии 9