Skip to content
This repository has been archived by the owner on Jul 8, 2023. It is now read-only.

Commit

Permalink
Merge branch 'hand-calculating-refactoring'
Browse files Browse the repository at this point in the history
  • Loading branch information
Nihisil committed Sep 25, 2017
2 parents 45a509f + ac0d57d commit e0cb25f
Show file tree
Hide file tree
Showing 65 changed files with 520 additions and 4,995 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ __pycache__
.DS_Store
logs
project/settings_local.py
old_version.py
project/game/ai/common

tests_validate_hand.py
loader.py
Expand Down
37 changes: 19 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,7 @@ For now only **Python 3.5+** is supported.

## Mahjong hands calculation

We have a code which can calculate hand cost (han, fu, yaku and scores) based on the hand's tiles.

It supports features like:

- Disable\enable aka dora in the hand
- Disable\enable open tanyao yaku
- By now it supports double yakumans (Dai Suushii, Daburu Kokushi musou, Suu ankou tanki,
Daburu Chuuren Poutou). Later I plan to have a disabling option in settings for it.

The code was validated on tenhou.net phoenix replays in total on **8'527'296 hands**, and
results were the same in 100% cases.

So, we can say that our own hand calculator works the same way that tenhou.net hand calculation.

The example of usage you can find here: https://github.com/MahjongRepository/tenhou-python-bot/blob/master/project/validate_hand.py#L194
You can find it here: https://github.com/MahjongRepository/mahjong

## Mahjong bot

Expand Down Expand Up @@ -74,8 +60,8 @@ For the next version I have a plan to improve win rate, probably bot should push

## How to run it?

Run `pythone main.py` it will connect to the tenhou.net and will play a match.
After the end of the match it will close connection to the server
1. `pip install -r requirements.txt`
2. Run `pythone main.py` it will connect to the tenhou.net and will play a game

## Configuration instructions

Expand All @@ -84,6 +70,21 @@ They will override settings from default `settings.py` file
2. Also you can override some default settings with command argument.
Use `python main.py -h` to check all available commands

## Implement your own AI

We tried to isolate default AI from the project as much as we could.

There is `game.ai.base.InterfaceAI` with one required method `discard_tile` that had to be implemented by your AI.

### Start with your AI

This command will make a copy of the simple bot (it is discarding random tiles from the hand)
1. `cd project`
2. `cp -a game/ai/random game/ai/common`
3. You can run new AI with command: `python main.py -a common` (or change `AI_PACKAGE` in settings)

After that you can change all what you want in `game.ai.common` package and test it in real games.

## Round reproducer

We built the way to reproduce already played round.
Expand Down Expand Up @@ -119,7 +120,7 @@ After this you can debug bot decisions.

### Reproduce from our log

Sometimes we had to debug bot <-> server communication. For this purpose we built this reproducer.
Sometimes we had to debug `bot <-> server` communication. For this purpose we built this reproducer.

Just use it with already played game:

Expand Down
File renamed without changes.
File renamed without changes.
Empty file.
80 changes: 80 additions & 0 deletions project/game/ai/base/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-


class InterfaceAI(object):
"""
Public interface of the bot AI
"""
version = 'none'

player = None
table = None

def __init__(self, player):
self.player = player
self.table = player.table

def discard_tile(self, discard_tile):
"""
AI should decide what tile had to be discarded from the hand on bot turn
:param discard_tile: 136 tile format. Sometimes we want to discard specific tile
:return: 136 tile format
"""
raise NotImplemented()

def init_hand(self):
"""
Method will be called after bot hand initialization (when tiles will be set to the player)
:return:
"""

def erase_state(self):
"""
Method will be called in the start of new round.
You can null here AI attributes that depends on round data
:return:
"""

def draw_tile(self, tile):
"""
:param tile: 136 tile format
:return:
"""

def should_call_win(self, tile, enemy_seat):
"""
When we can call win by other player discard this method will be called
:return: boolean
"""
return True

def should_call_riichi(self):
"""
When bot can call riichi this method will be called.
You can check additional params here to decide should be riichi called or not
:return: boolean
"""
return False

def should_call_kan(self, tile, is_open_kan):
"""
When bot can call kan or chankan this method will be called
:param tile: 136 tile format
:param is_open_kan: boolean
:return: kan type (Meld.KAN, Meld.CHANKAN) or None
"""
return False

def try_to_call_meld(self, tile, is_kamicha_discard):
"""
When bot can open hand with a set (chi or pon/kan) this method will be called
:param tile: 136 format tile
:param is_kamicha_discard: boolean
:return: Meld and DiscardOption objects or None, None
"""
return None, None

def enemy_called_riichi(self, enemy_seat):
"""
Will be called after other player riichi
"""
12 changes: 7 additions & 5 deletions project/mahjong/ai/discard.py → project/game/ai/discard.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
from mahjong.constants import AKA_DORA_LIST
from mahjong.tile import TilesConverter
from mahjong.utils import simplify, is_honor, plus_dora
from utils.settings_handler import settings
from mahjong.utils import is_honor, simplify, plus_dora, is_aka_dora


class DiscardOption(object):
Expand Down Expand Up @@ -48,7 +47,7 @@ def find_tile_in_hand(self, closed_hand):
Find and return 136 tile in closed player hand
"""

if settings.FIVE_REDS:
if self.player.table.has_aka_dora:
# special case, to keep aka dora in hand
if self.tile_to_discard in [4, 13, 22]:
aka_closed_hand = closed_hand[:]
Expand Down Expand Up @@ -78,8 +77,8 @@ def calculate_value(self, shanten=None):
honored_value = 0

if is_honor(self.tile_to_discard):
if self.tile_to_discard in self.player.ai.valued_honors:
count_of_winds = [x for x in self.player.ai.valued_honors if x == self.tile_to_discard]
if self.tile_to_discard in self.player.valued_honors:
count_of_winds = [x for x in self.player.valued_honors if x == self.tile_to_discard]
# for west-west, east-east we had to double tile value
value += honored_value * len(count_of_winds)
else:
Expand All @@ -89,6 +88,9 @@ def calculate_value(self, shanten=None):
value += suit_tile_grades[simplified_tile]

count_of_dora = plus_dora(self.tile_to_discard * 4, self.player.table.dora_indicators)
if is_aka_dora(self.tile_to_discard * 4, self.player.table.has_open_tanyao):
count_of_dora += 1

value += 50 * count_of_dora

if is_honor(self.tile_to_discard):
Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -1,19 +1,32 @@
from mahjong.tile import TilesConverter
from mahjong.utils import plus_dora, count_tiles_by_suits
from mahjong.utils import plus_dora, count_tiles_by_suits, is_aka_dora


class EnemyAnalyzer(object):
player = None
chosen_suit = None
initialized = False

def __init__(self, player):
"""
:param player: instance of EnemyPlayer
"""
self.player = player
self.table = player.table

self.chosen_suit = None

# we need it to determine user's chosen suit
self.initialized = self.is_threatening

@property
def is_dealer(self):
return self.player.is_dealer

@property
def all_safe_tiles(self):
return self.player.all_safe_tiles

@property
def in_tempai(self):
"""
Expand Down Expand Up @@ -48,6 +61,8 @@ def is_threatening(self):
meld_tiles_34 = TilesConverter.to_34_array(meld_tiles)
if meld_tiles:
dora_count = sum([plus_dora(x, self.table.dora_indicators) for x in meld_tiles])
# aka dora
dora_count += sum([1 for x in meld_tiles if is_aka_dora(x, self.table.has_open_tanyao)])
# enemy has a lot of dora tiles in his opened sets
# so better to fold against him
if dora_count >= 3:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# -*- coding: utf-8 -*-
from mahjong.ai.defence.defence import Defence, DefenceTile
from mahjong.constants import HONOR_INDICES

from game.ai.first_version.defence.defence import Defence, DefenceTile


class ImpossibleWait(Defence):

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# -*- coding: utf-8 -*-
from mahjong.ai.defence.defence import Defence, DefenceTile
from mahjong.constants import EAST
from mahjong.utils import simplify, is_man, is_pin, is_sou

from game.ai.first_version.defence.defence import Defence, DefenceTile


class Kabe(Defence):

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from mahjong.ai.defence.defence import DefenceTile
from mahjong.ai.defence.impossible_wait import ImpossibleWait
from mahjong.ai.defence.kabe import Kabe
from mahjong.ai.defence.suji import Suji
from mahjong.tile import TilesConverter
from mahjong.utils import is_honor, plus_dora
from mahjong.utils import plus_dora, is_honor, is_aka_dora

from game.ai.first_version.defence.defence import DefenceTile
from game.ai.first_version.defence.enemy_analyzer import EnemyAnalyzer
from game.ai.first_version.defence.impossible_wait import ImpossibleWait
from game.ai.first_version.defence.kabe import Kabe
from game.ai.first_version.defence.suji import Suji


class DefenceHandler(object):
Expand Down Expand Up @@ -59,6 +61,8 @@ def should_go_to_defence_mode(self, discard_candidate=None):
if shanten == 1:
# TODO calculate all possible hand costs for 1-2 shanten
dora_count = sum([plus_dora(x, self.table.dora_indicators) for x in self.player.tiles])
# aka dora
dora_count += sum([1 for x in self.player.tiles if is_aka_dora(x, self.table.has_open_tanyao)])
# we had 3+ dora in our almost done hand,
# we can try to push it
if dora_count >= 3:
Expand All @@ -81,8 +85,8 @@ def should_go_to_defence_mode(self, discard_candidate=None):
tiles.remove(temp_tile)

hand_result = self.player.ai.estimate_hand_value(tile, tiles, call_riichi)
if hand_result['error'] is None:
hands_estimated_cost.append(hand_result['cost']['main'])
if hand_result.error is None:
hands_estimated_cost.append(hand_result.cost['main'])

# probably we are with opened hand without yaku, let's fold it
if not hands_estimated_cost:
Expand Down Expand Up @@ -154,7 +158,7 @@ def try_to_find_safe_tile_to_discard(self, discard_results):
# let's find safe tiles for most dangerous player first
# and than for all other players if we failed find tile for dangerous player
for player in threatening_players:
player_safe_tiles = [DefenceTile(x, DefenceTile.SAFE) for x in player.all_safe_tiles]
player_safe_tiles = [DefenceTile(x, DefenceTile.SAFE) for x in player.player.all_safe_tiles]
player_suji_tiles = self.suji.find_tiles_to_discard([player])

# it can be that safe tile will be mark as "almost safe",
Expand Down Expand Up @@ -185,6 +189,11 @@ def try_to_find_safe_tile_to_discard(self, discard_results):
# we wasn't able to find safe tile to discard
return None

@property
def analyzed_enemies(self):
players = self.player.ai.enemy_players
return [EnemyAnalyzer(x) for x in players]

def _find_tile_to_discard(self, safe_tiles, discard_tiles):
"""
Try to find most effective safe tile to discard
Expand Down Expand Up @@ -217,12 +226,12 @@ def _get_threatening_players(self):
Sorted by threat level. Most threatening on the top
"""
result = []
for player in self.table.enemy_players:
for player in self.analyzed_enemies:
if player.is_threatening:
result.append(player)

# dealer is most threatening player
result = sorted(result, key=lambda x: x.is_dealer, reverse=True)
result = sorted(result, key=lambda x: x.player.is_dealer, reverse=True)

return result

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from mahjong.ai.defence.defence import Defence, DefenceTile
from mahjong.utils import is_man, is_pin, is_sou, simplify, plus_dora
from mahjong.utils import is_man, simplify, is_pin, is_sou, plus_dora, is_aka_dora

from game.ai.first_version.defence.defence import Defence, DefenceTile


class Suji(Defence):
Expand Down Expand Up @@ -107,7 +108,7 @@ def _suji_tiles(self, suji):

# mark dora tiles as dangerous tiles to discard
for tile in result:
if plus_dora(tile.value * 4, self.table.dora_indicators, False):
if plus_dora(tile.value * 4, self.table.dora_indicators) or is_aka_dora(tile.value * 4, self.table.has_open_tanyao):
tile.danger += 100

return result
Loading

0 comments on commit e0cb25f

Please sign in to comment.