DagazServer: Чему научили пользователи

_tziwsxawauumg0ykje4tmsqfmc.jpegДетали, мелочи, нюансы. Сочетание пустяков.
От перестановки слагаемых всё меняется.
Раз, и будущее — открытая книга.

Нюанс за нюансом, подробность за подробностью…
Это была не игра, это была откровенная дерзость.

Генри Лайон Олди «Нюансеры»


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

Прежде всего, должен сказать пару слов о том, как пользоваться сайтом
Также как первоначальный проект, DagazServer посвящён популяризации традиционных настольных игр. Проект бесплатный, но игра по сети подразумевает наличие учётной записи. Поэтому, прежде чем двигаться дальше, её придётся создать:
wmoepiasqxan_s4autjlqcfudj4.png

Регистрация свободная и не требует указания никаких конфиденциальных данных, даже адреса электронной почты (вы сможете указать его позже, в профиле пользователя, если захотите). После этого, можно заходить на сайт:
fmsxbumljlhfbgzfewh30u_62eu.png

Есть ещё карта, о которой я расскажу позже, показывающая все игры и не требующая авторизации, но при переходе к запуску конкретной игры придётся ввести свой логин и пароль.
pnz_di2ufscznfba30a-5puwsbs.png

Здесь можно выбрать игру, её разновидность и, в некоторых случаях, начальную расстановку фигур. Также можно выбрать игрока, за которого вы собираетесь играть и запуститься, но прежде чем это делать, поищите заинтересовавшую вас игру на вкладке «Sessions»:

og5d4dfbnmwepdqewedcyymwh8e.png
Возможно, кто-то только того и ждёт, чтобы сыграть с вами. Кстати, партии других игроков тоже можно просматривать (пока только в режиме прокрутки, с самого начала, но я работаю над тем, чтобы сделать пошаговый разбор). Кстати, картинки в этой статье кликабельные, но помните о том, что я говорил о регистрации: для перехода по ссылке, потребуется указать свой логин и пароль.


Жмурки и шкурки


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

Доска должна поворачиваться
Впервые эту мысль мне подсказал Ed van Zon. В рамках отказа от устаревшей технологии Java-апплетов, на своём сайте, он, практически самостоятельно, освоил разработку на Dagaz и за короткое время переписал с его помощью кучу игр. Некоторые из его технических решений удивляют даже меня. Одним из таких нововведений стала «игра по переписке». Вы сможете её опробовать, если зарегистрируетесь на сайте MindSports, но я хотел рассказать не совсем об этом.
uqir_ahcayfixbh0wh7m-ngeio4.png

Здесь, на самом верху, можно заметить кнопочку «Flip board». Эта удобная опция позволяет взглянуть на доску глазами противника. Разумеется, во время игры по переписке, или даже при игре за одной доской в режиме «Hot Seat», эта возможность активно применяется. Я совершенно не задумывался об этом, разрабатывая Dagaz и в результате оказался в положении человека, запирающего ворота вслед за сбежавшей лошадью.
Я стал придумывать сложные технические решения
Новая версия представления, позволяла описать всё что угодно: разворачивающиеся доски, всплывающие окна, для выбора фигур при превращении, относительное позиционирование внутри этих окон, отображение разных подложек, в зависимости от очерёдности хода — всё это.
agaz.View.configure = function(view) {
    var b = view.root.addRegion(0, 0, 604, 604);
    b.addBoard("WhiteBoard", [0]);
    b.addBoard("BlackBoard", [1]); // Другая подложка для второго игрока...
    var g = b.addGrid(2, 2, 50, 50);
    g.addScale("a/b/c/d/e/f/g/h/i/j/k/l", 50, 0);
    g.addScale("12/11/10/9/8/7/6/5/4/3/2/1", 0, 50);
    g.addTurns(0, [0]);
    g.addTurns(1, [1]); // и обратный порядок размещения фигур на доске
    view.addPiece(["WhitePawn", "BlackPawn"], Dagaz.View.drawPawn);
    view.addPiece(["WhiteKnight", "WhiteBishop", "WhiteRook", "WhiteQueen", "WhiteKing", "BlackKnight", "BlackBishop", "BlackRook", "BlackQueen", "BlackKing"]);
}

0v3q9aux7mym_2fajuafbjvmhca.png


Получилось неплохо. С каждым ходом доска разворачивается, как это и было задумано. Но всё это было не только не нужно, но и, возможно, вредно. Постоянные развороты доски «с ног на голову» не несут ничего полезного, а только дезориентируют.


Серверу известно за какого игрока играет тот или иной пользователь, так почему бы не выгружать разные версии игры? Два описания: это и это — различаются только координатами полей доски на экране (вызовы view.defPosition в Dagaz.View.configure) и это решает проблему! Можно пойти ещё дальше.

kdfulfciwt5dvzrg4uu0x_vln_k.png


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

aa2xj8kalolpcg4mw7noychujik.png


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

z5wsqx91m8bqeptxongjprt5a0s.png


Именно стилизации касалась одна из самых первых просьб пользователей. Это может показаться странным, но я совершенно не подумал о том, чтобы запоминать выбранный стиль, а постоянное его переключение (с европейского, на иероглифы), при каждом подключении к игре, сильно раздражает. Исправление заметили не сразу. Такой, на мой взгляд, и должна быть функциональность сайта: удобной и незаметной.

Автоботы


В отличии от простой странички с играми, новый сайт ориентирован на игру пользователей друг с другом. Играть можно как в online, ожидая хода противника, так и заходя на сайт время от времени. История игры сохраняется на сервере и не теряется при обновлении страницы. За играми других пользователей тоже наблюдать можно: при подключении, все ходы будут воспроизведены с самого начала, после чего игра перейдёт в режим ожидания очередных ходов. Всё это прекрасно, но иногда хочется просто посмотреть на незнакомую игру быстро, не надеясь на то, что кто-то подключится и сделает свой ход.

ry5p7peu-4xgllwq7x5ume-ffik.png


Именно с этой целью я добавил на сайт ботов. Боты есть не для всех игр, но если игра вычислительно не сложная, почему бы не добавить такую возможность? Нажимая кнопку «Launch» (или просто кликая мышкой по картинке с игрой), пользователь начинает игру с ботом, а поскольку продолжительность «раздумий» ботов исчисляется секундами, игроку не приходится тратить своё время на продолжительное ожидание ответного хода противника.

На самом деле, такой бот загружается вместе с самой игрой
Я вновь использую стилизацию, описанную выше. Игра с ботом — это отдельный html-файл, имя которого заканчивается суффиксом »-ai». Откуда launcher знает, что может загрузить игру с ботом?
В базе данных есть специальная табличка
@Entity()
export class game_bots {
    @PrimaryColumn()
    id: number;

    @Index()
    @Column({ nullable: false })
    game_id: number;
    @ManyToOne(type => games)
    @JoinColumn({ name: "game_id" })
    game: games;

    @Index()
    @Column({ nullable: true })
    variant_id: number;
    @ManyToOne(type => game_variants)
    @JoinColumn({ name: "variant_id" })
    variant: game_variants;

    @Column({ nullable: true })
    selector_value: number;

    @Column({ nullable: true })
    player_num: number;
}

Здесь стоит немного рассказать о том, как я храню игры.
4rsydyt-ckpf5bdbioce4tu5s4w.png

Прежде всего, есть табличка games, которая, на самом деле, описывает скорее не игры, а семейства игр. Более точное указание на игру — табличка game_variants, но запись в ней есть не всегда (например, Hex, пока что, пребывает в своём семействе в гордом одиночестве). Но этим дело не ограничивается! Есть ещё одно число, определяющее начальную расстановку фигур. Если я перехожу по ссылке '/launch/30/31/1', то могу сыграть в Шахматы. Переход же по ссылке '/launch/30/31/2' означает, что я хочу потренироваться в матовании слоном и конём. В некоторых играх, «расстановки» могут управлять конфигурацией доски и даже нюансами правил самой игры.
mkvuilcdmlcrvrmwvqi2qnqdbow.png

Третье число (селектор) — такой же полноправный участник «почтового адреса игры» как и первые два значения. Например, с его помощью я могу привязывать индивидуальные preview к расстановкам, отличающимся от стандартных. Примерно также привязываются боты. Если я укажу только id игры, то разрешу загрузку ботов для всего семейства. Указание variant_id позволяет уточнить привязку, selector_value разрешит загрузку бота только для указанной расстановки.

Можно пойти ещё дальше и разрешить загрузку бота только для определённого игрока. Это очень удобно для ассиметричных игр. Думаю, должно быть понятно, что разработать бота убегающего от мата гораздо проще, чем бота ставящего мат. Кстати, все эти настройки ничего не говорят о том, какой именно бот (а их несколько разных) будет загружен. Это определяется тем, какие именно js-скрипты загружаются html-файлом, а поскольку благодаря стилизации html-файлы для разных игроков загружаются разные, загрузить им различающихся ботов также не является проблемой.


Такое техническое решение вполне приемлемо, если игра не сложная и бот «думает» не дольше 2–3 секунд. Страничка ненадолго «подвисает», поскольку JavaScript занят интенсивными вычислениями, но пользователь этого даже не замечает, поскольку всё происходит очень быстро. У меня есть сообщения о проблемах с браузером «Safari», останавливающем скрипт, при обнаружении такого «зацикливания», но пока мне не удалось это воспроизвести. В любом случае, такое решение совершенно не подходит для более сложных игр, таких как Шахматы.

Я вынашиваю грандиозные планы, относительно прикручивания какого либо популярного шахматного движка (например GarboChess) к своему сайту. Такой подход позволит создавать по настоящему мощных ботов и запускать их на сервере. С точки зрения других пользователей, такой бот будет выглядеть как обычный игрок автоматически создающий игровую сессию и ожидающий подключения к ней. Он сможет «думать» дольше и гораздо эффективнее. Конечно, модель игры придётся дублировать в самом боте, но это наименьшее из того на что я готов пойти, чтобы заполучить подобную возможность.


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

Последний ход


Если игра ведётся в online, игроки видят ходы друг друга в реальном времени, но если кто-то из них выходит из игры и возвращается к ней позже (для этого имеется удобная вкладка «My Turn»), по текущей позиции бывает трудно понять какой именно ход сделал противник. Довольно быстро стало понятно, что с этим надо что-то делать. Я не хотел отмечать последние походившие фигуры так, как это делается, например, в Го. Такое изменение должно было бы затронуть каждую игру (причём по-разному), кроме того, простая подсветка сходившей фигуры, в таких играх как Шахматы, малоинформативна.

Вместо этого, я сделал следующее
    async rollbackSess(r: string, sid:number, uid: number): Promise {
        const last_id = await this.getLastId(sid, uid);
        if (last_id) {
            let x = await this.service.query(
                `select setup_str, turn_num
                 from   game_moves
                 where  id = $1`, [last_id]);
            if (!x || x.length == 0) {
                 return null;
            }
            r = x[0].setup_str;
            await this.service.createQueryBuilder("game_moves")
            .update(game_moves)
            .set({ 
                accepted: null
             })
            .where("session_id = :sid and turn_num > :turn", 
                      {sid: sid, turn: x[0].turn_num})
            .execute();
            await this.service.createQueryBuilder("game_sessions")
            .update(game_sessions)
            .set({ 
                last_turn: x[0].turn_num
             })
            .where("id = :sid", {sid: sid})
            .execute();
        }
        return r;
    }

    async recovery(user:number, s: Sess): Promise {
        try {
            ...
                if (x[0].last_user && s.uid && !s.ai && 
                   !x[0].result_id && (x[0].last_user != s.uid)) {
                    s.last_setup = await this.rollbackSess(s.last_setup, s.id, s.uid);
                }
            ...
        } catch (error) {
          console.error(error);
          throw new InternalServerErrorException({
              status: HttpStatus.BAD_REQUEST,
              error: error
          });
        }
    }

Если мы возвращаемся в игру (выполняя recovery) и сейчас наша очередь хода, все последние ходы противника (в некоторых играх их может быть несколько) «откатываются» — помечаются как непрочитанные. В результате, игра загружается в обычном режиме, а подгрузка и анимация последних ходов выполняется в штатном режиме.


Да, я понимаю, что это не идеальное решение. Хотелось бы иметь возможность «проматывать» ходы назад и вперёд неограниченно. И у меня даже есть такой модуль! Вот здесь, например, он прекрасно работает (просто сделайте несколько ходов и увидите наверху стрелочки).

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

Это кнопочки (или ссылочки) «Resign» и «Offer Draw». Иногда совершенно очевидно, что игру следует прервать досрочно. Первоначально такого механизма не было. С признанием поражения всё более менее просто, сервер надо просто уведомить о том, что игровую сессию можно закрыть, с поражением текущего игрока. А вот ничьи необходимо согласовывать. Так появилась табличка game_alerts. Один из игроков помещает в неё предложение ничьей, которое второй игрок видит, при запросе очередного хода. Если он не согласится — запись просто удаляется. В противном случае, сессия закрывается, с указанием ничьей. Конечно, хотелось бы расширить общение игроков, добавив внутриигровой чат, с сообщениями привязанными к ходам, но не всё сразу.


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


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

Нам нужна карта


Вот новшество, действительно горячо поддержанное всеми пользователями. Дело в том, что игр на сайте, в самом деле, довольно много. А поскольку не всегда бывает понятно, к какому именно семейству ту или иную игру отнести, ориентироваться на страничке «Launch» бывает непросто. Меня попросили сделать карту.

bzryf8fjy_3vzmawcm9rqjn3tzc.png


Самое прикольное в этой страничке то, что она не требует авторизации. Можно посмотреть на то, какие игры есть на сайте (их список постоянно пополняется), почитать правила (там где мне удалось их найти), выбрать игру (вот в этом месте сайт запросит логин и пароль), перейти к ней (пусть вас не смущает авторизация, мы перейдём к выбранной игре). Всё это не запустит игру, а только откроет страницу «Launch» на том месте, которое нас заинтересовало.

cltcuxqusl5romh9dgljsrb7e9u.png


Здесь тоже можно почитать правила (ссылка «Rules») и, собственно, запустить игру (кнопка «Launch»), выбрав игрока или игру с ботом, но не торопитесь это делать. Если вы видите кнопку «Join» — это означает, что кто-то из других игроков уже начал игру и ожидает присоединения партнёра.

x6fdqjpcpkgyplcwu8ypveniyoy.png


Таких партий может быть несколько. И вы можете выбрать любую. Кнопка «View» (если она есть) служит для просмотра ранее сыгранных партий. Это просто ещё один способ ознакомиться с игрой и нескучно провести время.

В общем, сайт меняется каждый день и, как я надеюсь, меняется в лучшую сторону, а всех присутствующих хочу поздравить с наступающими праздниками: Новым Годом и Рождеством!

© Habrahabr.ru