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

от автора

Как вам игровая сессия с 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. Анализ игры циклопических размеров с кротовыми норами.


ссылка на оригинал статьи https://habr.com/ru/post/708498/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *