Pull to refresh

Анализ шансов в настольных играх через эмуляции

Reading time6 min
Views2.8K

Как вам игровая сессия с 1000+ ходами в обычной ходилке? А такое вполне реально.
До этого я уже проанализировал одну немного бесячую настольную игру ходилку через эмуляции [1] [2]. В комментариях мне накидали кучу других запомнившихся игр с предложением и их потыкать. Ну вот я и потыкал. Для этого немного оптимизировал код эмулятора через javascript, чтобы он мог запускать по 100 миллионов игр. Скрипты выложены на гитхабе [3].

Вокруг света

Игровое поле
Игровое поле

В качестве механики большого отбрасывания (аналог чёрной дыры из прошлой статьи) я учитывал две позиции: 100->46, 107->37. А вот отбрасывание на начало 21->0 я не стал считать аналогом чёрной дыры, т.к. возврат на 21 ход примерно равнозначен обычным "стрелкам-назад". Статистика [4] вышла такая:

  • среднее число ходов 36;

  • максимальное число ходов 235;

  • минимальное число ходов 11;

  • число игр с попаданием хотя бы в одну отбрасывалку 54%, при этом игр с неравным числом попаданий в ловушки 43%;

  • вероятность проигрыша при более частом попадании в отбрасывалку 88%;

  • частота победы у первого игрока 50,85%.

График вероятности завершить игру за X ходов
График вероятности завершить игру за X ходов

Что интересного тут можно увидеть.
Плюсы:
- Красивая картинка, которую интересно разглядывать.
- Средняя длина игрового поля, очень долгая игровая сессия случается редко. Игра на 235 ходов случилась лишь однажды из 100 миллионов игр.
- Преимущество первого хода с 50,85% весьма небольшое.
Минусы:
- Мега отбрасывания, как всегда, подбешивают, но есть механика для камбека, так как оппонент сам может попасть в одну из двух ловушек у самого финиша.
- Если кого-то отбросило ловушкой чаще (что происходит с частотой 43%), то он проиграет с очень большой вероятностью: 88%.

Веселое путешествие

Игровое поле
Игровое поле

Здесь два отбрасывания в начало. При этом первая ловушка отбрасывает недалеко, поэтому её рассматривать как критическую я не стал. Поэтому ловушками я посчитал следующие комбинации: 63->0, 75->35. Статистика [5] вышла такая:

  • среднее число ходов 35;

  • максимальное число ходов 271;

  • минимальное число ходов 11;

  • число игр с попаданием хотя бы в одну отбрасывалку 50%, при этом игр с неравным числом попаданий в ловушки 41%;

  • вероятность проигрыша при более частом попадании в отбрасывалку 81%;

  • частота победы у первого игрока 50.78%.

График вероятности завершить игру за X ходов
График вероятности завершить игру за X ходов

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

Большое космическое путешествие (гребаный поезд)

Игровое поле
Игровое поле

Как подсказал, один из комментаторов AlexKoz1980, настоящее название этой игры - гребаный поезд. В качестве больших ловушек я считал за точки: 57, 70, 77, 88, 90. И судя по статистике такое название он полностью оправдывает [6].

  • среднее число ходов 102;

  • максимальное число ходов 1615;

  • минимальное число ходов 10;

  • число игр с попаданием хотя бы в одну отбрасывалку 92%, при этом игр с неравным числом попаданий в ловушки 70%;

  • вероятность проигрыша при более частом попадании в отбрасывалку 77%;

  • частота победы у первого игрока 50.14%.

График вероятности завершить игру за X ходов
График вероятности завершить игру за X ходов

Это самая несбалансированная игра из тех, что я видел. Помимо 5 самых опасных ловушек, тут есть ещё и мелкие ловушки, откатывающие на 1-2 этажа. Проблема в том, что после мелкой ловушки сбрасывается риск попадания в одну из опасных ловушек. И сохраняется он до самого конца. К 100-ому ходу становится уже неважно кто победит, лишь бы хоть кто-нибудь игру закончил.

Javascript для эмуляции использовался такой (можно запускать в консоли F12 в любой вкладке в любом браузере)

const finishStep = 93;
const countOfEmulatedGames = 100000000;
const bonusTurn = {
    9: true,
    24: true,
    43: true,
    56: true,
    82: true,
    84: true,
};
const moveBack = {};
const skipTurn = {
    2: true,
    8: true,
    11: true,
    21: true,
    23: true,
    28: true,
    29: true,
    32: true,
    39: true,
    44: true,
    45: true,
    49: true,
    58: true,
    59: true,
    68: true,
    73: true,
};
const instaDeath = {
    12: true,
};
const arrowMoves = {
    3: 5,
    4: 9,
    6: 27,
    13: 14,
    16: 18,
    19: 20,
    26: 46,
    30: 33,
    31: 36,
    34: 35,
    37: 38,
    40: 43,
    41: 46,
    51: 36,
    53: 54,
    57: 10,
    60: 47,
    61: 63,
    64: 67,
    65: 67,
    66: 46,
    69: 67,
    70: 10,
    72: 55,
    74: 78,
    75: 55,
    76: 78,
    77: 10,
    79: 78,
    83: 84,
    86: 87,
    88: 48,
    90: 10,
    91: 93,
};
const bigBack = {
    57: true,
    70: true,
    77: true,
    88: true,
    90: true,
};
let Stats = {
    totalGames: countOfEmulatedGames,
    iterationGames: 1000000,
    checkedGames: 0,
    turnsToGames: {},
    turnsToGamesPoints: {},
    catchedGames: 0,
    catchedGamesUnfair: 0,
    catchedMoreLoseGames: 0,
    firstPlayerWinCount: 0,
    totalTurns: 0,
    maxCountOfTurns: 0,
    minCountOfTurns: 999999,
};

function main() {
    let newCountOfGames = Math.min(Stats.checkedGames + Stats.iterationGames, Stats.totalGames);

    for (0; Stats.checkedGames < newCountOfGames; Stats.checkedGames++) {
        let game = emulateGame();

        Stats.totalTurns += game.turn;
        if (typeof Stats.turnsToGames[game.turn] === 'undefined') {
            Stats.turnsToGames[game.turn] = 0;
        }
        Stats.turnsToGames[game.turn]++;
        Stats.maxCountOfTurns = Math.max(Stats.maxCountOfTurns, game.turn);
        Stats.minCountOfTurns = Math.min(Stats.minCountOfTurns, game.turn);

        if (game.p1Catched > 0 || game.p2Catched > 0) {
            Stats.catchedGames++;
            if (game.p1Catched != game.p2Catched) {
                Stats.catchedGamesUnfair++;
            }
        }

        if (game.p1Catched > game.p2Catched && game.winner == 'p2') {
            Stats.catchedMoreLoseGames++;
        } else if (game.p1Catched < game.p2Catched && game.winner == 'p1') {
            Stats.catchedMoreLoseGames++;
        }

        if (game.winner == 'p1') {
            Stats.firstPlayerWinCount++;
        }
    }

    if (Stats.checkedGames >= Stats.totalGames) {
        console.log('Progress: 100% Done');

        Object.keys(Stats.turnsToGames).forEach(key => {
            Stats.turnsToGamesPoints[key] = 100*Stats.turnsToGames[key]/Stats.totalGames;
        });

        console.log('Count of games: ' + Stats.totalGames.toLocaleString());
        console.log('Average count of turns: ' + Math.round(100*Stats.totalTurns/Stats.totalGames)/100);
        console.log(JSON.stringify(Stats.turnsToGamesPoints));
        console.log('Max count of turns: ' + Stats.maxCountOfTurns);
        console.log('Min count of turns: ' + Stats.minCountOfTurns);
        console.log('--------------------');
        console.log('Percent of games with at least one big-back: ' + formatedRound(Stats.catchedGames/Stats.totalGames) + '%');
        console.log('Percent of unfair games with big-back: ' + formatedRound(Stats.catchedGamesUnfair/Stats.totalGames) + '%');
        console.log('If step to big-back more times then lose: ' + formatedRound(Stats.catchedMoreLoseGames/Stats.catchedGamesUnfair) + '%');
        console.log('--------------------');
        console.log('First player win rate: ' + formatedRound(Stats.firstPlayerWinCount/Stats.totalGames) + '%');
    } else {
        setTimeout(
            function() {
                console.log('Progress: ' + formatedRound(Stats.checkedGames/Stats.totalGames) + '%');
                main();
            },
            0
        );
    }
}

function emulateGame() {
    let game = {
        'p1': 0,
        'p2': 0,
        'winner': null,
        'p1Catched': 0,
        'p2Catched': 0,
        'turn': 0,
    }

    while(true) {
        game.turn++;

        game.p1 += getDice();
        game = checkMove(game, 'p1');

        if (game.p1 >= finishStep) {
            game.winner = 'p1';
            break;
        }

        game.p2 += getDice();
        game = checkMove(game, 'p2');

        if (game.p2 >= finishStep) {
            game.winner = 'p2';
            break;
        }
    }

    return game;
}

function checkMove(game, player) {
    let anotherPlayer = 'p1';
    if (player == anotherPlayer) {
        anotherPlayer = 'p2';
    }

    if (bigBack[game[player]]) {
        game[player + 'Catched']++;
    }

    if (bonusTurn[game[player]]) {
        game[player] += getDice();
        game = checkMove(game, player);
    }

    if (moveBack[game[player]]) {
        game[player] -= getDice();
        game = checkMove(game, player);
    }

    if (skipTurn[game[player]]) {
        game[anotherPlayer] += getDice();
        game = checkMove(game, anotherPlayer);
        game.turn++;
    }

    if (instaDeath[game[player]]) {
        game[player] = 0;
        //game[player + 'Catched']++; // skiped because zero return here almost at the start
    }

    if (typeof arrowMoves[game[player]] !== 'undefined') {
        game[player] = arrowMoves[game[player]];
    }

    return game;
}

function formatedRound(value) {
    return Math.round(10000*value)/100;
}

function getDice() {
    return Math.floor(Math.random() * 6 + 1);
}

main();

Космос от шестилетнего ребенка с дедушкой

А дальше идёт ходилка "Космическое приключение с чёрными дырами и кротовыми норами", созданная под руководством ребёнка. Картинка немного доработана, чтобы появились числа на шагах и легенда к игре.

Игровое поле
Игровое поле

Весьма типичная особенность новичка - циклопических размеров игровая карта, аж на 509 шагов. По первости часто кажется, что чем больше тем лучше, но это почти всегда не так.

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

Эмуляция [7] на 100 миллионов игр дало следующие результаты:

  • среднее число ходов 35;

  • максимальное число ходов 232;

  • минимальное число ходов 3;

  • число игр с попаданием хотя бы в одну чёрную дыру 65%, при этом игр с неравным числом попаданий в чёрные дыры 57%;

  • вероятность проигрыша при более частом попадании в чёрную дыру 54%;

  • побед через кротовую нору 83,5%;

  • частота победы у первого игрока 50,48%.

График вероятности завершить игру за X ходов
График вероятности завершить игру за X ходов

Что интересного тут можно увидеть.
Плюсы:
- Влияние чёрных дыр почти полностью нивелировано. 54% вероятности проиграть если ты попадал в чёрную дыру чаще оппонента - почти 50/50.
- Довольно часто игры заканчиваются до 20 ходов, быстрые игровые сессии это хорошо.
- Преимущество первого хода с 50,48% минимальное.
Минусы:
- Огромный путь в 509 шагов приводит к тому, что чаще всего игра очень сильно затягивается. Обычно это сильно утомляет. Рецепт простой - уменьшать карту до ~100 шагов и меньше.
- Победа почти всегда происходит за счёт попадания в кротовую нору. Поэтому, как вариант, следовало по максимуму использовать эту механику и многократно увеличить число кротовых нор при удалении от старта.

Заключение

Среди проверенных игр лишь гребаный поезд оказался сильно перекошенным. Остальные, на удивление, примерно одинаково проходятся за 35 ходов в среднем. Если вам известны другие безумные ходилки - скидывайте в комментариях. Если наберутся новые ещё более дикие, то я сделаю ещё подборку.

Источники

  1. GitHub. Javascript скрипты игровых эмуляторов.

  2. Насколько странный баланс в этой настолке с чёрной дырой на Хабре.

  3. Та же статья, но на Пикабу.

  4. GitHub. Анализ Вокруг света.

  5. GitHub. Веселое путешествие.

  6. GitHub. Гребаный поезд.

  7. GitHub. Анализ игры циклопических размеров с кротовыми норами.

Tags:
Hubs:
Total votes 20: ↑20 and ↓0+20
Comments9

Articles