Merge branch 'feature/#0/initialization'
commit
c25d86448c
|
|
@ -158,5 +158,5 @@ cython_debug/
|
||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
# 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
|
# 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.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
#.idea/
|
.idea/
|
||||||
|
|
||||||
|
|
|
||||||
35
README.md
35
README.md
|
|
@ -1,3 +1,38 @@
|
||||||
# Diamant-AI
|
# Diamant-AI
|
||||||
|
|
||||||
Repository of the project to create an AI to play the boardgame "Diamant".
|
Repository of the project to create an AI to play the boardgame "Diamant".
|
||||||
|
See: [Diamant board game - Wikipedia](https://en.wikipedia.org/wiki/Diamant_(board_game))
|
||||||
|
|
||||||
|
Note: Only the base game works for now, the Monte Carlo Tree Search has not been implemented yet :c
|
||||||
|
|
||||||
|
## User Guide
|
||||||
|
|
||||||
|
1) Install Python 3.11+ on your computer.
|
||||||
|
2) On Linux, open a CLI and run the script: `python3.11 src/main.py`
|
||||||
|
|
||||||
|
|
||||||
|
## TODO List
|
||||||
|
|
||||||
|
- [ ] Setup a virtual environment.
|
||||||
|
- [ ] Code the MCTS AI player.
|
||||||
|
- [ ] Integrate joblib library to help MCTS computer faster the win/lose probabilities.
|
||||||
|
|
||||||
|
|
||||||
|
## Code Styling
|
||||||
|
|
||||||
|
### Git Commit Message
|
||||||
|
|
||||||
|
Commit messages should follow the standard:
|
||||||
|
`<type>[optional scope]: <description>`
|
||||||
|
|
||||||
|
The following types are accepted:
|
||||||
|
- feat: A new feature.
|
||||||
|
- fix: A bug fix.
|
||||||
|
- docs: Documentation only changes.
|
||||||
|
- style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc).
|
||||||
|
- refactor: A code change that neither fixes a bug nor adds a feature.
|
||||||
|
- perf: A code change that improves performance.
|
||||||
|
- test: Adding tests.
|
||||||
|
- chore: Changes to the build process or auxiliary tools and libraries such as documentation generation.
|
||||||
|
|
||||||
|
Source: <https://ec.europa.eu/component-library/v1.15.0/eu/docs/conventions/git/>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,322 @@
|
||||||
|
# Python STD.
|
||||||
|
import random
|
||||||
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Callable, Final
|
||||||
|
|
||||||
|
|
||||||
|
# Project packages.
|
||||||
|
|
||||||
|
|
||||||
|
# Number of players that can play the game
|
||||||
|
MIN_NB_PLAYERS: int = 3
|
||||||
|
MAX_NB_PLAYERS: int = 8
|
||||||
|
|
||||||
|
|
||||||
|
class TrapType(StrEnum):
|
||||||
|
"""Type of traps that can be encountered during a round."""
|
||||||
|
|
||||||
|
BOULDER = "Boulder"
|
||||||
|
LAVA = "Lava"
|
||||||
|
LOG = "Log"
|
||||||
|
SERPENT = "Serpent"
|
||||||
|
SPIDER = "Spider"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Relic:
|
||||||
|
"""A Relic that can be encountered during a round."""
|
||||||
|
|
||||||
|
# The value of the relic, i.e. its equivalence with gems.
|
||||||
|
value: int
|
||||||
|
|
||||||
|
|
||||||
|
class Deck:
|
||||||
|
"""
|
||||||
|
Content of the game's deck, it should contain all the logic about drawing card for
|
||||||
|
the exploration of a single dungeon.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# List of cards to be put in a deck.
|
||||||
|
CARDS_GEMS: Final[tuple[int]] = [1, 2, 3, 4, 5, 5, 7, 7, 9, 11, 11, 13, 14, 15, 17]
|
||||||
|
CARDS_RELIC: Final[tuple[Relic]] = [
|
||||||
|
Relic(5),
|
||||||
|
Relic(7),
|
||||||
|
Relic(8),
|
||||||
|
Relic(10),
|
||||||
|
Relic(12),
|
||||||
|
]
|
||||||
|
CARDS_TRAPS: Final[tuple[str]] = [
|
||||||
|
TrapType.BOULDER,
|
||||||
|
TrapType.LAVA,
|
||||||
|
TrapType.LOG,
|
||||||
|
TrapType.SERPENT,
|
||||||
|
TrapType.SPIDER,
|
||||||
|
]
|
||||||
|
NB_TRAPS: Final[int] = 3
|
||||||
|
|
||||||
|
def __init__(self, i_round: int, lst_traps_removed: list[TrapType]) -> None:
|
||||||
|
"""
|
||||||
|
Initialize the deck where cards used to explore the dungeon are going to be drawn.
|
||||||
|
|
||||||
|
:param lst_traps_removed: The list of trap cards to remove, e.g. if a trap was the reason
|
||||||
|
for a cave collapse, it needs to be removed.
|
||||||
|
"""
|
||||||
|
# The deck from which cards can be drawn.
|
||||||
|
self._lst_cards: list[int | Relic | str] = (
|
||||||
|
list(Deck.CARDS_GEMS)
|
||||||
|
+ [Deck.CARDS_RELIC[i_round]]
|
||||||
|
+ list(Deck.CARDS_TRAPS * Deck.NB_TRAPS)
|
||||||
|
)
|
||||||
|
# Remove from the deck traps already encountered.
|
||||||
|
for trap in lst_traps_removed:
|
||||||
|
self._lst_cards.remove(trap)
|
||||||
|
|
||||||
|
# Number of cards drawn.
|
||||||
|
self.nb_cards_drawn: int = 0
|
||||||
|
|
||||||
|
def draw(self) -> int | Relic | str:
|
||||||
|
drawn_card: int | Relic | str = random.choice(self._lst_cards)
|
||||||
|
self._lst_cards.remove(drawn_card)
|
||||||
|
self.nb_cards_drawn += 1
|
||||||
|
return drawn_card
|
||||||
|
|
||||||
|
|
||||||
|
class Round:
|
||||||
|
"""
|
||||||
|
Model a round, i.e. the exploration of a cave until it collapses or until everyone leaves.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, nb_player: int, deck: Deck, lst_player_decision: list[Callable]) -> None:
|
||||||
|
"""
|
||||||
|
Initialize the round.
|
||||||
|
:param nb_player: The number of players in the game.
|
||||||
|
:param deck: The deck that the round will use.
|
||||||
|
"""
|
||||||
|
# The list of players still playing the round.
|
||||||
|
self._lst_player_exploring: list[bool] = [True] * nb_player
|
||||||
|
# Entry point for players' decision.
|
||||||
|
self._lst_player_decision: list[Callable] = lst_player_decision
|
||||||
|
|
||||||
|
# The deck that will be used during this round.
|
||||||
|
self._deck: Deck = deck
|
||||||
|
|
||||||
|
# Value of gems held by each player during a round.
|
||||||
|
# During a round, if two similar traps are discovered all those gems are lost.
|
||||||
|
self._lst_gems_held: list[int] = [0] * nb_player
|
||||||
|
# Value of gems saved by each player by quitting the cave.
|
||||||
|
self.lst_gems_saved: list[int] = [0] * nb_player
|
||||||
|
|
||||||
|
# Value of gems left on the floor
|
||||||
|
self._lst_gems_onfloor: list[int] = []
|
||||||
|
# The relic on the floor if it has been dropped and not collected yet.
|
||||||
|
self._relic_onfloor: Relic | None = None
|
||||||
|
|
||||||
|
# Initialize the collapse conditions.
|
||||||
|
self._dct_trap_activated: dict[TrapType, bool] = defaultdict(lambda: False)
|
||||||
|
self.collapse_reason: TrapType | None = None
|
||||||
|
self._cave_collapsed: bool = False
|
||||||
|
self._everyone_quit: bool = False
|
||||||
|
|
||||||
|
def _debug_round_status(self) -> str:
|
||||||
|
"""
|
||||||
|
Text with the status of each player.
|
||||||
|
"""
|
||||||
|
txt_debug: str = "("
|
||||||
|
|
||||||
|
#
|
||||||
|
lst_txt_player_status: list[str] = []
|
||||||
|
for _i_player, is_player_exploring in enumerate(self._lst_player_exploring):
|
||||||
|
lst_txt_player_status.append(
|
||||||
|
f"P{_i_player}: "
|
||||||
|
f"{is_player_exploring}/"
|
||||||
|
f"{self._lst_gems_held[_i_player]}H/"
|
||||||
|
f"{self.lst_gems_saved[_i_player]}S"
|
||||||
|
)
|
||||||
|
txt_debug += ", ".join(lst_txt_player_status) + ")"
|
||||||
|
txt_debug += f" + {self._lst_gems_onfloor}"
|
||||||
|
if self._relic_onfloor is not None:
|
||||||
|
txt_debug += f" + {self._relic_onfloor.value}R"
|
||||||
|
else:
|
||||||
|
txt_debug += " + None"
|
||||||
|
|
||||||
|
return txt_debug
|
||||||
|
|
||||||
|
def play_round(self) -> None:
|
||||||
|
"""
|
||||||
|
Play one round of the game until everyone quit the cave or until the cave collapses.
|
||||||
|
"""
|
||||||
|
# Draw cards until everyone quit the cave or until the cave collapses.
|
||||||
|
while not self._cave_collapsed and not self._everyone_quit:
|
||||||
|
# Draw a card.
|
||||||
|
card_drawn: int | Relic | str = self._deck.draw()
|
||||||
|
|
||||||
|
match card_drawn:
|
||||||
|
case int(gems_value):
|
||||||
|
self.on_gems_drawn(gems_value)
|
||||||
|
case Relic() as relic:
|
||||||
|
self.on_relic_drawn(relic)
|
||||||
|
case TrapType() as trap_type:
|
||||||
|
self.on_trap_drawn(trap_type)
|
||||||
|
case _:
|
||||||
|
print("Error")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not self._cave_collapsed:
|
||||||
|
self.make_players_decision()
|
||||||
|
|
||||||
|
def on_gems_drawn(self, gem_value: int) -> None:
|
||||||
|
"""
|
||||||
|
Logic executed when a gem card is drawn.
|
||||||
|
:param gem_value: The number of gems found with the gem card.
|
||||||
|
"""
|
||||||
|
# Split the gems between every remaining player equally.
|
||||||
|
nb_player = self._lst_player_exploring.count(True)
|
||||||
|
gem_per_player: int = gem_value // nb_player
|
||||||
|
gem_onfloor: int = gem_value % nb_player
|
||||||
|
|
||||||
|
# Place the gems on the floor.
|
||||||
|
self._lst_gems_onfloor.append(gem_onfloor)
|
||||||
|
|
||||||
|
# Give the gems to every remaining players.
|
||||||
|
for _i_player, is_player_exploring in enumerate(self._lst_player_exploring):
|
||||||
|
if is_player_exploring:
|
||||||
|
self._lst_gems_held[_i_player] += gem_per_player
|
||||||
|
|
||||||
|
print(f"Gem found: {gem_value} -> " + self._debug_round_status())
|
||||||
|
|
||||||
|
def on_relic_drawn(self, relic: Relic) -> None:
|
||||||
|
"""
|
||||||
|
Logic executed when a relic card is drawn.
|
||||||
|
:param relic: The relic found.
|
||||||
|
"""
|
||||||
|
print(f"Relic found: {relic.value}")
|
||||||
|
# Put the relic on the floor.
|
||||||
|
self._relic_onfloor = relic
|
||||||
|
|
||||||
|
def on_trap_drawn(self, trap_type: TrapType) -> None:
|
||||||
|
"""
|
||||||
|
Logic executed when a trap card is drawn.
|
||||||
|
:param trap_type: The trap triggered.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self._dct_trap_activated[trap_type]:
|
||||||
|
# Since the trap has made the cave collapse, it should be removed for
|
||||||
|
# the next round.
|
||||||
|
self.collapse_reason = trap_type
|
||||||
|
self._cave_collapsed = True
|
||||||
|
else:
|
||||||
|
self._dct_trap_activated[trap_type] = True
|
||||||
|
|
||||||
|
print(f"Trap activated: {trap_type} -> " + self._debug_round_status())
|
||||||
|
|
||||||
|
def make_players_decision(self) -> None:
|
||||||
|
"""
|
||||||
|
Logic about players making their decision about staying in the cave or not.
|
||||||
|
"""
|
||||||
|
# For each player that is exploring, ask them if they want to leave the cave or continue
|
||||||
|
# its exploration.
|
||||||
|
lst_i_player_quitting: list[int] = []
|
||||||
|
for _i_player, is_player_exploring in enumerate(self._lst_player_exploring):
|
||||||
|
# If the player is not exploring, skip their turn.
|
||||||
|
if not is_player_exploring:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Player's decision about quitting.
|
||||||
|
is_player_quitting: bool = self._lst_player_decision[_i_player](
|
||||||
|
i_card=self._deck.nb_cards_drawn
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keep if the player is quitting or not.
|
||||||
|
if is_player_quitting:
|
||||||
|
lst_i_player_quitting.append(_i_player)
|
||||||
|
|
||||||
|
# Check if the gems needs to be collected.
|
||||||
|
nb_quitting: int = len(lst_i_player_quitting)
|
||||||
|
if nb_quitting != 0:
|
||||||
|
# Compute the gems each leaving player will have.
|
||||||
|
gems_per_player: int = sum(
|
||||||
|
[_nb_gems // nb_quitting for _nb_gems in self._lst_gems_onfloor]
|
||||||
|
)
|
||||||
|
# Update the number of gems that will remain on the floor.
|
||||||
|
self._lst_gems_onfloor = [_nb_gems % nb_quitting for _nb_gems in self._lst_gems_onfloor]
|
||||||
|
|
||||||
|
# Give the gems to the leaving players and mark them as leavers.
|
||||||
|
for _i_player in lst_i_player_quitting:
|
||||||
|
self.lst_gems_saved[_i_player] = self._lst_gems_held[_i_player] + gems_per_player
|
||||||
|
self._lst_gems_held[_i_player] = 0
|
||||||
|
self._lst_player_exploring[_i_player] = False
|
||||||
|
|
||||||
|
# Check if only one person is leaving and if the relic is on the floor.
|
||||||
|
if nb_quitting == 1 and self._relic_onfloor is not None:
|
||||||
|
self.lst_gems_saved[lst_i_player_quitting[0]] += self._relic_onfloor.value
|
||||||
|
self._relic_onfloor = None
|
||||||
|
|
||||||
|
# Check if all players have quit the cave or not.
|
||||||
|
if self._lst_player_exploring.count(True) == 0:
|
||||||
|
self._everyone_quit = True
|
||||||
|
|
||||||
|
|
||||||
|
class Board:
|
||||||
|
"""
|
||||||
|
Model the whole game board.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Number of rounds in a single game.
|
||||||
|
NB_ROUND: Final[int] = 5
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# The number of player playing the game.
|
||||||
|
self._nb_player: int = 3
|
||||||
|
|
||||||
|
# Value of gems in each player's chest.
|
||||||
|
self._lst_chest_content: list[int] = [0] * self._nb_player
|
||||||
|
|
||||||
|
# Entry point for players' decision.
|
||||||
|
self._lst_player_decision: list[Callable] = []
|
||||||
|
|
||||||
|
# ##### Board Status ##### #
|
||||||
|
# Number of caves already explored.
|
||||||
|
self.i_round: int = 0
|
||||||
|
|
||||||
|
# List of traps removed from the next rounds.
|
||||||
|
self._lst_traps_removed: list[TrapType] = []
|
||||||
|
|
||||||
|
def play_game(self) -> None:
|
||||||
|
"""
|
||||||
|
Play the whole five rounds.
|
||||||
|
"""
|
||||||
|
current_round: Round
|
||||||
|
while self.i_round < Board.NB_ROUND:
|
||||||
|
print("------------------------")
|
||||||
|
print(f"Round: {self.i_round + 1}")
|
||||||
|
|
||||||
|
# Initialize the deck used for this round.
|
||||||
|
deck = Deck(self.i_round, self._lst_traps_removed)
|
||||||
|
|
||||||
|
# Initialize the round and play it.
|
||||||
|
current_round = Round(self._nb_player, deck, self._lst_player_decision)
|
||||||
|
current_round.play_round()
|
||||||
|
|
||||||
|
# Retrieve the result of the current round.
|
||||||
|
collapse_reason = current_round.collapse_reason
|
||||||
|
if collapse_reason is not None:
|
||||||
|
print(f"Trap removed: {collapse_reason}")
|
||||||
|
self._lst_traps_removed.append(collapse_reason)
|
||||||
|
|
||||||
|
lst_saved_gems: list[int] = current_round.lst_gems_saved
|
||||||
|
for i_player in range(self._nb_player):
|
||||||
|
self._lst_chest_content[i_player] += lst_saved_gems[i_player]
|
||||||
|
print(
|
||||||
|
"("
|
||||||
|
+ ", ".join(
|
||||||
|
[
|
||||||
|
f"P{_i_player}: {self._lst_chest_content[_i_player]} gems"
|
||||||
|
for _i_player in range(self._nb_player)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
+ ")"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.i_round += 1
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
# Python STD.
|
||||||
|
import random
|
||||||
|
import math
|
||||||
|
|
||||||
|
# Project packages.
|
||||||
|
from board import Board
|
||||||
|
|
||||||
|
|
||||||
|
class MetaBoard(Board):
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self._lst_player_decision = [
|
||||||
|
Quit5Decision().is_quitting,
|
||||||
|
RandomHalfDecision().is_quitting,
|
||||||
|
RandomAtanDecision().is_quitting,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BasicDecision:
|
||||||
|
"""Basic decision about leaving the cave or not."""
|
||||||
|
|
||||||
|
def is_quitting(self, **kwargs) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class RandomHalfDecision(BasicDecision):
|
||||||
|
|
||||||
|
def is_quitting(self, **kwargs) -> bool:
|
||||||
|
return random.random() < 0.5
|
||||||
|
|
||||||
|
|
||||||
|
class RandomAtanDecision(BasicDecision):
|
||||||
|
|
||||||
|
def is_quitting(self, **kwargs) -> bool:
|
||||||
|
|
||||||
|
if "i_card" not in kwargs:
|
||||||
|
print('RandomAtanDecision needs parameter "i_card" for decision.')
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Probability limit for the player to leave the game depending on the number of cards
|
||||||
|
# discovered.
|
||||||
|
threshold: float = math.atan((kwargs["i_card"] - 1) / 5) / (math.pi / 2)
|
||||||
|
return random.random() < threshold
|
||||||
|
|
||||||
|
|
||||||
|
class Quit5Decision(BasicDecision):
|
||||||
|
|
||||||
|
def is_quitting(self, **kwargs) -> bool:
|
||||||
|
if "i_card" not in kwargs:
|
||||||
|
print('RandomAtanDecision needs parameter "i_card" for decision.')
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Quit after five rounds.
|
||||||
|
return kwargs["i_card"] > 5
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
from decision import MetaBoard
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
board = MetaBoard()
|
||||||
|
board.play_game()
|
||||||
Loading…
Reference in New Issue