← gridGameOptimized.py
下载源文件
PygridGameOptimized.py
#!/usr/bin/env python3
"""回合制网格淘汰游戏:联合状态枚举 + 稀疏吸收链(浮点数值计算)。

Python >= 3.10。安装:python -m pip install numpy scipy tqdm
创建输入示例:python gridGameOptimized.py --init demo
计算:python gridGameOptimized.py --map demo/map.txt --config demo/config.json --rounds 50
不传地图时运行内置示例。函数、变量、参数名使用小驼峰。

地图:# 障碍;. 空地;A-Z/a-z/0-9 为唯一角色标签。行内空白忽略。
外部边界自动不可通行;允许矩形地图;空行忽略;不可用 # 写注释。
坐标从 0 起,row 向下、col 向右。初始角色按标签排序,可用 turnOrder 覆盖。
对决暂停移动、不消耗额外回合;胜者占相遇格;轮到死者时跳过。
若被攻击的角色尚未行动且获胜,本回合仍有自己的行动。
只剩一人立即停止,包括后续回合;无胜负历史、血量等隐藏状态。

输出 endProbabilities.csv: 恰在本回合结束、累计结束、仍未结束概率。
输出 positionProbabilities.csv: 回合末各角色在各格的无条件概率及存活概率。
淘汰角色无位置;已结束路径将胜者冻结;位置概率之和等于存活概率。
输出 encounterProbabilities.csv: 本回合每个方格至少相遇一次的概率。
输出 roundEncounterProbabilities.csv: 本回合任意位置至少相遇一次的概率。
相遇记录在移动进入占用格时、对决前;同格重复相遇仅计一次事件。
输出 survivalProbabilities.csv: 各角色回合末存活、淘汰及仍在游戏中的概率。
输出 summary.json: 期望回合数、各角色存活期望及计算方法。
--method exact: 完整枚举,按存活集合分块、缓存 LU;适合小地图。
--method simulate: NumPy 批量模拟;适合当前 200 格/7 人地图。
--method auto(默认): 状态组合上界超过 maxStates 时选择模拟。
模拟增加概率标准误;有未结束样本时,报告截断期望而不冒充完整期望。
默认模拟 10000 局,每局上限 5000 回合,可用 --samples/--maxSimulationRounds 调整。
精确枚举、子对局求解、模拟和导出均使用 tqdm;--noProgress 关闭。
各角色 expectedActiveRounds 为游戏结束前进入回合时仍存活的回合数期望。
淘汰/结束所在回合计入;初始只有一人时为 0。
expectedRoundsUntilElimination 为首次淘汰回合的期望,永不淘汰则无穷。
精确模式 null 配合 IsInfinite=true 表示无穷大。
模拟模式 null/IsInfinite=null 也可能表示尚不能判断或完整期望被截断;见 notes。
精确模式期望来自分块线性方程;模拟模式明确标记样本均值与截断情况。
默认移动为上/下/左/右/停各 1,默认双方胜率 0.5。
pairWinProb 中 A:B 表示 A 对 B 获胜概率,与谁主动移动无关。
所有参数时间不变、位置无关,选择在合法方向中重新归一化。
"""

import argparse
import csv
import json
import math
import string
from collections import defaultdict, deque
from pathlib import Path
from tqdm.auto import tqdm


demoMap = """
......6........
1..............
...............
...............
.........5.....
.....#####.....
.....#####.....
.....#####.....
2....#####....7
.....#####.....
.....4.........
...............
...............
...............
.....3.........
"""
demoConfig = {
    "turnOrder": ["1", "2", "3", "4", "5", "6", "7"],
    "defaultMoveWeights": {"up": 1, "down": 1, "left": 1, "right": 1, "stay": 1},
    # "moveWeights": {"A": {"right": 2, "stay": 0.5}},
    "defaultWinProb": 0.5,
    # "pairWinProb": {"A:B": 0.6, "A:C": 0.5, "B:C": 0.4},
}
directionOffsets = {"up": (-1, 0), "down": (1, 0), "left": (0, -1),
                    "right": (0, 1), "stay": (0, 0)}


def readMap(mapText):
    rows = ["".join(line.split()) for line in mapText.splitlines() if line.strip()]
    if not rows or not rows[0] or any(len(row) != len(rows[0]) for row in rows):
        raise ValueError("地图必须非空且每行格数相同。")
    cells, starts = [], {}
    for rowIndex, row in enumerate(rows):
        for colIndex, label in enumerate(row):
            if label == "#":
                continue
            if label != "." and label not in string.ascii_letters + string.digits:
                raise ValueError(f"非法地图字符 {label!r};仅允许 # . 和英文字母/数字。")
            cellIndex = len(cells)
            cells.append((rowIndex, colIndex))
            if label != ".":
                if label in starts:
                    raise ValueError(f"角色标签 {label!r} 重复。")
                starts[label] = cellIndex
    if not starts:
        raise ValueError("至少需要一名角色。")
    return rows, cells, starts


def checkNumber(value, name, probability=False):
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise ValueError(f"{name} 必须是数值。")
    if not math.isfinite(value) or value < 0 or (probability and value > 1):
        raise ValueError(f"{name} 必须为有限非负数" + ("且不大于 1。" if probability else "。"))
    return float(value)


def prepareGame(mapText, config):
    if not isinstance(config, dict):
        raise ValueError("配置必须是 JSON 对象。")
    allowedKeys = {"turnOrder", "defaultMoveWeights", "moveWeights", "defaultWinProb", "pairWinProb"}
    if set(config) - allowedKeys:
        raise ValueError(f"未知配置项:{set(config) - allowedKeys}")
    rows, cells, starts = readMap(mapText)
    labels = config.get("turnOrder", sorted(starts))
    if not isinstance(labels, list) or any(not isinstance(label, str) for label in labels):
        raise ValueError("turnOrder 必须是角色标签数组。")
    if len(labels) != len(starts) or set(labels) != set(starts):
        raise ValueError("turnOrder 必须恰好包含每名角色一次。")
    cellLookup = {cell: index for index, cell in enumerate(cells)}
    defaultWeights = dict.fromkeys(directionOffsets, 1.0)
    specifiedDefaults = config.get("defaultMoveWeights", {})
    moveWeights = config.get("moveWeights", {})
    if not isinstance(specifiedDefaults, dict) or not isinstance(moveWeights, dict):
        raise ValueError("移动权重必须是 JSON 对象。")
    if set(moveWeights) - set(labels):
        raise ValueError("moveWeights 中存在未知角色。")
    if set(specifiedDefaults) - set(directionOffsets):
        raise ValueError("defaultMoveWeights 中存在未知方向。")
    defaultWeights.update(specifiedDefaults)
    moveOptions = []
    for label in labels:
        overrides = moveWeights.get(label, {})
        if not isinstance(overrides, dict) or set(overrides) - set(directionOffsets):
            raise ValueError(f"角色 {label} 的方向配置无效。")
        weights = {**defaultWeights, **overrides}
        weights = {key: checkNumber(value, f"{label}.{key}") for key, value in weights.items()}
        actorOptions = []
        for rowIndex, colIndex in cells:
            legalOptions = []
            for direction, (rowOffset, colOffset) in directionOffsets.items():
                target = cellLookup.get((rowIndex + rowOffset, colIndex + colOffset))
                if target is not None and weights[direction] > 0:
                    legalOptions.append((target, weights[direction]))
            totalWeight = sum(weight for _, weight in legalOptions)
            if not math.isfinite(totalWeight) or totalWeight <= 0:
                raise ValueError(f"角色 {label} 在 {(rowIndex, colIndex)} 无正权重合法方向;请给 stay 正权重。")
            actorOptions.append([(target, weight / totalWeight) for target, weight in legalOptions])
        moveOptions.append(actorOptions)
    defaultWinProb = checkNumber(config.get("defaultWinProb", 0.5), "defaultWinProb", True)
    pairWinProb = config.get("pairWinProb", {})
    if not isinstance(pairWinProb, dict):
        raise ValueError("pairWinProb 必须是 JSON 对象。")
    winLookup = {}
    for pair, value in pairWinProb.items():
        parts = pair.split(":")
        if len(parts) != 2 or parts[0] == parts[1] or any(label not in labels for label in parts):
            raise ValueError(f"无效对决组合 {pair!r},格式应为 A:B。")
        first, second = map(labels.index, parts)
        if (second, first) in winLookup:
            raise ValueError(f"对决组合 {pair!r} 重复定义了反向胜率。")
        winLookup[first, second] = checkNumber(value, pair, True)
    # 未指定组合:按 turnOrder 较早者作为 defaultWinProb 对应的一方。
    for first in range(len(labels)):
        for second in range(first + 1, len(labels)):
            if (first, second) not in winLookup and (second, first) not in winLookup:
                winLookup[first, second] = defaultWinProb
    for (first, second), value in list(winLookup.items()):
        winLookup[second, first] = 1.0 - value
    initialState = tuple(starts[label] for label in labels)
    return rows, cells, labels, moveOptions, winLookup, initialState


def isFinished(state):
    return sum(cell >= 0 for cell in state) == 1


def getRoundTransitions(state, moveOptions, winLookup, maxStates, collectEncounters=False):
    # mask 记录本回合发生过相遇的方格,防止把重复相遇次数当作概率。
    distribution = {(state, 0): 1.0}
    for actorIndex in range(len(state)):
        nextDistribution = defaultdict(float)
        for (currentState, encounterMask), stateProb in distribution.items():
            actorCell = currentState[actorIndex]
            if actorCell < 0 or isFinished(currentState):
                nextDistribution[currentState, encounterMask] += stateProb
                continue
            for targetCell, moveProb in moveOptions[actorIndex][actorCell]:
                opponentIndex = next((index for index, cell in enumerate(currentState)
                                      if index != actorIndex and cell == targetCell), None)
                movedState = list(currentState)
                movedState[actorIndex] = targetCell
                branchProb = stateProb * moveProb
                if opponentIndex is None:
                    nextDistribution[tuple(movedState), encounterMask] += branchProb
                else:
                    nextMask = encounterMask | (1 << targetCell) if collectEncounters else 0
                    winProb = winLookup[actorIndex, opponentIndex]
                    if winProb > 0:
                        winnerState = movedState.copy()
                        winnerState[opponentIndex] = -1
                        nextDistribution[tuple(winnerState), nextMask] += branchProb * winProb
                    if winProb < 1:
                        loserState = movedState.copy()
                        loserState[actorIndex] = -1
                        nextDistribution[tuple(loserState), nextMask] += branchProb * (1 - winProb)
            if len(nextDistribution) > maxStates:
                raise ValueError("单回合分支数超过 maxStates,请缩小地图/减少角色,或提高上限。")
        distribution = nextDistribution
    if not math.isclose(sum(distribution.values()), 1.0, abs_tol=1e-10):
        raise ArithmeticError("单回合转移概率不守恒。")
    transitions = defaultdict(float)
    encounterRates = defaultdict(float)
    anyEncounterRate = 0.0
    for (targetState, encounterMask), probability in distribution.items():
        transitions[targetState] += probability
        if encounterMask:
            anyEncounterRate += probability
        while encounterMask:
            lowestBit = encounterMask & -encounterMask
            encounterRates[lowestBit.bit_length() - 1] += probability
            encounterMask ^= lowestBit
    if collectEncounters:
        return dict(transitions), dict(encounterRates), anyEncounterRate
    return dict(transitions)


def buildChain(initialState, moveOptions, winLookup, maxStates, showProgress=True):
    import numpy as np
    from scipy.sparse import csr_matrix
    states = [initialState]
    stateIds = {initialState: 0}
    rowIndices, colIndices, probabilities = [], [], []
    encounterRows, encounterCols, encounterValues = [], [], []
    cellCount = len(moveOptions[0])
    stateIndex = 0
    progress = tqdm(desc="枚举可达状态", unit="状态", disable=not showProgress)
    while stateIndex < len(states):
        transitions, encounterRates, anyEncounterRate = getRoundTransitions(
            states[stateIndex], moveOptions, winLookup, maxStates, collectEncounters=True)
        for cellIndex, probability in encounterRates.items():
            encounterRows.append(stateIndex)
            encounterCols.append(cellIndex)
            encounterValues.append(probability)
        if anyEncounterRate > 0:
            encounterRows.append(stateIndex)
            encounterCols.append(cellCount)
            encounterValues.append(anyEncounterRate)
        for targetState, probability in transitions.items():
            if probability <= 0:
                continue
            if targetState not in stateIds:
                if len(states) >= maxStates:
                    raise ValueError(f"可达状态数超过 {maxStates};请缩小地图/减少角色,或提高 --maxStates。")
                stateIds[targetState] = len(states)
                states.append(targetState)
            rowIndices.append(stateIndex)
            colIndices.append(stateIds[targetState])
            probabilities.append(probability)
        stateIndex += 1
        progress.update(1)
        if stateIndex % 100 == 0:
            progress.set_postfix(discovered=len(states), edges=len(probabilities))
    progress.close()
    transitionMatrix = csr_matrix((probabilities, (rowIndices, colIndices)), shape=(len(states), len(states)))
    if not np.allclose(np.asarray(transitionMatrix.sum(axis=1)).ravel(), 1.0, atol=1e-10, rtol=0):
        raise ArithmeticError("转移矩阵行和不为 1。")
    encounterMatrix = csr_matrix((encounterValues, (encounterRows, encounterCols)),
                                 shape=(len(states), cellCount + 1))
    return states, transitionMatrix, encounterMatrix


class survivorBlockSolver:
    """按存活集合拆分子对局:先求人数少的块,复用各块的 LU 分解。

    H_A = (I - M_AA)^(-1) [r_A + sum_{B subset A} M_AB H_B]。
    保留角色标签和位置;不能仅按人数合并状态,也不重置回合内操作顺序。
    枚举得到的是回合边界状态,回合中途淘汰由 getRoundTransitions 完整处理。
    """

    def __init__(self, states, transitionMatrix, showProgress=True):
        from collections import OrderedDict
        self.transitionMatrix = transitionMatrix
        self.survivorMasks = [sum(1 << index for index, cell in enumerate(state) if cell >= 0)
                              for state in states]
        self.factorCache = OrderedDict()
        self.factorizations = 0
        self.cacheHits = 0
        self.maxBlockSize = 0
        self.showProgress = showProgress

    def solve(self, selectedIds, rightSide):
        import numpy as np
        from scipy.sparse import eye
        from scipy.sparse.linalg import splu
        groups = defaultdict(list)
        for localIndex, globalIndex in enumerate(selectedIds):
            groups[self.survivorMasks[int(globalIndex)]].append(localIndex)
        values = np.zeros(len(selectedIds))
        blockOrder = sorted(groups, key=lambda mask: (mask.bit_count(), mask))
        for mask in tqdm(blockOrder, desc="求解存活集合子对局", unit="块", disable=not self.showProgress):
            localIds = np.array(groups[mask], dtype=int)
            globalIds = selectedIds[localIds]
            cacheKey = tuple(int(index) for index in globalIds)
            factor = self.factorCache.get(cacheKey)
            if factor is None:
                blockMatrix = self.transitionMatrix[globalIds][:, globalIds]
                factor = splu((eye(len(globalIds), format="csc") - blockMatrix).tocsc())
                self.factorCache[cacheKey] = factor
                self.factorizations += 1
                self.maxBlockSize = max(self.maxBlockSize, len(globalIds))
                # 限制缓存数量,避免为大地图无限保存 LU 分解。
                if len(self.factorCache) > 32:
                    self.factorCache.popitem(last=False)
            else:
                self.cacheHits += 1
                self.factorCache.move_to_end(cacheKey)
            lowerContribution = self.transitionMatrix[globalIds][:, selectedIds] @ values
            values[localIds] = factor.solve(rightSide[localIds] + lowerContribution)
        return values


def expectedMaskedRounds(transitionMatrix, stateMask, blockSolver=None):
    """求初始状态起、首次离开 stateMask 前经历的回合数;调用方保证不能重新进入。"""
    import numpy as np
    from scipy.sparse import eye
    from scipy.sparse.linalg import spsolve
    if not stateMask[0]:
        return False, 0.0
    selectedIds = np.flatnonzero(stateMask)
    subMatrix = transitionMatrix[selectedIds][:, selectedIds]
    # 根据转移图寻找具有离开 mask 的路径的状态,不用浮点行和判断出口。
    canExit = np.zeros(len(selectedIds), dtype=bool)
    for localIndex, globalIndex in enumerate(selectedIds):
        targetIds = transitionMatrix.indices[transitionMatrix.indptr[globalIndex]:transitionMatrix.indptr[globalIndex + 1]]
        canExit[localIndex] = bool(np.any(~stateMask[targetIds]))
    reverseMatrix = subMatrix.transpose().tocsr()
    pending = deque(np.flatnonzero(canExit).tolist())
    while pending:
        targetIndex = pending.popleft()
        for sourceIndex in reverseMatrix.indices[reverseMatrix.indptr[targetIndex]:reverseMatrix.indptr[targetIndex + 1]]:
            if not canExit[sourceIndex]:
                canExit[sourceIndex] = True
                pending.append(int(sourceIndex))
    # 所有枚举状态均可达,且离开后不能重入,因此任一封闭子类都导致期望无穷。
    if np.any(~canExit):
        return True, None
    systemMatrix = eye(len(selectedIds), format="csr") - subMatrix
    rightSide = np.ones(len(selectedIds))
    values = blockSolver.solve(selectedIds, rightSide) if blockSolver else spsolve(systemMatrix, rightSide)
    if not np.all(np.isfinite(values)) or not np.allclose(systemMatrix @ values, rightSide, atol=1e-8, rtol=1e-8):
        raise ArithmeticError("期望存活回合数的线性方程求解不稳定。")
    return False, float(values[0])


def solveActorLifetimes(states, transitionMatrix, labels, blockSolver=None):
    import numpy as np
    unfinishedMask = np.array([not isFinished(state) for state in states])
    actorResults = {}
    for actorIndex, label in enumerate(labels):
        aliveMask = np.array([state[actorIndex] >= 0 for state in states])
        activeIsInfinite, activeExpectation = expectedMaskedRounds(transitionMatrix, aliveMask & unfinishedMask, blockSolver)
        lifeIsInfinite, lifeExpectation = expectedMaskedRounds(transitionMatrix, aliveMask, blockSolver)
        actorResults[label] = {
            "expectedActiveRounds": activeExpectation,
            "expectedActiveRoundsIsInfinite": activeIsInfinite,
            "expectedRoundsUntilElimination": lifeExpectation,
            "expectedRoundsUntilEliminationIsInfinite": lifeIsInfinite,
        }
    return actorResults


def solveSummary(states, transitionMatrix, blockSolver=None):
    import numpy as np
    from scipy.sparse import eye
    from scipy.sparse.linalg import spsolve
    finishedMask = np.array([isFinished(state) for state in states])
    if finishedMask[0]:
        return {"eventualEndProbability": 1.0, "expectedRounds": 0.0,
                "expectedRoundsIsInfinite": False, "stateCount": len(states)}
    # 反向搜索:识别能到达吸收态的状态。每个枚举状态均从初始态可达。
    reverseMatrix = transitionMatrix.transpose().tocsr()
    canFinish = finishedMask.copy()
    pending = deque(np.flatnonzero(finishedMask).tolist())
    while pending:
        targetIndex = pending.popleft()
        for sourceIndex in reverseMatrix.indices[reverseMatrix.indptr[targetIndex]:reverseMatrix.indptr[targetIndex + 1]]:
            if not canFinish[sourceIndex]:
                canFinish[sourceIndex] = True
                pending.append(int(sourceIndex))
    goodIds = np.flatnonzero(canFinish & ~finishedMask)
    endProbability = 0.0
    expectedRounds = None
    isInfinite = bool(np.any(~canFinish))
    if len(goodIds):
        goodMatrix = transitionMatrix[goodIds][:, goodIds]
        systemMatrix = eye(len(goodIds), format="csr") - goodMatrix
        endRates = np.asarray(transitionMatrix[goodIds][:, finishedMask].sum(axis=1)).ravel()
        def solveChecked(rightSide):
            values = blockSolver.solve(goodIds, rightSide) if blockSolver else spsolve(systemMatrix, rightSide)
            if not np.all(np.isfinite(values)) or not np.allclose(systemMatrix @ values, rightSide, atol=1e-8, rtol=1e-8):
                raise ArithmeticError("线性方程求解不稳定;请调整极端权重或改用更高精度。")
            return values
        endValues = solveChecked(endRates)
        if goodIds[0] == 0:
            endProbability = float(np.clip(endValues[0], 0, 1))
        if not isInfinite:
            expectedRounds = float(solveChecked(np.ones(len(goodIds)))[0])
    return {"eventualEndProbability": endProbability, "expectedRounds": expectedRounds,
            "expectedRoundsIsInfinite": isInfinite, "stateCount": len(states)}


def writeResults(states, transitionMatrix, encounterMatrix, labels, cells, roundCount, outputDir, showProgress=True):
    import numpy as np
    outputDir.mkdir(parents=True, exist_ok=True)
    finishedMask = np.array([isFinished(state) for state in states])
    distribution = np.zeros(len(states))
    distribution[0] = 1.0
    transposeMatrix = transitionMatrix.transpose().tocsr()
    transposeEncounters = encounterMatrix.transpose().tocsr()
    from scipy.sparse import csr_matrix
    observationRows, observationCols = [], []
    activeRows, activeCols = [], []
    for stateIndex, state in enumerate(states):
        for actorIndex, cellIndex in enumerate(state):
            if cellIndex >= 0:
                observationRows.append(actorIndex * len(cells) + cellIndex)
                observationCols.append(stateIndex)
                if not finishedMask[stateIndex]:
                    activeRows.append(actorIndex)
                    activeCols.append(stateIndex)
    positionObservation = csr_matrix((np.ones(len(observationRows)), (observationRows, observationCols)),
                                     shape=(len(labels) * len(cells), len(states)))
    activeObservation = csr_matrix((np.ones(len(activeRows)), (activeRows, activeCols)),
                                   shape=(len(labels), len(states)))
    previousCumulative = 0.0
    with (outputDir / "endProbabilities.csv").open("w", encoding="utf-8-sig", newline="") as endFile, \
         (outputDir / "positionProbabilities.csv").open("w", encoding="utf-8-sig", newline="") as positionFile, \
         (outputDir / "encounterProbabilities.csv").open("w", encoding="utf-8-sig", newline="") as encounterFile, \
         (outputDir / "roundEncounterProbabilities.csv").open("w", encoding="utf-8-sig", newline="") as roundEncounterFile, \
         (outputDir / "survivalProbabilities.csv").open("w", encoding="utf-8-sig", newline="") as survivalFile:
        endWriter, positionWriter = csv.writer(endFile), csv.writer(positionFile)
        encounterWriter = csv.writer(encounterFile)
        roundEncounterWriter = csv.writer(roundEncounterFile)
        survivalWriter = csv.writer(survivalFile)
        endWriter.writerow(["round", "endThisRoundProbability", "endedByRoundProbability", "notEndedProbability"])
        positionWriter.writerow(["round", "actor", "row", "col", "positionProbability", "aliveProbability", "positionGivenAliveProbability"])
        encounterWriter.writerow(["round", "row", "col", "atLeastOneEncounterProbability"])
        roundEncounterWriter.writerow(["round", "atLeastOneEncounterAnywhereProbability"])
        survivalWriter.writerow(["round", "actor", "aliveProbability", "eliminatedProbability", "aliveAndGameOngoingProbability"])
        for roundIndex in tqdm(range(roundCount + 1), desc="导出精确概率", unit="回合", disable=not showProgress):
            encounters = np.zeros(len(cells) + 1)
            if roundIndex > 0:
                # 本回合的事件概率必须从上一回合末分布计算。
                encounters = transposeEncounters @ distribution
                distribution = transposeMatrix @ distribution
            roundEncounterWriter.writerow([roundIndex, encounters[-1]])
            for cellIndex, (rowIndex, colIndex) in enumerate(cells):
                encounterWriter.writerow([roundIndex, rowIndex, colIndex, encounters[cellIndex]])
            cumulative = float(distribution[finishedMask].sum())
            roundProbability = cumulative - previousCumulative
            notEnded = float(distribution[~finishedMask].sum())
            endWriter.writerow([roundIndex, roundProbability, cumulative, notEnded])

            previousCumulative = cumulative
            positions = (positionObservation @ distribution).reshape(len(labels), len(cells))
            activeProbabilities = activeObservation @ distribution
            aliveProbabilities = positions.sum(axis=1)
            for actorIndex, label in enumerate(labels):
                aliveProbability = float(aliveProbabilities[actorIndex])
                activeProbability = float(activeProbabilities[actorIndex])
                survivalWriter.writerow([roundIndex, label, aliveProbability, 1.0 - aliveProbability, activeProbability])
                for cellIndex, (rowIndex, colIndex) in enumerate(cells):
                    conditionalPosition = positions[actorIndex, cellIndex] / aliveProbability if aliveProbability > 0 else ""
                    positionWriter.writerow([roundIndex, label, rowIndex, colIndex,
                                             positions[actorIndex, cellIndex], aliveProbability, conditionalPosition])


def probabilitySe(probability, sampleCount):
    """单个概率估计的标准误;不是所有格子/回合同时覆盖的置信区间。"""
    return math.sqrt(max(0.0, probability * (1 - probability)) / sampleCount)


def runSimulation(initialState, moveOptions, winLookup, labels, cells, args):
    """批量 NumPy 模拟,不枚举联合状态。回合中的淘汰、剩余行动顺序保持不变。"""
    import numpy as np
    actorCount, cellCount = len(labels), len(cells)
    roundCount, sampleCount = args.roundCount, args.samples
    randomGenerator = np.random.default_rng(args.seed)
    targets = np.zeros((actorCount, cellCount, 5), dtype=np.int32)
    cumulativeWeights = np.ones((actorCount, cellCount, 5))
    for actorIndex, options in enumerate(moveOptions):
        for cellIndex, choices in enumerate(options):
            total = 0.0
            for choiceIndex, (target, probability) in enumerate(choices):
                targets[actorIndex, cellIndex, choiceIndex] = target
                total += probability
                cumulativeWeights[actorIndex, cellIndex, choiceIndex] = total
            cumulativeWeights[actorIndex, cellIndex, len(choices) - 1:] = 1.0
    winMatrix = np.zeros((actorCount, actorCount))
    for (first, second), probability in winLookup.items():
        winMatrix[first, second] = probability
    positionCounts = np.zeros((roundCount + 1, actorCount, cellCount), dtype=np.int64)
    activeCounts = np.zeros((roundCount + 1, actorCount), dtype=np.int64)
    encounterCounts = np.zeros((roundCount + 1, cellCount), dtype=np.int64)
    anyEncounterCounts = np.zeros(roundCount + 1, dtype=np.int64)
    endCounts = np.zeros(roundCount + 1, dtype=np.int64)
    endLengths = np.zeros(sampleCount, dtype=np.int64)
    activeLengths = np.zeros((sampleCount, actorCount), dtype=np.int64)
    eliminatedAt = np.full((sampleCount, actorCount), -1, dtype=np.int64)
    winners = np.full(sampleCount, -1, dtype=np.int32)
    censored = np.zeros(sampleCount, dtype=bool)
    progress = tqdm(total=sampleCount, desc="批量模拟", unit="局", disable=args.noProgress)
    for batchStart in range(0, sampleCount, args.batchSize):
        batchCount = min(args.batchSize, sampleCount - batchStart)
        batchSlice = slice(batchStart, batchStart + batchCount)
        positions = np.tile(initialState, (batchCount, 1)).astype(np.int32)
        aliveCounts = np.full(batchCount, actorCount, dtype=np.int32)
        finishRounds = np.full(batchCount, -1, dtype=np.int64)
        if actorCount == 1:
            finishRounds[:] = 0
        def recordPositions(roundIndex):
            for actorIndex in range(actorCount):
                liveCells = positions[:, actorIndex]
                positionCounts[roundIndex, actorIndex] += np.bincount(liveCells[liveCells >= 0], minlength=cellCount)
                activeCounts[roundIndex, actorIndex] += np.count_nonzero((liveCells >= 0) & (aliveCounts > 1))
        recordPositions(0)
        for roundIndex in range(1, args.maxSimulationRounds + 1):
            ongoing = aliveCounts > 1
            if not np.any(ongoing) and roundIndex > roundCount:
                break
            activeLengths[batchSlice] += (positions >= 0) & ongoing[:, None]
            encounteredCells = np.zeros((batchCount, cellCount), dtype=bool) if roundIndex <= roundCount else None
            for actorIndex in range(actorCount):
                sampleIds = np.flatnonzero((aliveCounts > 1) & (positions[:, actorIndex] >= 0))
                if not len(sampleIds):
                    continue
                actorCells = positions[sampleIds, actorIndex]
                uniforms = randomGenerator.random(len(sampleIds))
                choiceIds = np.sum(uniforms[:, None] >= cumulativeWeights[actorIndex, actorCells], axis=1)
                targetCells = targets[actorIndex, actorCells, choiceIds]
                matches = positions[sampleIds] == targetCells[:, None]
                matches[:, actorIndex] = False
                hasOpponent = matches.any(axis=1)
                positions[sampleIds, actorIndex] = targetCells
                hitIds = sampleIds[hasOpponent]
                if not len(hitIds):
                    continue
                opponents = matches[hasOpponent].argmax(axis=1)
                if encounteredCells is not None:
                    encounteredCells[hitIds, targetCells[hasOpponent]] = True
                actorWins = randomGenerator.random(len(hitIds)) < winMatrix[actorIndex, opponents]
                loserIds = np.where(actorWins, opponents, actorIndex)
                positions[hitIds, loserIds] = -1
                eliminatedAt[batchStart + hitIds, loserIds] = roundIndex
                aliveCounts[hitIds] -= 1
                newFinished = hitIds[aliveCounts[hitIds] == 1]
                finishRounds[newFinished] = roundIndex
            if roundIndex <= roundCount:
                recordPositions(roundIndex)
                encounterCounts[roundIndex] += encounteredCells.sum(axis=0)
                anyEncounterCounts[roundIndex] += np.count_nonzero(encounteredCells.any(axis=1))
        batchCensored = finishRounds < 0
        censored[batchSlice] = batchCensored
        endLengths[batchSlice] = np.where(batchCensored, args.maxSimulationRounds, finishRounds)
        for actorIndex in range(actorCount):
            winners[batchStart + np.flatnonzero((positions[:, actorIndex] >= 0) & ~batchCensored)] = actorIndex
        exportedEnds = finishRounds[(finishRounds >= 0) & (finishRounds <= roundCount)]
        endCounts += np.bincount(exportedEnds, minlength=roundCount + 1)
        progress.update(batchCount)
        progress.set_postfix(censored=int(censored[:batchStart + batchCount].sum()))
    progress.close()
    args.outputDir.mkdir(parents=True, exist_ok=True)
    def makeWriter(fileName, header):
        file = (args.outputDir / fileName).open("w", encoding="utf-8-sig", newline="")
        writer = csv.writer(file)
        writer.writerow(header)
        return file, writer
    from contextlib import ExitStack
    with ExitStack() as stack:
        specifications = [
            ("endProbabilities.csv", ["round", "endThisRoundProbability", "endedByRoundProbability", "notEndedProbability", "endThisRoundStandardError", "endedByRoundStandardError"]),
            ("positionProbabilities.csv", ["round", "actor", "row", "col", "positionProbability", "aliveProbability", "positionGivenAliveProbability", "positionStandardError"]),
            ("encounterProbabilities.csv", ["round", "row", "col", "atLeastOneEncounterProbability", "standardError"]),
            ("roundEncounterProbabilities.csv", ["round", "atLeastOneEncounterAnywhereProbability", "standardError"]),
            ("survivalProbabilities.csv", ["round", "actor", "aliveProbability", "eliminatedProbability", "aliveAndGameOngoingProbability", "aliveStandardError"])]
        writers = []
        for fileName, header in specifications:
            file, writer = makeWriter(fileName, header)
            stack.enter_context(file)
            writers.append(writer)
        endWriter, positionWriter, encounterWriter, roundEncounterWriter, survivalWriter = writers
        cumulativeEnds = np.cumsum(endCounts) / sampleCount
        for roundIndex in tqdm(range(roundCount + 1), desc="导出模拟概率", unit="回合", disable=args.noProgress):
            endProbability = endCounts[roundIndex] / sampleCount
            endedProbability = cumulativeEnds[roundIndex]
            endWriter.writerow([roundIndex, endProbability, endedProbability, 1 - endedProbability,
                                probabilitySe(endProbability, sampleCount), probabilitySe(endedProbability, sampleCount)])
            encounterProbability = anyEncounterCounts[roundIndex] / sampleCount
            roundEncounterWriter.writerow([roundIndex, encounterProbability, probabilitySe(encounterProbability, sampleCount)])
            for cellIndex, (rowIndex, colIndex) in enumerate(cells):
                probability = encounterCounts[roundIndex, cellIndex] / sampleCount
                encounterWriter.writerow([roundIndex, rowIndex, colIndex, probability, probabilitySe(probability, sampleCount)])
            for actorIndex, label in enumerate(labels):
                aliveCount = int(positionCounts[roundIndex, actorIndex].sum())
                aliveProbability = aliveCount / sampleCount
                survivalWriter.writerow([roundIndex, label, aliveProbability, 1 - aliveProbability,
                                         activeCounts[roundIndex, actorIndex] / sampleCount, probabilitySe(aliveProbability, sampleCount)])
                for cellIndex, (rowIndex, colIndex) in enumerate(cells):
                    count = int(positionCounts[roundIndex, actorIndex, cellIndex])
                    probability = count / sampleCount
                    positionWriter.writerow([roundIndex, label, rowIndex, colIndex, probability, aliveProbability,
                                             count / aliveCount if aliveCount else "", probabilitySe(probability, sampleCount)])
    allFinished = not bool(censored.any())
    def meanSe(values):
        return float(values.std(ddof=1) / math.sqrt(sampleCount)) if sampleCount > 1 else None
    summary = {
        "method": "batched Monte Carlo; estimates, not exact enumeration",
        "samples": sampleCount, "seed": args.seed, "batchSize": args.batchSize,
        "maxSimulationRounds": args.maxSimulationRounds,
        "censoredSampleCount": int(censored.sum()), "censoredFraction": float(censored.mean()),
        "endedBySimulationLimitProbability": float(1 - censored.mean()),
        "eventualEndProbability": None,
        "expectedRounds": float(endLengths.mean()) if allFinished else None,
        "expectedRoundsStandardError": meanSe(endLengths) if allFinished else None,
        "expectedRoundsIsInfinite": None,
        "expectedTruncatedRounds": float(endLengths.mean()),
        "expectedTruncatedRoundsStandardError": meanSe(endLengths),
        "probabilityStandardErrorUpperBound": 0.5 / math.sqrt(sampleCount),
        "notes": "未完成样本存在时仅报告 E[min(T,H)] 的估计,不把截断均值当作完整期望;有限模拟不能证明最终结束概率或无穷期望。单项标准误不构成同时置信区间,零命中也不证明概率为零。",
        "actorLifetimes": {},
    }
    for actorIndex, label in enumerate(labels):
        lengths = activeLengths[:, actorIndex]
        # 对该角色,只要已淘汰或游戏结束,其参与回合数已完全观测。
        actorCensored = censored & (eliminatedAt[:, actorIndex] < 0)
        observedWinner = bool(np.any(winners == actorIndex))
        allEliminated = bool(np.all(eliminatedAt[:, actorIndex] >= 0))
        summary["actorLifetimes"][label] = {
            "expectedActiveRounds": float(lengths.mean()) if not actorCensored.any() else None,
            "expectedActiveRoundsStandardError": meanSe(lengths) if not actorCensored.any() else None,
            "expectedActiveRoundsIsInfinite": None,
            "expectedTruncatedActiveRounds": float(lengths.mean()),
            "activeCensoredFraction": float(actorCensored.mean()),
            "observedWinProbability": float(np.mean(winners == actorIndex)),
            "expectedRoundsUntilElimination": float(eliminatedAt[:, actorIndex].mean()) if allEliminated else None,
            "expectedRoundsUntilEliminationIsInfinite": True if observedWinner else None,
            "eliminationExpectationNote": "观察到获胜路径可证明永不淘汰概率为正,因此期望无穷;未观察到不证明不可能获胜。",
        }
    return summary


def main():
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--init", type=Path, help="创建 map.txt 和 config.json,不覆盖已有输入")
    parser.add_argument("--map", dest="mapPath", type=Path)
    parser.add_argument("--config", dest="configPath", type=Path)
    parser.add_argument("--rounds", dest="roundCount", type=int, default=50, help="逐回合 CSV 输出上限")
    parser.add_argument("--method", choices=["auto", "exact", "simulate"], default="auto")
    parser.add_argument("--samples", type=int, default=10000, help="模拟局数,默认 10000")
    parser.add_argument("--batchSize", type=int, default=1000, help="每批局数,默认 1000")
    parser.add_argument("--maxSimulationRounds", type=int, default=5000, help="每局模拟上限,不能小于 rounds")
    parser.add_argument("--seed", type=int, default=20261012)
    parser.add_argument("--maxStates", type=int, default=50000)
    parser.add_argument("--noProgress", action="store_true", help="关闭 tqdm 进度条")
    parser.add_argument("--out", dest="outputDir", type=Path, default=Path("results"))
    args = parser.parse_args()
    try:
        if args.init is not None:
            args.init.mkdir(parents=True, exist_ok=True)
            paths = [args.init / "map.txt", args.init / "config.json"]
            if any(path.exists() for path in paths):
                raise ValueError("输入文件已存在,请选择其他目录。")
            paths[0].write_text(demoMap, encoding="utf-8")
            paths[1].write_text(json.dumps(demoConfig, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
            print(f"已创建 {paths[0]} 和 {paths[1]}")
            return
        if args.roundCount < 0 or args.maxStates < 1 or args.samples < 1 or args.batchSize < 1 or args.seed < 0:
            raise ValueError("rounds/seed 必须非负;maxStates/samples/batchSize 必须为正。")
        mapText = args.mapPath.read_text(encoding="utf-8-sig") if args.mapPath else demoMap
        config = json.loads(args.configPath.read_text(encoding="utf-8-sig")) if args.configPath else (demoConfig if args.mapPath is None else {})
        rows, cells, labels, moveOptions, winLookup, initialState = prepareGame(mapText, config)
        print("地图:\n" + "\n".join(rows))
        print("移动顺序:" + " → ".join(labels))
        # 所有存活集合的状态数上界 sum C(N,k) * P(C,k),可达子集通常更少。
        stateUpperBound = sum(math.comb(len(labels), count) * math.perm(len(cells), count)
                              for count in range(1, len(labels) + 1))
        method = args.method
        if method == "auto":
            method = "exact" if stateUpperBound <= args.maxStates else "simulate"
        print(f"方法:{method};联合状态组合上界约 {stateUpperBound:.3g}")
        if method == "simulate":
            if args.maxSimulationRounds < max(1, args.roundCount):
                raise ValueError("maxSimulationRounds 必须至少为 max(1, rounds)。")
            summary = runSimulation(initialState, moveOptions, winLookup, labels, cells, args)
            print(f"截断平均结束回合:{summary['expectedTruncatedRounds']:.8g};未完成样本:{summary['censoredSampleCount']}/{args.samples}")
            if summary["expectedRounds"] is not None:
                print(f"结束回合数样本均值:{summary['expectedRounds']:.8g},标准误 {summary['expectedRoundsStandardError']}")
            print(f"单个概率估计的标准误上界:{summary['probabilityStandardErrorUpperBound']:.6g}")
            for label, values in summary["actorLifetimes"].items():
                value = values["expectedActiveRounds"]
                if value is None:
                    print(f"角色 {label}:截断参与回合均值 {values['expectedTruncatedActiveRounds']:.8g};仍存活未结束比例 {values['activeCensoredFraction']:.6g}")
                else:
                    print(f"角色 {label}:参与存活回合样本均值 {value:.8g}")
        else:
            states, transitionMatrix, encounterMatrix = buildChain(initialState, moveOptions, winLookup, args.maxStates, not args.noProgress)
            blockSolver = survivorBlockSolver(states, transitionMatrix, not args.noProgress)
            summary = solveSummary(states, transitionMatrix, blockSolver)
            summary["actorLifetimes"] = solveActorLifetimes(states, transitionMatrix, labels, blockSolver)
            writeResults(states, transitionMatrix, encounterMatrix, labels, cells, args.roundCount, args.outputDir, not args.noProgress)
            summary.update({"method": "exact state enumeration; survivor-set block solve; floating point",
                            "blockFactorizations": blockSolver.factorizations, "blockCacheHits": blockSolver.cacheHits,
                            "maxBlockSize": blockSolver.maxBlockSize})
            print(f"可达状态:{len(states)};子对局分解:{blockSolver.factorizations};缓存命中:{blockSolver.cacheHits}")
            print(f"最终结束概率:{summary['eventualEndProbability']:.12g};期望回合:{summary['expectedRounds']}")
        summary.update({"turnOrder": labels, "roundsExported": args.roundCount, "stateCountUpperBound": stateUpperBound})
        args.outputDir.mkdir(parents=True, exist_ok=True)
        (args.outputDir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
        print(f"结果目录:{args.outputDir.resolve()}")
    except (ValueError, OSError, ArithmeticError, RuntimeError) as error:
        parser.exit(2, f"错误:{error}\n")
    except ImportError:
        parser.exit(2, "缺少依赖,请执行:python -m pip install numpy scipy tqdm\n")


if __name__ == "__main__":
    main()