shortdeck1.1
This commit is contained in:
Binary file not shown.
BIN
shortdeck_arena/__pycache__/game_stage.cpython-313.pyc
Normal file
BIN
shortdeck_arena/__pycache__/game_stage.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
@@ -50,3 +50,5 @@ class Card:
|
||||
for s in Suit:
|
||||
cards.append(Card(r, s))
|
||||
return cards
|
||||
|
||||
|
||||
|
||||
91
shortdeck_arena/game_stage.py
Normal file
91
shortdeck_arena/game_stage.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class GameStage(Enum):
|
||||
PREFLOP = "preflop"
|
||||
FLOP = "flop"
|
||||
TURN = "turn"
|
||||
RIVER = "river"
|
||||
SHOWDOWN = "showdown"
|
||||
FINISHED = "finished"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
@classmethod
|
||||
def get_next_stage(cls, current: 'GameStage') -> Optional['GameStage']:
|
||||
stage_order = [cls.PREFLOP, cls.FLOP, cls.TURN, cls.RIVER, cls.SHOWDOWN, cls.FINISHED]
|
||||
current_idx = stage_order.index(current)
|
||||
if current_idx < len(stage_order) - 1:
|
||||
return stage_order[current_idx + 1]
|
||||
return None
|
||||
|
||||
def get_board_card_count(self) -> int:
|
||||
if self == GameStage.PREFLOP:
|
||||
return 0
|
||||
elif self == GameStage.FLOP:
|
||||
return 3
|
||||
elif self == GameStage.TURN:
|
||||
return 4
|
||||
elif self in (GameStage.RIVER, GameStage.SHOWDOWN):
|
||||
return 5
|
||||
return 0
|
||||
|
||||
|
||||
class PlayerState(Enum):
|
||||
ACTIVE = "active" # 可行动
|
||||
FOLDED = "folded" # 已弃牌
|
||||
ALLIN = "allin" # 全下
|
||||
CALLED = "called" # 已跟注
|
||||
RAISE = "raised" # 已加注
|
||||
WAITING = "waiting" # 等待其他玩家
|
||||
OUT = "out" # 出局
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class BlindConfig:
|
||||
# 对抗玩法是否需要前注?
|
||||
def __init__(self, small_blind: int = 1, big_blind: int = 2, ante: int = 0):
|
||||
self.small_blind = small_blind
|
||||
self.big_blind = big_blind
|
||||
self.ante = ante
|
||||
|
||||
|
||||
def get_sb_position(self, num_players, dealer_position) -> int:
|
||||
"""
|
||||
获取小盲位置
|
||||
heads-up时小盲为庄位
|
||||
"""
|
||||
if num_players == 2:
|
||||
return dealer_position # heads-up: 小盲 = 庄位
|
||||
else:
|
||||
return (dealer_position + 1) % num_players
|
||||
|
||||
def get_bb_position(self, num_players, dealer_position) -> int:
|
||||
"""
|
||||
获取大盲位置
|
||||
"""
|
||||
if num_players == 2:
|
||||
return (dealer_position + 1) % 2 # heads-up: 大盲 = 庄位
|
||||
return (dealer_position + 2) % num_players # 多人: 大盲为庄位后两位
|
||||
|
||||
def get_first_to_act(self, stage: GameStage, num_players) -> int:
|
||||
"""
|
||||
获取首个行动玩家位置
|
||||
区分preflop和postflop
|
||||
1. preflop: 大盲后第一位 (UTG)
|
||||
2. postflop: 小盲位先行动
|
||||
"""
|
||||
if stage == GameStage.PREFLOP:
|
||||
if num_players == 2:
|
||||
return self.get_sb_position(num_players) # heads-up: 小盲先行动
|
||||
else: # preflop: 大盲后第一位
|
||||
return (self.get_bb_position(num_players) + 1) % num_players
|
||||
else:
|
||||
# flop/river/turn: 小盲位先行动
|
||||
return self.get_sb_position(num_players)
|
||||
115
shortdeck_arena/hand_evaluator.py
Normal file
115
shortdeck_arena/hand_evaluator.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from typing import List, Tuple, Dict
|
||||
from collections import Counter
|
||||
from itertools import combinations
|
||||
from .card import Card, Rank, Suit
|
||||
from .hand_ranking import HandRanking, HandType
|
||||
|
||||
|
||||
class HandEvaluator:
|
||||
@staticmethod
|
||||
def evaluateHand(cards) -> HandRanking:
|
||||
"""
|
||||
从7张牌中找出最好的5张牌组合
|
||||
"""
|
||||
if len(cards) != 7:
|
||||
raise ValueError(f"Expected 7 cards, got {len(cards)}")
|
||||
|
||||
best_ranking = None
|
||||
best_cards = None
|
||||
|
||||
# 所有可能的5张牌组合
|
||||
for five_cards in combinations(cards, 5):
|
||||
ranking = HandEvaluator.evaluate5Cards(list(five_cards))
|
||||
|
||||
if best_ranking is None or ranking > best_ranking:
|
||||
best_ranking = ranking
|
||||
best_cards = list(five_cards)
|
||||
best_ranking.cards = best_cards
|
||||
return best_ranking
|
||||
|
||||
@staticmethod
|
||||
def evaluate5Cards(cards) -> HandRanking:
|
||||
|
||||
if len(cards) != 5:
|
||||
raise ValueError(f"Expected 5 cards, got {len(cards)}")
|
||||
|
||||
# 按点数排序(降序)
|
||||
sorted_cards = sorted(cards, key=lambda c: c.rank.numeric_value, reverse=True)
|
||||
ranks = [card.rank for card in sorted_cards]
|
||||
suits = [card.suit for card in sorted_cards]
|
||||
|
||||
# 统计点数出现次数
|
||||
rank_counts = Counter(ranks)
|
||||
count_values = sorted(rank_counts.values(), reverse=True)
|
||||
|
||||
# 同花
|
||||
is_flush = len(set(suits)) == 1
|
||||
|
||||
# 顺子
|
||||
is_straight, straight_high = HandEvaluator._isStraight(ranks)
|
||||
|
||||
# 根据牌型返回相应的HandRanking
|
||||
if is_straight and is_flush:
|
||||
if straight_high == Rank.ACE and ranks == [Rank.ACE, Rank.KING, Rank.QUEEN, Rank.JACK, Rank.TEN]:
|
||||
return HandRanking(HandType.ROYAL_FLUSH, [Rank.ACE], sorted_cards) # 皇家同花顺
|
||||
else:
|
||||
return HandRanking(HandType.STRAIGHT_FLUSH, [straight_high], sorted_cards) # 同花顺
|
||||
|
||||
elif count_values == [4, 1]: # 四条
|
||||
quad_rank = [rank for rank, count in rank_counts.items() if count == 4][0]
|
||||
kicker = [rank for rank, count in rank_counts.items() if count == 1][0]
|
||||
return HandRanking(HandType.FOUR_OF_A_KIND, [quad_rank, kicker], sorted_cards)
|
||||
|
||||
elif count_values == [3, 2]: # 三条+一对
|
||||
trips_rank = [rank for rank, count in rank_counts.items() if count == 3][0]
|
||||
pair_rank = [rank for rank, count in rank_counts.items() if count == 2][0]
|
||||
return HandRanking(HandType.FULL_HOUSE, [trips_rank, pair_rank], sorted_cards)
|
||||
|
||||
elif is_flush: # 同花
|
||||
return HandRanking(HandType.FLUSH, ranks, sorted_cards)
|
||||
|
||||
elif is_straight: # 顺子
|
||||
return HandRanking(HandType.STRAIGHT, [straight_high], sorted_cards)
|
||||
|
||||
elif count_values == [3, 1, 1]: # 三条
|
||||
trips_rank = [rank for rank, count in rank_counts.items() if count == 3][0]
|
||||
kickers = sorted([rank for rank, count in rank_counts.items() if count == 1], reverse=True)
|
||||
return HandRanking(HandType.THREE_OF_A_KIND, [trips_rank] + kickers, sorted_cards)
|
||||
|
||||
elif count_values == [2, 2, 1]: # 两对
|
||||
pairs = sorted([rank for rank, count in rank_counts.items() if count == 2], reverse=True)
|
||||
kicker = [rank for rank, count in rank_counts.items() if count == 1][0]
|
||||
return HandRanking(HandType.TWO_PAIR, pairs + [kicker], sorted_cards)
|
||||
|
||||
elif count_values == [2, 1, 1, 1]: # 一对
|
||||
pair_rank = [rank for rank, count in rank_counts.items() if count == 2][0]
|
||||
kickers = sorted([rank for rank, count in rank_counts.items() if count == 1], reverse=True)
|
||||
return HandRanking(HandType.ONE_PAIR, [pair_rank] + kickers, sorted_cards)
|
||||
|
||||
else: # 高牌
|
||||
return HandRanking(HandType.HIGH_CARD, ranks, sorted_cards)
|
||||
|
||||
@staticmethod
|
||||
def _isStraight(ranks: List[Rank]) -> Tuple[bool, Rank]:
|
||||
|
||||
values = sorted([rank.numeric_value for rank in ranks], reverse=True)
|
||||
|
||||
is_regular_straight = True
|
||||
for i in range(1, len(values)):
|
||||
if values[i-1] - values[i] != 1:
|
||||
is_regular_straight = False
|
||||
break
|
||||
|
||||
if is_regular_straight:
|
||||
# 返回最高牌
|
||||
highest_rank = None
|
||||
for rank in ranks:
|
||||
if rank.numeric_value == values[0]:
|
||||
highest_rank = rank
|
||||
break
|
||||
return True, highest_rank
|
||||
|
||||
if values == [14, 5, 4, 3, 2]: # A, 5, 4, 3, 2
|
||||
return True, Rank.FIVE
|
||||
|
||||
return False, None
|
||||
57
shortdeck_arena/hand_ranking.py
Normal file
57
shortdeck_arena/hand_ranking.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from enum import Enum
|
||||
from typing import List, Tuple
|
||||
from .card import Card, Rank
|
||||
|
||||
|
||||
class HandType(Enum):
|
||||
HIGH_CARD = (1, "High Card")
|
||||
ONE_PAIR = (2, "Pair")
|
||||
TWO_PAIR = (3, "Two Pair")
|
||||
THREE_OF_A_KIND = (4, "Three of a Kind")
|
||||
STRAIGHT = (5, "Straight")
|
||||
FLUSH = (6, "Flush")
|
||||
FULL_HOUSE = (7, "Full House")
|
||||
FOUR_OF_A_KIND = (8, "Four of a Kind")
|
||||
STRAIGHT_FLUSH = (9, "Straight Flush")
|
||||
ROYAL_FLUSH = (10, "Royal Flush")
|
||||
|
||||
def __new__(cls, strength, name):
|
||||
obj = object.__new__(cls)
|
||||
obj._value_ = strength
|
||||
obj.strength = strength
|
||||
obj.type_name = name
|
||||
return obj
|
||||
|
||||
|
||||
|
||||
class HandRanking:
|
||||
|
||||
def __init__(self, hand_type: HandType, key_ranks: List[Rank], cards: List[Card]):
|
||||
self.hand_type = hand_type
|
||||
self.key_ranks = key_ranks # 用于比较的关键点数
|
||||
self.cards = cards # 组成这个ranking的5张牌
|
||||
|
||||
def __str__(self):
|
||||
if self.hand_type == HandType.FOUR_OF_A_KIND:
|
||||
return f"Quad({self.key_ranks[0].symbol})"
|
||||
elif self.hand_type == HandType.FULL_HOUSE:
|
||||
return f"Full House({self.key_ranks[0].symbol} over {self.key_ranks[1].symbol})"
|
||||
elif self.hand_type == HandType.FLUSH:
|
||||
return f"Flush({self.key_ranks[0].symbol} high)"
|
||||
elif self.hand_type == HandType.STRAIGHT:
|
||||
return f"Straight({self.key_ranks[0].symbol} high)"
|
||||
elif self.hand_type == HandType.STRAIGHT_FLUSH:
|
||||
if self.key_ranks[0] == Rank.ACE:
|
||||
return "Royal Flush"
|
||||
else:
|
||||
return f"Straight Flush({self.key_ranks[0].symbol} high)"
|
||||
elif self.hand_type == HandType.ROYAL_FLUSH:
|
||||
return "Royal Flush"
|
||||
elif self.hand_type == HandType.THREE_OF_A_KIND:
|
||||
return f"Three of a Kind({self.key_ranks[0].symbol})"
|
||||
elif self.hand_type == HandType.TWO_PAIR:
|
||||
return f"Two Pair({self.key_ranks[0].symbol} and {self.key_ranks[1].symbol})"
|
||||
elif self.hand_type == HandType.ONE_PAIR:
|
||||
return f"Pair({self.key_ranks[0].symbol})"
|
||||
else:
|
||||
return f"High Card({self.key_ranks[0].symbol})"
|
||||
@@ -9,26 +9,100 @@ from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from .agent import Agent
|
||||
from .card import Card
|
||||
from .game_stage import GameStage, PlayerState, BlindConfig
|
||||
|
||||
|
||||
class Simulation:
|
||||
def __init__(self, agents: List[Agent]):
|
||||
def __init__(self, agents: List[Agent], blind_config: Optional[BlindConfig] = None):
|
||||
self.agents = agents
|
||||
self.history: List[Dict] = []
|
||||
self.cards: List[Card] = []
|
||||
self.saved = False
|
||||
|
||||
# 游戏状态管理
|
||||
self.current_stage = GameStage.PREFLOP
|
||||
self.player_states: List[PlayerState] = [PlayerState.ACTIVE] * len(agents)
|
||||
self.current_turn = 0
|
||||
self.betting_round_complete = False
|
||||
|
||||
# 盲注配置
|
||||
self.blind_config = blind_config or BlindConfig()
|
||||
|
||||
# 筹码和底池管理
|
||||
self.pot: List[int] = [0] * len(agents) # 每个玩家在当前轮的投入
|
||||
self.total_pot = 0
|
||||
self.last_raise_amount = 0
|
||||
self.min_raise = self.blind_config.big_blind
|
||||
self.dealer_position = -1
|
||||
|
||||
self.new_round()
|
||||
|
||||
def new_round(self):
|
||||
self.history = []
|
||||
self.cards = Card.all_short()
|
||||
random.shuffle(self.cards)
|
||||
random.shuffle(self.cards) # 洗牌
|
||||
self.saved = False
|
||||
|
||||
# 重置游戏状态
|
||||
self.current_stage = GameStage.PREFLOP
|
||||
self.player_states = [PlayerState.ACTIVE] * len(self.agents)
|
||||
self.betting_round_complete = False
|
||||
|
||||
# 重置下注状态
|
||||
self.pot = [0] * len(self.agents)
|
||||
self.total_pot = 0
|
||||
self.last_raise_amount = 0
|
||||
self.min_raise = self.blind_config.big_blind
|
||||
|
||||
# 设置盲注
|
||||
self._setup_blinds()
|
||||
|
||||
def player_cards(self, pid: int) -> List[Card]:
|
||||
return self.cards[pid * 2 : pid * 2 + 2]
|
||||
# 庄家位置
|
||||
self.dealer_position = random.choice(range(len(self.agents)))
|
||||
|
||||
def _setup_blinds(self):
|
||||
"""设置盲注"""
|
||||
num_players = len(self.agents)
|
||||
|
||||
# 至少需要2个玩家才能设置盲注
|
||||
if num_players < 2:
|
||||
self.current_turn = 0 if num_players > 0 else 0
|
||||
return
|
||||
|
||||
def board_cards(self, street: str) -> List[Card]:
|
||||
sb_pos = self.blind_config.get_sb_position(num_players,self.dealer_position)
|
||||
bb_pos = self.blind_config.get_bb_position(num_players,self.dealer_position)
|
||||
|
||||
# 确保位置有效
|
||||
if sb_pos >= num_players or bb_pos >= num_players:
|
||||
self.current_turn = 0
|
||||
return
|
||||
|
||||
# 扣除小盲
|
||||
self.pot[sb_pos] = self.blind_config.small_blind
|
||||
self.total_pot += self.blind_config.small_blind
|
||||
self.history.append({
|
||||
"pid": sb_pos,
|
||||
"action": "small_blind",
|
||||
"amount": self.blind_config.small_blind
|
||||
})
|
||||
|
||||
# 扣除大盲
|
||||
self.pot[bb_pos] = self.blind_config.big_blind
|
||||
self.total_pot += self.blind_config.big_blind
|
||||
self.history.append({
|
||||
"pid": bb_pos,
|
||||
"action": "big_blind",
|
||||
"amount": self.blind_config.big_blind
|
||||
})
|
||||
|
||||
# 首个行动玩家
|
||||
self.current_turn = self.blind_config.get_first_to_act(self.current_stage, num_players)
|
||||
self.last_raise_amount = self.blind_config.big_blind
|
||||
|
||||
def player_cards(self, pid) -> List[Card]:
|
||||
return self.cards[pid * 2 : (pid * 2 + 2)]
|
||||
|
||||
def board_cards(self, street) -> List[Card]:
|
||||
nplayers = len(self.agents)
|
||||
idx_start = nplayers * 2
|
||||
if street == "flop":
|
||||
@@ -39,11 +113,159 @@ class Simulation:
|
||||
return self.cards[idx_start: idx_start + 5]
|
||||
return []
|
||||
|
||||
def node_info(self) -> Dict:
|
||||
return {"bet_min": 1, "bet_max": 100}
|
||||
def get_current_max_bet(self) -> int:
|
||||
"""
|
||||
获取当前最高下注额, 用于计算跟注金额
|
||||
"""
|
||||
return max(self.pot) if self.pot else 0
|
||||
|
||||
def get_call_amount(self, pid) -> int:
|
||||
"""
|
||||
计算玩家跟注所需金额
|
||||
"""
|
||||
if pid >= len(self.pot):
|
||||
return 0
|
||||
max_pot = self.get_current_max_bet()
|
||||
return max(0, max_pot - self.pot[pid])
|
||||
|
||||
def is_betting_round_complete(self) -> bool:
|
||||
"""
|
||||
检查当前下注轮是否完成
|
||||
"""
|
||||
active_players = [i for i, state in enumerate(self.player_states)
|
||||
if state in (PlayerState.ACTIVE, PlayerState.CALLED)]
|
||||
|
||||
if len(active_players) <= 1:
|
||||
return True
|
||||
|
||||
# 检查所有active玩家是否都已投入相同金额
|
||||
max_pot = self.get_current_max_bet()
|
||||
for i in active_players: # allin玩家不要求跟注
|
||||
if self.pot[i] < max_pot and self.player_states[i] != PlayerState.ALLIN:
|
||||
return False
|
||||
return True
|
||||
|
||||
def advance_to_next_street(self):
|
||||
if self.current_stage == GameStage.FINISHED:
|
||||
return
|
||||
|
||||
next_stage = GameStage.get_next_stage(self.current_stage)
|
||||
if next_stage is None:
|
||||
self.current_stage = GameStage.FINISHED
|
||||
return
|
||||
|
||||
self.current_stage = next_stage
|
||||
|
||||
# 重置下注轮状态
|
||||
self.betting_round_complete = False
|
||||
|
||||
#### 重置pot为累计投入 (不清零,为了计算边池)
|
||||
|
||||
def apply_action(self, pid: int, action: str, amount: Optional[int] = None):
|
||||
# 重置行动状态
|
||||
for i, state in enumerate(self.player_states):
|
||||
if state == PlayerState.CALLED:
|
||||
self.player_states[i] = PlayerState.ACTIVE
|
||||
|
||||
# 设置首个行动玩家
|
||||
num_players = len(self.agents)
|
||||
self.current_turn = self.blind_config.get_first_to_act(self.current_stage, num_players)
|
||||
self.last_raise_amount = 0
|
||||
self.min_raise = self.blind_config.big_blind
|
||||
|
||||
def get_next_active_player(self, start_pos) -> Optional[int]:
|
||||
for i in range(len(self.agents)):
|
||||
pos = (start_pos + i) % len(self.agents)
|
||||
if self.player_states[pos] == PlayerState.ACTIVE:
|
||||
return pos
|
||||
return None
|
||||
|
||||
def node_info(self) -> Dict:
|
||||
if self.current_turn >= len(self.pot):
|
||||
return {"bet_min": self.min_raise, "bet_max": 0, "call_amount": 0}
|
||||
# 跟注
|
||||
call_amount = self.get_call_amount(self.current_turn)
|
||||
return {
|
||||
"bet_min": max(self.min_raise, call_amount + self.min_raise),
|
||||
"bet_max": 100, ########## 需要从ArenaGame获取实际大小
|
||||
"call_amount": call_amount
|
||||
}
|
||||
|
||||
def apply_action(self, pid, action, amount):
|
||||
"""
|
||||
应用玩家动作
|
||||
"""
|
||||
if pid != self.current_turn:
|
||||
raise ValueError(f"不是玩家 {pid} 的回合")
|
||||
|
||||
if self.player_states[pid] not in (PlayerState.ACTIVE,):
|
||||
raise ValueError(f"玩家 {pid} 无法行动,当前状态: {self.player_states[pid]}")
|
||||
|
||||
action = action.lower()
|
||||
|
||||
self.history.append({"pid": pid, "action": action, "amount": amount})
|
||||
|
||||
if action == "fold":
|
||||
self.player_states[pid] = PlayerState.FOLDED
|
||||
|
||||
elif action == "call":
|
||||
call_amount = self.get_call_amount(pid)
|
||||
if call_amount == 0:
|
||||
# check
|
||||
self.history[-1]["action"] = "check"
|
||||
else:
|
||||
self.pot[pid] += call_amount
|
||||
self.total_pot += call_amount
|
||||
self.player_states[pid] = PlayerState.CALLED
|
||||
|
||||
elif action == "check":
|
||||
call_amount = self.get_call_amount(pid)
|
||||
if call_amount > 0:
|
||||
raise ValueError("跟注金额>0, 无法过牌,需要跟注或弃牌")
|
||||
self.player_states[pid] = PlayerState.CALLED
|
||||
|
||||
elif action in ("bet", "raise"):
|
||||
if amount is None:
|
||||
raise ValueError(f"{action} 需要指定金额")
|
||||
# 加注需要补齐跟注金额
|
||||
call_amount = self.get_call_amount(pid)
|
||||
total_amount = call_amount + (amount or 0)
|
||||
|
||||
self.pot[pid] += total_amount
|
||||
self.total_pot += total_amount
|
||||
|
||||
if amount and amount > 0:
|
||||
self.last_raise_amount = amount
|
||||
self.min_raise = amount
|
||||
self.player_states[pid] = PlayerState.CALLED
|
||||
|
||||
# 其他跟注的玩家
|
||||
for i, state in enumerate(self.player_states):
|
||||
if i != pid and state == PlayerState.CALLED:
|
||||
self.player_states[i] = PlayerState.ACTIVE
|
||||
|
||||
else:
|
||||
raise ValueError(f"未知动作: {action}")
|
||||
|
||||
# 下一个玩家
|
||||
self._advance_turn()
|
||||
|
||||
def _advance_turn(self):
|
||||
"""
|
||||
推进回合
|
||||
"""
|
||||
# 检查下注轮是否完成
|
||||
if self.is_betting_round_complete():
|
||||
self.betting_round_complete = True
|
||||
self.advance_to_next_street()
|
||||
else:
|
||||
# 找到下一个可行动玩家
|
||||
next_player = self.get_next_active_player(self.current_turn + 1)
|
||||
if next_player is not None:
|
||||
self.current_turn = next_player
|
||||
else:
|
||||
# 没有玩家需要行动,结束下注轮
|
||||
self.betting_round_complete = True
|
||||
self.advance_to_next_street()
|
||||
|
||||
def to_save_data(self) -> Dict:
|
||||
players = [f"Agent{a.pid}" for a in self.agents]
|
||||
@@ -60,6 +282,6 @@ class Simulation:
|
||||
if path is None:
|
||||
path = Path.cwd() / "shortdeck_arena_history.jsonl"
|
||||
with path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(self.to_save_data(), ensure_ascii=False))
|
||||
f.write(json.dumps(self.to_save_data()))
|
||||
f.write("\n")
|
||||
self.saved = True
|
||||
|
||||
Reference in New Issue
Block a user