Initial commit, with rules 0.5 and basic bots
This commit is contained in:
@@ -0,0 +1,584 @@
|
||||
"""Rules engine: one game of Il Labirinto del Dojo.
|
||||
|
||||
Every choice a player makes is delegated to an ``Agent``; dice and deck order come from a
|
||||
``Randomness`` source, so games are reproducible and tests can script every roll.
|
||||
Rule references are to RULES.md.
|
||||
"""
|
||||
|
||||
import itertools
|
||||
import random
|
||||
from collections import deque
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
from functools import cache
|
||||
from typing import Protocol
|
||||
|
||||
from dojo.board import QUADRANTS, Board, Pos, Square, is_tunnel
|
||||
from dojo.events import (
|
||||
CardDrawn,
|
||||
Event,
|
||||
ExamTaken,
|
||||
FightKind,
|
||||
GameOver,
|
||||
KumiteFought,
|
||||
Moved,
|
||||
SentToStart,
|
||||
Source,
|
||||
Teleported,
|
||||
TrackChanged,
|
||||
TurnSkipped,
|
||||
TurnStarted,
|
||||
)
|
||||
from dojo.movement import Path, walks
|
||||
from dojo.rules import TECNICHE, Card, RulesConfig
|
||||
|
||||
MIN_PLAYERS = 2
|
||||
MAX_PLAYERS = 8
|
||||
SENSEI_OSSERVA_GAIN = 2
|
||||
|
||||
|
||||
class Randomness(Protocol):
|
||||
def d6(self) -> int: ...
|
||||
|
||||
def shuffle(self, cards: list[Card]) -> None: ...
|
||||
|
||||
|
||||
class RandomSource:
|
||||
def __init__(self, seed: int | None = None) -> None:
|
||||
self._rng = random.Random(seed)
|
||||
|
||||
def d6(self) -> int:
|
||||
return self._rng.randint(1, 6)
|
||||
|
||||
def shuffle(self, cards: list[Card]) -> None:
|
||||
self._rng.shuffle(cards)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Player:
|
||||
id: int
|
||||
corner: int
|
||||
position: Pos
|
||||
track: int
|
||||
tecnica: Card | None = None
|
||||
meditating: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FightView:
|
||||
"""What a fighter sees when deciding whether to play its Tecnica."""
|
||||
|
||||
attacker: int
|
||||
defender: int
|
||||
exam: bool
|
||||
attacker_total: int
|
||||
defender_total: int
|
||||
played: tuple[tuple[int, Card], ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlayerView:
|
||||
"""What everyone at the table knows about a player: whether they hold a Tecnica, not which."""
|
||||
|
||||
id: int
|
||||
corner: int
|
||||
position: Pos
|
||||
track: int
|
||||
holds_tecnica: bool
|
||||
meditating: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GameView:
|
||||
"""The game as player ``me`` sees it: public state plus its own Tecnica (RULES.md, D26)."""
|
||||
|
||||
board: Board
|
||||
config: RulesConfig
|
||||
round: int
|
||||
me: int
|
||||
tecnica: Card | None
|
||||
players: tuple[PlayerView, ...]
|
||||
|
||||
def belt(self, player: int) -> int:
|
||||
return self.config.belt(self.players[player].track)
|
||||
|
||||
|
||||
class Agent(Protocol):
|
||||
def place_pawn(self, view: GameView, squares: Sequence[Pos]) -> Pos: ...
|
||||
|
||||
def choose_path(self, view: GameView, paths: Sequence[Path]) -> Path: ...
|
||||
|
||||
def use_tunnel(self, view: GameView, exit: Pos) -> bool: ...
|
||||
|
||||
def choose_tunnel_destination(self, view: GameView, tunnels: Sequence[Pos]) -> Pos: ...
|
||||
|
||||
def choose_sfida_target(self, view: GameView, targets: Sequence[int]) -> int: ...
|
||||
|
||||
def choose_irimi_target(self, view: GameView, targets: Sequence[int]) -> int: ...
|
||||
|
||||
def choose_exam_defender(self, view: GameView, candidates: Sequence[int]) -> int: ...
|
||||
|
||||
def keep_tecnica(self, view: GameView, held: Card, drawn: Card) -> Card: ...
|
||||
|
||||
def play_tecnica(self, view: GameView, fight: FightView) -> bool: ...
|
||||
|
||||
|
||||
class Step(Enum):
|
||||
MOVED = auto()
|
||||
WON = auto()
|
||||
LOST = auto()
|
||||
EXAM = auto()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FightResult:
|
||||
attacker_won: bool
|
||||
kind: FightKind
|
||||
attacker_uke: bool
|
||||
defender_uke: bool
|
||||
|
||||
|
||||
@cache
|
||||
def seat_corners(players: int) -> tuple[int, ...]:
|
||||
"""Corner of each seat, in turn order. Corner-mates are never consecutive (cyclically)."""
|
||||
if not MIN_PLAYERS <= players <= MAX_PLAYERS:
|
||||
raise ValueError(f"{players} players: supported range is {MIN_PLAYERS}-{MAX_PLAYERS}")
|
||||
corners = sorted([*range(min(players, QUADRANTS)), *range(max(0, players - QUADRANTS))])
|
||||
for order in itertools.permutations(corners):
|
||||
if all(order[i] != order[(i + 1) % players] for i in range(players)):
|
||||
return order
|
||||
raise AssertionError(f"no seating for {players} players")
|
||||
|
||||
|
||||
class Game:
|
||||
def __init__(
|
||||
self,
|
||||
board: Board,
|
||||
config: RulesConfig,
|
||||
agents: Sequence[Agent],
|
||||
randomness: Randomness,
|
||||
) -> None:
|
||||
self.board = board
|
||||
self.config = config
|
||||
self.agents = tuple(agents)
|
||||
self.events: list[Event] = []
|
||||
self.round = 1
|
||||
self.current = 0
|
||||
self.winner: int | None = None
|
||||
self._random = randomness
|
||||
self._dojo = board.dojo
|
||||
|
||||
cards = [card for card, quantity in config.deck for _ in range(quantity)]
|
||||
randomness.shuffle(cards)
|
||||
self._deck: deque[Card] = deque(cards)
|
||||
self._discard: list[Card] = []
|
||||
|
||||
self.players = [
|
||||
Player(seat, corner, min(board.safe_room(corner)), config.start_position)
|
||||
for seat, corner in enumerate(seat_corners(len(self.agents)))
|
||||
]
|
||||
for player in self.players:
|
||||
self._place(player)
|
||||
|
||||
@property
|
||||
def finished(self) -> bool:
|
||||
return self.winner is not None or self.round > self.config.max_rounds
|
||||
|
||||
def belt(self, player: int) -> int:
|
||||
return self.config.belt(self.players[player].track)
|
||||
|
||||
def view(self, player: int) -> GameView:
|
||||
me = self.players[player]
|
||||
return GameView(
|
||||
board=self.board,
|
||||
config=self.config,
|
||||
round=self.round,
|
||||
me=player,
|
||||
tecnica=me.tecnica,
|
||||
players=tuple(
|
||||
PlayerView(p.id, p.corner, p.position, p.track, p.tecnica is not None, p.meditating)
|
||||
for p in self.players
|
||||
),
|
||||
)
|
||||
|
||||
def run(self) -> int | None:
|
||||
while not self.finished:
|
||||
self.play_turn()
|
||||
return self.winner
|
||||
|
||||
def play_turn(self) -> None:
|
||||
if self.finished:
|
||||
raise RuntimeError("the game is over")
|
||||
player = self.players[self.current]
|
||||
if player.meditating:
|
||||
player.meditating = False
|
||||
self._emit(TurnSkipped(self.round, player.id))
|
||||
else:
|
||||
roll = self._random.d6() + self._random.d6()
|
||||
self._emit(TurnStarted(self.round, player.id, roll))
|
||||
self._move(player, roll)
|
||||
|
||||
if self.winner is not None:
|
||||
self._emit(GameOver(self.winner, self.round))
|
||||
return
|
||||
self.current = (self.current + 1) % len(self.players)
|
||||
if self.current == 0:
|
||||
self.round += 1
|
||||
if self.round > self.config.max_rounds:
|
||||
self._emit(GameOver(None, self.config.max_rounds))
|
||||
|
||||
# Movement -----------------------------------------------------------------------------
|
||||
|
||||
def _move(self, player: Player, steps: int) -> None:
|
||||
origin = player.position
|
||||
visited = {origin}
|
||||
remaining = steps
|
||||
while True:
|
||||
paths = self._path_choices(player, remaining, visited)
|
||||
if not paths:
|
||||
break
|
||||
path = self._pick(
|
||||
self._agent(player).choose_path(self.view(player.id), paths), paths, "path"
|
||||
)
|
||||
walked, step = self._walk(player, path, visited)
|
||||
if step in (Step.LOST, Step.EXAM):
|
||||
return
|
||||
if step is not Step.WON or walked == len(path):
|
||||
break
|
||||
# A won Kumite may have changed the belt (and Dojo access): choose again.
|
||||
remaining -= walked
|
||||
if self._settle(player, visited):
|
||||
self._resolve_square(player, origin)
|
||||
|
||||
def _walk(self, player: Player, path: Path, visited: set[Pos]) -> tuple[int, Step]:
|
||||
"""Walk ``path`` until it ends or a Kumite happens. Returns (squares walked, last step)."""
|
||||
step = Step.MOVED
|
||||
walked = 0
|
||||
for cell in path:
|
||||
visited.add(cell)
|
||||
walked += 1
|
||||
step = self._step(player, cell, previous=player.position)
|
||||
if step is not Step.MOVED:
|
||||
break
|
||||
self._emit(Moved(player.id, path[:walked]))
|
||||
return walked, step
|
||||
|
||||
def _path_choices(self, player: Player, steps: int, visited: set[Pos]) -> list[Path]:
|
||||
"""Legal walks, one per distinct outcome (end square and opponents crossed in order)."""
|
||||
brown = self.config.is_brown(player.track)
|
||||
choices: dict[tuple[Pos, tuple[Pos, ...]], Path] = {}
|
||||
for path in walks(self.board, player.position, steps, visited, can_enter_dojo=brown):
|
||||
crossed = tuple(cell for cell in path if self._fightable_at(cell, player))
|
||||
choices.setdefault((path[-1], crossed), path)
|
||||
return list(choices.values())
|
||||
|
||||
def _step(self, player: Player, cell: Pos, previous: Pos) -> Step:
|
||||
if cell in self._dojo:
|
||||
self._exam(player, entrance=previous)
|
||||
return Step.EXAM
|
||||
defender = self._fightable_at(cell, player)
|
||||
player.position = cell
|
||||
if defender is None:
|
||||
return Step.MOVED
|
||||
result = self._kumite(player, defender)
|
||||
self._apply_fight(player, defender, result, attacker_retreat=previous)
|
||||
return Step.WON if result.attacker_won else Step.LOST
|
||||
|
||||
def _settle(self, player: Player, visited: set[Pos]) -> bool:
|
||||
"""Meditazione rule: a move may not end on another pawn's square (outside Safe Rooms).
|
||||
|
||||
Take one extra step while sharing a square; stay if no step is possible.
|
||||
Returns whether the final square's effect should be resolved.
|
||||
"""
|
||||
while self._shares_square(player):
|
||||
brown = self.config.is_brown(player.track)
|
||||
options = walks(self.board, player.position, 1, visited, can_enter_dojo=brown)
|
||||
if not options:
|
||||
return True
|
||||
path = self._pick(
|
||||
self._agent(player).choose_path(self.view(player.id), options), options, "path"
|
||||
)
|
||||
_, step = self._walk(player, path, visited)
|
||||
if step in (Step.LOST, Step.EXAM):
|
||||
return False
|
||||
return True
|
||||
|
||||
# Kumite -------------------------------------------------------------------------------
|
||||
|
||||
def _kumite(self, attacker: Player, defender: Player, *, exam: bool = False) -> FightResult:
|
||||
config = self.config
|
||||
belts = (config.belt(attacker.track), config.belt(defender.track))
|
||||
bonus = min(abs(belts[0] - belts[1]), config.max_belt_bonus)
|
||||
dice = [self._random.d6(), self._random.d6()]
|
||||
extra = [bonus if belts[0] > belts[1] else 0, bonus if belts[1] > belts[0] else 0]
|
||||
uke = [False, False]
|
||||
played: list[tuple[int, Card]] = []
|
||||
|
||||
for side, fighter in enumerate((attacker, defender)):
|
||||
card = fighter.tecnica
|
||||
if card is None:
|
||||
continue
|
||||
view = FightView(
|
||||
attacker=attacker.id,
|
||||
defender=defender.id,
|
||||
exam=exam,
|
||||
attacker_total=dice[0] + extra[0],
|
||||
defender_total=dice[1] + extra[1],
|
||||
played=tuple(played),
|
||||
)
|
||||
if not self._agent(fighter).play_tecnica(self.view(fighter.id), view):
|
||||
continue
|
||||
fighter.tecnica = None
|
||||
self._discard.append(card)
|
||||
played.append((fighter.id, card))
|
||||
match card:
|
||||
case Card.KIAI:
|
||||
extra[side] += config.kiai_bonus
|
||||
case Card.KAESHI:
|
||||
dice[side] = self._random.d6()
|
||||
case Card.UKE:
|
||||
uke[side] = True
|
||||
|
||||
totals = (dice[0] + extra[0], dice[1] + extra[1])
|
||||
attacker_won = totals[0] >= totals[1] # Senshu: ties go to the attacker.
|
||||
winner_belt, loser_belt = belts if attacker_won else belts[::-1]
|
||||
if winner_belt == loser_belt:
|
||||
kind = FightKind.EQUAL
|
||||
elif winner_belt < loser_belt:
|
||||
kind = FightKind.UPSET
|
||||
else:
|
||||
kind = FightKind.EXPECTED
|
||||
self._emit(
|
||||
KumiteFought(
|
||||
attacker=attacker.id,
|
||||
defender=defender.id,
|
||||
attacker_belt=belts[0],
|
||||
defender_belt=belts[1],
|
||||
attacker_total=totals[0],
|
||||
defender_total=totals[1],
|
||||
attacker_won=attacker_won,
|
||||
kind=kind,
|
||||
exam=exam,
|
||||
cards=tuple(played),
|
||||
)
|
||||
)
|
||||
return FightResult(attacker_won, kind, attacker_uke=uke[0], defender_uke=uke[1])
|
||||
|
||||
def _apply_fight(
|
||||
self, attacker: Player, defender: Player, result: FightResult, attacker_retreat: Pos
|
||||
) -> None:
|
||||
if result.attacker_won:
|
||||
winner, loser, loser_uke = attacker, defender, result.defender_uke
|
||||
else:
|
||||
winner, loser, loser_uke = defender, attacker, result.attacker_uke
|
||||
|
||||
stripes = self.config.stripes_per_belt
|
||||
if result.kind is FightKind.EXPECTED:
|
||||
self._change_track(winner, self.config.expected_win_gain, Source.KUMITE)
|
||||
else:
|
||||
self._change_track(winner, stripes, Source.KUMITE)
|
||||
self._change_track(loser, -stripes, Source.KUMITE)
|
||||
|
||||
if not loser_uke:
|
||||
self._send_to_start(loser)
|
||||
elif loser is attacker:
|
||||
self._relocate(attacker, attacker_retreat, "uke")
|
||||
|
||||
# Final Exam ---------------------------------------------------------------------------
|
||||
|
||||
def _exam(self, challenger: Player, entrance: Pos) -> None:
|
||||
challenger.position = entrance # No pawn ever remains inside the Dojo.
|
||||
eligible = [p for p in self.players if p is not challenger and not p.meditating]
|
||||
if not eligible:
|
||||
return
|
||||
top = max(p.track for p in eligible)
|
||||
candidates = [p.id for p in eligible if p.track == top]
|
||||
if len(candidates) == 1:
|
||||
defender_id = candidates[0]
|
||||
else:
|
||||
choice = self._agent(challenger).choose_exam_defender(
|
||||
self.view(challenger.id), candidates
|
||||
)
|
||||
defender_id = self._pick(choice, candidates, "exam defender")
|
||||
|
||||
result = self._kumite(challenger, self.players[defender_id], exam=True)
|
||||
self._emit(ExamTaken(challenger.id, defender_id, result.attacker_won))
|
||||
if result.attacker_won:
|
||||
self.winner = challenger.id
|
||||
return
|
||||
self._change_track(challenger, -self.config.stripes_per_belt, Source.EXAM)
|
||||
if not result.attacker_uke:
|
||||
self._send_to_start(challenger)
|
||||
|
||||
# Squares ------------------------------------------------------------------------------
|
||||
|
||||
def _resolve_square(self, player: Player, origin: Pos) -> None:
|
||||
code = self.board.code(player.position)
|
||||
match code:
|
||||
case Square.ALLENAMENTO:
|
||||
self._change_track(player, 1, Source.SQUARE)
|
||||
case Square.FATICA:
|
||||
self._change_track(player, -1, Source.SQUARE)
|
||||
case Square.NEBBIA:
|
||||
self._draw(player, origin)
|
||||
case Square.PASSO_FALSO:
|
||||
self._relocate(player, origin, "passo falso")
|
||||
case Square.SFIDA:
|
||||
self._sfida(player)
|
||||
case _ if is_tunnel(code):
|
||||
exit_square = self.board.tunnels[player.position]
|
||||
if not self._has_unfightable_pawn(exit_square, player) and self._agent(
|
||||
player
|
||||
).use_tunnel(self.view(player.id), exit_square):
|
||||
self._teleport(player, exit_square, "tunnel")
|
||||
|
||||
def _sfida(self, player: Player) -> None:
|
||||
targets = self._targets(player)
|
||||
if not targets:
|
||||
return
|
||||
choice = self._agent(player).choose_sfida_target(self.view(player.id), targets)
|
||||
defender = self.players[self._pick(choice, targets, "Sfida target")]
|
||||
result = self._kumite(player, defender)
|
||||
self._apply_fight(player, defender, result, attacker_retreat=player.position)
|
||||
|
||||
def _teleport(self, player: Player, destination: Pos, reason: str) -> None:
|
||||
"""Arrive on ``destination`` without walking; an opponent there means a Kumite."""
|
||||
source = player.position
|
||||
defender = self._fightable_at(destination, player)
|
||||
self._relocate(player, destination, reason)
|
||||
if defender is not None:
|
||||
result = self._kumite(player, defender)
|
||||
self._apply_fight(player, defender, result, attacker_retreat=source)
|
||||
|
||||
# Nebbia del Dojo ----------------------------------------------------------------------
|
||||
|
||||
def _draw(self, player: Player, origin: Pos) -> None:
|
||||
if not self._deck and self._discard:
|
||||
self._random.shuffle(self._discard)
|
||||
self._deck.extend(self._discard)
|
||||
self._discard.clear()
|
||||
if not self._deck:
|
||||
return
|
||||
card = self._deck.popleft()
|
||||
self._emit(CardDrawn(player.id, card))
|
||||
if card in TECNICHE:
|
||||
self._take_tecnica(player, card)
|
||||
return
|
||||
|
||||
self._discard.append(card)
|
||||
match card:
|
||||
case Card.TSUGI_ASHI:
|
||||
self._move(player, self._random.d6() + self._random.d6())
|
||||
case Card.TUNNEL_SEGRETO:
|
||||
tunnels = sorted(
|
||||
p
|
||||
for p in self.board.tunnels
|
||||
if p != player.position and not self._has_unfightable_pawn(p, player)
|
||||
)
|
||||
if tunnels:
|
||||
choice = self._agent(player).choose_tunnel_destination(
|
||||
self.view(player.id), tunnels
|
||||
)
|
||||
self._teleport(player, self._pick(choice, tunnels, "tunnel"), "tunnel segreto")
|
||||
case Card.IRIMI:
|
||||
self._irimi(player)
|
||||
case Card.SFIDA:
|
||||
self._sfida(player)
|
||||
case Card.SENSEI_OSSERVA:
|
||||
lowest = min(p.track for p in self.players)
|
||||
for p in [p for p in self.players if p.track == lowest]:
|
||||
self._change_track(p, SENSEI_OSSERVA_GAIN, Source.CARD)
|
||||
case Card.PESO_DELLA_CINTURA:
|
||||
highest = max(p.track for p in self.players)
|
||||
for p in [p for p in self.players if p.track == highest]:
|
||||
self._change_track(p, -1, Source.CARD)
|
||||
case Card.ALLENAMENTO_INTENSIVO:
|
||||
self._change_track(player, 1, Source.CARD)
|
||||
case Card.COLPO_PROIBITO:
|
||||
self._change_track(player, -1, Source.CARD)
|
||||
case Card.PASSO_FALSO:
|
||||
self._relocate(player, origin, "passo falso")
|
||||
case Card.MEDITAZIONE:
|
||||
player.meditating = True
|
||||
|
||||
def _take_tecnica(self, player: Player, drawn: Card) -> None:
|
||||
held = player.tecnica
|
||||
if held is None:
|
||||
player.tecnica = drawn
|
||||
return
|
||||
choice = self._agent(player).keep_tecnica(self.view(player.id), held, drawn)
|
||||
kept = self._pick(choice, (held, drawn), "Tecnica")
|
||||
player.tecnica = kept
|
||||
self._discard.append(drawn if kept is held else held)
|
||||
|
||||
def _irimi(self, player: Player) -> None:
|
||||
targets = self._targets(player)
|
||||
if not targets:
|
||||
return
|
||||
choice = self._agent(player).choose_irimi_target(self.view(player.id), targets)
|
||||
other = self.players[self._pick(choice, targets, "Irimi target")]
|
||||
mine, theirs = player.position, other.position
|
||||
self._relocate(player, theirs, "irimi")
|
||||
self._relocate(other, mine, "irimi")
|
||||
|
||||
# State helpers ------------------------------------------------------------------------
|
||||
|
||||
def _change_track(self, player: Player, delta: int, source: Source) -> None:
|
||||
from_board = source in (Source.SQUARE, Source.CARD)
|
||||
after = self.config.shifted(player.track, delta, from_board=from_board)
|
||||
if after != player.track:
|
||||
self._emit(TrackChanged(player.id, player.track, after, source))
|
||||
player.track = after
|
||||
|
||||
def _place(self, player: Player) -> None:
|
||||
squares = sorted(self.board.safe_room(player.corner))
|
||||
choice = self._agent(player).place_pawn(self.view(player.id), squares)
|
||||
player.position = self._pick(choice, squares, "start square")
|
||||
|
||||
def _send_to_start(self, player: Player) -> None:
|
||||
self._place(player)
|
||||
self._emit(SentToStart(player.id, player.position))
|
||||
|
||||
def _relocate(self, player: Player, destination: Pos, reason: str) -> None:
|
||||
if destination != player.position:
|
||||
self._emit(Teleported(player.id, player.position, destination, reason))
|
||||
player.position = destination
|
||||
|
||||
def _in_safe_room(self, square: Pos) -> bool:
|
||||
return self.board.code(square) == Square.SAFE
|
||||
|
||||
def _fightable_at(self, square: Pos, attacker: Player) -> Player | None:
|
||||
if self._in_safe_room(square):
|
||||
return None
|
||||
for p in self.players:
|
||||
if p is not attacker and p.position == square and not p.meditating:
|
||||
return p
|
||||
return None
|
||||
|
||||
def _has_unfightable_pawn(self, square: Pos, player: Player) -> bool:
|
||||
return any(p is not player and p.position == square and p.meditating for p in self.players)
|
||||
|
||||
def _shares_square(self, player: Player) -> bool:
|
||||
if self._in_safe_room(player.position):
|
||||
return False
|
||||
return any(p is not player and p.position == player.position for p in self.players)
|
||||
|
||||
def _targets(self, player: Player) -> list[int]:
|
||||
return [
|
||||
p.id
|
||||
for p in self.players
|
||||
if p is not player and not p.meditating and not self._in_safe_room(p.position)
|
||||
]
|
||||
|
||||
def _agent(self, player: Player) -> Agent:
|
||||
return self.agents[player.id]
|
||||
|
||||
def _emit(self, event: Event) -> None:
|
||||
self.events.append(event)
|
||||
|
||||
@staticmethod
|
||||
def _pick[T](choice: T, options: Sequence[T], what: str) -> T:
|
||||
if choice not in options:
|
||||
raise ValueError(f"agent chose an illegal {what}: {choice!r}")
|
||||
return choice
|
||||
Reference in New Issue
Block a user