commit fa26d48cc70f3bf930a45ea629dcf037deece64d Author: Stanislav Nikolov Date: Wed Aug 3 18:39:53 2022 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45342ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.pyc +__pycache__/ +.idea/ + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fd863f8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2022 Stanislav Nikolov + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0312e33 --- /dev/null +++ b/README.md @@ -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. diff --git a/bot_ai.py b/bot_ai.py new file mode 100644 index 0000000..a6bdc0e --- /dev/null +++ b/bot_ai.py @@ -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, + ) diff --git a/engine.py b/engine.py new file mode 100644 index 0000000..a286510 --- /dev/null +++ b/engine.py @@ -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) diff --git a/main.py b/main.py new file mode 100644 index 0000000..b62d346 --- /dev/null +++ b/main.py @@ -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() diff --git a/pirate_names_generator.py b/pirate_names_generator.py new file mode 100644 index 0000000..049fe29 --- /dev/null +++ b/pirate_names_generator.py @@ -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] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3d8af8e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests==2.28.1 +scipy==1.9.0 diff --git a/ui.py b/ui.py new file mode 100644 index 0000000..3055e9e --- /dev/null +++ b/ui.py @@ -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)