File size: 5,091 Bytes
20d7fde
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
"""Togyzkumalak rules engine, ported from the 9Q C++ engine
(~/Desktop/9Q/src/togyzkumalak_rules.cpp) and validated against its
shortest-game replay fixture (tests/test_rules.py).

Notation matches 9Q and the scoresheet classifier classes:
"{source_hole}{landing_column}" with an "x" suffix when the move creates a
tuzdyk, e.g. "98", "13x". Source hole and landing column are 1-9; the landing
column is the pit's index within its own side, regardless of side.
"""

from dataclasses import dataclass, field

WIN_THRESHOLD = 82
PITS_PER_SIDE = 9


@dataclass
class Move:
    action: int  # 0-8 source hole index on the mover's side
    notation: str  # e.g. "98" or "13x"
    captured: int  # stones captured by the landing rule (excl. tuzdyk income)
    makes_tuzdyk: bool


@dataclass
class Game:
    pits: list[int] = field(default_factory=lambda: [9] * 18)  # P1: 0-8, P2: 9-17
    kazans: list[int] = field(default_factory=lambda: [0, 0])
    # tuzdyks[p] = column (0-8) OWNED BY p on the opponent's side, -1 = none
    tuzdyks: list[int] = field(default_factory=lambda: [-1, -1])
    to_play: int = 0

    def copy(self) -> "Game":
        return Game(self.pits[:], self.kazans[:], self.tuzdyks[:], self.to_play)

    # --- core mechanics -------------------------------------------------

    def _side_of(self, pit: int) -> int:
        return pit // PITS_PER_SIDE

    def _apply(self, action: int) -> Move:
        """Apply a (legal) move for `to_play`; returns the Move played."""
        p = self.to_play
        opp = 1 - p
        start = p * PITS_PER_SIDE + action
        stones = self.pits[start]

        if stones == 1:
            self.pits[start] = 0
            last = (start + 1) % 18
            self.pits[last] += 1
        else:
            self.pits[start] = 1
            pit = start
            for _ in range(stones - 1):
                pit = (pit + 1) % 18
                self.pits[pit] += 1
            last = pit

        # stones sown into a tuzdyk go to its owner's kazan
        for owner in (0, 1):
            col = self.tuzdyks[owner]
            if col >= 0:
                tuz_pit = (1 - owner) * PITS_PER_SIDE + col
                if self.pits[tuz_pit]:
                    self.kazans[owner] += self.pits[tuz_pit]
                    self.pits[tuz_pit] = 0

        captured = 0
        makes_tuzdyk = False
        if self._side_of(last) == opp:
            col = last % PITS_PER_SIDE
            count = self.pits[last]
            if (
                count == 3
                and self.tuzdyks[p] == -1
                and col != PITS_PER_SIDE - 1  # hole 9 can never become a tuzdyk
                and self.tuzdyks[opp] != col  # no mirrored tuzdyks
            ):
                self.tuzdyks[p] = col
                self.kazans[p] += 3
                self.pits[last] = 0
                makes_tuzdyk = True
            elif count % 2 == 0:
                captured = count
                self.kazans[p] += count
                self.pits[last] = 0

        notation = f"{action + 1}{(last % PITS_PER_SIDE) + 1}"
        if makes_tuzdyk:
            notation += "x"
        self.to_play = opp
        return Move(action, notation, captured, makes_tuzdyk)

    # --- public API -----------------------------------------------------

    def legal_actions(self) -> list[int]:
        p = self.to_play
        opp = 1 - p
        return [
            a
            for a in range(PITS_PER_SIDE)
            if self.pits[p * PITS_PER_SIDE + a] > 0 and self.tuzdyks[opp] != a
        ]

    def legal_moves(self) -> list[Move]:
        """The <=9 legal moves with their exact notation (via simulation)."""
        return [self.copy()._apply(a) for a in self.legal_actions()]

    def play(self, action: int) -> Move:
        if action not in self.legal_actions():
            raise ValueError(f"illegal action {action + 1} for player {self.to_play + 1}")
        return self._apply(action)

    def play_notation(self, notation: str) -> Move:
        """Play by full notation string; raises ValueError if not legal."""
        for move in self.legal_moves():
            if move.notation == notation:
                return self._apply(move.action)
        raise ValueError(f"move {notation!r} is not legal here")

    @property
    def is_over(self) -> bool:
        return (
            max(self.kazans) >= WIN_THRESHOLD
            or not self.legal_actions()
        )

    def winner(self) -> int:
        """0/1 = player, -1 = draw. If the side to move is stuck (atsyrau),
        the opponent collects the stones remaining on their own side."""
        kazans = self.kazans[:]
        if max(kazans) < WIN_THRESHOLD and not self.legal_actions():
            opp = 1 - self.to_play
            kazans[opp] += sum(
                self.pits[opp * PITS_PER_SIDE : (opp + 1) * PITS_PER_SIDE]
            )
        if kazans[0] == kazans[1]:
            return -1
        return 0 if kazans[0] > kazans[1] else 1

    def side_totals(self) -> tuple[int, int]:
        return sum(self.pits[:PITS_PER_SIDE]), sum(self.pits[PITS_PER_SIDE:])