Merge branch 'feature/#0/initialization'

Gaëlle Martin 2025-09-04 22:49:18 +02:00
commit 9a20ed2edc
6 changed files with 421 additions and 2 deletions

2
.gitignore vendored
View File

@ -158,5 +158,5 @@ cython_debug/
# 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/
.idea/

View File

@ -1,3 +1,38 @@
# 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
src/__init__.py Normal file
View File

322
src/board.py Normal file
View File

@ -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

56
src/decision.py Normal file
View File

@ -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

6
src/main.py Normal file
View File

@ -0,0 +1,6 @@
from decision import MetaBoard
if __name__ == "__main__":
board = MetaBoard()
board.play_game()