Initial commit

This commit is contained in:
Stanislav Nikolov 2022-08-03 18:39:53 +03:00
commit fa26d48cc7
9 changed files with 422 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
*.pyc
__pycache__/
.idea/

19
LICENSE Normal file
View File

@ -0,0 +1,19 @@
Copyright (c) 2022 Stanislav Nikolov <hello@snikolov.me>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

5
README.md Normal file
View File

@ -0,0 +1,5 @@
# Liar's Dice
This is a Python CLI implementation of the [Liar's dice](https://en.wikipedia.org/wiki/Liar%27s_dice) game. It provides
a simplistic AI, allowing a human player to play against a chosen number of AI opponents. An optional "wild dice"
variant is available, whereby the 1-face is considered "wild" - matching the face of the current bid.

46
bot_ai.py Normal file
View File

@ -0,0 +1,46 @@
import random
from scipy.stats import binom
from engine import LiarsDiceEngine, Bid
class BotAi:
def ai_turn(self, game_state: LiarsDiceEngine) -> Bid | None:
bid = game_state.current_bid or Bid(die_face=1, num_dice=0)
# Challenge a bid if we deem it sufficiently unlikely, based on the statistical odds of the bid being true. Do
# not hardcode a value to avoid the AI always reacting in a predetermined manner.
if (
self.calculate_odds(game_state, bid.die_face, bid.num_dice)
< random.uniform(0.1, 0.5)
):
return None
max_confidence = 0
max_confidence_bids = []
for d in range(bid.die_face, 7):
for n in range(1, game_state.num_all_dice):
if (d == bid.die_face and n > bid.num_dice) or d > bid.die_face:
confidence = self.calculate_odds(game_state, d, n)
if confidence == max_confidence:
max_confidence_bids.append(Bid(d, n))
elif confidence > max_confidence:
max_confidence = confidence
max_confidence_bids = [Bid(d, n)]
return random.choice(max_confidence_bids) if max_confidence_bids else None
@staticmethod
def calculate_odds(game_state: LiarsDiceEngine, die_face: int, num_values: int) -> float:
num_minimum_needed_values = (
num_values - game_state.dice[game_state.active_player_index][die_face] - 1
)
die_face_probability = 2.0 / 6 if game_state.wild_ones_variant else 1.0 / 6
return binom.sf(
k=num_minimum_needed_values,
n=game_state.num_opponent_dice,
p=die_face_probability,
)

135
engine.py Normal file
View File

@ -0,0 +1,135 @@
import random
from collections import Counter
from dataclasses import dataclass
@dataclass
class Bid:
die_face: int
num_dice: int
class EngineHandler:
def on_game_started(self, game_state: "LiarsDiceEngine"):
pass
def on_new_round(self):
pass
def on_bid_placed(self, bid):
pass
def on_challenge_bid(self):
pass
def on_player_eliminated(self, player_index):
pass
def get_players_turn(self) -> Bid | None:
pass
@dataclass
class LiarsDiceEngine:
NUM_INITIAL_DICE = 5
WILD_DIE_FACE = 1
listener: EngineHandler
num_players: int
round_number: int
wild_ones_variant: bool
active_player_index: int
current_bid: Bid | None
dice: list[Counter[int]]
@staticmethod
def new_game(
num_players: int, wild_ones_variant: bool, listener: EngineHandler
) -> "LiarsDiceEngine":
return LiarsDiceEngine(
num_players=num_players,
active_player_index=random.randint(0, num_players - 1),
wild_ones_variant=wild_ones_variant,
current_bid=None,
dice=[
Counter([random.randint(1, 6) for _ in range(LiarsDiceEngine.NUM_INITIAL_DICE)])
for _ in range(num_players)
],
round_number=0,
listener=listener,
)
def start_game(self) -> None:
self.listener.on_game_started(self)
while self.num_players > 1:
self.new_round()
while True:
bid = self.listener.get_players_turn()
if bid:
self.place_bid(bid)
else:
self.challenge_current_bid()
break
def new_round(self):
self.current_bid = None
self.active_player_index = self.active_player_index % self.num_players
self.round_number += 1
self.listener.on_new_round()
def place_bid(self, bid: Bid):
self.current_bid = bid
self.listener.on_bid_placed(bid)
self.active_player_index = (self.active_player_index + 1) % self.num_players
def challenge_current_bid(self):
self.listener.on_challenge_bid()
losing_player_index = (
self.active_player_index
if self.is_current_bid_successful()
else self.active_player_index - 1
)
num_dice_per_player = [c.total() for c in self.dice]
if num_dice_per_player[losing_player_index] > 1:
num_dice_per_player[losing_player_index] -= 1
else:
self.listener.on_player_eliminated(losing_player_index)
self.num_players -= 1
num_dice_per_player = [
d for (i, d) in enumerate(num_dice_per_player) if i != losing_player_index
]
self.active_player_index = losing_player_index
self.dice = [
Counter([random.randint(1, 6) for _ in range(num_dice_per_player[p])])
for p in range(self.num_players)
]
def is_current_bid_successful(self) -> bool:
num_wild_dice = (
self.num_all_dice_with_value(self.WILD_DIE_FACE) if self.wild_ones_variant else 0
)
return (
self.num_all_dice_with_value(self.current_bid.die_face) + num_wild_dice
>= self.current_bid.num_dice
)
@property
def num_opponent_dice(self) -> int:
return sum(c.total() for (i, c) in enumerate(self.dice) if i != self.active_player_index)
@property
def num_all_dice(self) -> int:
return sum(c.total() for c in self.dice)
def num_all_dice_with_value(self, die_face: int) -> int:
return sum(c[die_face] for c in self.dice)

46
main.py Normal file
View File

@ -0,0 +1,46 @@
import argparse
import pirate_names_generator
from bot_ai import BotAi
from engine import LiarsDiceEngine
from ui import MyEngineHandler
def play_game(args: argparse.Namespace):
names = ["You"] + pirate_names_generator.get_names(args.num_players - 1, False)
listener = MyEngineHandler(state=None, player_names=names, bot_ai=BotAi())
engine = LiarsDiceEngine.new_game(args.num_players, args.wild_ones, listener)
try:
engine.start_game()
except KeyboardInterrupt:
print("\nYou run away, cowardly...")
def main():
parser = argparse.ArgumentParser(
"Liar's dice", description="A classical dice game of chance and bluffing."
)
parser.add_argument(
"--num-players",
"-n",
type=int,
default=2,
help="The number of players (1 human, N-1 bots).",
)
parser.add_argument(
"--wild-ones",
"-w",
action="store_true",
help="If set, uses the advanced game rules, whereby the '1' die face is considered wild "
"(always matching the current bid).",
)
args = parser.parse_args()
play_game(args)
if __name__ == "__main__":
main()

58
pirate_names_generator.py Normal file
View File

@ -0,0 +1,58 @@
import sys
from dataclasses import dataclass
import random
import requests
@dataclass(frozen=True)
class PirateName:
first_name: str
last_name: str
nickname: str
def __str__(self):
return f'{self.first_name} "{self.nickname}" {self.last_name}'
def _get_local_names(num_names: int) -> list[str]:
return [f"Bot #{n}" for n in range(1, num_names + 1)]
def get_names(num_names: int, prefer_local: bool) -> list[str]:
if prefer_local:
return _get_local_names(num_names)
num_female = random.randint(1, num_names)
num_male = num_names - num_female
pirate_names = []
for is_male in (True, False):
gender = "male" if is_male else "female"
url = f"https://story-shack-cdn-v2.glitch.me/generators/pirate-name-generator/{gender}"
count = num_male if is_male else num_female
if count == 0:
continue
# Seems like the API doesn't handle the case "count == 1" too well and crashes. Just generate 2 if only is
# needed.
resp = requests.get(url=url, params={"count": count if count != 1 else 2})
if not resp.ok:
print(f"Failed to generate bot names:\n\n{resp.text}\n", file=sys.stderr)
return [f"Bot #{n}" for n in range(1, num_names + 1)]
for i, n in enumerate(resp.json()["data"]):
if i == count:
break
pirate_names.append(
PirateName(
first_name=n["name"],
last_name=n["lastName"],
nickname=n["nicknameMale"].strip("'")
if is_male
else n["nicknameFemale"].strip("'"),
),
)
return [str(p) for p in pirate_names]

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
requests==2.28.1
scipy==1.9.0

107
ui.py Normal file
View File

@ -0,0 +1,107 @@
import time
from dataclasses import dataclass
from bot_ai import BotAi
from engine import EngineHandler, LiarsDiceEngine, Bid
@dataclass
class MyEngineHandler(EngineHandler):
state: LiarsDiceEngine | None
player_names: list[str]
bot_ai: BotAi
def is_current_player_numan(self):
return self.state.active_player_index == 0
def on_game_started(self, game_state: LiarsDiceEngine):
self.state = game_state
print(f"Ahoy, challenger! You're playing against {', '.join(self.player_names[1:])}.")
print()
def on_bid_placed(self, bid: Bid):
print(
f"{self.player_names[self.state.active_player_index]} "
f"bid{'s' if self.state.active_player_index != 0 else ''} "
f"{bid.num_dice}x{bid.die_face}{'s' if bid.num_dice > 1 else ''}!"
)
def on_challenge_bid(self):
bid_owner = (
self.player_names[self.state.active_player_index - 1] + "'s"
if self.is_current_player_numan()
else "your"
)
print(
f"\n{self.player_names[self.state.active_player_index]} "
f"challenge{'' if self.is_current_player_numan() else 's'} "
f"{bid_owner} bid... "
f"{'unsuccessfully' if self.state.is_current_bid_successful() else 'SUCCESSFULLY'}!"
)
print(f"Your dice: {', '.join(str(d) for d in sorted(self.state.dice[0].elements()))}")
for i in range(1, len(self.player_names)):
print(
f"{self.player_names[i]}'s dice: {', '.join(str(d) for d in sorted(self.state.dice[i].elements()))}"
)
time.sleep(2)
def on_new_round(self):
print(f"\n--- Round {self.state.round_number} ---\n")
print(f"Your dice: {', '.join(str(d) for d in sorted(self.state.dice[0].elements()))}")
for i in range(1, len(self.player_names)):
total = self.state.dice[i].total()
print(f"{self.player_names[i]}'s has {total} {'die' if total == 1 else 'dice'} left.")
print()
def on_player_eliminated(self, player_index: int) -> None:
human_player_eliminated = player_index == 0
print(
f"*** {self.player_names[player_index]} {'have' if human_player_eliminated else 'has'} been eliminated!\n"
)
self.player_names = [n for (i, n) in enumerate(self.player_names) if i != player_index]
if len(self.player_names) == 1:
print(
f"+++ {self.player_names[0]} {'is' if human_player_eliminated else 'are'} the winner!\n"
)
def get_players_turn(self) -> Bid | None:
if self.is_current_player_numan():
return self.get_human_turn_input()
else:
return self.bot_ai.ai_turn(self.state)
def get_human_turn_input(self):
include_challenge_option = bool(self.state.current_bid)
help_message = (
"Please use the input format of NX, whereby N is the number of dice and X is the die face of"
"the bid. Example: '43' means a bid of 3 dice with the 'four' face."
)
while True:
bid_text = input(
f"Your bid [{{NX}}{'/L' if include_challenge_option else ''}/?]: "
).lower()
if bid_text == "?":
print(f"\n{help_message}\n")
if include_challenge_option and bid_text == "l":
return None
if len(bid_text) == 2 and bid_text.isdigit():
num_dice = int(bid_text[0])
die_face = int(bid_text[1])
if die_face < 1 or die_face > 6:
print(
"\nProper pirates only use d6'es, please chose a dice face appropriately! (1-6)"
)
continue
return Bid(die_face=die_face, num_dice=num_dice)
print("\nInvalid bid!")
print(help_message)