diff --git a/.gitignore b/.gitignore index b694934..5290d13 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,216 @@ -.venv \ No newline at end of file +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml diff --git a/logger.py b/logger.py new file mode 100644 index 0000000..3d65862 --- /dev/null +++ b/logger.py @@ -0,0 +1,31 @@ +import logging + +log = logging.getLogger(__name__) +log.setLevel(logging.DEBUG) +formatter = logging.Formatter( + "%(asctime)s \033[1m%(levelname)s\033[0m\033[0m %(message)s" +) + +# stdout +stream_handler = logging.StreamHandler() +stream_handler.setLevel(logging.DEBUG) +stream_handler.setFormatter(formatter) +log.addHandler(stream_handler) + + +# Add custom log level names with colors +logging.addLevelName( + logging.DEBUG, "\033[1;30m%s\033[1;0m" % logging.getLevelName(logging.DEBUG) +) # gray +logging.addLevelName( + logging.INFO, "\033[1;34m%s\033[1;0m" % logging.getLevelName(logging.INFO) +) # blue +logging.addLevelName( + logging.WARNING, "\033[1;33m%s\033[1;0m" % logging.getLevelName(logging.WARNING) +) # yellow +logging.addLevelName( + logging.ERROR, "\033[1;31m%s\033[1;0m" % logging.getLevelName(logging.ERROR) +) # red +logging.addLevelName( + logging.CRITICAL, "\033[1;35m%s\033[1;0m" % logging.getLevelName(logging.CRITICAL) +) # \ No newline at end of file diff --git a/main.py b/main.py index aee98f3..9ab617f 100644 --- a/main.py +++ b/main.py @@ -1,97 +1,275 @@ - import random -import time +from typing import Dict, List, Optional +from dataclasses import dataclass +import sys +from logger import log +from fuzzywuzzy import process -playerFile = open("Players.txt") -rolesFile = open ("Roles.txt") - -PlayersName = [] -RolesList = [] -KilledDuringNight = [] -PlayersInLove = [] -players_dict = {} +@dataclass +class Player: + name: str + role: str + alive: bool = True + protected: bool = False - -def GiveRoleToPlayers(): - if PlayersName and RolesList: - for player in PlayersName: - ChoseRole = random.choice(RolesList) - players_dict[player] = {'name': player, 'role': ChoseRole, 'alive': True, 'InLove': False, 'Protected': False} - RolesList.remove(ChoseRole) - else: - print("Players are not initialized") - - -def CreateLists(): - for line in playerFile: - PlayersName.append(line.strip()) - for line in rolesFile: - RolesList.append(line.strip()) - playerFile.close() - rolesFile.close() - - -def Kill(player): - if players_dict[player]['InLove'] == False: - players_dict[player]['alive'] = False - KilledDuringNight.append(player) - elif players_dict[player]['InLove'] == True: - print("in love check") - for player in players_dict: - if players_dict[player]['InLove'] == True: - print("found one") - players_dict[player]['alive'] = False - KilledDuringNight.append(player) - elif players_dict[player]['Protected'] == True: - return - +class Game: + def __init__(self): + # single source of truth: name -> Player + self.players: Dict[str, Player] = {} - -def Revive(player): - players_dict[player]["alive"] = True - KilledDuringNight.remove(player) -def PutInLove(player1, player2): - players_dict[player1]['InLove'] = True - players_dict[player2]['InLove'] = True - time.sleep(1) - print("The people selected wake up and show their role") - time.sleep(1) - print("They go back to sleep") + # lovers + self.lovers: Optional[Tuple[str, str]] = None -def Cupidon(): - print("You choose two people to be linked to the death") - player1 = SelectSomeone() - player2 = SelectSomeone() - PutInLove(player1, player2) + # ------------------------- + # 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" -def Savior(): - print("You choose someone to protect") - ProtectedPlayer = SelectSomeone() - players_dict[ProtectedPlayer]['Protected'] = False + 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 + + # ------------------------- + # 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 -def SelectSomeone(): - while True: - SelectedPlayer = input("Enter the name of the player:") - if SelectedPlayer in PlayersName: - return SelectedPlayer + 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) + p.alive = False + return + + # else just kill them + log.debug("Killed %s" % p) + target.alive = False + + def revive(self, player: str) -> None: + """Revive a player.""" + 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: - print("This player don't exist") + log.info("%s is already alive.", player) -def FirstDayCycle(): - print("The villagers fall asleep") - time.sleep(1) - print("Cupidon get up") - Cupidon() - print("Cupidon go back to sleep") - time.sleep(1) - print("The savior get up") - Savior() + # ------------------------- + # 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) + + + # ------------------------- + # game flow + # ------------------------- + def first_day_cycle(self) -> None: + log.info("All the villagers fall asleep.") + self.cupidon() + self.savior() -CreateLists() -GiveRoleToPlayers() -FirstDayCycle() \ No newline at end of file +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) + diff --git a/Players.txt b/players.txt similarity index 100% rename from Players.txt rename to players.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..516af7e --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +fuzzywuzzy==0.18.0 \ No newline at end of file diff --git a/Roles.txt b/roles.txt similarity index 100% rename from Roles.txt rename to roles.txt