diff --git a/.gitignore b/.gitignore index 1910b04..f0c75bb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__ venv/ .venv/ +.python-version diff --git a/src/game/commands.py b/src/game/commands.py index 161baa1..565dcac 100644 --- a/src/game/commands.py +++ b/src/game/commands.py @@ -5,6 +5,7 @@ from . import gamelogic from . import factions +from . import strategy_cards from ..typing import * from discord.ext import commands @@ -22,8 +23,10 @@ def __init__( ) -> None: """Initialize the Commands cog with factions.""" self.factions = factions.read_factions() + self.strategy_cards = strategy_cards.read_strategy_cards() self.logic = gamelogic.GameLogic(bot, engine) + async def __send_embed_or_pretty_err(self, ctx: commands.Context, result: Result[discord.Embed]) -> None: match result: case Ok(embed): @@ -44,6 +47,15 @@ def __game_id(self, ctx: commands.Context): # Let's use the channel ID for the game ID. return ctx.channel.id + @commands.command(name="strategy-cards") + async def strat_cards( + self, ctx: commands.Context) -> None: + """Returns the list of strategy cards in the game.""" + + await ctx.send( + f"{'\n'.join([str(sc) for sc in self.strategy_cards])}" + ) + @commands.command(name="factions") async def random_factions( self, ctx: commands.Context, number: int = 8, *, sources: str = "" diff --git a/src/game/data/ti4_strategy_cards.csv b/src/game/data/ti4_strategy_cards.csv new file mode 100644 index 0000000..2c82cd6 --- /dev/null +++ b/src/game/data/ti4_strategy_cards.csv @@ -0,0 +1,9 @@ +Initiative order,Strategy card,Primary ability,Secondary ability +1,Leadership,Gain 3 command tokens. Spend any amount of influence to gain 1 command token for every 3 influence spent.,Spend any amount of influence to gain 1 command token for every 3 influence spent. +2,Diplomacy,Choose 1 system other than the Mecatol Rex system that contains a planet you control; each other player places a command token from their reinforcements in the chosen system. Then ready up to 2 exhausted planets you control.,Spend 1 token from your strategy pool to ready up to 2 exhausted planets you control. +3,Politics,Choose a player other than the speaker. That player gains the speaker token. Draw 2 action cards. Look at the top 2 cards of the agenda deck. Place each card on the top or bottom of the deck in any order.,Spend 1 token from your strategy pool to draw 2 action cards. +4,Construction,Place 1 PDS or 1 Space Dock on a planet you control. Place 1 PDS on a planet you control.,Spend 1 token from your strategy pool and place it in any system; you may place either 1 space dock or 1 PDS on a planet you control in that system. +5,Trade,Gain 3 trade goods. Replenish commodities. Choose any number of other players. Those players use the secondary ability of this strategy card without spending a command token.,Spend 1 token from your strategy pool to replenish your commodities. +6,Warfare,Remove 1 of your command tokens from the game board; then gain 1 command token. Redistribute any number of the command tokens on your command sheet, Spend 1 token from your strategy pool to use the Production ability of 1 of your space docks in your home system (This token is not placed in your home system). +7,Technology,Research 1 technology. Spend 6 resources to research 1 technology.,Spend 1 token from your strategy pool and 4 resources to research 1 technology. +8,Imperial,Immediately score 1 public objective if you fulfill its requirements. Gain 1 victory point if you control Mecatol Rex; otherwise draw 1 secret objective.,Spend 1 token from your strategy pool to draw 1 secret objective. diff --git a/src/game/draftingmodes.py b/src/game/draftingmodes.py index 2448960..9f4ea5e 100644 --- a/src/game/draftingmodes.py +++ b/src/game/draftingmodes.py @@ -8,11 +8,12 @@ from . import model, controller from . import gamelogic from . import factions as fs +from . import strategy_cards from ..typing import * from itertools import batched from sqlalchemy.orm import Session, attributes -from typing import Optional +from typing import Optional, List, Dict class GameMode: @@ -44,6 +45,8 @@ def create(cls, game: model.Game) -> GameMode: return PicksOnly(game) case model.DraftingMode.PICKS_AND_BANS: return PicksAndBans(game) + case model.DraftingMode.HOMEBREW_DRAFT: + return HomeBrewDraft(game) return GameMode(game) def draft( @@ -251,7 +254,6 @@ class PicksAndBans(GameMode): def draft( self, session: Session, - game: model.Game, player: model.GamePlayer, faction: Optional[str], ) -> Optional[str]: @@ -262,13 +264,13 @@ def draft( if not faction: return f"Your available factions are:\n{"\n".join(player.factions)}" - current_drafter = self.controller.current_drafter(session, game) + current_drafter = self.controller.current_drafter(session, self.game) - if game.turn != player.turn_order: + if self.game.turn != player.turn_order: return f"It is not your turn to draft! It is {current_drafter.player.name}'s turn" all_bans = [] - for game_player in game.game_players: + for game_player in self.game.game_players: if game_player.bans: all_bans.extend(game_player.bans) @@ -285,10 +287,10 @@ def draft( return f"Faction {best} has already been banned." player.faction = best - game.turn += 1 + self.game.turn += 1 other_players = [ - other for other in game.game_players if other.player_id != player.player_id + other for other in self.game.game_players if other.player_id != player.player_id ] for other_player in other_players: # Somehow the typing doesn't work without the if-statement @@ -297,14 +299,14 @@ def draft( other_player.factions.remove(player.faction) session.merge(other_player) - session.merge(game) + session.merge(self.game) session.merge(player) lines = [f"{player.player.name} has selected {player.faction}."] - if game.turn == len(game.game_players): + if self.game.turn == len(self.game.game_players): return None session.commit() - current_drafter = self.controller.current_drafter(session, game) + current_drafter = self.controller.current_drafter(session, self.game) lines.append(f"Next drafter is <@{current_drafter.player_id}>. Use !draft.") return "\n".join(lines) @@ -416,3 +418,204 @@ def ban( session.commit() return "\n".join(lines) + +class HomeBrewDraft(GameMode): + def draft( + self, + session: Session, + player: model.GamePlayer, + draft_choice: Optional[str], + ) -> Result[discord.Embed|GameStarted]: + + if player.faction and player.position and player.strategy_card: + return Ok(discord.Embed( + title="Drafting phase", + description=f"You have drafted {player.faction}, starting with {player.strategy_card} in position {player.position}", + color=discord.Color.green() + )) + + + strategy_cards_map : Dict[str, strategy_cards.StrategyCard] = {sc.name.lower(): sc for sc in strategy_cards.read_strategy_cards()} + available_strategy_cards: Dict[str, strategy_cards.StrategyCard] = {sc.name.lower(): sc for sc in strategy_cards_map.values() if sc.name not in [other.strategy_card for other in self.game.game_players if other.strategy_card]} + available_positions: List[int] = [x for x in range(1, len(self.game.game_players) + 1) if x not in [other.position for other in self.game.game_players if other.position]] + + lines = [] + if not draft_choice: + if not player.faction: + lines.append("You may draft **Faction**.") + if not player.strategy_card: + lines.append("You may draft **Strategy card**.") + if not player.position: + lines.append("You may draft **Starting position**.") + if not player.faction: + lines.append(f"\nYour available Factions are:\n* {"\n* ".join(player.factions)}") + if not player.strategy_card: + lines.append(f"\nYour available Strategy cards are:\n{"\n".join([str(sc) for sc in sorted(available_strategy_cards.values(), key=lambda x: x.initiative_order)])}") + if not player.position: + lines.append(f"\nYour available Starting positions are: {", ".join(map(str,available_positions))}") + return Ok(discord.Embed( + title="Draft Options", + description="\n".join(lines), + color=discord.Color.blue() + )) + + current_drafter = self.controller.current_drafter(session, self.game) + + if self.game.turn != player.turn_order: + return Err(f"It is not your turn to draft! It is {current_drafter.player.name}'s turn") + + + def get_direction(game: model.Game) -> int: + num_drafts = sum([bool(player.faction) + bool(player.strategy_card) + bool(player.position) for player in game.game_players]) + return 1 if ((num_drafts // len(game.game_players)) % 2) == 0 else -1 + + # This is used later, but need to be calculated before the drafting + direction = get_direction(self.game) + + all_bans: List[str] = [] + for game_player in self.game.game_players: + if game_player.bans: + all_bans.extend(game_player.bans) + + available_factions = player.factions.copy() + available_factions.extend(all_bans) + + # Draft position + if draft_choice.isdigit(): + if not player.position: + if int(draft_choice) not in available_positions: + return Err(f"You can't draft position {draft_choice}.") + else: + player.position = int(draft_choice) + lines.append(f"Player <@{current_drafter.player_id}> drafted position {player.position}") + else: + return Err("You've already drafted position") + # Draft strategy card + elif draft_choice.lower() in strategy_cards_map.keys(): + if not player.strategy_card: + if draft_choice.lower() not in available_strategy_cards.keys(): + return Err(f"Strategy card {available_strategy_cards[draft_choice.lower()].name} is already picked.") + else: + player.strategy_card = available_strategy_cards[draft_choice.lower()].name + lines.append(f"Player <@{current_drafter.player_id}> drafted Strategy card {player.strategy_card}") + else: + return Err("You've already drafted Strategy card") + + # Check what the best match is. If it's a faction, draft faction + else: + possible_matches : Dict[str, Union[str, strategy_cards.StrategyCard]] = {x.lower(): x for x in available_factions} + possible_matches.update({sc.name.lower(): sc for sc in strategy_cards_map.values()}) + + best = gamelogic.GameLogic._closest_match(draft_choice.lower(), possible_matches.keys()) + if not best: + return Err(f"Not possible to match {draft_choice} to anything. Check your spelling or available picks") + if best in strategy_cards_map.keys(): + if not player.strategy_card: + return Err(f"Did you mean to draft {strategy_cards_map[best].name}? Strict spelling is enforced for strategy cards.") + else: + return Err(f"Best match was {strategy_cards_map[best].name}, but you've already drafted Strategy card") + if not player.faction: + # Check if this faction is already banned by anyone + value = possible_matches[best] + if value in all_bans: + return Err(f"Faction {value} is banned.") + if isinstance(value, str): + player.faction = value + lines = [f"<@{current_drafter.player_id}> has drafted {player.faction}."] + else: + return Err("An error occurred when matching text to strategy card or faction.") + + # Update other players available factions + other_players = [ + other for other in self.game.game_players if other.player_id != player.player_id + ] + for other_player in other_players: + if player.faction: + other_player.factions.remove(player.faction) + session.merge(other_player) + else: + return Err("You've already drafted faction.") + + + def check_draft_over(game: model.Game) -> bool: + done = [bool(player.faction) and bool(player.strategy_card) and bool(player.position) for player in game.game_players] + return all(done) + + self.game.turn += direction + + session.merge(player) + if self.game.turn == -1 or self.game.turn == len(self.game.game_players): + if check_draft_over(self.game): + session.merge(self.game) + session.commit() + return Ok(GameStarted()) + else: + # Flip direction and go back. + self.game.turn -= direction + session.merge(self.game) + session.commit() + + current_drafter = self.controller.current_drafter(session, self.game) + + lines.append(f"Next drafter is <@{current_drafter.player_id}>. Use !draft.") + return Ok(discord.Embed( + title="Next Drafter", + description="\n".join(lines), + color=discord.Color.blue() + )) + + def start(self, session: Session, factions: fs.Factions) -> Result[discord.Embed]: + + self.direction: int = 1 + + players = self.game.game_players + number_of_players = len(players) + + factions_per_player = self.game.game_settings.factions_per_player + fs = factions.get_random_factions( + number_of_players * factions_per_player, self._get_faction_sources() + ) + if len(fs) < number_of_players * factions_per_player: + return Err( + f"There are too many factions selected per player. Max allowed for a {number_of_players} player game is {len(fs)//number_of_players}." + ) + fs = [faction.name for faction in fs] + + turn_order = random.sample(range(number_of_players), number_of_players) + faction_slices = batched(fs, factions_per_player) + + factions_lines = [] + + player_from_turn = {} + for i, (player, player_factions) in enumerate( + zip(self.game.game_players, faction_slices) + ): + player.turn_order = turn_order[i] + player_from_turn[player.turn_order] = player.player.name + player.factions = list(player_factions) + factions_lines.extend( + list(map(lambda x: f"{x} ({player.player.name})", player_factions)) + ) + session.merge(player) + + players_info_lines = [] + for i in range(number_of_players): + name = player_from_turn[i] + players_info_lines.append(f"{name}") + + self.game.game_state = model.GameState.DRAFT + session.merge(self.game) + session.commit() + + lines = [ + f"State: {self.game.game_state.value}\n\nSnake drafting enabled\n\nPlayers (in draft order):\n{"\n".join(players_info_lines)}\n\nFactions:\n{"\n".join(factions_lines)}" + ] + + current_drafter = self.controller.current_drafter(session, self.game) + + lines.append(f"<@{current_drafter.player_id}> begins drafting. Use !draft.") + return Ok(discord.Embed( + title="🎲 Drafting Phase", + description="\n".join(lines), + color=discord.Color.blue() + )) \ No newline at end of file diff --git a/src/game/gamelogic.py b/src/game/gamelogic.py index da7cc80..21b9d21 100644 --- a/src/game/gamelogic.py +++ b/src/game/gamelogic.py @@ -7,6 +7,7 @@ import enum from . import factions as fs +from . import strategy_cards from . import model from . import draftingmodes from . import controller @@ -18,7 +19,7 @@ from sqlalchemy import inspect, Enum, Boolean, String, Integer, select from sqlalchemy.orm import Session from string import Template -from typing import Optional, Dict, Any, Iterable, Sequence +from typing import Optional, Dict, Any, Iterable, Sequence, List from blinker import signal @@ -243,7 +244,10 @@ def cancel(self, game_id: int) -> Result[discord.Embed]: def _start_game(self, session: Session, game: model.Game) -> discord.Embed: players_info_lines = [] for player in game.game_players: - players_info_lines.append(f"{player.player.name} playing {player.faction}") + if game.game_settings.drafting_mode == model.DraftingMode.HOMEBREW_DRAFT: + players_info_lines.append(f"<@{player.player_id}> playing {player.faction}, starting with {player.strategy_card} at position {player.position}") + else: + players_info_lines.append(f"{player.player.name} playing {player.faction}") game.game_state = model.GameState.STARTED session.merge(game) @@ -552,11 +556,11 @@ def get_valid_values(dtype): dtype = valid_keys[property] if isinstance(dtype, Enum): - enum_list = list(dtype.enums) - best_value = GameLogic._closest_match(value, enum_list) + enum_map: Dict[str, Any] = {text.lower(): text for text in dtype.enums} + best_value = GameLogic._closest_match(value.lower(), enum_map.keys()) if not best_value: - return Err(f"Valid values are: {enum_list}") - new_value = best_value + return Err(f"Valid values are: {enum_map.values()}") + new_value = enum_map[best_value] elif isinstance(dtype, Boolean): val = value.lower() if val in ["true", "t", "yes", "y", "1"]: diff --git a/src/game/model.py b/src/game/model.py index 3326376..782972b 100644 --- a/src/game/model.py +++ b/src/game/model.py @@ -21,7 +21,7 @@ class DraftingMode(enum.Enum): PICKS_ONLY = "PICKS_ONLY" PICKS_AND_BANS = "PICKS_AND_BANS" EXCLUSIVE_POOL = "EXCLUSIVE_POOL" - MILTY_DRAFT = "MILTY_DRAFT" + HOMEBREW_DRAFT = "HOMEBREW_DRAFT" class GamePlayer(models.Base): @@ -34,10 +34,10 @@ class GamePlayer(models.Base): points: Mapped[int] = mapped_column(Integer, default=0) turn_order: Mapped[int] = mapped_column(Integer, default=0) - # Used in exclusive pool mode and Milty draft + # Used in exclusive pool mode and Homebrew draft factions: Mapped[List[str]] = mapped_column(JSON, default=[]) - # Used in Milty draft + # Used in Homebrew draft position: Mapped[Optional[int]] = mapped_column(Integer) strategy_card: Mapped[Optional[str]] = mapped_column(String) diff --git a/src/game/strategy_cards.py b/src/game/strategy_cards.py new file mode 100644 index 0000000..88cf505 --- /dev/null +++ b/src/game/strategy_cards.py @@ -0,0 +1,31 @@ +import csv +from pathlib import Path +from typing import List + + +class StrategyCard: + def __init__(self, initiative_order: int, name: str, primary: str, secondary: str) -> None: + self.initiative_order: int = initiative_order + self.name: str = name + self.primary: str = primary + self.secondary: str = secondary + + def __str__(self) -> str: + return f"{self.initiative_order}. {self.name}" + + +def read_strategy_cards(file_path: str = "data/ti4_strategy_cards.csv") -> List[StrategyCard]: + here = Path(__file__).parent + + strategy_cards: List[StrategyCard] = [] + with open(here / file_path, newline="") as csvfile: + reader = csv.reader(csvfile, delimiter=",") + next(reader) # Skip header row + for row in reader: + if len(row) >= 3: # Ensure there are at least two columns + initiative: int = int(row[0].strip()) + name: str = row[1].strip() + primary: str = row[2].strip() + secondary: str = row[3].strip() + strategy_cards.append(StrategyCard(initiative, name, primary, secondary)) + return strategy_cards diff --git a/src/game/util/data_clean.csv b/src/game/util/data_clean.csv new file mode 100644 index 0000000..b908cb5 --- /dev/null +++ b/src/game/util/data_clean.csv @@ -0,0 +1,85 @@ +juli 2025,Jan,The Vuil’Raith Cabal,14 +juli 2025,Gurkan,The Celdauri Trade Confederation,13 +juli 2025,Änkå,The L’Tokk Khrask,12 +juli 2025,Mattias,The Augurs of Ilyxum,11 +juli 2025,Jake,The Mirveda Protectorate,4 +juni 2023,Honing,The L1Z1X Mindnet,14 +juni 2023,Jake,The Winnu,12 +juni 2023,Änkå,The Federation of Sol,7 +juni 2023,Hanky,The Nekro Virus,6 +juni 2023,Edu,The Xxcha Kingdom,5 +juni 2023,Gurkan,The Arborec,4 +juni 2022,Gurkan,The Council Keleres,12 +juni 2022,Jake,Sardakk N’orr,12 +juni 2022,Honing,The Mentak Coalition,10 +juni 2022,Änkå,The Barony of Letnev,7 +juni 2022,Ampi,The Nekro Virus,9 +juni 2022,Edu,Sardakk N’orr,8 +maj 2022,Honing,The Winnu,14 +maj 2022,Jake,The Mentak Coalition,12 +maj 2022,Ampi,The Argent Flight,12 +maj 2022,Hanky,The Nekro Virus,10 +maj 2022,Mattias,The Clan of Saar,10 +december 2021,Jake,The Titans of Ul,14 +december 2021,Mattias,The Emirates of Hacan,11 +december 2021,Hanky,The Nekro Virus,11 +december 2021,Honing,The Universities of Jol-Nar,10 +december 2021,Gurkan,The Argent Flight,10 +september 2021,Änkå,The Argent Flight,10 +september 2021,Mattias,The Universities of Jol-Nar,8 +september 2021,Gurkan,The Embers of Muaat,8 +september 2021,Honing,Unknown,6 +september 2021,Ampi,The Vuil’Raith Cabal,9 +september 2021,Hanky,The Empyrean,6 +maj 2021,Jake,The Nekro Virus,10 +maj 2021,Mattias,The Nomad,8 +maj 2021,Tove,The Argent Flight,8 +maj 2021,Honing,Unknown,8 +maj 2021,Ampi,The Vuil’Raith Cabal,7 +maj 2021,Hanky,The Embers of Muaat,7 +april 2021,Jake,The Titans of Ul,10 +april 2021,Mattias,The Nomad,8 +april 2021,Honing,The Naaz‑Rokha Alliance,8 +april 2021,Gurkan,The Embers of Muaat,5 +april 2021,Ampi,The Vuil’Raith Cabal,4 +april 2021,Kuben,The Yssaril Tribes,3 +Sommaren 2020 del 2,Honing,The Yin Brotherhood,10 +Sommaren 2020 del 2,Jake,The Kyro Sodality,8 +Sommaren 2020 del 2,Mattias,The Emirates of Hacan,8 +Sommaren 2020 del 2,Ragge,The Ghosts of Creuss,7 +Sommaren 2020 del 2,Gurkan,The Arborec,6 +Sommaren 2020 del 2,Ampi,The Naalu Collective,4 +Sommaren 2020,Honing,The Xxcha Kingdom,10 +Sommaren 2020,Jake,The Naalu Collective,8 +Sommaren 2020,Mattias,The Emirates of Hacan,8 +Sommaren 2020,Änkå,Sardakk N’orr,8 +Sommaren 2020,Ragge,The Barony of Letnev,7 +Sommaren 2020,Gurkan,The Mentak Coalition,6 +Våren 2020,Edu,The Universities of Jol-Nar,10 +Våren 2020,Honing,Sardakk N’orr,8 +Våren 2020,Gurkan,The Mentak Coalition,8 +Våren 2020,Ragge,The Clan of Saar,7 +Våren 2020,Änkå,The Federation of Sol,6 +Våren 2020,Mattias,The Ghosts of Creuss,5 +Vintern 2019,Jake,The Clan of Saar,10 +Vintern 2019,Gurkan,The Arborec,7 +Vintern 2019,Ragge,The Kortali Tribunal,7 +Vintern 2019,Mattias,The L1Z1X Mindnet,6 +Våren 2019,Änkå,The Emirates of Hacan,10 +Våren 2019,Edu,The L1Z1X Mindnet,9 +Våren 2019,Jake,The Nekro Virus,8 +Våren 2019,Honing,Sardakk N’orr,8 +Våren 2019,Gurkan,The Arborec,5 +Våren 2019,Normal-Anton,The Clan of Saar,6 +Sommaren 2018,Jake,The Nekro Virus,10 +Sommaren 2018,Ragge,The Winnu,9 +Sommaren 2018,Gurkan,The Barony of Letnev,8 +Sommaren 2018,Jan,The Nomad,7 +Sommaren 2018,Honing,The Ghosts of Creuss,7 +Sommaren 2018,Änkå,The Embers of Muaat,6 +Vintern 2017/2018,Jake,The Winnu,10 +Vintern 2017/2018,Kuben,The Emirates of Hacan,9 +Vintern 2017/2018,Änkå,The L1Z1X Mindnet,8 +Vintern 2017/2018,Honing,The Yssaril Tribes,7 +Vintern 2017/2018,Ampi,Sardakk N’orr,5 +Vintern 2017/2018,Gurkan,The Mentak Coalition,4 \ No newline at end of file diff --git a/src/game/util/player.csv b/src/game/util/player.csv new file mode 100644 index 0000000..de72ec4 --- /dev/null +++ b/src/game/util/player.csv @@ -0,0 +1,13 @@ +336231094208823296,Gurkan +634845730653208613,Jake +271362040541609996,Ampi +1107095377301221416,Honing +219471183773564938,Hanky +326051912187248640,Änkå +630385617087234069,Edu +169925909468413952,Ragge +197427296938885120,Mattias +210060232527708160,Tove +148879014759628800,Kuben +463446125588774922,Normal-Anton +960166252020629535,Jan \ No newline at end of file