Compare commits

..

1 commit

Author SHA1 Message Date
Jannes Höke
8343737cb4 initial commit for AI opponent using ISMCTS 2016-05-16 16:55:40 +02:00
162 changed files with 1092 additions and 7563 deletions

8
.gitignore vendored
View file

@ -1,6 +1,3 @@
# Config file
config.json
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
@ -50,7 +47,7 @@ coverage.xml
# Translations # Translations
*.mo *.mo
# *.pot *.pot
# Django stuff: # Django stuff:
*.log *.log
@ -66,6 +63,3 @@ target/
# PyCharm # PyCharm
.idea .idea
# Database file
uno.sqlite3

View file

@ -7,8 +7,5 @@
The following wonderful people contributed directly or indirectly to this project: The following wonderful people contributed directly or indirectly to this project:
- [imlonghao](https://github.com/imlonghao) - [imlonghao](https://github.com/imlonghao)
- [pan93412](https://github.com/pan93412)
- [qubitnerd](https://github.com/qubitnerd)
- [SYHGroup](https://github.com/SYHGroup)
Please add yourself here alphabetically when you submit your first pull request. Please add yourself here alphabetically when you submit your first pull request.

332
ISMCTS.py Normal file
View file

@ -0,0 +1,332 @@
# This is a very simple Python 2.7 implementation of the Information Set Monte Carlo Tree Search algorithm.
# The function ISMCTS(rootstate, itermax, verbose = False) is towards the bottom of the code.
# It aims to have the clearest and simplest possible code, and for the sake of clarity, the code
# is orders of magnitude less efficient than it could be made, particularly by using a
# state.GetRandomMove() or state.DoRandomRollout() function.
#
# An example GameState classes for Knockout Whist is included to give some idea of how you
# can write your own GameState to use ISMCTS in your hidden information game.
#
# Written by Peter Cowling, Edward Powley, Daniel Whitehouse (University of York, UK) September 2012 - August 2013.
#
# Licence is granted to freely use and distribute for any sensible/legal purpose so long as this comment
# remains in any distributed code.
#
# For more information about Monte Carlo Tree Search check out our web site at www.mcts.ai
# Also read the article accompanying this code at ***URL HERE***
from math import *
import random, sys
from game import Game as UNOGame
from player import Player as UNOPlayer
from utils import list_subtract_unsorted
import card as c
class GameState:
""" A state of the game, i.e. the game board. These are the only functions which are
absolutely necessary to implement ISMCTS in any imperfect information game,
although they could be enhanced and made quicker, for example by using a
GetRandomMove() function to generate a random move during rollout.
By convention the players are numbered 1, 2, ..., self.numberOfPlayers.
"""
def __init__(self):
pass
def GetNextPlayer(self, p):
""" Return the player to the left of the specified player
"""
raise NotImplementedError()
def Clone(self):
""" Create a deep clone of this game state.
"""
raise NotImplementedError()
def CloneAndRandomize(self, observer):
""" Create a deep clone of this game state, randomizing any information not visible to the specified observer player.
"""
raise NotImplementedError()
def DoMove(self, move):
""" Update a state by carrying out the given move.
Must update playerToMove.
"""
raise NotImplementedError()
def GetMoves(self):
""" Get all possible moves from this state.
"""
raise NotImplementedError()
def GetResult(self, player):
""" Get the game result from the viewpoint of player.
"""
raise NotImplementedError()
def __repr__(self):
""" Don't need this - but good style.
"""
pass
class UNOState(GameState):
""" A state of the game UNO.
"""
def __init__(self, game):
""" Initialise the game state. n is the number of players (from 2 to 7).
"""
self.game = game
@property
def playerToMove(self):
return self.game.current_player
@property
def numberOfPlayers(self):
return len(self.game.players)
def CloneAndRandomize(self, observer):
""" Create a deep clone of this game state.
"""
game = UNOGame(None)
game.deck.cards.append(game.last_card)
game.draw_counter = self.game.draw_counter
game.last_card = self.game.last_card
game.deck.cards = list_subtract_unsorted(game.deck.cards,
self.game.deck.graveyard)
game.deck.graveyard = list(self.game.deck.graveyard)
for player in self.game.players:
p = UNOPlayer(game, None)
if player is observer:
p.cards = list(player.cards)
else:
for i in range(len(player.cards)):
p.cards.append(game.deck.draw())
return UNOState(game)
def DoMove(self, move):
""" Update a state by carrying out the given move.
Must update playerToMove.
"""
if move == 'draw':
for n in range(self.game.draw_counter or 1):
self.game.current_player.cards.append(
self.game.deck.draw()
)
self.game.draw_counter = 0
self.game.turn()
else:
self.game.current_player.cards.remove(move)
self.game.play_card(move)
if move.special:
self.game.turn()
self.game.choosing_color = False
def GetMoves(self):
""" Get all possible moves from this state.
"""
if self.game.current_player.cards:
playable = self.game.current_player.playable_cards()
playable_converted = list()
for card in playable:
if not card.color:
for color in c.COLORS:
playable_converted.append(
c.Card(color, None, card.special)
)
else:
playable_converted.append(card)
# playable_converted.append('draw')
return playable_converted or ['draw']
else:
return list()
def GetResult(self, player):
""" Get the game result from the viewpoint of player.
"""
return 1 if not player.cards else 0
def __repr__(self):
""" Return a human-readable representation of the state
"""
return '\n'.join(
['%s: %s' % (p.user, [str(c) for c in p.cards])
for p in self.game.players]
) + "\nDeck: %s" % str([str(crd) for crd in self.game.deck.cards]) \
+ "\nGrav: %s" % str([str(crd) for crd in self.game.deck.graveyard])
class Node:
""" A node in the game tree. Note wins is always from the viewpoint of playerJustMoved.
"""
def __init__(self, move=None, parent=None, playerJustMoved=None):
self.move = move # the move that got us to this node - "None" for the root node
self.parentNode = parent # "None" for the root node
self.childNodes = []
self.wins = 0
self.visits = 0
self.avails = 1
self.playerJustMoved = playerJustMoved # the only part of the state that the Node needs later
def GetUntriedMoves(self, legalMoves):
""" Return the elements of legalMoves for which this node does not have children.
"""
# Find all moves for which this node *does* have children
triedMoves = [child.move for child in self.childNodes]
# Return all moves that are legal but have not been tried yet
return [move for move in legalMoves if move not in triedMoves]
def UCBSelectChild(self, legalMoves, exploration=0.7):
""" Use the UCB1 formula to select a child node, filtered by the given list of legal moves.
exploration is a constant balancing between exploitation and exploration, with default value 0.7 (approximately sqrt(2) / 2)
"""
# Filter the list of children by the list of legal moves
legalChildren = [child for child in self.childNodes if
child.move in legalMoves]
# Get the child with the highest UCB score
s = max(legalChildren, key=lambda c: float(c.wins) / float(
c.visits) + exploration * sqrt(log(c.avails) / float(c.visits)))
# Update availability counts -- it is easier to do this now than during backpropagation
for child in legalChildren:
child.avails += 1
# Return the child selected above
return s
def AddChild(self, m, p):
""" Add a new child node for the move m.
Return the added child node
"""
n = Node(move=m, parent=self, playerJustMoved=p)
self.childNodes.append(n)
return n
def Update(self, terminalState):
""" Update this node - increment the visit count by one, and increase the win count by the result of terminalState for self.playerJustMoved.
"""
self.visits += 1
if self.playerJustMoved is not None:
self.wins += terminalState.GetResult(self.playerJustMoved)
def __repr__(self):
return "[M:%s W/V/A: %4i/%4i/%4i]" % (
self.move, self.wins, self.visits, self.avails)
def TreeToString(self, indent):
""" Represent the tree as a string, for debugging purposes.
"""
s = self.IndentString(indent) + str(self)
for c in self.childNodes:
s += c.TreeToString(indent + 1)
return s
def IndentString(self, indent):
s = "\n"
for i in range(1, indent + 1):
s += "| "
return s
def ChildrenToString(self):
s = ""
for c in self.childNodes:
s += str(c) + "\n"
return s
def ISMCTS(rootstate, itermax, verbose=False):
""" Conduct an ISMCTS search for itermax iterations starting from rootstate.
Return the best move from the rootstate.
"""
rootnode = Node()
for i in range(itermax):
node = rootnode
# Determinize
state = rootstate.CloneAndRandomize(rootstate.playerToMove)
# Select
while state.GetMoves() != [] and node.GetUntriedMoves(
state.GetMoves()) == []: # node is fully expanded and non-terminal
node = node.UCBSelectChild(state.GetMoves())
state.DoMove(node.move)
# Expand
untriedMoves = node.GetUntriedMoves(state.GetMoves())
if untriedMoves != []: # if we can expand (i.e. state/node is non-terminal)
m = random.choice(untriedMoves)
player = state.playerToMove
state.DoMove(m)
node = node.AddChild(m, player) # add child and descend tree
# Simulate
while state.GetMoves() != []: # while state is non-terminal
state.DoMove(random.choice(state.GetMoves()))
# Backpropagate
while node != None: # backpropagate from the expanded node and work back to the root node
node.Update(state)
node = node.parentNode
# Output some information about the tree - can be omitted
if (verbose):
print(rootnode.TreeToString(0))
else:
print(rootnode.ChildrenToString())
return max(rootnode.childNodes, key=lambda
c: c.visits).move # return the move that was most visited
def PlayGame():
""" Play a sample game between two ISMCTS players.
*** This is only a demo and not used by the actual bot ***
"""
game = UNOGame(None)
me = UNOPlayer(game, "Player 1")
UNOPlayer(game, "Player 2")
UNOPlayer(game, "Player 3")
UNOPlayer(game, "Player 4")
UNOPlayer(game, "Player 5")
state = UNOState(game)
while (state.GetMoves() != []):
print(str(state))
# Use different numbers of iterations (simulations, tree nodes) for different players
m = ISMCTS(rootstate=state, itermax=10, verbose=False)
# if state.playerToMove is me:
# m = ISMCTS(rootstate=state, itermax=1000, verbose=False)
# else:
# m = ISMCTS(rootstate=state, itermax=100, verbose=False)
print("Best Move: " + str(m) + "\n")
state.DoMove(m)
someoneWon = False
for p in game.players:
if state.GetResult(p) > 0:
print("Player " + str(p) + " wins!")
someoneWon = True
if not someoneWon:
print("Nobody wins!")
if __name__ == "__main__":
PlayGame()

13
Pipfile
View file

@ -1,13 +0,0 @@
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true
[dev-packages]
[packages]
python-telegram-bot = "==8.1.1"
pony = "*"
[requires]
python_version = "3.7"

49
Pipfile.lock generated
View file

@ -1,49 +0,0 @@
{
"_meta": {
"hash": {
"sha256": "de56c4d5f516205e99d141cd7d372f67b602b6f981306971c01ffe25a5abf5c6"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.7"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"certifi": {
"hashes": [
"sha256:046832c04d4e752f37383b628bc601a7ea7211496b4638f6514d0e5b9acc4939",
"sha256:945e3ba63a0b9f577b1395204e13c3a231f9bc0223888be653286534e5873695"
],
"version": "==2019.6.16"
},
"future": {
"hashes": [
"sha256:67045236dcfd6816dc439556d009594abf643e5eb48992e36beac09c2ca659b8"
],
"version": "==0.17.1"
},
"pony": {
"hashes": [
"sha256:55bb9d4d12029d8c2bbbc7a284970e72225035db7e6370c0a15ec93d1886fe88"
],
"index": "pypi",
"version": "==0.7.10"
},
"python-telegram-bot": {
"hashes": [
"sha256:238c4a88b09d93c52d413bcf7e7fe14dfeb02f5f9222ffe4cafd4bd3d55489a3",
"sha256:997983e5082dc6aa811bce3a6014731201fc64b0a9c02fdb26beac686029d94b"
],
"index": "pypi",
"version": "==8.1.1"
}
},
"develop": {}
}

View file

@ -1,24 +1,10 @@
# UNO Bot # UNO Bot
Telegram Bot that allows you to play the popular card game UNO via inline queries. The bot currently runs as [@mau_mau_bot](http://telegram.me/mau_mau_bot)
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](./LICENSE)
Telegram Bot that allows you to play the popular card game UNO via inline queries. The bot currently runs as [@unobot](http://telegram.me/unobot).
To run the bot yourself, you will need: To run the bot yourself, you will need:
- Python (tested with 3.4+) - Python (tested with 3.4 and 3.5)
- The [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) module - The [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) module version 4.0.3
- [Pony ORM](https://ponyorm.com/)
## Setup Get a bot token from [@BotFather](http://telegram.me/BotFather), place it in `credentials.py` and run the bot with `python3 bot.py`
- Get a bot token from [@BotFather](http://telegram.me/BotFather) and change configurations in `config.json`.
- Convert all language files from `.po` files to `.mo` by executing the bash script `compile.sh` located in the `locales` folder.
Another option is: `find . -maxdepth 2 -type d -name 'LC_MESSAGES' -exec bash -c 'msgfmt {}/unobot.po -o {}/unobot.mo' \;`.
- Use `/setinline` and `/setinlinefeedback` with BotFather for your bot.
- Install requirements (using a `virtualenv` is recommended): `pip install -r requirements.txt`
You can change some gameplay parameters like turn times, minimum amount of players and default gamemode in `config.json`. Code documentation is minimal but there
Current gamemodes available: classic, fast and wild. Check the details with the `/modes` command.
Then run the bot with `python3 bot.py`.
Code documentation is minimal but there.

View file

@ -1,18 +0,0 @@
# Translators
The following awesome people contributed to this project by translating it:
| Locale | Translators |
|--------|--------------------------------------------------------------------------------------------------------------|
| de_DE | [Jannes Höke](https://github.com/jh0ker) |
| es_ES | [José.A Rojo](https://github.com/J4RV), [Ricardo Valverde Hernández](https://telegram.me/rivh1), Victor, Yuga|
| id_ID | [Erwin Guo](https://www.facebook.com/erwinfransiscus) |
| it_IT | Carola Mariano, ɳick |
| pt_BR | [João Rodrigo Couto de Oliveira](http://twitter.com/JoaoRodrigoJR)
| tr_TR | [Kasım Ağca](https://telegram.me/Holytotem)
|
| zh_CN | [imlonghao](https://github.com/imlonghao) |
| zh_HK | [Jed Cheng](https://www.facebook.com/profile.php?id=100002258388821) |
| zh_TW | [Eugene Lam](https://www.facebook.com/eugenelam1118), [jimchen5209](https://www.youtube.com/user/jimchen5209), [pan93412](https://www.github.com/pan93412)|
Please add yourself here alphabetically when you submit your first translation.

View file

@ -1,213 +0,0 @@
import random
import logging
import card as c
from datetime import datetime
from telegram import Message, Chat
from config import TIME_REMOVAL_AFTER_SKIP, MIN_FAST_TURN_TIME
from errors import DeckEmptyError, NotEnoughPlayersError
from internationalization import __, _
from shared_vars import gm
from user_setting import UserSetting
from utils import send_async, display_name, game_is_running
logger = logging.getLogger(__name__)
class Countdown(object):
player = None
job_queue = None
def __init__(self, player, job_queue):
self.player = player
self.job_queue = job_queue
# TODO do_skip() could get executed in another thread (it can be a job), so it looks like it can't use game.translate?
def do_skip(bot, player, job_queue=None):
game = player.game
chat = game.chat
skipped_player = game.current_player
next_player = game.current_player.next
if skipped_player.waiting_time > 0:
skipped_player.anti_cheat += 1
skipped_player.waiting_time -= TIME_REMOVAL_AFTER_SKIP
if (skipped_player.waiting_time < 0):
skipped_player.waiting_time = 0
try:
skipped_player.draw()
except DeckEmptyError:
pass
n = skipped_player.waiting_time
send_async(bot, chat.id,
text=__("Waiting time to skip this player has "
"been reduced to {time} seconds.\n"
"Next player: {name}", multi=game.translate)
.format(time=n,
name=display_name(next_player.user))
)
logger.info("{player} was skipped! "
.format(player=display_name(player.user)))
game.turn()
if job_queue:
start_player_countdown(bot, game, job_queue)
else:
try:
gm.leave_game(skipped_player.user, chat)
send_async(bot, chat.id,
text=__("{name1} ran out of time "
"and has been removed from the game!\n"
"Next player: {name2}", multi=game.translate)
.format(name1=display_name(skipped_player.user),
name2=display_name(next_player.user)))
logger.info("{player} was skipped! "
.format(player=display_name(player.user)))
if job_queue:
start_player_countdown(bot, game, job_queue)
except NotEnoughPlayersError:
send_async(bot, chat.id,
text=__("{name} ran out of time "
"and has been removed from the game!\n"
"The game ended.", multi=game.translate)
.format(name=display_name(skipped_player.user)))
gm.end_game(chat, skipped_player.user)
def do_play_card(bot, player, result_id):
"""Plays the selected card and sends an update to the group if needed"""
card = c.from_str(result_id)
player.play(card)
game = player.game
chat = game.chat
user = player.user
us = UserSetting.get(id=user.id)
if not us:
us = UserSetting(id=user.id)
if us.stats:
us.cards_played += 1
if game.choosing_color:
send_async(bot, chat.id, text=__("Please choose a color", multi=game.translate))
if len(player.cards) == 1:
send_async(bot, chat.id, text="UNO!")
if len(player.cards) == 0:
send_async(bot, chat.id,
text=__("{name} won!", multi=game.translate)
.format(name=user.first_name))
if us.stats:
us.games_played += 1
if game.players_won is 0:
us.first_places += 1
game.players_won += 1
try:
gm.leave_game(user, chat)
except NotEnoughPlayersError:
send_async(bot, chat.id,
text=__("Game ended!", multi=game.translate))
us2 = UserSetting.get(id=game.current_player.user.id)
if us2 and us2.stats:
us2.games_played += 1
gm.end_game(chat, user)
def do_draw(bot, player):
"""Does the drawing"""
game = player.game
draw_counter_before = game.draw_counter
try:
player.draw()
except DeckEmptyError:
send_async(bot, player.game.chat.id,
text=__("There are no more cards in the deck.",
multi=game.translate))
if (game.last_card.value == c.DRAW_TWO or
game.last_card.special == c.DRAW_FOUR) and \
draw_counter_before > 0:
game.turn()
def do_call_bluff(bot, player):
"""Handles the bluff calling"""
game = player.game
chat = game.chat
if player.prev.bluffing:
send_async(bot, chat.id,
text=__("Bluff called! Giving 4 cards to {name}",
multi=game.translate)
.format(name=player.prev.user.first_name))
try:
player.prev.draw()
except DeckEmptyError:
send_async(bot, player.game.chat.id,
text=__("There are no more cards in the deck.",
multi=game.translate))
else:
game.draw_counter += 2
send_async(bot, chat.id,
text=__("{name1} didn't bluff! Giving 6 cards to {name2}",
multi=game.translate)
.format(name1=player.prev.user.first_name,
name2=player.user.first_name))
try:
player.draw()
except DeckEmptyError:
send_async(bot, player.game.chat.id,
text=__("There are no more cards in the deck.",
multi=game.translate))
game.turn()
def start_player_countdown(bot, game, job_queue):
player = game.current_player
time = player.waiting_time
if time < MIN_FAST_TURN_TIME:
time = MIN_FAST_TURN_TIME
if game.mode == 'fast':
if game.job:
game.job.schedule_removal()
job = job_queue.run_once(
#lambda x,y: do_skip(bot, player),
skip_job,
time,
context=Countdown(player, job_queue)
)
logger.info("Started countdown for player: {player}. {time} seconds."
.format(player=display_name(player.user), time=time))
player.game.job = job
def skip_job(bot, job):
player = job.context.player
game = player.game
if game_is_running(game):
job_queue = job.context.job_queue
do_skip(bot, player, job_queue)

960
bot.py

File diff suppressed because it is too large Load diff

42
card.py
View file

@ -1,5 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*-
# #
# Telegram bot to play UNO in group chats # Telegram bot to play UNO in group chats
# Copyright (c) 2016 Jannes Höke <uno@jhoeke.de> # Copyright (c) 2016 Jannes Höke <uno@jhoeke.de>
@ -18,6 +17,8 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from telegram.emoji import Emoji
# Colors # Colors
RED = 'r' RED = 'r'
BLUE = 'b' BLUE = 'b'
@ -28,10 +29,10 @@ BLACK = 'x'
COLORS = (RED, BLUE, GREEN, YELLOW) COLORS = (RED, BLUE, GREEN, YELLOW)
COLOR_ICONS = { COLOR_ICONS = {
RED: '❤️', RED: Emoji.HEAVY_BLACK_HEART,
BLUE: '💙', BLUE: Emoji.BLUE_HEART,
GREEN: '💚', GREEN: Emoji.GREEN_HEART,
YELLOW: '💛', YELLOW: Emoji.YELLOW_HEART,
BLACK: '⬛️' BLACK: '⬛️'
} }
@ -52,7 +53,6 @@ SKIP = 'skip'
VALUES = (ZERO, ONE, TWO, THREE, FOUR, FIVE, SIX, SEVEN, EIGHT, NINE, DRAW_TWO, VALUES = (ZERO, ONE, TWO, THREE, FOUR, FIVE, SIX, SEVEN, EIGHT, NINE, DRAW_TWO,
REVERSE, SKIP) REVERSE, SKIP)
WILD_VALUES = (ONE, TWO, THREE, FOUR, FIVE, DRAW_TWO, REVERSE, SKIP)
# Special cards # Special cards
CHOOSE = 'colorchooser' CHOOSE = 'colorchooser'
@ -114,9 +114,17 @@ STICKERS = {
'y_skip': 'BQADBAADQwIAAl9XmQABO_AZKtxY6IMC', 'y_skip': 'BQADBAADQwIAAl9XmQABO_AZKtxY6IMC',
'y_reverse': 'BQADBAADQQIAAl9XmQABZdQFahGG6UQC', 'y_reverse': 'BQADBAADQQIAAl9XmQABZdQFahGG6UQC',
'draw_four': 'BQADBAAD9QEAAl9XmQABVlkSNfhn76cC', 'draw_four': 'BQADBAAD9QEAAl9XmQABVlkSNfhn76cC',
'draw_four_r': 'BQADBAAD9QEAAl9XmQABVlkSNfhn76cC',
'draw_four_b': 'BQADBAAD9QEAAl9XmQABVlkSNfhn76cC',
'draw_four_g': 'BQADBAAD9QEAAl9XmQABVlkSNfhn76cC',
'draw_four_y': 'BQADBAAD9QEAAl9XmQABVlkSNfhn76cC',
'colorchooser': 'BQADBAAD8wEAAl9XmQABl9rUOPqx4E4C', 'colorchooser': 'BQADBAAD8wEAAl9XmQABl9rUOPqx4E4C',
'option_draw': 'BQADBAAD-AIAAl9XmQABxEjEcFM-VHIC', 'colorchooser_r': 'BQADBAAD8wEAAl9XmQABl9rUOPqx4E4C',
'option_pass': 'BQADBAAD-gIAAl9XmQABcEkAAbaZ4SicAg', 'colorchooser_b': 'BQADBAAD8wEAAl9XmQABl9rUOPqx4E4C',
'colorchooser_g': 'BQADBAAD8wEAAl9XmQABl9rUOPqx4E4C',
'colorchooser_y': 'BQADBAAD8wEAAl9XmQABl9rUOPqx4E4C',
'option_draw': 'BQADBAADzAIAAl9XmQABTkPaOqA5HIMC',
'option_pass': 'BQADBAADzgIAAl9XmQABWSDq3RIg3c0C',
'option_bluff': 'BQADBAADygIAAl9XmQABJoLfB9ntI2UC', 'option_bluff': 'BQADBAADygIAAl9XmQABJoLfB9ntI2UC',
'option_info': 'BQADBAADxAIAAl9XmQABC5v3Z77VLfEC' 'option_info': 'BQADBAADxAIAAl9XmQABC5v3Z77VLfEC'
} }
@ -180,7 +188,9 @@ STICKERS_GREY = {
class Card(object): class Card(object):
"""This class represents an UNO card""" """
This class represents a card.
"""
def __init__(self, color, value, special=None): def __init__(self, color, value, special=None):
self.color = color self.color = color
@ -189,6 +199,9 @@ class Card(object):
def __str__(self): def __str__(self):
if self.special: if self.special:
if self.color:
return '%s_%s' % (self.special, self.color)
else:
return self.special return self.special
else: else:
return '%s_%s' % (self.color, self.value) return '%s_%s' % (self.color, self.value)
@ -204,7 +217,14 @@ class Card(object):
def __eq__(self, other): def __eq__(self, other):
""" Needed for sorting the cards """ """ Needed for sorting the cards """
return str(self) == str(other) s1 = str(self)
s2 = str(other)
return (s1 == s2
if not self.special else
s1 == s2 or
s1[:-2] == s2[:-2] or
s1[:-2] == s2 or
s1 == s2[:-2])
def __lt__(self, other): def __lt__(self, other):
""" Needed for sorting the cards """ """ Needed for sorting the cards """
@ -212,7 +232,7 @@ class Card(object):
def from_str(string): def from_str(string):
"""Decodes a Card object from a string""" """ Decode a Card object from a string """
if string not in SPECIALS: if string not in SPECIALS:
color, value = string.split('_') color, value = string.split('_')
return Card(color, value) return Card(color, value)

View file

@ -1,12 +0,0 @@
{
"token": "token_here",
"admin_list": [0],
"open_lobby": true,
"enable_translations": false,
"workers": 32,
"default_gamemode": "fast",
"waiting_time": 120,
"time_removal_after_skip": 20,
"min_fast_turn_time": 15,
"min_players": 2
}

View file

@ -1,35 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Telegram bot to play UNO in group chats
# Copyright (c) 2016 Jannes Höke <uno@jhoeke.de>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import json
with open("config.json","r") as f:
config = json.loads(f.read())
TOKEN=config.get("token")
WORKERS=config.get("workers", 32)
ADMIN_LIST = config.get("admin_list", None)
OPEN_LOBBY = config.get("open_lobby", True)
ENABLE_TRANSLATIONS = config.get("enable_translations", False)
DEFAULT_GAMEMODE = config.get("default_gamemode", "fast")
WAITING_TIME = config.get("waiting_time", 120)
TIME_REMOVAL_AFTER_SKIP = config.get("time_removal_after_skip", 20)
MIN_FAST_TURN_TIME = config.get("min_fast_turn_time", 15)
MIN_PLAYERS = config.get("min_players", 2)

View file

@ -1,5 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*-
# #
# Telegram bot to play UNO in group chats # Telegram bot to play UNO in group chats
# Copyright (c) 2016 Jannes Höke <uno@jhoeke.de> # Copyright (c) 2016 Jannes Höke <uno@jhoeke.de>
@ -18,7 +17,5 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from pony.orm import Database TOKEN = 'TOKEN'
BOTAN_TOKEN = '' # Optional: Add a botan.io token if you want bot statistics
# Database singleton
db = Database()

76
deck.py
View file

@ -1,5 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*-
# #
# Telegram bot to play UNO in group chats # Telegram bot to play UNO in group chats
# Copyright (c) 2016 Jannes Höke <uno@jhoeke.de> # Copyright (c) 2016 Jannes Höke <uno@jhoeke.de>
@ -19,11 +18,9 @@
from random import shuffle from random import shuffle
import logging
import card as c import card as c
from card import Card from card import Card
from errors import DeckEmptyError import logging
class Deck(object): class Deck(object):
@ -34,55 +31,40 @@ class Deck(object):
self.graveyard = list() self.graveyard = list()
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.logger.debug(self.cards) # Fill deck
def shuffle(self):
"""Shuffles the deck"""
self.logger.debug("Shuffling Deck")
shuffle(self.cards)
def draw(self):
"""Draws a card from this deck"""
try:
card = self.cards.pop()
self.logger.debug("Drawing card " + str(card))
return card
except IndexError:
if len(self.graveyard):
while len(self.graveyard):
self.cards.append(self.graveyard.pop())
self.shuffle()
return self.draw()
else:
raise DeckEmptyError()
def dismiss(self, card):
"""Returns a card to the deck"""
if card.special:
card.color = None
self.graveyard.append(card)
def _fill_classic_(self):
# Fill deck with the classic card set
self.cards.clear()
for color in c.COLORS: for color in c.COLORS:
for value in c.VALUES: for value in c.VALUES:
self.cards.append(Card(color, value)) self.cards.append(Card(color, value))
if not value == c.ZERO: if not value == c.ZERO:
self.cards.append(Card(color, value)) self.cards.append(Card(color, value))
for special in c.SPECIALS:
for _ in range(4): for special in c.SPECIALS * 4:
self.cards.append(Card(None, None, special=special)) self.cards.append(Card(None, None, special=special))
self.logger.debug(self.cards)
self.shuffle() self.shuffle()
def _fill_wild_(self): def shuffle(self):
# Fill deck with a wild card set """ Shuffle the deck """
self.cards.clear() self.logger.debug("Shuffling Deck")
for color in c.COLORS: shuffle(self.cards)
for value in c.WILD_VALUES:
for _ in range(4): def draw(self):
self.cards.append(Card(color, value)) """ Draw a card from this deck """
for special in c.SPECIALS: try:
for _ in range(6): card = self.cards.pop()
self.cards.append(Card(None, None, special=special)) if card.special:
card = Card(None, None, card.special)
self.logger.debug("Drawing card " + str(card))
return card
except IndexError:
while len(self.graveyard):
self.cards.append(self.graveyard.pop())
self.shuffle() self.shuffle()
return self.draw()
def dismiss(self, card):
""" All played cards should be returned into the deck """
# if card.special:
# card.color = None
self.graveyard.append(card)

View file

@ -1,38 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Telegram bot to play UNO in group chats
# Copyright (c) 2016 Jannes Höke <uno@jhoeke.de>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
class NoGameInChatError(Exception):
pass
class AlreadyJoinedError(Exception):
pass
class LobbyClosedError(Exception):
pass
class NotEnoughPlayersError(Exception):
pass
class DeckEmptyError(Exception):
pass

61
game.py
View file

@ -1,5 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*-
# #
# Telegram bot to play UNO in group chats # Telegram bot to play UNO in group chats
# Copyright (c) 2016 Jannes Höke <uno@jhoeke.de> # Copyright (c) 2016 Jannes Höke <uno@jhoeke.de>
@ -19,38 +18,36 @@
import logging import logging
from config import ADMIN_LIST, OPEN_LOBBY, DEFAULT_GAMEMODE, ENABLE_TRANSLATIONS
from datetime import datetime from datetime import datetime
from deck import Deck from deck import Deck
import card as c import card as c
class Game(object): class Game(object):
""" This class represents a game of UNO """ """ This class represents a game of UNO """
current_player = None current_player = None
reversed = False reversed = False
draw_counter = 0
choosing_color = False choosing_color = False
started = False started = False
draw_counter = 0 owner = None
players_won = 0 open = True
starter = None
mode = DEFAULT_GAMEMODE
job = None
owner = ADMIN_LIST
open = OPEN_LOBBY
translate = ENABLE_TRANSLATIONS
def __init__(self, chat): def __init__(self, chat):
self.chat = chat self.chat = chat
self.last_card = None
self.deck = Deck() self.deck = Deck()
self.last_card = self.deck.draw()
while self.last_card.special:
self.deck.cards.append(self.last_card)
self.deck.shuffle()
self.last_card = self.deck.draw()
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
@property @property
def players(self): def players(self):
"""Returns a list of all players in this game"""
players = list() players = list()
if not self.current_player: if not self.current_player:
return players return players
@ -63,50 +60,19 @@ class Game(object):
itplayer = itplayer.next itplayer = itplayer.next
return players return players
def start(self):
if self.mode == None or self.mode != "wild":
self.deck._fill_classic_()
else:
self.deck._fill_wild_()
self._first_card_()
self.started = True
def set_mode(self, mode):
self.mode = mode
def reverse(self): def reverse(self):
"""Reverses the direction of game""" """ Reverse the direction of play """
self.reversed = not self.reversed self.reversed = not self.reversed
def turn(self): def turn(self):
"""Marks the turn as over and change the current player""" """ Mark the turn as over and change the current player """
self.logger.debug("Next Player") self.logger.debug("Next Player")
self.current_player = self.current_player.next self.current_player = self.current_player.next
self.current_player.drew = False self.current_player.drew = False
self.current_player.turn_started = datetime.now() self.current_player.turn_started = datetime.now()
self.choosing_color = False
def _first_card_(self):
# In case that the player did not select a game mode
if not self.deck.cards:
self.set_mode(DEFAULT_GAMEMODE)
# The first card should not be a special card
while not self.last_card or self.last_card.special:
self.last_card = self.deck.draw()
# If the card drawn was special, return it to the deck and loop again
if self.last_card.special:
self.deck.dismiss(self.last_card)
self.play_card(self.last_card)
def play_card(self, card): def play_card(self, card):
""" """ Play a card and trigger its effects """
Plays a card and triggers its effects.
Should be called only from Player.play or on game start to play the
first card
"""
self.deck.dismiss(self.last_card) self.deck.dismiss(self.last_card)
self.last_card = card self.last_card = card
@ -137,3 +103,4 @@ class Game(object):
""" Carries out the color choosing and turns the game """ """ Carries out the color choosing and turns the game """
self.last_card.color = color self.last_card.color = color
self.turn() self.turn()
self.choosing_color = False

View file

@ -1,5 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*-
# #
# Telegram bot to play UNO in group chats # Telegram bot to play UNO in group chats
# Copyright (c) 2016 Jannes Höke <uno@jhoeke.de> # Copyright (c) 2016 Jannes Höke <uno@jhoeke.de>
@ -22,8 +21,6 @@ import logging
from game import Game from game import Game
from player import Player from player import Player
from errors import (AlreadyJoinedError, LobbyClosedError, NoGameInChatError,
NotEnoughPlayersError)
class GameManager(object): class GameManager(object):
@ -33,8 +30,6 @@ class GameManager(object):
self.chatid_games = dict() self.chatid_games = dict()
self.userid_players = dict() self.userid_players = dict()
self.userid_current = dict() self.userid_current = dict()
self.remind_dict = dict()
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
def new_game(self, chat): def new_game(self, chat):
@ -43,31 +38,22 @@ class GameManager(object):
""" """
chat_id = chat.id chat_id = chat.id
self.logger.debug("Creating new game in chat " + str(chat_id)) self.logger.info("Creating new game with id " + str(chat_id))
game = Game(chat) game = Game(chat)
if chat_id not in self.chatid_games: if chat_id not in self.chatid_games:
self.chatid_games[chat_id] = list() self.chatid_games[chat_id] = list()
# remove old games
for g in list(self.chatid_games[chat_id]):
if not g.players:
self.chatid_games[chat_id].remove(g)
self.chatid_games[chat_id].append(game) self.chatid_games[chat_id].append(game)
return game return game
def join_game(self, user, chat): def join_game(self, chat_id, user):
""" Create a player from the Telegram user and add it to the game """ """ Create a player from the Telegram user and add it to the game """
self.logger.info("Joining game with id " + str(chat.id)) self.logger.info("Joining game with id " + str(chat_id))
try: try:
game = self.chatid_games[chat.id][-1] game = self.chatid_games[chat_id][-1]
except (KeyError, IndexError): except (KeyError, IndexError):
raise NoGameInChatError() return None
if not game.open:
raise LobbyClosedError()
if user.id not in self.userid_players: if user.id not in self.userid_players:
self.userid_players[user.id] = list() self.userid_players[user.id] = list()
@ -75,54 +61,28 @@ class GameManager(object):
players = self.userid_players[user.id] players = self.userid_players[user.id]
# Don not re-add a player and remove the player from previous games in # Don not re-add a player and remove the player from previous games in
# this chat, if he is in one of them # this chat
for player in players: for player in players:
if player in game.players: if player in game.players:
raise AlreadyJoinedError() return False
else:
try: self.leave_game(user, chat_id)
self.leave_game(user, chat)
except NoGameInChatError:
pass
except NotEnoughPlayersError:
self.end_game(chat, user)
if user.id not in self.userid_players:
self.userid_players[user.id] = list()
players = self.userid_players[user.id]
player = Player(game, user) player = Player(game, user)
if game.started:
player.draw_first_hand()
players.append(player) players.append(player)
self.userid_current[user.id] = player self.userid_current[user.id] = player
return True
def leave_game(self, user, chat): def leave_game(self, user, chat_id):
""" Remove a player from its current game """ """ Remove a player from its current game """
try:
players = self.userid_players[user.id]
games = self.chatid_games[chat_id]
player = self.player_for_user_in_chat(user, chat) for player in players:
players = self.userid_players.get(user.id, list()) for game in games:
if player in game.players:
if not player:
games = self.chatid_games[chat.id]
for g in games:
for p in g.players:
if p.user.id == user.id:
if p is g.current_player:
g.turn()
p.leave()
return
raise NoGameInChatError
game = player.game
if len(game.players) < 3:
raise NotEnoughPlayersError()
if player is game.current_player: if player is game.current_player:
game.turn() game.turn()
@ -130,61 +90,49 @@ class GameManager(object):
players.remove(player) players.remove(player)
# If this is the selected game, switch to another # If this is the selected game, switch to another
if self.userid_current.get(user.id, None) is player: if self.userid_current[user.id] is player:
if players: if len(players):
self.userid_current[user.id] = players[0] self.userid_current[user.id] = players[0]
else: else:
del self.userid_current[user.id] del self.userid_current[user.id]
del self.userid_players[user.id] return True
else:
return False
def end_game(self, chat, user): except KeyError:
return False
def end_game(self, chat_id, user):
""" """
End a game End a game
""" """
self.logger.info("Game in chat " + str(chat.id) + " ended") self.logger.info("Game in chat " + str(chat_id) + " ended")
players = self.userid_players[user.id]
games = self.chatid_games[chat_id]
the_game = None
# Find the correct game instance to end # Find the correct game instance to end
player = self.player_for_user_in_chat(user, chat)
if not player:
raise NoGameInChatError
game = player.game
# Clear game
for player_in_game in game.players:
this_users_players = \
self.userid_players.get(player_in_game.user.id, list())
try:
this_users_players.remove(player_in_game)
except ValueError:
pass
if this_users_players:
try:
self.userid_current[player_in_game.user.id] = this_users_players[0]
except KeyError:
pass
else:
try:
del self.userid_players[player_in_game.user.id]
except KeyError:
pass
try:
del self.userid_current[player_in_game.user.id]
except KeyError:
pass
self.chatid_games[chat.id].remove(game)
if not self.chatid_games[chat.id]:
del self.chatid_games[chat.id]
def player_for_user_in_chat(self, user, chat):
players = self.userid_players.get(user.id, list())
for player in players: for player in players:
if player.game.chat.id == chat.id: for game in games:
return player if player in game.players:
return None the_game = game
break
if the_game:
break
else:
return
for player in the_game.players:
if player.ai:
continue
this_users_players = self.userid_players[player.user.id]
this_users_players.remove(player)
if len(this_users_players) is 0:
del self.userid_players[player.user.id]
del self.userid_current[player.user.id]
else:
self.userid_current[player.user.id] = this_users_players[0]
self.chatid_games[chat_id].remove(the_game)
return

View file

@ -1,12 +0,0 @@
#!/usr/bin/bash
currentVer='1.0'
xgettext *.py -o ./locales/unobot.pot --foreign-user \
--package-name="uno_bot" \
--package-version="$currentVer" \
--msgid-bugs-address='uno@jhoeke.de' \
--keyword=__ \
--keyword=_ \
--keyword=_:1,2 \
--keyword=__:1,2

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Some files were not shown because too many files have changed in this diff Show more