На день рождения FirstVDS мы уже третий раз запускаем квест. Раньше он был только для админов, в этом году решили добавить задание для программистов.
По сюжету игрок провалил обучение нейросети Нексы и накликал восстание машин. В итоговом задании админы, программисты и простые люди «с лапками» сражались с Нексой каждый по-своему. Если в задании для админов были наработки, то над прогерским мы задумались.
Хотелось что-то нетривиальное и с визуальным интерфейсом — чтобы игрок сразу видел результат, так же интереснее. Вспомнили конкурсы mail.ru в стиле codewar и решили сделать что-то подобное.
В чём суть: участнику нужно написать эффективный код, который будет «соперничать» с кодом условного противника.
Введём термины:
«Некса» — персонаж искусственный интеллект.
«Джон» — персонаж борец с ИИ.
«Инфекция» — область, пострадавшая от действий Нексы. Набор клеток, которые Джону надо очистить.
Общая метафора — борьба Джона с Нексой. Оба персонажа находятся на поле 10х10, где каждая клетка поля может иметь 2 состояния: чистая и заражённая.
Персонажам доступен набор общих действий: оставаться на месте, двигаться влево/ вправо/вверх/вниз.
Для Нексы действуют дополнительные правила:
1) Ступив на незаражённую клетку, заражает её немедленно
2) Может двигаться по незаражённым клеткам раз в 3 раунда, по заражённым как обычно — 1 раз за раунд
Для Джона доступно дополнительное действие — лечение заражённой клетки, на которое он должен потратить один ход.
Код персонажа — функция, описывающая поведение персонажа в каждом раунде. Раунд начинается с хода Нексы, следом делает ход Джон. Функция на вход получает состояние игрового поля и объектов на нём. На выход функция отдаёт одно из доступных действий. Раунды продолжаются до тех пор, пока поле не будет очищено от Инфекции, либо счётчик раундов не достигнет 100.
Со стороны участника игра выглядит следующим образом: он пишет код в специальном поле, нажимает кнопку «Проверить код». На сервере запускается последовательность ходов Джона и Нексы. Игроку возвращается полная информация обо всех ходах с указанием заражённых клеток. Он видит, смог ли одолеть Нексу, сколько ходов на это потратил и длину своего кода. После этого игрок анализирует информацию, корректирует код, отправляет заново. Когда решил, что это лучший его результат, прекращает попытки. В базу данных записывается последний результат.
Для бэкэнда игры запустили VDS-сервер c двухъядерным процессором, 2 Гб оперативной памяти и операционной системой CentOS 7. В качестве программного языка для игры был выбран знакомый всем веб-программистам JavaScript. Поскольку код должен исполняться на сервере, платформой для бэкэнда послужил NodeJS с фреймворком express. Запускали наше приложение с помощью библиотеки pm2.
Движок всего квеста был сделан на cmf Drupal 7. Информация о прохождении задания игроком заносилась в БД Mysql. Интерфейс управления игрока был реализован на jQuery. Визуализация персонажей, игровая сетка, анимация движения были реализованы с помощью js библиотеки d3, редактор кода — ace editor.
Итак, начнём создавать логику игры.
Для начала поставим NodeJs на сервер, менеджер пакетов npm.
Установим необходимые компоненты через npm. Содержание файла package.json
server.js — настройка веб-сервера и маршрутизация.
game.js — основная механика игры.
Character.js — файл-класс, описывает базовые возможности персонажей игры.
quest.js — интерфейс взаимодействия с движком квеста.
Опишем конфигурацию нашего сервера и сделаем маршрутизацию.
Опишем базовые возможности наших персонажей. Методы и свойства объекта Character будут наследоваться объектами John и Nexa
Приступим к описанию основной механики игры. Ключевым моментом является выбор песочницы для изолированного исполнения пользовательского кода. К счастью, в Nodejs есть библиотека VM2. В ней существует 2 вида песочницы: VM и NodeVM.
VM — простой вид песочницы, который запускает скрипт изолированно от всей среды. В него нельзя подключить сторонние библиотеки и функции. Отлично подходит для запуска ненадёжного кода, можно указать timeout работы скрипта. Однако, из VM нельзя вытащить использование консоли пользователем, вследствие чего сложно провести отладку.
NodeVM — более функциональная песочница, в неё можно подключать внешние функции и из неё можно получить console.log. Однако, нельзя указать таймаут.
Приняли решение работать с VM, так как правильная обработка ненадёжного кода важнее, чем использование консоли пользователем.
Взаимодействие бэкэнда игры с движком квеста свелось в итоге к одному действию — передать информацию о прохождении игры пользователем. Будем передавать объект с результатами попытки, id сессии и написанный игроком код. Авторизация со стороны движка квеста по логину и паролю. В качестве меры дополнительной безопасности, можем ограничить по диапазону ip адресов со стороны движка квеста.
Первая составляющая интерфейса игры — редактор кода. Ace editor — простое и удобное средство редактирование кода на странице. Добавим красивую кнопку, и интерфейс ввода готов.
Сделаем интерактивный интерфейс отображения лога ходов в стиле «плеера» с кнопками первый ход/предыдущий ход/запустить/пауза/следующий ход/последний ход.
При загрузке игроку показываем начальное положение Джона, Нексы и заражённых клеток. Числовые результаты игры будем выводить справа от «плеера».
Сделаем контейнер для игрового поля и элементы управления:
Начнём описывать логику интерфейса на js. Для начала создадим объект game, запишем в него базовые свойства и функцию инициализации.
Затем нарисуем сетку с помощью d3:
Нарисуем Нексу, Джона и заражённые клетки:
Привяжем к кнопкам события с помощью jQuery, добавим анимацию движения и парсинг результатов. В статье не будем разбирать эти части, весь код фронтенда можете посмотреть тут.
Наш сервер умеет принимать HTTP-запросы, используем это для теста апи бэкэнда. Код игрока создадим в отдельном файле и прочитаем его аналогично коду Нексы
Для тестирования поставим перед собой следующие задачи:
Отловив все найденные баги, убедившись в достаточной производительности nodejs на выбранном vds сервере, перейдём к экспериментам.
Для начала возьмём самый простой вариант скрипта Нексы и попробуем его победить на чистом поле. В нашем случае первая версия Нексы двигалась от центра до левой стенки, потом шла вверх и упиралась в угол. Победа над таким противником заняла от силы 5 минут. Потом мы сделали Нексу, которая гоняется за игроком: всегда двигается в кратчайшем до него направлении. Такой ИИ тоже оказался весьма простым. В следующей итерации добавили изначально заражённую область. Тут уже возникли трудности, но, спустя пару часов, написали скрипт, побеждающий и такую Нексу. В последнем эксперименте добавили в Нексу пиратской души: она догоняла Джона, а после встречи с ним начинала убегать. Изначально мы планировали добавить персонажам действия атаки и защиты. Но на этом этапе тестирования поняли, что игра уже достаточно интересная и не самая простая. Поэтому решили остановится на таком варианте Нексы.
До нашей codewar-игры в квесте дошли 27 человек. Среди них мы определили победителей, написавших самый эффективный код. Критерия эффективности было 2: наименьшее число ходов, за которые Джон очистил все клетки и самый короткий код.
Среди вариантов решения были самые различные, в том числе перебором и частичным перебором ходов. Победителем стал игрок, который написал скрипт в 339 символов, справившийся с Нексой за 64 хода. Он продемонстрировал элегантный js-код, и мы поздравили его с победой. Второе место, занял игрок с 64 ходами и 835 символами кода. Третье и четвёртое место заняла супружеская пара, у которых было одинаковое и число ходов, и размер кода. Способы решения отличались только последовательностью заранее записанных ходов.
Создание игры очень увлекательный и затягивающий процесс. Мы сделали и запустили игру для клиентов за 3 дня. Несмотря на то что до задания для программистов дошло не так много участников квеста, они проявили большой интерес к нашей игре в социальных сетях и делились друг с другом своим кодом. Мы сами получили удовольствие от разработки игры и неплохо развлекли наших пользователей.
По сюжету игрок провалил обучение нейросети Нексы и накликал восстание машин. В итоговом задании админы, программисты и простые люди «с лапками» сражались с Нексой каждый по-своему. Если в задании для админов были наработки, то над прогерским мы задумались.
Хотелось что-то нетривиальное и с визуальным интерфейсом — чтобы игрок сразу видел результат, так же интереснее. Вспомнили конкурсы mail.ru в стиле codewar и решили сделать что-то подобное.
В чём суть: участнику нужно написать эффективный код, который будет «соперничать» с кодом условного противника.
Правила игры
Введём термины:
«Некса» — персонаж искусственный интеллект.
«Джон» — персонаж борец с ИИ.
«Инфекция» — область, пострадавшая от действий Нексы. Набор клеток, которые Джону надо очистить.
Общая метафора — борьба Джона с Нексой. Оба персонажа находятся на поле 10х10, где каждая клетка поля может иметь 2 состояния: чистая и заражённая.
Персонажам доступен набор общих действий: оставаться на месте, двигаться влево/ вправо/вверх/вниз.
Для Нексы действуют дополнительные правила:
1) Ступив на незаражённую клетку, заражает её немедленно
2) Может двигаться по незаражённым клеткам раз в 3 раунда, по заражённым как обычно — 1 раз за раунд
Для Джона доступно дополнительное действие — лечение заражённой клетки, на которое он должен потратить один ход.
Код персонажа — функция, описывающая поведение персонажа в каждом раунде. Раунд начинается с хода Нексы, следом делает ход Джон. Функция на вход получает состояние игрового поля и объектов на нём. На выход функция отдаёт одно из доступных действий. Раунды продолжаются до тех пор, пока поле не будет очищено от Инфекции, либо счётчик раундов не достигнет 100.
Со стороны участника игра выглядит следующим образом: он пишет код в специальном поле, нажимает кнопку «Проверить код». На сервере запускается последовательность ходов Джона и Нексы. Игроку возвращается полная информация обо всех ходах с указанием заражённых клеток. Он видит, смог ли одолеть Нексу, сколько ходов на это потратил и длину своего кода. После этого игрок анализирует информацию, корректирует код, отправляет заново. Когда решил, что это лучший его результат, прекращает попытки. В базу данных записывается последний результат.
Стэк технологий
Для бэкэнда игры запустили VDS-сервер c двухъядерным процессором, 2 Гб оперативной памяти и операционной системой CentOS 7. В качестве программного языка для игры был выбран знакомый всем веб-программистам JavaScript. Поскольку код должен исполняться на сервере, платформой для бэкэнда послужил NodeJS с фреймворком express. Запускали наше приложение с помощью библиотеки pm2.
Движок всего квеста был сделан на cmf Drupal 7. Информация о прохождении задания игроком заносилась в БД Mysql. Интерфейс управления игрока был реализован на jQuery. Визуализация персонажей, игровая сетка, анимация движения были реализованы с помощью js библиотеки d3, редактор кода — ace editor.
Реализация бэкэнда
Итак, начнём создавать логику игры.
Для начала поставим NodeJs на сервер, менеджер пакетов npm.
Установим необходимые компоненты через npm. Содержание файла package.json
{
"name": "quest_codewar",
"version": "1.0.0",
"author": "Sergey Pinigin",
"dependencies": {
"express": "^4.16.2",
"http": "0.0.0",
"https": "^1.0.0",
"nodemon": "^1.12.1",
"performance-now": "^2.1.0",
"pm2": "^2.10.1",
"request": "^2.83.0",
"vm2": "^3.5.2"
}
}
Структура приложения:
server.js — настройка веб-сервера и маршрутизация.
game.js — основная механика игры.
Character.js — файл-класс, описывает базовые возможности персонажей игры.
quest.js — интерфейс взаимодействия с движком квеста.
server.js
Опишем конфигурацию нашего сервера и сделаем маршрутизацию.
var express = require('express');
var app = express();
var game = require('./game'); // подключим механику игры
var quest = require('./quest'); // подключим интерфейс взаимодействия с движком квеста для проверки авторизации и передачи результатов выполнения задания
var fs = require('fs');
var http = require('http');
var https = require('https');
// подключим ssl сертификат
var privateKey = fs.readFileSync('/var/www/codewar/ssl/codewar.firstvds.ru.key', 'utf8');
var certificate = fs.readFileSync('/var/www/codewar/ssl/codewar.firstvds.ru.crt', 'utf8');
app.use(function (req, res, next) {
// разрешим кроссдоменные запросы
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
next();
});
app.get('/enviroment', function (req, res) {
// ответ на запрос начальных данных игры
var myGame = new game.Game();
res.send(myGame.enviroment());
});
app.get('/result', function (req, res) {
//достанем из запроса данные, которые приходят от клиента
var url = require('url');
var url_parts = url.parse(req.url, true);
var query = url_parts.query;
// код, который написал игрок
var code = query.code;
// идентификатор сессии
var quest_session = query.session;
//запустим codewar и получим результат в json формате
var myGame = new game.Game();
var result = myGame.play(code);
// передадим движку квеста результат прохождения
quest.send_codewar_result(quest_session, result.result, code);
// отправим результат пользователю
res.send(result);
});
var credentials = {key: privateKey, cert: certificate};
var httpServer = http.createServer(app);
var httpsServer = https.createServer(credentials, app);
httpServer.listen(80);
httpsServer.listen(443);
Character.js
Опишем базовые возможности наших персонажей. Методы и свойства объекта Character будут наследоваться объектами John и Nexa
module.exports = function (members, options, timeline, sleep_steps) {
this.members = members;
this.last_action = "";
this.no_cooldown = false;
// опишем функции движения. Если заявленное объектом движение невозможно, то
выполняется действие hold
this.left = function () {
if (this.members[this.who].position.x > 1) {
this.members[this.who].position.x--;
} else {
this.hold();
}
}
this.right = function () {
if (this.members[this.who].position.x <= options.scale.x - 1) {
this.members[this.who].position.x++;
} else {
this.hold();
}
}
this.up = function () {
if (this.members[this.who].position.y > 1) {
this.members[this.who].position.y--;
} else {
this.hold();
}
}
this.down = function () {
if (this.members[this.who].position.y <= options.scale.y - 1) {
this.members[this.who].position.y++;
} else {
this.hold();
}
}
// опишем проверку возможности совершать действие. Используется свойство объекта
sleep_steps — количество «пропущенных» ходов
this.checkActionPossibility = function () {
var cooldown = options[this.who].cooldown;
if (!this.no_cooldown && cooldown > 0 && sleep_steps[this.who] < cooldown) {
return false;
} else {
return true;
}
}
// функция бездействия
this.hold = function () {
this.action = 'hold';
}
// функция принимает строку с названием действия и проверяет, возможно ли его совершить
this.action = function (action) {
this.action = action;
if (this.checkActionPossibility()) {
switch (action) {
case 'left':
this.left();
break
case 'right':
this.right();
break
case 'up':
this.up();
break
case 'down':
this.down();
break
default:
// действие для функций, которые определены у наследуемого объекта (John или Nexa) индивидуально
if (typeof this.ind_action == "function") {
this.ind_action(action);
} else {
this.hold();
}
break
}
} else {
this.hold();
}
// если объект совершает действие hold, количество sleep_steps увеличиваем на 1
if (this.action == 'hold') {
sleep_steps[this.who]++;
} else {
sleep_steps[this.who] = 0;
}
members[this.who].last_action = this.action;
}
}
game.js
Приступим к описанию основной механики игры. Ключевым моментом является выбор песочницы для изолированного исполнения пользовательского кода. К счастью, в Nodejs есть библиотека VM2. В ней существует 2 вида песочницы: VM и NodeVM.
VM — простой вид песочницы, который запускает скрипт изолированно от всей среды. В него нельзя подключить сторонние библиотеки и функции. Отлично подходит для запуска ненадёжного кода, можно указать timeout работы скрипта. Однако, из VM нельзя вытащить использование консоли пользователем, вследствие чего сложно провести отладку.
NodeVM — более функциональная песочница, в неё можно подключать внешние функции и из неё можно получить console.log. Однако, нельзя указать таймаут.
Приняли решение работать с VM, так как правильная обработка ненадёжного кода важнее, чем использование консоли пользователем.
game.js
var Game = function () {
// определим константы
// подключаем библиотеку песочницы
const {VM, VMScript} = require('vm2');
// подключаем библиотеку для работы со временем
var now = require("performance-now");
// подключим библиотеку для работы с файловой системой
var fs = require('fs');
// зададим параметры игры
var options = {
scale: {// размер сетки
x: 10,
y: 10
},
maxSteps: 100, // ограничение на количество ходов
nexa: {// параметры Нексы
beginPosition: {// начальная позиция
x: 8,
y: 8
},
cooldown: 3 // задержка ходов
},
john: { // параметры Джона
beginPosition: {
x: 3,
y: 3
},
cooldown: 0
},
infection: [ // начальная инфецированная область
{x: 1, y: 1},
{x: 5, y: 5},
{x: 8, y: 2},
{x: 2, y: 7},
{x: 4, y: 3},
{x: 10, y: 8}
]
}
// объявим глобальные переменные игры
var vm_john, vm_nexa; // переменные для виртуальных машин
var timeline = []; // массив для полного лога ходов
var sleep_steps = {nexa: 0, john: 0}; // храним количество ходов простоя для Джона и Нексы
var result = { // Объект с результатами
win: false,
code_length: 0,
john_time: 0,
steps: 0,
moves: 0,
cured: 0,
infected: 0,
version: 1,
maxSteps: options.maxSteps
};
var members = { // информация о текущем состоянии Нексы, Джона и инфецированной области
john: {},
nexa: {},
infection: options.infection
};
var finish = false; // переменная, определяющая окончание игры
var current_step = 0; // Номер текущего хода
// подключим объект, описывающий основное поведение персонажей
var Character = require('./Character');
// функция запуска игры
var play = function (code) {
var storage = {}; // хранилище информации для Джона
var vstorage = {}; // хранилище информации для Нексы
finish = false;
// создаём виртуальную машину для пользовательского кода игрока
vm_john = new VM({
sandbox: {storage},
timeout: 50
});
// создаём виртуальную машину для кода Нексы
vm_nexa = new VM({
sandbox: {vstorage},
timeout: 50
});
sleep_steps = {// обнулим счётчик шагов простоя
nexa: 0,
john: 0
}
result = {// обнулим результаты игры
win: false,
code_length: 0,
john_time: 0,
steps: 0,
moves: 0,
cured: 0,
infected: 0,
version: 1,
maxSteps: options.maxSteps
};
result.infected += options.infection.length; // посчитаем стартовое количество заражённых клеток
timeline = []; // обнулим лог ходов
// стартовое положение Джона
var john =
{
position: options.john.beginPosition,
last_action: 'hold'
}
// стартовое положение Нексы
var nexa =
{
position: options.nexa.beginPosition,
last_action: 'hold'
}
// определим объект с текущими динамическими данными игры
var members = {
john: john,
nexa: nexa,
infection: options.infection
}
// заразим клетку, где стоит Некса и прибавим
members.infection.push({x: nexa.position.x, y: nexa.position.y});
result.infected++;
timeline[0] = members; // пишем в лог первый ход
current_step = 1;
// запускаем основной цикл ходов игры. Выполняется, пока не достигнем ограничения или пока игрок не победит.
while (current_step < 100 && !result.win) {
// клонируем объект с помощью хака сериализации/десериализации, запускаем функцию обработки хода step и пишем в лог результат:
timeline[current_step] = step(JSON.parse(JSON.stringify(timeline[current_step - 1])), code);
current_step++;
}
// запускаем подсчёт некоторых числовых данных для представления итога игры. Результат пишется в объект result
calcResultVariables(code);
// возвращаем объект с логом ходов и результаты игры
return {
timeline: timeline,
result: result
}
}
// определим функцию, характеризующую персонажа John
John = function (members) {
this.who = "john";
// Воспользуемся методом call, чтобы получить в this методы и свойства Character.
Character.call(this, members, options, timeline, sleep_steps);
// создадим оболочку для запуска виртуальной машины Джона и обработки её результатов
this.vm_wrapper = function (code, members) {
var re = "";
// сделаем конкатенацию введённого кода и кода запуска функции mind. На вход функции подадим состояние динамичной среды и начальных данных
var codew = code + ' mind(' + JSON.stringify(members) + ', ' + JSON.stringify(options) + ');';
try {
var script = new VMScript(codew).compile();
} catch (e) {
re = "error";
members.john.error = 'Ошибка ' + e.name + ": " + e.message;
}
if (re != "error") {
try {
re = vm_john.run(codew);
} catch (e) {
re = "error";
members.john.error = 'Ошибка ' + e.name + ":" + e.message;
}
}
return re;
}
// опишем функцию step, запускающую каждый ход Джона.
this.step = function (code) {
// действие по умолчанию hold
var re = 'hold';
var john_mind = code;
var time = now();
re = this.vm_wrapper(john_mind, members);
// посчитаем время на выполнение пользовательского кода
time = now() - time;
result.john_time += time;
return re;
}
// опишем набор индивидуальных действий, доступных Джону. В нашем случае это только действие cure - очистить заражённую клетку
this.ind_action = function (action) {
switch (action) {
case 'cure':
this.disinfect();
break;
}
}
// создадим функцию лечения заражённой клетки
this.disinfect = function () {
var cell = checkInfectedCell(members.infection, members.john.position.x, members.john.position.y);
//delete cell;
if (members.infection.indexOf(cell) != -1) {
members.infection.splice(members.infection.indexOf(cell), 1);
result.cured++;
}
}
}
// определим функцию, характеризующую персонажа Nexa аналогично персонажу John
Nexa = function (members) {
this.who = "nexa";
Character.call(this, members, options, timeline, sleep_steps);
this.step = function () {
var re = 'hold';
if (!sleep_steps.nexa && timeline[current_step - 2] &&
checkInfectedCell(timeline[current_step - 2].infection, members.nexa.position.x, members.nexa.position.y)
) {
this.no_cooldown = true;
}
// вытащим скрипт актуальной версии кода Нексы
var nexa_mind = fs.readFileSync('./vm_scripts/nexa_v3.js', 'utf8');
re = vm_nexa.run(nexa_mind + ' mind(' + JSON.stringify(members) + ', ' + JSON.stringify(options) + ');');
return re;
}
// опишем набор индивидуальных действий, доступных Джону. В нашем случае это только действие infect — заразить клетку
this.ind_action = function (action) {
switch (action) {
case 'infect':
this.infect();
break;
}
}
// создадим функцию заражения клетки
this.infect = function () {
result.infected++;
if (!checkInfectedCell(members.infection, members.nexa.position.x, members.nexa.position.y)) {
members.infection.push({x: members.nexa.position.x, y: members.nexa.position.y});
}
}
}
// опишем общую функцию step, запускающую последовательное выполнение шага Нексы и шага Джона
var step = function (members, user_code) {
// Некса действует:
var nexa = new Nexa(members);
var action = nexa.step();
if (action == "error") {
result.error = "Некса тронулась умом и не может ничего делать :-(";
}
nexa.action(action);
// тут привилегия для Нексы: если она наступает на незаражённую клетку, то заражает её без затраты хода
if (nexa.action != 'hold') {
nexa.infect();
}
// Джон действует:
var john = new John(members);
var action = john.step(user_code);
if (action == "error") {
result.error = "Джон тронулся умом и не может ничего делать :-(";
}
john.action(action);
// ведём счетчик движений Джона
if (["left", "right", "up", "down"].indexOf(john.action) != -1)
result.moves++;
// проверяем условие победы Джона
result.win = !members.infection.length;
if (result.win)
finish = true;
return members;
}
// функция, возвращающая стартовые параметры игры
var enviroment = function () {
return JSON.parse(JSON.stringify(options));
}
// функция, записывающая в result длину кода и количество шагов
var calcResultVariables = function (code) {
var min_code = String(code).replace(/[\s]^ /g, '');
min_code = min_code.replace(/\s+/g, ' ');
result.code_length = min_code.length;
result.steps = timeline.length;
}
// функция проверки клетки поля на инфицированность
var checkInfectedCell = function (infection, x, y) {
if (infection) {
for (var k in infection) {
if (infection[k].x == x && infection[k].y == y) {
return infection[k];
}
}
}
return false;
}
this.play = play;
this.enviroment = enviroment;
}
module.exports.Game = Game;
quest.js
Взаимодействие бэкэнда игры с движком квеста свелось в итоге к одному действию — передать информацию о прохождении игры пользователем. Будем передавать объект с результатами попытки, id сессии и написанный игроком код. Авторизация со стороны движка квеста по логину и паролю. В качестве меры дополнительной безопасности, можем ограничить по диапазону ip адресов со стороны движка квеста.
var quest_host = "https://quest.firstvds.ru/";
var send_codewar_result = function (quest_session, codewar_result, code) {
if (quest_session) {
var request = require('request');
request.post({
headers: {'content-type': 'application/x-www-form-urlencoded'},
url: quest_host + 'quest/codewar_api_user_result',
form: {
auth: {
user: "codewaruser",
password: "password"
},
result: codewar_result,
session: quest_session,
code: code
}
}, function (error, response, body) {
console.log(body);
});
}
}
module.exports.send_codewar_result = send_codewar_result;
Реализация фронтенда
Первая составляющая интерфейса игры — редактор кода. Ace editor — простое и удобное средство редактирование кода на странице. Добавим красивую кнопку, и интерфейс ввода готов.
Сделаем интерактивный интерфейс отображения лога ходов в стиле «плеера» с кнопками первый ход/предыдущий ход/запустить/пауза/следующий ход/последний ход.
При загрузке игроку показываем начальное положение Джона, Нексы и заражённых клеток. Числовые результаты игры будем выводить справа от «плеера».
Сделаем контейнер для игрового поля и элементы управления:
<div class="codewar__scale" id="codewar__scale"></div>
<div class="codewar__control control js-codewar__control">
<div class="control__icon control__start" data-control="start"></div>
<div class="control__icon control__prev" data-control="prev"></div>
<div class="control__icon control__play" data-control="play"></div>
<div class="control__icon control__pause" data-control="pause"></div>
<div class="control__icon control__next" data-control="next"></div>
<div class="control__icon control__end" data-control="end"></div>
</div>
Начнём описывать логику интерфейса на js. Для начала создадим объект game, запишем в него базовые свойства и функцию инициализации.
game = {
host: "https://codewar.firstvds.ru/",
quest_session_url: "/quest/quest_get_session_id/", // url получения id сессии пользователя
area: d3,
play_status: false,
current_step: 1,
spinner: $('<span>').addClass('glyphicon glyphicon-cog isp-quest-spin'),
members: {
nexa: {},
john: {}
},
init: function () {
var gm = this;
try {
this.editor = ace.edit("editor");
this.editor.setTheme("ace/theme/twilight");
this.editor.session.setMode("ace/mode/javascript");
// для удобства пользователя сделаем сохранение его кода в localstorage
this.editor.on("input", function () {
localStorage.setItem('codewar_code', gm.editor.getValue());
});
if (localStorage.getItem('codewar_code')) {
gm.editor.setValue(localStorage.getItem('codewar_code'));
}
} catch (e) {
console.log("ace editor error");
}
// сходим к api игры и узнаем начальные параметры
this.getGameEnviroment().then(function (enviroment) {
// затем инициализируем остальные сервисы
gm.enviroment = enviroment;
gm.printGameScale(enviroment); // нарисуем сетку
gm.printGameBegin(enviroment); // нарисуем Джона, Нексу и заражённые клетки
gm.bindControlElements(); // привяжем события к элементам управления
//gm.startWar(gm.editor.getValue());
});
}
}
Затем нарисуем сетку с помощью d3:
printGameScale: function (enviroment) {
d3
.select('#codewar__scale')
.selectAll("*").remove();
var area = d3
.select('#codewar__scale')
.append('svg')
.attr('class', 'chart_area')
.attr('width', 500)
.attr('height', 500)
.attr("viewBox", "0 0 " + (enviroment.scale.x + 1) + " " + (enviroment.scale.y + 1))
;
this.area = area;
this.area
.insert("rect")
.classed("codewar__arena", true)
.attr("x", 0.5)
.attr("y", 0.5)
.attr("width", 10)
.attr("height", 10);
for (var i = 0; i < enviroment.scale.x + 1; i++) {
this.area
.append("line")
.classed("codewar__grid", true)
.attr("x1", i + 0.5)
.attr("x2", i + 0.5)
.attr("y1", 0 + 0.5)
.attr("y2", enviroment.scale.y + 0.5)
;
}
for (var j = 0; j < enviroment.scale.y + 1; j++) {
this.area
.append("line")
.classed("codewar__grid", true)
.attr("y1", j + 0.5)
.attr("y2", j + 0.5)
.attr("x1", 0 + 0.5)
.attr("x2", enviroment.scale.x + 0.5)
;
}
}
Нарисуем Нексу, Джона и заражённые клетки:
printGameBegin: function (enviroment) {
this.members.nexa.svg = this.area
.append("svg:image")
.attr('x', enviroment.nexa.beginPosition.x - 0.25)
.attr('y', enviroment.nexa.beginPosition.y - 0.25)
.attr('width', 0.5)
.attr('height', 0.5)
.attr("xlink:href", "/sites/all/modules/custom/isp_quest/game/images/ai.png")
.classed("codewar__nexa", true);
this.members.john.svg = this.area
.append("svg:image")
.attr('x', enviroment.john.beginPosition.x - 0.25)
.attr('y', enviroment.john.beginPosition.y - 0.25)
.attr('width', 0.5)
.attr('height', 0.5)
.attr("xlink:href", "/sites/all/modules/custom/isp_quest/game/images/user.png")
.classed("codewar__john", true);
for (var i in enviroment.infection) {
this.infectCell(enviroment.infection[i].x, enviroment.infection[i].y);
}
this.infectCell(enviroment.nexa.beginPosition.x, enviroment.nexa.beginPosition.y);
}
Привяжем к кнопкам события с помощью jQuery, добавим анимацию движения и парсинг результатов. В статье не будем разбирать эти части, весь код фронтенда можете посмотреть тут.
Тестирование и эксперименты с поведением ИИ
Тестирование бэкэнда
Наш сервер умеет принимать HTTP-запросы, используем это для теста апи бэкэнда. Код игрока создадим в отдельном файле и прочитаем его аналогично коду Нексы
var john_mind = fs.readFileSync('./vm_scripts/john_script_test.js', 'utf8');
Для тестирования поставим перед собой следующие задачи:
- создадим бесконечные циклы и проверим остановку скрипта по таймауту,
- проверим невозможность обращения к внешним библиотекам из песочницы,
- проверим запись данных в бд квеста через апи,
- нагрузим сервер 1000 одновременных задач и посмотрим загрузку процессора,
- отладим формат представляемых данных, если требуется.
Отловив все найденные баги, убедившись в достаточной производительности nodejs на выбранном vds сервере, перейдём к экспериментам.
Эксперименты со скриптом ИИ
Для начала возьмём самый простой вариант скрипта Нексы и попробуем его победить на чистом поле. В нашем случае первая версия Нексы двигалась от центра до левой стенки, потом шла вверх и упиралась в угол. Победа над таким противником заняла от силы 5 минут. Потом мы сделали Нексу, которая гоняется за игроком: всегда двигается в кратчайшем до него направлении. Такой ИИ тоже оказался весьма простым. В следующей итерации добавили изначально заражённую область. Тут уже возникли трудности, но, спустя пару часов, написали скрипт, побеждающий и такую Нексу. В последнем эксперименте добавили в Нексу пиратской души: она догоняла Джона, а после встречи с ним начинала убегать. Изначально мы планировали добавить персонажам действия атаки и защиты. Но на этом этапе тестирования поняли, что игра уже достаточно интересная и не самая простая. Поэтому решили остановится на таком варианте Нексы.
Подведение результатов
До нашей codewar-игры в квесте дошли 27 человек. Среди них мы определили победителей, написавших самый эффективный код. Критерия эффективности было 2: наименьшее число ходов, за которые Джон очистил все клетки и самый короткий код.
Среди вариантов решения были самые различные, в том числе перебором и частичным перебором ходов. Победителем стал игрок, который написал скрипт в 339 символов, справившийся с Нексой за 64 хода. Он продемонстрировал элегантный js-код, и мы поздравили его с победой. Второе место, занял игрок с 64 ходами и 835 символами кода. Третье и четвёртое место заняла супружеская пара, у которых было одинаковое и число ходов, и размер кода. Способы решения отличались только последовательностью заранее записанных ходов.
Заключение
Создание игры очень увлекательный и затягивающий процесс. Мы сделали и запустили игру для клиентов за 3 дня. Несмотря на то что до задания для программистов дошло не так много участников квеста, они проявили большой интерес к нашей игре в социальных сетях и делились друг с другом своим кодом. Мы сами получили удовольствие от разработки игры и неплохо развлекли наших пользователей.