"""
=============================================================================
第 14 讲 · 共享模块：井字棋——规则、完美解（极小极大）与评测工具
=============================================================================

对应讲义：lectures/lecture14_mcts_alphazero.md

-----------------------------------------------------------------------------
【为什么用井字棋？】
-----------------------------------------------------------------------------
围棋有约 10^170 个局面，没法验证一个搜索算法下得"对不对"。井字棋只有 5478 个合法局面，
可以用极小极大（minimax）**精确地**算出每个局面的胜负和最优着法——这给了我们一个"标准答案"，
可以精确地衡量 MCTS、AlphaZero 式算法的每一步下得好不好。
算法本身与围棋中用的完全相同，只是规模小得多。

-----------------------------------------------------------------------------
【棋盘表示】
-----------------------------------------------------------------------------
棋盘是一个长度为 9 的元组（tuple），按下面的编号：
        0 | 1 | 2
        ---------
        3 | 4 | 5
        ---------
        6 | 7 | 8
每个位置：1 = X（先手），-1 = O（后手），0 = 空。
轮到谁走由棋子数决定：X 和 O 一样多时轮到 X，否则轮到 O。

【Python 小知识】为什么用元组而不是列表？元组是"不可变"的，可以作为字典的键（dict key），
    这样就能把"局面 → 价值"存进字典里（记忆化），列表不行。

-----------------------------------------------------------------------------
【本模块提供】
-----------------------------------------------------------------------------
    EMPTY                    : 空棋盘
    to_move(board)           : 轮到谁（1 或 -1）
    legal_moves(board)       : 所有合法着法
    play(board, move)        : 走一步，返回新棋盘
    winner(board)            : 1 / -1（有人赢了）、0（平局）、None（还没结束）
    solve(board)             : 极小极大的精确解：从"轮到的一方"看的价值（1 赢、0 平、-1 输）
    optimal_moves(board)     : 所有最优着法的集合
    all_positions()          : 所有合法的、未结束的局面（用来评测"每一步下得对不对"）
    render(board)            : 把棋盘画成文字
"""

from functools import lru_cache

EMPTY = (0,) * 9
LINES = [(0, 1, 2), (3, 4, 5), (6, 7, 8), (0, 3, 6), (1, 4, 7), (2, 5, 8), (0, 4, 8), (2, 4, 6)]


def to_move(board):
    """轮到谁走：X（1）和 O（-1）的棋子一样多时轮到 X。"""
    return 1 if sum(board) == 0 else -1


def legal_moves(board):
    """所有空位的编号。"""
    return [i for i in range(9) if board[i] == 0]


def play(board, move):
    """
    在 move 位置放下当前一方的棋子，返回**新的**棋盘（原棋盘不变）。
    【边界】move 必须是空位，否则抛出异常——这能帮助及早发现算法中的 bug。
    """
    if board[move] != 0:
        raise ValueError(f"位置 {move} 已经有棋子了")
    b = list(board)
    b[move] = to_move(board)
    return tuple(b)


def winner(board):
    """
    判断胜负。
    【返回】1（X 赢）、-1（O 赢）、0（棋盘下满，平局）、None（游戏还没结束）。
    """
    for a, b, c in LINES:
        if board[a] != 0 and board[a] == board[b] == board[c]:
            return board[a]
    if 0 not in board:
        return 0
    return None


@lru_cache(maxsize=None)
def solve(board):
    """
    极小极大（negamax 写法）：返回从"轮到走棋的一方"的角度看，双方都完美下棋时的结果。
        1 = 轮到的一方能赢，0 = 和棋，-1 = 轮到的一方必输

    【negamax】对手的好就是我的坏：我的价值 = max_{着法} ( - 对手在新局面下的价值 )。
        这样只需要一个 max，不用区分"我方取 max、对方取 min"。
    【边界】已结束的局面：刚刚走棋的一方赢了 → 对"轮到的一方"来说是 -1；平局是 0。
    【Python 小知识】@lru_cache 装饰器会自动记住每个参数对应的返回值（记忆化）：
        同一个局面可以由不同的走棋顺序到达，但只需要计算一次。
    """
    w = winner(board)
    if w is not None:
        return 0 if w == 0 else -1          # 如果有人赢了，一定是刚走棋的一方（不是轮到的一方）
    return max(-solve(play(board, m)) for m in legal_moves(board))


def optimal_moves(board):
    """所有能达到极小极大价值的着法（可能不止一个）。"""
    best = solve(board)
    return {m for m in legal_moves(board) if -solve(play(board, m)) == best}


def all_positions():
    """
    从空棋盘出发，枚举所有能到达的、**还没结束**的局面。
    【返回】列表（顺序固定，方便复现）。
    【数量】井字棋共有 5478 个合法局面（包括已结束的），其中还没结束的有 4520 个。
    """
    seen, order, stack = set(), [], [EMPTY]
    while stack:
        b = stack.pop()
        if b in seen or winner(b) is not None:
            continue
        seen.add(b)
        order.append(b)
        for m in legal_moves(b):
            stack.append(play(b, m))
    return sorted(order)


def critical_positions():
    """
    "关键"局面：并非所有着法都一样好的局面（至少有一步会让结果变差）。
    在这些局面上，下错一步就会丢掉胜利或者输棋——用它们来评测算法更有区分度。
    """
    out = []
    for b in all_positions():
        vals = {-solve(play(b, m)) for m in legal_moves(b)}
        if len(vals) > 1:
            out.append(b)
    return out


def render(board):
    """把棋盘画成三行文字，空位显示编号。"""
    sym = {1: "X", -1: "O"}
    rows = []
    for r in range(3):
        rows.append(" ".join(sym.get(board[3 * r + c], str(3 * r + c)) for c in range(3)))
    return "\n".join(rows)


if __name__ == "__main__":
    print("空棋盘的极小极大价值（X 先走）：", solve(EMPTY), "（0 = 双方完美下棋时是和棋）")
    print("未结束的局面数：", len(all_positions()))
    print("关键局面数：", len(critical_positions()))
