353 lines
9.8 KiB
Python
353 lines
9.8 KiB
Python
import random
|
|
from typing import Dict, List, Optional
|
|
from dataclasses import dataclass
|
|
import sys
|
|
from logger import log
|
|
from fuzzywuzzy import process
|
|
|
|
@dataclass
|
|
class Player:
|
|
name: str
|
|
role: str
|
|
alive: bool = True
|
|
protected: bool = False
|
|
|
|
|
|
class Game:
|
|
def __init__(self):
|
|
# single source of truth: name -> Player
|
|
self.players: Dict[str, Player] = {}
|
|
|
|
# lovers
|
|
self.lovers: Optional[Tuple[str, str]] = None
|
|
|
|
# Dead this night
|
|
self.dead_this_night: [List[str]] = []
|
|
|
|
# Used potions
|
|
self.used_potions: [list[str]] = []
|
|
# -------------------------
|
|
# setup / I/O
|
|
# -------------------------
|
|
def load_lists(self) -> Optional[Dict[str, List[str]]]:
|
|
"""
|
|
Load players and roles from files and return them as lists.
|
|
Returns dict with keys 'players' and 'roles'.
|
|
"""
|
|
players_file = "players.txt"
|
|
roles_file = "roles.txt"
|
|
|
|
log.info("Loading lists...")
|
|
|
|
try:
|
|
with open(players_file, "r", encoding="utf-8") as pf:
|
|
player_names = [line.strip() for line in pf if line.strip()]
|
|
except FileNotFoundError:
|
|
log.critical("Players file not found: %s", players_file)
|
|
return None
|
|
|
|
try:
|
|
with open(roles_file, "r", encoding="utf-8") as rf:
|
|
roles_list = [line.strip() for line in rf if line.strip()]
|
|
except FileNotFoundError:
|
|
log.critical("Roles file not found: %s", roles_file)
|
|
return None
|
|
|
|
log.info("Load complete!")
|
|
|
|
return {"players": player_names, "roles": roles_list}
|
|
|
|
# -------------------------
|
|
# player / role assignment
|
|
# -------------------------
|
|
def give_roles_to_players(self, player_names: Optional[List[str]] = None,
|
|
roles_list: Optional[List[str]] = None) -> None:
|
|
"""
|
|
Assign a random role to each player. Accepts optional lists (returned by load_lists).
|
|
If lists are not supplied, it will attempt to read files itself.
|
|
"""
|
|
|
|
if not player_names:
|
|
log.info("Players are not initialized")
|
|
return
|
|
if not roles_list:
|
|
log.info("Roles are not initialized")
|
|
return
|
|
if len(roles_list) < len(player_names):
|
|
log.error("Not enough roles for players (roles: %d, players: %d)",
|
|
len(roles_list), len(player_names))
|
|
return
|
|
|
|
available_roles = list(roles_list)
|
|
random.shuffle(available_roles)
|
|
|
|
# clear any existing players (safe to reassign)
|
|
self.players.clear()
|
|
|
|
for name in player_names:
|
|
chosen_role = available_roles.pop()
|
|
self.players[name] = Player(name=name, role=chosen_role)
|
|
|
|
log.debug("Assigned roles:")
|
|
for player in self.players.values():
|
|
log.debug("%s: %s", player.name, player.role)
|
|
|
|
# -------------------------
|
|
# utilities
|
|
# -------------------------
|
|
def select_someone(self, prompt: Optional[str] = None) -> Optional[str]:
|
|
"""
|
|
Prompt the user to enter a player name until a valid one is entered.
|
|
Returns None on EOF/KeyboardInterrupt.
|
|
"""
|
|
prompt = prompt or "Enter the name of the player: "
|
|
while True:
|
|
selected = input(prompt).strip()
|
|
|
|
# exact match
|
|
if selected in self.players:
|
|
return selected
|
|
|
|
# fuzzy matching
|
|
match = process.extract(selected, self.players, limit=1)
|
|
log.debug(match)
|
|
if match:
|
|
fuzz_player, fuzz_score = match[0][0], match[0][1] # player name, score
|
|
log.debug(fuzz_player)
|
|
log.debug(fuzz_score)
|
|
if fuzz_score >= 60:
|
|
log.info("You meant %s!", fuzz_player.name)
|
|
log.debug("Fuzz score: %s" % fuzz_score)
|
|
return fuzz_player
|
|
|
|
def choose_between(self, options):
|
|
#Prompt the user to choose between options until a valid one is entered
|
|
|
|
options_available = ', '.join(map(str, options))
|
|
#Make a prompt with a list of options, accepting either a list or a tuple.
|
|
prompt = f"Choose between {options_available}: "
|
|
while True:
|
|
selected = input(prompt)
|
|
|
|
if selected in options:
|
|
return selected
|
|
|
|
# fuzzy matching
|
|
match = process.extract(selected, options, limit=1)
|
|
log.debug(match)
|
|
if match:
|
|
fuzz_option, fuzz_score = match[0][0], match[0][1] #options, score
|
|
log.debug(fuzz_option)
|
|
log.debug(fuzz_score)
|
|
if fuzz_score >= 60:
|
|
log.info("You meant %s!", fuzz_option)
|
|
log.debug("Fuzz score: %s" % fuzz_score)
|
|
return fuzz_option
|
|
|
|
|
|
# -------------------------
|
|
# game actions
|
|
# -------------------------
|
|
def put_in_love(self, player1: str, player2: str) -> None:
|
|
"""Link two players such that if one dies the other dies too."""
|
|
if player1 not in self.players or player2 not in self.players:
|
|
log.error("One or both players do not exist: %s, %s", player1, player2)
|
|
return
|
|
|
|
self.lovers = (player1, player2)
|
|
|
|
log.debug("Players now put in love: %s <-> %s", player1, player2)
|
|
|
|
log.info("They are now bounded by love.")
|
|
log.info("They wake up and reveal their role to each other.")
|
|
log.info("Those two go back to sleep.")
|
|
|
|
|
|
def kill(self, player: str) -> None:
|
|
"""Kill a player."""
|
|
if player not in self.players:
|
|
log.error("Couldn't kill unknown player: %s", player)
|
|
return
|
|
|
|
target = self.players[player]
|
|
|
|
# already dead?
|
|
if not target.alive:
|
|
log.error("%s is already dead!", player)
|
|
return
|
|
|
|
# protected?
|
|
if target.protected:
|
|
log.debug("%s was protected and survives.", player)
|
|
return
|
|
|
|
# in love?
|
|
if target in self.lovers:
|
|
log.debug("%s is in love! Killing them and their lover.", target)
|
|
for p in self.lovers:
|
|
# kill them and their lover
|
|
log.debug("Killed %s", p)
|
|
self.dead_this_night.append(p)
|
|
p.alive = False
|
|
return
|
|
|
|
# else just kill them
|
|
log.info("Killed %s" % player)
|
|
self.dead_this_night.append(player)
|
|
target.alive = False
|
|
|
|
def revive(self, player: str) -> None:
|
|
"""Revive a player."""
|
|
log.info("Players that will die this night are: %s", self.dead_this_night)
|
|
if player not in self.players:
|
|
log.error("Tried to revive unknown player: %s", player)
|
|
return
|
|
p = self.players[player]
|
|
if not p.alive:
|
|
p.alive = True
|
|
log.info("%s has been revived.", player)
|
|
else:
|
|
log.info("%s is already alive.", player)
|
|
|
|
# -------------------------
|
|
# helpers
|
|
# -------------------------
|
|
def status(self) -> None:
|
|
"""Log current players' statuses for debugging."""
|
|
|
|
# player values
|
|
for p in self.players.values():
|
|
log.debug("%s -> role: %s, alive: %s, Protected: %s",
|
|
p.name, p.role, p.alive, p.protected)
|
|
# lovers
|
|
log.debug("Lovers: %s" % ",".join(self.lovers))
|
|
|
|
def role(func):
|
|
"""Decorator for roles"""
|
|
def wrapper(*args, **kwargs):
|
|
# the role is the name of the function thats decorated
|
|
role = func.__name__.capitalize()
|
|
log.info("%s wakes up." % role)
|
|
func(*args, **kwargs)
|
|
log.info("%s goes back to sleep." % role)
|
|
|
|
# workaround to call status from the instance
|
|
# is it ok? no idea but i dont have self anyway
|
|
instance = args[0]
|
|
instance.status()
|
|
|
|
return wrapper
|
|
|
|
# -------------------------
|
|
# roles
|
|
# -------------------------
|
|
|
|
@role
|
|
def cupidon(self) -> None:
|
|
"""Interactively pick two players to link by love."""
|
|
log.info("Choose two people to be linked to death.")
|
|
log.info("Give me the first person that shall be bounded!")
|
|
p1 = self.select_someone()
|
|
if p1 is None:
|
|
return
|
|
log.info("What about the second one?")
|
|
p2 = self.select_someone()
|
|
if p2 is None:
|
|
return
|
|
if p1 == p2:
|
|
log.info("Cannot link a player to themselves.")
|
|
return
|
|
self.put_in_love(p1, p2)
|
|
|
|
@role
|
|
def savior(self) -> None:
|
|
"""Interactively choose someone to protect."""
|
|
log.info("Choose someone to protect.")
|
|
protected = self.select_someone()
|
|
if protected is None:
|
|
return
|
|
|
|
self.players[protected].protected = True
|
|
log.debug("Protected: %s", protected)
|
|
|
|
@role
|
|
def witch(self) -> None:
|
|
"""Interactively choose to kill or revive someone"""
|
|
log.info("With the Revive Potion, you can revive someone, and with the Death Potion, you can kill someone. You can only use each potion once, and you can also choose to do nothing.")
|
|
log.info("Players who might die are %s", ' '.join(map(str, self.dead_this_night)))
|
|
if len(self.used_potions) >= 2:
|
|
log.info("You already used all of your potions. ")
|
|
else:
|
|
while True:
|
|
options = ("Death", "Revive", "Nothing")
|
|
potionchoice = self.choose_between(options).capitalize()
|
|
if potionchoice == "Revive" and "Revive" not in self.used_potions:
|
|
player = self.select_someone()
|
|
if player in self.dead_this_night:
|
|
self.used_potions.append("Revive")
|
|
self.revive(player)
|
|
return
|
|
elif player not in self.dead_this_night and not self.players[player].alive:
|
|
log.info("You cannot bring this person back to life because they have been buried.")
|
|
elif self.players[player].alive:
|
|
log.info("This player is not dead.")
|
|
else:
|
|
log.info("Unknown error: Invalid player choice")
|
|
return
|
|
elif potionchoice == "Death" and "Death" not in self.used_potions:
|
|
player = self.select_someone()
|
|
if self.players[player].alive:
|
|
self.used_potions.append("Death")
|
|
self.kill(player)
|
|
return
|
|
elif not self.players[player].alive:
|
|
log.info("This player is already dead.")
|
|
else:
|
|
log.info("Unknown error: Invalid player choice")
|
|
return
|
|
elif potionchoice == "Nothing":
|
|
log.info("You are not doing anything tonight.")
|
|
return
|
|
elif potionchoice in self.used_potions:
|
|
log.info("You already used this potion.")
|
|
else:
|
|
log.critical("Unknown error: Invalid potion choice.")
|
|
return
|
|
|
|
# -------------------------
|
|
# game flow
|
|
# -------------------------
|
|
def first_day_cycle(self) -> None:
|
|
log.info("All the villagers fall asleep.")
|
|
self.cupidon()
|
|
self.savior()
|
|
self.witch()
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print("---\nWerewolfGame\n---")
|
|
|
|
# instantiate the game
|
|
game = Game()
|
|
|
|
# I/O: read files and assign roles
|
|
loaded = game.load_lists()
|
|
try:
|
|
if loaded:
|
|
# start the game!
|
|
game.give_roles_to_players(loaded["players"], loaded["roles"])
|
|
game.first_day_cycle()
|
|
|
|
# CTRL+C
|
|
except KeyboardInterrupt:
|
|
print() # new line
|
|
log.info("Bye bye!")
|
|
sys.exit(0)
|
|
|
|
# Any unhandled exception
|
|
except Exception as e:
|
|
log.exception("Unhandled exception: %s" % e)
|
|
sys.exit(1)
|
|
|