Initial commit
This commit is contained in:
commit
fa26d48cc7
|
|
@ -0,0 +1,4 @@
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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]
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
requests==2.28.1
|
||||||
|
scipy==1.9.0
|
||||||
|
|
@ -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)
|
||||||
Loading…
Reference in New Issue