Skill: zhangbin2025/weiqi-sgf (version 2.0.2)

--- name: 围棋打谱工具 description: SGF围棋棋谱转HTML打谱工具 - 将棋谱文件生成可在浏览器中交互回放的网页，支持多级变化分支、试下功能、真实音效。当用户需要"SGF转网页"、"打谱"、"棋谱查看"、"变化图浏览"时使用此技能。 tags: ["围棋", "weiqi", "go", "棋谱", "SGF", "打谱", "HTML", "网页", "变化图"] version: 2.0.2 --- # SGF 围棋打谱网页生成器 将 SGF 格式的围棋棋谱转换为单文件 HTML 网页，支持本地浏览器打开进行交互式打谱。 ## 功能亮点 - 🎯 **多级变化分支** - 支持任意深度的嵌套变化图，可自由切换主分支和变化分支 - 🔊 **真实音效系统** - 落子声、提子声、撤销声各有不同，Web Audio API 实时生成 - ✋ **试下功能** - 在任意局面下自由试下，支持提子判断 - 📱 **响应式设计** - 手机/平板/桌面端自适应，触屏滑动支持 - 🌳 **树状结构解析** - 完整支持 SGF 树状结构，支持 MultiGo、野狐等多种格式 ## 安全说明 🔒 本技能是**纯本地离线工具**，无任何网络依赖： | 安全项 | 说明 | |--------|------| | **网络访问** | ❌ 无任何网络请求 | | **文件系统** | 仅读写用户指定路径 | | **第三方依赖** | ❌ 零依赖，仅用 Python 标准库 | | **XSS防护** | ✅ `html.escape()` 转义所有 SGF 元数据 | | **JSON注入防护** | ✅ `json.dumps()` + `html.escape()` + DOM解码三重保护 | **代码审计路径**： - `replay.py:43-48` - 所有用户输入的 HTML 转义 - `replay.py:58` - JSON 数据的安全编码 - `replay.html` - JSON 解码使用 textarea.innerHTML 安全解码 ## 功能描述 根据 SGF 格式的围棋棋谱数据，生成一个单文件 HTML 网页，支持本地浏览器打开进行交互式打谱。 ## 依赖 **无第三方依赖** - 使用 Python 标准库（sys, re, os, json, html） ## 核心文件 | 文件 | 功能 | |------|------| | `scripts/replay.py` | SGF 转 HTML 主脚本 | | `scripts/sgf_parser.py` | SGF 树状解析模块 | | `scripts/templates/replay.html` | HTML 页面模板 | ## 使用方法 ```bash # 基本用法 python3 scripts/replay.py <输入.sgf> [输出.html] # 示例 python3 scripts/replay.py game.sgf # 输出到 /tmp/sgf-viewer/ python3 scripts/replay.py game.sgf mygame.html # 指定输出文件名 python3 scripts/replay.py game.sgf -o /path/to/dir # 指定输出目录 ``` 直接用浏览器打开生成的 HTML 文件即可打谱。 ### 快捷操作 | 平台 | 操作 | |------|------| | 桌面端 | `←`/`→` 上一手/下一手，`空格` 播放/暂停 | | 移动端 | 左滑下一手，右滑上一手 | ## 功能详解 ### 变化图浏览 当棋谱包含 AI 变化图（如野狐围棋棋谱）时： - 显示变化图按钮（带胜率信息，如"黑62%"） - 点击进入变化图，自动显示手数标记 - 支持多级嵌套变化分支 ### 试下功能 在棋盘任意空点点击可进入试下模式： - 自由放置棋子，支持提子判断 - 可撤销/前进试下着法 - 点击 ✕ 退出试下，恢复原局面 ### 音效系统 使用 Web Audio API 实时生成，无需加载音频文件： - 落子声 - 清脆的"嗒" - 提子声 - 短促的"啪" - 撤销声 - 低沉的反馈 ## SGF 格式支持 | 格式 | 说明 | |------|------| | 标准格式 | `(;GM[1];B[pd];W[pp]...)` | | MultiGo | 多分支嵌套格式 | | 野狐格式 | 平面格式、带AI变化图 | | MULTIGOGM | 定式库多向变化 | ## API 接口 ```python from sgf_parser import parse_sgf, parse_sgf_file # 解析 SGF 字符串 result = parse_sgf(sgf_content) # 返回: {"game_info": {...}, "tree": {...}, "stats": {...}, "errors": [...]} # 解析 SGF 文件 result = parse_sgf_file("game.sgf") ``` ## 更新日志 ### v2.0.1 (2026-04-09) - 修复 JosekiDB 格式棋谱解析错误（支持分支起始处属性） - 修复打谱网页滑条在主分支模式下无法跳转的问题 - 新增变化分支注释显示功能 - 新增快速返回上级按钮（⬅） - 非主分支时自动隐藏音效按钮 - 使用棋局名称作为网页标题 ### v2.0.0 (2026-04-08) - 重构打谱工具，支持嵌套变化图 ## 使用指令示例 > "把 xxx.sgf 生成打谱网页" > "查看这盘棋的变化图" > "生成一个围棋打谱页面"

path: scripts/replay.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
SGF围棋打谱网页生成器 - 纯本地离线工具
将野狐围棋SGF文件转换为交互式HTML打谱网页

使用方法:
    python3 replay.py input.sgf [output.html]
    python3 replay.py input.sgf --output-dir /path/to/dir

示例:
    python3 replay.py game.sgf                    # 输出到 /tmp/sgf-viewer/
    python3 replay.py game.sgf mygame.html        # 输出到 /tmp/sgf-viewer/mygame.html
"""

import sys
import os
import html
import json

# 导入 SGF 解析模块
from sgf_parser import parse_sgf


def load_template():
    """加载 HTML 模板文件"""
    template_path = os.path.join(
        os.path.dirname(os.path.abspath(__file__)),
        'templates', 'replay.html'
    )
    with open(template_path, 'r', encoding='utf-8') as f:
        return f.read()


def generate_html(tree, game_info, output_path, input_base_name='棋谱', start_move=0):
    """生成HTML打谱网页"""
    
    # 加载模板
    template = load_template()
    
    # 准备替换数据
    board_size = int(game_info.get('board_size', '19'))
    handicap = game_info.get('handicap', '0')
    handicap_stones = game_info.get('handicap_stones', [])
    
    # 各种转义处理
    black_name = html.escape(game_info.get('black', '黑棋'))
    white_name = html.escape(game_info.get('white', '白棋'))
    black_rank = html.escape(game_info.get('black_rank', ''))
    white_rank = html.escape(game_info.get('white_rank', ''))
    game_name = html.escape(game_info.get('game_name', '围棋棋谱'))
    result = html.escape(game_info.get('result', ''))
    
    # 构建游戏标题和信息
    game_title = game_name
    game_info_text = f"{black_name} vs {white_name}"
    if int(handicap) > 0:
        game_info_text += f" · 让{handicap}子"
    if result:
        game_info_text += f" · {result}"
    
    # 树形数据 JSON（用于前端分支导航）
    tree_json = html.escape(json.dumps(tree, ensure_ascii=False)).replace('\\', '&#92;')
    
    # 让子石 JSON
    handicap_stones_json = json.dumps(handicap_stones)
    
    # 执行模板替换
    html_content = template
    html_content = html_content.replace('{{GAME_NAME}}', game_name)
    html_content = html_content.replace('{{GAME_TITLE}}', game_title)
    html_content = html_content.replace('{{GAME_INFO}}', game_info_text)
    html_content = html_content.replace('{{BOARD_SIZE}}', str(board_size))
    html_content = html_content.replace('{{HANDICAP_STONES}}', handicap_stones_json)
    html_content = html_content.replace('{{HANDICAP_COUNT}}', str(handicap))
    html_content = html_content.replace('{{TREE_JSON}}', tree_json)
    html_content = html_content.replace('{{BLACK_NAME}}', black_name + (' ' + black_rank if black_rank else ''))
    html_content = html_content.replace('{{WHITE_NAME}}', white_name + (' ' + white_rank if white_rank else ''))
    html_content = html_content.replace('{{DOWNLOAD_FILENAME}}', html.escape(input_base_name) + '.sgf')
    html_content = html_content.replace('{{DEFAULT_MOVE}}', str(start_move))
    
    # 写入文件
    with open(output_path, 'w', encoding='utf-8') as f:
        f.write(html_content)
    
    print(f"✅ 已生成打谱网页: {output_path}")
    print(f"   黑棋: {black_name}")
    print(f"   白棋: {white_name}")


def main():
    if len(sys.argv) < 2:
        print("用法: python3 replay.py input.sgf [output.html]")
        print("       python3 replay.py input.sgf --output-dir /path/to/dir")
        print("       python3 replay.py input.sgf --start-move 50")
        print("       python3 replay.py input.sgf --start-move last")
        print("示例: python3 replay.py game.sgf")
        print("       python3 replay.py game.sgf -o /tmp/myviewer")
        print("       python3 replay.py game.sgf --start-move 100  # 默认跳转到第100手")
        print("       python3 replay.py game.sgf --start-move last # 跳转到最后一手")
        sys.exit(1)

    input_file = sys.argv[1]

    # 解析参数
    output_file = None
    output_dir = None
    start_move = 0

    i = 2
    while i < len(sys.argv):
        if sys.argv[i] in ('--output-dir', '-o'):
            if i + 1 < len(sys.argv):
                output_dir = sys.argv[i + 1]
                i += 2
            else:
                print("❌ 错误: --output-dir 需要指定目录路径")
                sys.exit(1)
        elif sys.argv[i] == '--start-move':
            if i + 1 < len(sys.argv):
                move_arg = sys.argv[i + 1]
                if move_arg.lower() == 'last':
                    start_move = -1  # 特殊标记，表示最后一手
                else:
                    try:
                        start_move = int(move_arg)
                        if start_move < 0:
                            print("❌ 错误: --start-move 必须是非负整数")
                            sys.exit(1)
                    except ValueError:
                        print("❌ 错误: --start-move 必须是整数或 'last'")
                        sys.exit(1)
                i += 2
            else:
                print("❌ 错误: --start-move 需要指定手数")
                sys.exit(1)
        elif not output_file:
            output_file = sys.argv[i]
            i += 1
        else:
            i += 1

    # 默认输出目录为 /tmp/sgf-viewer
    if output_dir is None:
        output_dir = '/tmp/sgf-viewer'

    # 确保输出目录存在
    os.makedirs(output_dir, exist_ok=True)

    # 确定输出文件名
    if output_file:
        if os.path.isabs(output_file):
            output_path = output_file
        else:
            output_path = os.path.join(output_dir, output_file)
    else:
        base_name = os.path.splitext(os.path.basename(input_file))[0]
        output_path = os.path.join(output_dir, base_name + '.html')

    # 读取SGF文件
    try:
        with open(input_file, 'r', encoding='utf-8') as f:
            sgf_content = f.read()
    except FileNotFoundError:
        print(f"❌ 错误: 找不到文件 {input_file}")
        sys.exit(1)
    except Exception as e:
        print(f"❌ 错误: 读取文件失败 - {e}")
        sys.exit(1)

    # 解析 SGF
    print(f"📖 正在解析: {input_file}")
    result = parse_sgf(sgf_content)
    
    tree = result['tree']
    game_info = result['game_info']
    stats = result['stats']
    errors = result['errors']

    if stats['move_nodes'] == 0:
        print("❌ 错误: 无法从SGF中提取着法")
        if errors:
            print("   解析错误:")
            for err in errors:
                print(f"     - {err}")
        sys.exit(1)

    print(f"   总节点数: {stats['total_nodes']}")
    print(f"   着法节点: {stats['move_nodes']}")
    print(f"   最大深度: {stats['max_depth']}")
    print(f"   分支数: {stats['branch_count']}")

    if errors:
        print(f"   ⚠️  解析过程中遇到 {len(errors)} 个错误/警告")
        for err in errors[:5]:  # 最多显示5个
            print(f"     - {err}")

    # 获取输入文件名（不含扩展名）
    input_base_name = os.path.splitext(os.path.basename(input_file))[0]

    # 生成HTML
    generate_html(tree, game_info, output_path, input_base_name, start_move)
    
    if start_move > 0:
        print(f"   默认跳转: 第 {start_move} 手")
    elif start_move == -1:
        print(f"   默认跳转: 最后一手")

    print(f"\n✅ 生成完成: {output_path}")


if __name__ == '__main__':
    main()


path: scripts/sgf_parser.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
SGF 树状解析模块 - 完整支持嵌套变化分支

对外接口仅2个:
- parse_sgf(sgf_content: str) -> dict
- parse_sgf_file(filepath: str) -> dict

树节点固定结构:
{
    "properties": dict,
    "is_root": bool,
    "move_number": int,
    "color": str|None,
    "coord": str|None,
    "children": list
}
"""

import re
import json
import unittest
from typing import Optional, List, Dict, Any


def parse_sgf(sgf_content: str) -> dict:
    """
    解析 SGF 内容
    
    返回结构:
    {
        "game_info": {
            "board_size": 19,
            "black": "黑棋",
            "white": "白棋",
            "black_rank": "9d",
            "white_rank": "9d",
            "game_name": "围棋棋谱",
            "date": "2024-01-01",
            "result": "B+R",
            "komi": "375",
            "handicap": 0,
            "handicap_stones": [{"x": 15, "y": 3}, ...]
        },
        "tree": {
            "properties": {"GM": "1", "FF": "4", ...},
            "is_root": true,
            "move_number": 0,
            "color": null,
            "coord": null,
            "children": [...]
        },
        "stats": {
            "total_nodes": 150,
            "move_nodes": 149,
            "max_depth": 80,
            "branch_count": 3
        },
        "errors": []
    }
    """
    parser = _SGFParser()
    return parser.parse(sgf_content)


def parse_sgf_file(filepath: str) -> dict:
    """解析 SGF 文件"""
    with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
        content = f.read()
    return parse_sgf(content)


class _SGFParser:
    """内部解析器实现"""
    
    def __init__(self):
        self.errors: List[str] = []
        self._pending_branch_props: Dict[str, Any] = {}  # 缓存分支起始处的属性
    
    def parse(self, sgf_content: str) -> dict:
        """解析 SGF 内容"""
        self.errors = []
        
        content = sgf_content.strip()
        if not content:
            self.errors.append("SGF内容为空")
            return self._create_empty_result()
        
        try:
            root_node = self._parse_tree(content)
            tree = self._node_to_dict(root_node)
            stats = self._calc_stats(tree)
            game_info = self._extract_game_info(tree)
            
            return {
                "game_info": game_info,
                "tree": tree,
                "stats": stats,
                "errors": self.errors
            }
        except Exception as e:
            self.errors.append(f"解析错误: {str(e)}")
            return self._create_empty_result()
    
    def _create_empty_result(self) -> dict:
        """创建空结果"""
        empty_tree = {
            "properties": {},
            "is_root": True,
            "move_number": 0,
            "color": None,
            "coord": None,
            "children": []
        }
        return {
            "game_info": self._extract_game_info(empty_tree),
            "tree": empty_tree,
            "stats": {"total_nodes": 1, "move_nodes": 0, "max_depth": 0, "branch_count": 0},
            "errors": self.errors
        }
    
    def _parse_tree(self, content: str) -> '_SGFNode':
        """解析树状结构
        
        SGF格式: (;GM[1](;B[pd];W[pp])(;B[dd]))
        - 序列本身不创建节点，序列内的第一个 ';' 决定父节点
        - '(' 只标记进入新层级，')' 标记退出
        """
        # 父节点栈，存储序列的父节点（即序列内第一个节点的父节点）
        parent_stack: List['_SGFNode'] = []
        # 序列内当前处理的节点
        seq_current: Optional['_SGFNode'] = None
        root: Optional['_SGFNode'] = None
        i = 0
        n = len(content)
        paren_count = 0
        
        while i < n:
            char = content[i]
            
            if char == '(':
                # 开始新序列
                if seq_current is not None:
                    # 有序列内当前节点，作为新序列的父节点
                    parent_stack.append(seq_current)
                elif parent_stack:
                    # 没有序列内节点但有父栈，复制栈顶
                    parent_stack.append(parent_stack[-1])
                elif root is not None:
                    # 没有父栈但有根，根是新序列的父
                    parent_stack.append(root)
                # else: 第一个序列，parent_stack保持为空
                
                paren_count += 1
                seq_current = None
                i += 1
                
                # 预读并缓存 '(' 后的属性（如 C[...]），直到遇到 ';' 或 ')'
                self._pending_branch_props = {}
                while i < n:
                    c = content[i]
                    if c in '();':
                        break
                    if c in ' \t\n\r':
                        i += 1
                        continue
                    if c.isupper():
                        prop_name = ''
                        while i < n and content[i].isupper():
                            prop_name += content[i]
                            i += 1
                        values = []
                        while i < n and content[i] == '[':
                            value, i, closed = self._parse_property_value(content, i + 1)
                            if not closed:
                                self.errors.append(f"属性 {prop_name} 的值未闭合")
                            values.append(value)
                        if values:
                            self._pending_branch_props[prop_name] = values if len(values) > 1 else values[0]
                    else:
                        self.errors.append(f"位置 {i}: 分支注释中意外字符 '{c}'，跳过")
                        i += 1
            
            elif char == ')':
                # 结束当前序列
                if paren_count > 0:
                    if parent_stack:
                        parent = parent_stack.pop()
                        # 序列结束后，seq_current 应该是序列的父节点
                        # 以便下一个序列能正确地挂在同一父节点下
                        seq_current = parent
                    else:
                        seq_current = None
                    paren_count -= 1
                else:
                    self.errors.append(f"位置 {i}: 多余的右括号")
                i += 1
            
            elif char == ';':
                # 创建新节点
                new_node = _SGFNode()
                
                # 确定父节点
                if seq_current is not None and not seq_current.properties:
                    # seq_current 是刚由'('创建的空白节点，给它属性
                    # 这不应该发生，因为我们不再在'('时创建节点
                    parent = seq_current
                elif seq_current is not None:
                    # 序列内已有节点，新节点作为 seq_current 的子（主分支延续）
                    seq_current.children.append(new_node)
                    new_node.parent = seq_current
                    new_node.move_number = seq_current.move_number + 1 if not seq_current.is_root else 1
                elif parent_stack:
                    # 序列的第一个节点，父节点是 parent_stack 栈顶
                    parent = parent_stack[-1]
                    parent.children.append(new_node)
                    new_node.parent = parent
                    new_node.move_number = parent.move_number + 1 if not parent.is_root else 1
                elif root is None:
                    # 第一个节点，作为根
                    root = new_node
                    new_node.is_root = True
                    new_node.move_number = 0
                else:
                    # 另一个顶级节点（无括号包裹的情况）
                    if root.is_root and len(root.children) == 0 and not root.properties:
                        # 根是空的，直接使用
                        root.properties = {}
                        new_node.parent = root
                        new_node.move_number = 1
                        root.children.append(new_node)
                    elif root.is_root:
                        # 创建包裹节点
                        wrapper = _SGFNode()
                        wrapper.is_root = True
                        wrapper.move_number = 0
                        
                        if not root.properties and len(root.children) == 0:
                            # root 是空的，替换
                            root = wrapper
                        else:
                            # 移动原root
                            root.parent = wrapper
                            root.move_number = 1
                            wrapper.children.append(root)
                            root = wrapper
                        
                        new_node.parent = root
                        new_node.move_number = 1
                        root.children.append(new_node)
                    else:
                        new_node.parent = root
                        new_node.move_number = 1
                        root.children.append(new_node)
                
                # 解析属性
                props, i = self._parse_properties(content, i + 1)
                
                # 合并缓存的分支属性（如果有）
                if self._pending_branch_props:
                    # 缓存的属性优先，但已被解析的属性不会被覆盖
                    merged_props = self._pending_branch_props.copy()
                    merged_props.update(props)
                    props = merged_props
                    self._pending_branch_props = {}  # 清空缓存
                
                new_node.properties = props
                self._extract_move_info(new_node)
                
                seq_current = new_node
            
            elif char in ' \t\n\r':
                i += 1
            else:
                self.errors.append(f"位置 {i}: 意外字符 '{char}'，跳过")
                i += 1
        
        if paren_count > 0:
            self.errors.append("警告: 括号未完全闭合")
        

        
        return root or _SGFNode()
    
    def _parse_properties(self, content: str, start: int) -> tuple:
        """解析属性列表，返回 (属性字典, 新位置)"""
        props: Dict[str, Any] = {}
        i = start
        n = len(content)
        
        while i < n:
            char = content[i]
            
            if char in '();':
                break
            
            if char in ' \t\n\r':
                i += 1
                continue
            
            if char.isupper():
                prop_name = ''
                while i < n and content[i].isupper():
                    prop_name += content[i]
                    i += 1
                
                values = []
                while i < n and content[i] == '[':
                    value, i, closed = self._parse_property_value(content, i + 1)
                    if not closed:
                        self.errors.append(f"属性 {prop_name} 的值未闭合")
                    values.append(value)
                
                if values:
                    props[prop_name] = values if len(values) > 1 else values[0]
                else:
                    props[prop_name] = ''
            else:
                self.errors.append(f"位置 {i}: 属性名应为大写字母，跳过 '{char}'")
                i += 1
        
        return props, i
    
    def _parse_property_value(self, content: str, start: int) -> tuple:
        """解析属性值，正确处理转义
        返回: (值, 新位置, 是否正常闭合)
        """
        value = []
        i = start
        n = len(content)
        
        while i < n:
            char = content[i]
            
            if char == '\\' and i + 1 < n:
                next_char = content[i + 1]
                # SGF 转义规则：\] -> ], \\ -> \, \n -> 换行等
                if next_char == ']':
                    value.append(']')
                    i += 2
                elif next_char == '\\':
                    value.append('\\')
                    i += 2
                elif next_char == 'n':
                    value.append('\n')
                    i += 2
                elif next_char == 'r':
                    value.append('\r')
                    i += 2
                elif next_char == 't':
                    value.append('\t')
                    i += 2
                else:
                    # 其他字符直接保留
                    value.append(next_char)
                    i += 2
            elif char == ']':
                # 找到闭合括号
                i += 1
                return ''.join(value), i, True
            else:
                value.append(char)
                i += 1
        
        # 未正常闭合
        return ''.join(value), i, False
    
    def _extract_move_info(self, node: '_SGFNode'):
        """从属性中提取 color 和 coord"""
        if 'B' in node.properties:
            node.color = 'B'
            node.coord = self._normalize_coord(node.properties['B'])
        elif 'W' in node.properties:
            node.color = 'W'
            node.coord = self._normalize_coord(node.properties['W'])
    
    def _normalize_coord(self, val: Any) -> Optional[str]:
        """统一坐标格式"""
        if isinstance(val, list) and val:
            return val[0] if val[0] else None
        return val if val else None
    
    def _node_to_dict(self, node: '_SGFNode') -> dict:
        """将节点转换为字典"""
        return {
            "properties": node.properties,
            "is_root": node.is_root,
            "move_number": node.move_number,
            "color": node.color,
            "coord": node.coord,
            "children": [self._node_to_dict(c) for c in node.children]
        }
    
    def _calc_stats(self, tree: dict) -> dict:
        """计算统计信息"""
        total_nodes = 0
        move_nodes = 0
        max_depth = 0
        branch_count = 0
        
        def traverse(node: dict):
            nonlocal total_nodes, move_nodes, max_depth, branch_count
            total_nodes += 1
            if not node.get("is_root", False):
                move_nodes += 1
            max_depth = max(max_depth, node.get("move_number", 0))
            children = node.get("children", [])
            # 子节点数 > 1 表示有分支（除第一个主分支外的都是变化）
            if len(children) > 1:
                branch_count += len(children) - 1
            for child in children:
                traverse(child)
        
        traverse(tree)
        
        return {
            "total_nodes": total_nodes,
            "move_nodes": move_nodes,
            "max_depth": max_depth,
            "branch_count": branch_count
        }
    
    def _extract_game_info(self, tree: dict) -> dict:
        """从根节点提取棋局信息"""
        props = tree.get("properties", {})
        
        def get_prop(key: str, default: str = '') -> str:
            val = props.get(key, default)
            if isinstance(val, list) and val:
                return str(val[0])
            return str(val) if val else default
        
        # 棋盘大小
        try:
            board_size = int(get_prop('SZ', '19'))
        except ValueError:
            board_size = 19
        
        # 让子数
        try:
            handicap = int(get_prop('HA', '0'))
        except ValueError:
            handicap = 0
        
        # 让子位置
        handicap_stones = []
        ab_prop = props.get('AB', [])
        if isinstance(ab_prop, str):
            ab_prop = [ab_prop]
        if isinstance(ab_prop, list):
            for coord in ab_prop:
                if coord and len(str(coord)) >= 2:
                    c = str(coord)
                    x = ord(c[0]) - 97
                    y = ord(c[1]) - 97
                    if 0 <= x < board_size and 0 <= y < board_size:
                        handicap_stones.append({"x": x, "y": y})
        
        return {
            "board_size": board_size,
            "black": get_prop('PB', '黑棋'),
            "white": get_prop('PW', '白棋'),
            "black_rank": get_prop('BR'),
            "white_rank": get_prop('WR'),
            "game_name": get_prop('GN', '围棋棋谱'),
            "date": get_prop('DT'),
            "result": get_prop('RE'),
            "komi": get_prop('KM', '375'),
            "handicap": handicap,
            "handicap_stones": handicap_stones
        }


class _SGFNode:
    """内部节点类"""
    
    def __init__(self):
        self.properties: Dict[str, Any] = {}
        self.is_root: bool = False
        self.move_number: int = 0
        self.color: Optional[str] = None
        self.coord: Optional[str] = None
        self.parent: Optional['_SGFNode'] = None
        self.children: List['_SGFNode'] = []


# ============ 单元测试 ============

class TestSGFParser(unittest.TestCase):
    """SGF 解析器单元测试"""
    
    def test_empty_sgf(self):
        """测试空 SGF"""
        result = parse_sgf("")
        self.assertIn("SGF内容为空", result['errors'])
        self.assertEqual(result['stats']['total_nodes'], 1)
        self.assertEqual(result['stats']['move_nodes'], 0)
    
    def test_root_only(self):
        """测试只有根节点"""
        sgf = "(;GM[1]FF[4]PB[黑棋]PW[白棋])"
        result = parse_sgf(sgf)
        
        self.assertEqual(result['game_info']['black'], '黑棋')
        self.assertEqual(result['game_info']['white'], '白棋')
        self.assertEqual(result['tree']['is_root'], True)
        self.assertEqual(result['tree']['move_number'], 0)
        self.assertEqual(result['tree']['color'], None)
        self.assertEqual(result['tree']['coord'], None)
        self.assertEqual(len(result['tree']['children']), 0)
        self.assertEqual(result['stats']['total_nodes'], 1)
        self.assertEqual(result['stats']['move_nodes'], 0)
        self.assertEqual(len(result['errors']), 0)
    
    def test_single_branch(self):
        """测试单分支（标准棋谱）"""
        sgf = "(;GM[1];B[pd];W[pp];B[dd])"
        result = parse_sgf(sgf)
        
        self.assertEqual(result['stats']['total_nodes'], 4)
        self.assertEqual(result['stats']['move_nodes'], 3)
        self.assertEqual(result['stats']['max_depth'], 3)
        self.assertEqual(result['stats']['branch_count'], 0)
        
        tree = result['tree']
        self.assertEqual(len(tree['children']), 1)
        
        first_move = tree['children'][0]
        self.assertEqual(first_move['is_root'], False)
        self.assertEqual(first_move['move_number'], 1)
        self.assertEqual(first_move['color'], 'B')
        self.assertEqual(first_move['coord'], 'pd')
        self.assertEqual(first_move['properties']['B'], 'pd')
        
        second_move = first_move['children'][0]
        self.assertEqual(second_move['move_number'], 2)
        self.assertEqual(second_move['color'], 'W')
        self.assertEqual(second_move['coord'], 'pp')
    
    def test_root_variations(self):
        """测试根节点多分支（无主分支）"""
        sgf = "(;GM[1](;B[pd])(;B[dd]))"
        result = parse_sgf(sgf)
        
        self.assertEqual(result['stats']['total_nodes'], 3)
        self.assertEqual(result['stats']['move_nodes'], 2)
        self.assertEqual(result['stats']['branch_count'], 1)
        
        tree = result['tree']
        self.assertEqual(len(tree['children']), 2)
        
        # 第一个分支
        self.assertEqual(tree['children'][0]['color'], 'B')
        self.assertEqual(tree['children'][0]['coord'], 'pd')
        
        # 第二个分支（变化）
        self.assertEqual(tree['children'][1]['color'], 'B')
        self.assertEqual(tree['children'][1]['coord'], 'dd')
    
    def test_nested_variations(self):
        """测试嵌套变化分支"""
        sgf = "(;GM[1];B[pd](;W[pp])(;W[dp](;B[dd])(;B[qd])))"
        result = parse_sgf(sgf)
        
        # 树结构: Root -> B[pd] -> (W[pp], W[dp] -> (B[dd], B[qd]))
        # B[pd] 有 2 个子，贡献 1 个 branch
        # W[dp] 有 2 个子，贡献 1 个 branch
        # 总计 2 个 branch
        self.assertEqual(result['stats']['total_nodes'], 6)
        self.assertEqual(result['stats']['move_nodes'], 5)
        self.assertEqual(result['stats']['branch_count'], 2)
        
        tree = result['tree']
        # 根 -> B[pd]
        b_node = tree['children'][0]
        self.assertEqual(b_node['color'], 'B')
        
        # B[pd] 有两个子: W[pp] 和 W[dp]
        self.assertEqual(len(b_node['children']), 2)
        self.assertEqual(b_node['children'][0]['coord'], 'pp')
        self.assertEqual(b_node['children'][1]['coord'], 'dp')
        
        # W[dp] 有两个子: B[dd] 和 B[qd]
        w_dp_node = b_node['children'][1]
        self.assertEqual(len(w_dp_node['children']), 2)
        self.assertEqual(w_dp_node['children'][0]['coord'], 'dd')
        self.assertEqual(w_dp_node['children'][1]['coord'], 'qd')
    
    def test_escape_chars(self):
        """测试转义字符"""
        sgf = r"(;GM[1]C[Comment \] test])"
        result = parse_sgf(sgf)
        
        self.assertEqual(result['tree']['properties']['C'], "Comment ] test")
        self.assertEqual(len(result['errors']), 0)
    
    def test_handicap(self):
        """测试让子棋"""
        sgf = "(;GM[1]SZ[19]HA[2]AB[pd][dp];W[pp])"
        result = parse_sgf(sgf)
        
        self.assertEqual(result['game_info']['handicap'], 2)
        self.assertEqual(len(result['game_info']['handicap_stones']), 2)
        self.assertEqual(result['game_info']['handicap_stones'][0], {'x': 15, 'y': 3})
        self.assertEqual(result['game_info']['handicap_stones'][1], {'x': 3, 'y': 15})
        
        # 让子位置应在根节点
        ab = result['tree']['properties']['AB']
        self.assertIsInstance(ab, list)
        self.assertEqual(len(ab), 2)
    
    def test_multi_value_property(self):
        """测试多值属性"""
        sgf = "(;GM[1]AB[aa][bb][cc])"
        result = parse_sgf(sgf)
        
        ab = result['tree']['properties']['AB']
        self.assertIsInstance(ab, list)
        self.assertEqual(len(ab), 3)
        self.assertEqual(ab[0], 'aa')
        self.assertEqual(ab[1], 'bb')
        self.assertEqual(ab[2], 'cc')
    
    def test_invalid_sgf(self):
        """测试无效 SGF（未闭合属性值）"""
        sgf = "(;GM[1];B[pd;W[pp)"  # B的属性值未闭合
        result = parse_sgf(sgf)
        
        # 应该有错误（属性值未闭合）
        has_error = any("未闭合" in err or "属性" in err for err in result['errors'])
        self.assertTrue(has_error or len(result['errors']) > 0)
    
    def test_extra_close_paren(self):
        """测试多余右括号"""
        sgf = "(;GM[1];B[pd]))"
        result = parse_sgf(sgf)
        
        # 应该有多余的右括号错误或解析错误
        has_paren_error = any("括号" in err for err in result['errors'])
        self.assertTrue(has_paren_error or len(result['errors']) > 0)
    
    def test_pass_move(self):
        """测试虚手（停着）"""
        sgf = "(;GM[1];B[pd];W[];B[dd])"
        result = parse_sgf(sgf)
        
        tree = result['tree']
        w_node = tree['children'][0]['children'][0]
        self.assertEqual(w_node['color'], 'W')
        self.assertIsNone(w_node['coord'])
    
    def test_complex_tree(self):
        """测试复杂树结构"""
        sgf = """(;GM[1]FF[4]PB[黑棋]PW[白棋]
            (;B[pd];W[pp])
            (;B[dd];W[dp]
                (;B[pd])
                (;B[pp];W[pd](;B[qf])(;B[pf]))
            )
            (;B[dp];W[dd])
        )"""
        result = parse_sgf(sgf)
        
        # 根有 3 个子，贡献 2 个 branch
        # 第二个分支下又有分支，总共应有 4 个 branch
        self.assertEqual(result['stats']['branch_count'], 4)
        
        # 验证 game_info
        self.assertEqual(result['game_info']['black'], '黑棋')
        self.assertEqual(result['game_info']['white'], '白棋')
        self.assertEqual(result['game_info']['board_size'], 19)
    
    def test_multigo_format(self):
        """测试 MultiGo 格式的复杂棋谱（用户提供的棋谱）"""
        sgf = """(;CA[gb2312]AP[MultiGo:4.4.4]MULTIGOGM[0]

(;B[pd]N[b1];W[qc];B[qd];W[pc];B[oc];W[ob];B[nb];W[nc];B[od];W[mb];B[pb];W[na];B[qb])
(;B[pd]N[b2];W[qf]
(;B[qe]N[b21];W[pf];B[nd];W[pj])
(;B[nc]N[b22];W[rd];B[qc];W[qi]))
(;B[qd]N[b3];W[oc];B[pc];W[od];B[qf];W[kc]))"""
        
        result = parse_sgf(sgf)
        
        # 验证基本结构
        self.assertEqual(result['stats']['total_nodes'], 30)
        self.assertEqual(result['stats']['move_nodes'], 29)
        self.assertEqual(result['stats']['max_depth'], 13)
        self.assertEqual(result['stats']['branch_count'], 3)
        
        # 验证根节点属性
        self.assertEqual(result['tree']['properties']['CA'], 'gb2312')
        self.assertEqual(result['tree']['properties']['AP'], 'MultiGo:4.4.4')
        
        # 验证根有 3 个直接子节点（三个分支）
        self.assertEqual(len(result['tree']['children']), 3)
        
        # 验证 b1 分支
        b1 = result['tree']['children'][0]
        self.assertEqual(b1['properties']['N'], 'b1')
        self.assertEqual(b1['coord'], 'pd')
        self.assertEqual(b1['move_number'], 1)
        
        # 验证 b2 分支及其子分支
        b2 = result['tree']['children'][1]
        self.assertEqual(b2['properties']['N'], 'b2')
        # B[pd] N=b2 -> W[qf] -> (B[qe] N=b21, B[nc] N=b22)
        self.assertEqual(len(b2['children']), 1)  # W[qf]
        w_qf = b2['children'][0]
        self.assertEqual(len(w_qf['children']), 2)  # b21, b22 子分支
        
        # 验证 b3 分支
        b3 = result['tree']['children'][2]
        self.assertEqual(b3['properties']['N'], 'b3')
        self.assertEqual(b3['coord'], 'qd')


if __name__ == '__main__':
    print("=" * 60)
    print("SGF Parser 单元测试")
    print("=" * 60)
    
    # 创建测试套件
    loader = unittest.TestLoader()
    suite = loader.loadTestsFromTestCase(TestSGFParser)
    
    # 运行测试
    runner = unittest.TextTestRunner(verbosity=2)
    result = runner.run(suite)
    
    print("=" * 60)
    if result.wasSuccessful():
        print("✓ 所有测试通过")
    else:
        print("✗ 测试失败")
        # 输出失败详情
        for failure in result.failures + result.errors:
            print(f"\n失败: {failure[0]}")
            print(failure[1])
    print("=" * 60)


path: scripts/templates/replay.html
content: <!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <title>{{GAME_NAME}}</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
        body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; justify-content: center; align-items: flex-start; padding: 0; }
        .container { background: white; border-radius: 0; box-shadow: none; padding: 4px; max-width: 100%; width: 100%; min-height: 100vh; }
        .header { text-align: center; margin-bottom: 8px; }
        .header h1 { font-size: 15px; color: #333; margin-bottom: 2px; line-height: 1.2; }
        .header .info { color: #666; font-size: 11px; line-height: 1.3; }
        .board-container { display: flex; justify-content: center; margin: 10px 0; position: relative; width: 100%; }
        #board { border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); max-width: 100%; width: 100%; height: auto; aspect-ratio: 1 / 1; display: block; }
        .btn { width: 44px; height: 44px; border: none; border-radius: 50%; cursor: pointer; font-size: 20px; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease; background: #f0f0f0; color: #333; }
        .btn:disabled { opacity: 0.3; cursor: not-allowed; }
        .btn:active:not(:disabled) { transform: scale(0.95); }
        .btn-danger { background: #ff6b6b; color: white; }
        .btn-variation { width: auto; min-width: 60px; padding: 6px 10px; height: 32px; font-size: 13px; border-radius: 6px; background: #667eea; color: white; white-space: nowrap; display: flex; align-items: center; gap: 6px; }
        .btn-trial { background: #28a745; color: white; }
        .stone-icon { width: 16px; height: 16px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
        .stone-icon.black { background: radial-gradient(circle at 30% 30%, #666, #000); }
        .stone-icon.white { background: radial-gradient(circle at 30% 30%, #fff, #ccc); border: 1px solid #999; }
        .status { text-align: center; margin: 10px 0; }
        .move-info { font-size: 16px; font-weight: bold; color: #333; margin-bottom: 4px; }
        .captured-info { font-size: 14px; color: #888; }
        .legend { display: flex; justify-content: center; gap: 20px; margin-top: 10px; padding-top: 10px; border-top: 1px solid #eee; }
        .legend-item { display: flex; align-items: center; gap: 8px; font-size: 14px; color: #555; }
        .stone-black, .stone-white { width: 20px; height: 20px; border-radius: 50%; }
        .stone-black { background: radial-gradient(circle at 30% 30%, #666, #000); }
        .stone-white { background: radial-gradient(circle at 30% 30%, #fff, #ccc); border: 1px solid #ddd; }
        .variation-panel { margin: 8px 0; padding: 10px; background: #f8f9fa; border-radius: 8px; display: none; }
        .variation-panel.visible { display: block; }
        .variation-title { font-size: 12px; color: #666; margin-bottom: 8px; text-align: center; }
        .variation-list { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
        .controls-row { display: flex; align-items: center; justify-content: space-between; gap: 4px; margin: 6px 0; flex-wrap: nowrap; padding: 0 2px; }
        .variation-comment { font-size: 12px; color: #666; margin-right: 8px; white-space: nowrap; flex-shrink: 0; }
        .controls-group { display: flex; align-items: center; gap: 8px; flex: 1; }
        .slider-container { flex: 1; min-width: 60px; }
        #moveSlider { width: 100%; height: 18px; }
        .main-controls { gap: 24px; transition: gap 0.2s ease; }
        .main-controls.expanded { gap: 8px; }
        .extra-buttons { display: none; align-items: center; gap: 8px; }
        .extra-buttons.visible { display: flex; }
        #moreBtn { background: #f0f0f0; transition: all 0.2s ease; }
        #moreBtn.active { background: #667eea; color: white; }
        .controls-divider { width: 1px; height: 20px; background: #ddd; margin: 0 3px; flex-shrink: 0; }
        #moveSlider { width: 100%; height: 18px; }
        .trial-panel { display: none; margin: 6px 0; padding: 8px 12px; background: #e8f5e9; border-radius: 8px; border: 1px solid #4caf50; justify-content: center; gap: 24px; }
        .trial-panel.visible { display: flex; }
        .trial-info { display: flex; align-items: center; gap: 10px; font-size: 14px; }
        .trial-info span:first-child { font-weight: bold; color: #2e7d32; }
        .trial-info span:last-child { color: #666; font-size: 12px; }
        @media (min-width: 600px) { body { padding: 8px; } .container { max-width: 500px; padding: 20px; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.3); } .header h1 { font-size: 20px; } .controls-group { gap: 12px; } }
        @media (min-width: 900px) { .container { max-width: 700px; padding: 30px; } }
    </style>
</head>
<body>
    <div class="container">
        <div class="header">
            <h1 id="gameTitle">{{GAME_TITLE}}</h1>
            <div class="info" id="gameInfo">{{GAME_INFO}}</div>
        </div>
        <div class="board-container"><canvas id="board"></canvas></div>
        <div class="controls-row" id="mainControls">
            <div class="controls-group">
                <div id="variationComment" class="variation-comment"></div>
                <div class="slider-container"><input type="range" id="moveSlider" min="0" max="0" value="0" oninput="goToMove(this.value)"></div>
            </div>
            <div class="controls-divider"></div>
            <div class="controls-group main-controls" id="mainControlsGroup" style="flex: 0; margin-left: auto;">
                <button class="btn" id="backToParentBtn" onclick="backToParent()" title="返回上级" style="width: 32px; height: 32px; font-size: 14px; display: none;">⬅</button>
                <button class="btn" id="prevBtn" onclick="prevMove()" title="上一手" style="width: 32px; height: 32px; font-size: 14px;">◀</button>
                <button class="btn" id="nextBtn" onclick="nextMove()" title="下一手" style="width: 32px; height: 32px; font-size: 14px;">▶</button>
                <button class="btn" id="playBtn" onclick="togglePlay()" title="播放/暂停" style="width: 32px; height: 32px; font-size: 13px; font-weight: bold;">播</button>
                <div class="extra-buttons" id="extraButtons">
                    <button class="btn" id="soundToggleBtn" onclick="toggleSound()" title="音效开关" style="width: 32px; height: 32px; font-size: 14px;">🔊</button>
                    <button class="btn" id="numToggleBtn" onclick="toggleNumbers()" title="显示/隐藏手数" style="width: 32px; height: 32px; font-size: 11px;">1️⃣</button>
                    <button class="btn" onclick="downloadSGF()" title="下载SGF" style="width: 32px; height: 32px; font-size: 14px;">💾</button>
                </div>
                <button class="btn" id="moreBtn" onclick="toggleMorePanel()" title="更多" style="width: 32px; height: 32px; font-size: 14px;">⋮</button>
            </div>
        </div>
        <div class="trial-panel" id="trialPanel">
            <button class="btn" id="trialPrevBtn" onclick="trialPrev()" title="上一步" style="width: 32px; height: 32px; font-size: 14px; background: #667eea; color: white;">◀</button>
            <button class="btn" onclick="exitTrial()" title="退出试下" style="width: 32px; height: 32px; font-size: 14px; background: #ff6b6b; color: white;">✕</button>
            <button class="btn" id="trialNextBtn" onclick="trialNext()" title="下一步" style="width: 32px; height: 32px; font-size: 14px; background: #667eea; color: white;">▶</button>
        </div>
        <div class="variation-panel" id="variationPanel">
            <div class="variation-list" id="variationList"></div>
        </div>
        <div class="status">
            <div class="move-info" id="moveInfo">请选择分支开始</div>
            <div class="captured-info" id="capturedInfo"></div>
        </div>
        <div class="legend">
            <div class="legend-item"><div class="stone-black"></div><span id="blackName">{{BLACK_NAME}}</span></div>
            <div class="legend-item"><div class="stone-white"></div><span id="whiteName">{{WHITE_NAME}}</span></div>
        </div>
    </div>
    <script>
        const tree = (() => { const textarea = document.createElement('textarea'); textarea.innerHTML = '{{TREE_JSON}}'; return JSON.parse(textarea.value); })();
        const BOARD_SIZE = {{BOARD_SIZE}};
        const handicapStones = {{HANDICAP_STONES}};
        const DEFAULT_MOVE = {{DEFAULT_MOVE}};
        
        // 核心状态
        const state = {
            currentPath: [],
            displayIndex: 0,
            variationBaseMove: 0,
            trialMode: false,
            trialMoves: [],
            trialStartPath: [],
            trialStartIndex: 0
        };
        
        let isPlaying = false, playInterval = null, showMoveNumbers = false, soundEnabled = true;
        let currentBlackCaptured = 0, currentWhiteCaptured = 0;  // 全局变量，记录当前提子数
        
        function coordToDisplay(coord) {
            if (!coord || coord.length < 2) return coord;
            // 处理停一手 (pass)：tt 或空坐标
            if (coord === 'tt' || coord === 'TT') return '停一手';
            const x = coord.charCodeAt(0) - 97;
            const y = coord.charCodeAt(1) - 97;
            // 检查坐标是否在有效范围内
            if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE) {
                return coord; // 返回原坐标（可能是特殊标记）
            }
            const col = String.fromCharCode(65 + x + (x >= 8 ? 1 : 0));
            const row = BOARD_SIZE - y;
            return col + row;
        }
        
        function getNodeAt(tree, path) {
            let node = tree;
            for (const index of path) {
                if (!node.children || node.children.length <= index) return null;
                node = node.children[index];
            }
            return node;
        }
        
        function getCurrentNode() {
            let node = getNodeAt(tree, state.currentPath);
            if (!node) return null;
            // 当 displayIndex > 0 时，表示已经在分支中显示了一些棋子
            // 此时应该返回当前显示的最后一个节点，以便获取其子分支
            for (let i = 0; i < state.displayIndex && node.children && node.children.length > 0; i++) {
                node = node.children[0];
            }
            return node;
        }
        
        function getNodeAtPathIndex() {
            // 返回当前路径指向的节点（不考虑 displayIndex 推进）
            return getNodeAt(tree, state.currentPath);
        }
        
        function getCurrentChildren() {
            const node = getCurrentNode();
            return node ? (node.children || []) : [];
        }
        
        function getDefaultPathDepth(node) {
            if (!node) return 0;
            let depth = 0;
            let current = node;
            while (current.children && current.children.length > 0) {
                depth++;
                current = current.children[0];
            }
            return depth;
        }
        
        function isPassMove(coord) {
            // 判断是否为停一手：空坐标、tt、或超出棋盘范围的坐标
            if (!coord || coord === '' || coord === 'tt' || coord === 'TT') return true;
            if (coord.length >= 2) {
                const x = coord.charCodeAt(0) - 97;
                const y = coord.charCodeAt(1) - 97;
                if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE) return true;
            }
            return false;
        }

        function getMovesToCurrent() {
            const moves = [];
            let currentPathNode = tree;
            for (let i = 0; i < state.currentPath.length; i++) {
                const index = state.currentPath[i];
                if (!currentPathNode.children || currentPathNode.children.length <= index) break;
                currentPathNode = currentPathNode.children[index];
                if (currentPathNode.color) {
                    const isPass = isPassMove(currentPathNode.coord);
                    moves.push({
                        color: currentPathNode.color === 'B' ? 'black' : 'white',
                        x: isPass ? -1 : currentPathNode.coord.charCodeAt(0) - 97,
                        y: isPass ? -1 : currentPathNode.coord.charCodeAt(1) - 97,
                        coord: currentPathNode.coord,
                        isPass: isPass,
                        name: currentPathNode.name,
                        properties: currentPathNode.properties
                    });
                }
            }
            let current = currentPathNode;
            for (let i = 0; i < state.displayIndex && current.children && current.children.length > 0; i++) {
                current = current.children[0];
                if (current.color) {
                    const isPass = isPassMove(current.coord);
                    moves.push({
                        color: current.color === 'B' ? 'black' : 'white',
                        x: isPass ? -1 : current.coord.charCodeAt(0) - 97,
                        y: isPass ? -1 : current.coord.charCodeAt(1) - 97,
                        coord: current.coord,
                        isPass: isPass,
                        name: current.name,
                        properties: current.properties
                    });
                }
            }
            return moves;
        }
        
        function isValidMove(x, y) {
            const moves = getMovesToCurrent();
            const board = createBoard();
            for (const move of moves) {
                board[move.y][move.x] = move.color;
            }
            for (const move of state.trialMoves) {
                board[move.y][move.x] = move.color;
            }
            return x >= 0 && x < BOARD_SIZE && y >= 0 && y < BOARD_SIZE && board[y][x] === null;
        }
        
        function hasStoneAt(x, y) {
            // 使用 buildBoardFromMoves 来正确处理提子
            const board = buildBoardFromMoves();
            
            // 如果是试下模式，还要应用当前已走的试下着法
            if (state.trialMode) {
                for (let i = 0; i < trialIndex && i < state.trialMoves.length; i++) {
                    const m = state.trialMoves[i];
                    board[m.y][m.x] = m.color;
                    removeDeadStonesOnBoard(board, m.x, m.y, m.color);
                }
            }
            
            return board[y][x] !== null;
        }
        
        function isInCurrentMoves(x, y) {
            const moves = getMovesToCurrent();
            return moves.some(move => move.x === x && move.y === y);
        }
        
        function getBoardPosition(e) {
            const rect = canvas.getBoundingClientRect();
            const { margin, gridSize } = getGridParams();
            const x = e.clientX - rect.left;
            const y = e.clientY - rect.top;
            const boardX = Math.round((x - margin) / gridSize);
            const boardY = Math.round((y - margin) / gridSize);
            return { x: boardX, y: boardY };
        }
        
        const canvas = document.getElementById('board'), ctx = canvas.getContext('2d');
        
        function getGridParams() {
            const baseMargin = canvas.width * 0.02;
            const coordMargin = canvas.width * 0.018;
            const margin = baseMargin + coordMargin;
            const gridSize = (canvas.width - 2 * margin) / (BOARD_SIZE - 1);
            return { margin, gridSize, baseMargin, coordMargin };
        }
        
        function createBoard() {
            const board = Array(BOARD_SIZE).fill(null).map(() => Array(BOARD_SIZE).fill(null));
            if (handicapStones && handicapStones.length > 0) {
                for (const stone of handicapStones) board[stone.y][stone.x] = 'black';
            }
            return board;
        }
        
        function getNeighbors(x, y) {
            const neighbors = [];
            const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]];
            for (const [dx, dy] of dirs) {
                const nx = x + dx, ny = y + dy;
                if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE) {
                    neighbors.push([nx, ny]);
                }
            }
            return neighbors;
        }
        
        function getGroup(board, x, y, color, group = null) {
            if (!group) group = new Set();
            const key = x + ',' + y;
            if (group.has(key)) return group;
            if (board[y][x] !== color) return group;
            group.add(key);
            for (const [nx, ny] of getNeighbors(x, y)) {
                getGroup(board, nx, ny, color, group);
            }
            return group;
        }
        
        function getLiberties(board, x, y) {
            const color = board[y][x];
            const group = getGroup(board, x, y, color);
            const liberties = new Set();
            for (const key of group) {
                const [gx, gy] = key.split(',').map(Number);
                for (const [nx, ny] of getNeighbors(gx, gy)) {
                    if (board[ny][nx] === null) liberties.add(nx + ',' + ny);
                }
            }
            return liberties;
        }
        
        function drawBoard() {
            const { margin, gridSize, baseMargin, coordMargin } = getGridParams();
            const size = canvas.width;
            ctx.fillStyle = '#E3C16F';
            ctx.fillRect(0, 0, size, size);
            ctx.strokeStyle = '#666';
            ctx.lineWidth = 1;
            for (let i = 0; i < BOARD_SIZE; i++) {
                ctx.beginPath();
                ctx.moveTo(margin, margin + i * gridSize);
                ctx.lineTo(size - margin, margin + i * gridSize);
                ctx.stroke();
                ctx.beginPath();
                ctx.moveTo(margin + i * gridSize, margin);
                ctx.lineTo(margin + i * gridSize, size - margin);
                ctx.stroke();
            }
            ctx.fillStyle = '#333';
            ctx.font = 'bold ' + Math.max(8, gridSize * 0.28) + 'px Arial';
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            for (let i = 0; i < BOARD_SIZE; i++) {
                const colLabel = String.fromCharCode(65 + i + (i >= 8 ? 1 : 0));
                ctx.fillText(colLabel, margin + i * gridSize, baseMargin + coordMargin * 0.3);
                ctx.fillText(colLabel, margin + i * gridSize, size - baseMargin - coordMargin * 0.3);
                const rowLabel = (BOARD_SIZE - i).toString();
                ctx.fillText(rowLabel, baseMargin + coordMargin * 0.3, margin + i * gridSize);
                ctx.fillText(rowLabel, size - baseMargin - coordMargin * 0.3, margin + i * gridSize);
            }
            const stars = BOARD_SIZE === 19 ? [3, 9, 15] : [2, 6, 10];
            ctx.fillStyle = '#333';
            for (const x of stars) {
                for (const y of stars) {
                    ctx.beginPath();
                    ctx.arc(margin + x * gridSize, margin + y * gridSize, 3, 0, Math.PI * 2);
                    ctx.fill();
                }
            }
        }
        
        function drawStone(x, y, color, isLast, moveNum) {
            const { margin, gridSize } = getGridParams();
            const cx = margin + x * gridSize;
            const cy = margin + y * gridSize;
            const radius = gridSize * 0.48;
            ctx.beginPath();
            ctx.arc(cx, cy, radius, 0, Math.PI * 2);
            const gradient = ctx.createRadialGradient(
                cx - radius * 0.3, cy - radius * 0.3, radius * 0.1,
                cx, cy, radius
            );
            if (color === 'black') {
                gradient.addColorStop(0, '#666');
                gradient.addColorStop(1, '#000');
            } else {
                gradient.addColorStop(0, '#fff');
                gradient.addColorStop(1, '#ccc');
            }
            ctx.fillStyle = gradient;
            ctx.fill();
            if (moveNum !== null && moveNum !== undefined) {
                ctx.fillStyle = color === 'black' ? '#fff' : '#000';
                ctx.font = 'bold ' + Math.floor(gridSize * 0.55) + 'px Arial';
                ctx.textAlign = 'center';
                ctx.textBaseline = 'middle';
                ctx.fillText(moveNum.toString(), cx, cy);
            } else if (isLast) {
                ctx.beginPath();
                ctx.arc(cx, cy, radius * 0.25, 0, Math.PI * 2);
                ctx.fillStyle = color === 'black' ? '#fff' : '#000';
                ctx.fill();
            }
        }
        
        function resizeCanvas() {
            const container = document.querySelector('.board-container');
            const isMobile = window.innerWidth < 768;
            const margin = isMobile ? 4 : 20;
            const availableWidth = Math.min(container.clientWidth, window.innerWidth) - margin;
            const availableHeight = window.innerHeight - 250;
            const maxSize = Math.min(availableWidth, availableHeight, isMobile ? 760 : 720);
            const size = Math.max(isMobile ? 320 : 400, maxSize);
            canvas.width = size;
            canvas.height = size;
            canvas.style.width = size + 'px';
            canvas.style.height = size + 'px';
            updateDisplay();
        }
        
        const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
        let audioUnlocked = false, noiseBuffer = null;
        
        function unlockAudio() {
            if (!audioUnlocked && audioCtx.state === 'suspended') {
                audioCtx.resume().then(() => { audioUnlocked = true; });
            }
            if (!noiseBuffer) noiseBuffer = createNoiseBuffer();
        }
        
        function createNoiseBuffer() {
            const bufferSize = audioCtx.sampleRate * 0.1;
            const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
            const data = buffer.getChannelData(0);
            for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
            return buffer;
        }
        
        document.addEventListener('click', unlockAudio, { once: true });
        document.addEventListener('touchstart', unlockAudio, { once: true });
        
        function playStoneSound() {
            // 落子声
            if (!soundEnabled) return;
            if (audioCtx.state === 'suspended') audioCtx.resume();
            if (!noiseBuffer) noiseBuffer = createNoiseBuffer();
            const noise = audioCtx.createBufferSource();
            noise.buffer = noiseBuffer;
            const filter = audioCtx.createBiquadFilter();
            filter.type = 'bandpass';
            filter.frequency.value = 2500;
            filter.Q.value = 1;
            const gain = audioCtx.createGain();
            gain.gain.setValueAtTime(0.5, audioCtx.currentTime);
            gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.06);
            noise.connect(filter);
            filter.connect(gain);
            gain.connect(audioCtx.destination);
            noise.start(audioCtx.currentTime);
            noise.stop(audioCtx.currentTime + 0.06);
        }

        function playCaptureSound() {
            // 提子声 - 更短促、频率稍低
            if (!soundEnabled) return;
            if (audioCtx.state === 'suspended') audioCtx.resume();
            if (!noiseBuffer) noiseBuffer = createNoiseBuffer();
            const noise = audioCtx.createBufferSource();
            noise.buffer = noiseBuffer;
            const filter = audioCtx.createBiquadFilter();
            filter.type = 'highpass';
            filter.frequency.value = 1500;
            filter.Q.value = 0.5;
            const gain = audioCtx.createGain();
            gain.gain.setValueAtTime(0.4, audioCtx.currentTime);
            gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.04);
            noise.connect(filter);
            filter.connect(gain);
            gain.connect(audioCtx.destination);
            noise.start(audioCtx.currentTime);
            noise.stop(audioCtx.currentTime + 0.04);
        }

        function playUndoSound() {
            // 撤销声 - 更低沉
            if (!soundEnabled) return;
            if (audioCtx.state === 'suspended') audioCtx.resume();
            if (!noiseBuffer) noiseBuffer = createNoiseBuffer();
            const noise = audioCtx.createBufferSource();
            noise.buffer = noiseBuffer;
            const filter = audioCtx.createBiquadFilter();
            filter.type = 'lowpass';
            filter.frequency.value = 1200;
            filter.Q.value = 1;
            const gain = audioCtx.createGain();
            gain.gain.setValueAtTime(0.3, audioCtx.currentTime);
            gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.05);
            noise.connect(filter);
            filter.connect(gain);
            gain.connect(audioCtx.destination);
            noise.start(audioCtx.currentTime);
            noise.stop(audioCtx.currentTime + 0.05);
        }
        
        function updateDisplay() {
            drawBoard();
            const board = createBoard();
            let blackCaptured = 0, whiteCaptured = 0;
            const moves = getMovesToCurrent();
            for (let i = 0; i < moves.length; i++) {
                const move = moves[i];
                if (!move || move.isPass) continue;  // 跳过停一手
                const opponent = move.color === 'black' ? 'white' : 'black';
                board[move.y][move.x] = move.color;
                for (const [nx, ny] of getNeighbors(move.x, move.y)) {
                    if (board[ny][nx] === opponent) {
                        const liberties = getLiberties(board, nx, ny);
                        if (liberties.size === 0) {
                            const group = getGroup(board, nx, ny, opponent);
                            for (const key of group) {
                                const [gx, gy] = key.split(',').map(Number);
                                board[gy][gx] = null;
                                if (opponent === 'white') blackCaptured++;
                                else whiteCaptured++;
                            }
                        }
                    }
                }
            }
            if (state.trialMode) {
                // 试下模式：构建完整棋盘状态（包含提子）
                const trialBoard = buildBoardFromMoves();
                // 应用试下着法到trialIndex位置
                for (let i = 0; i < trialIndex && i < state.trialMoves.length; i++) {
                    const m = state.trialMoves[i];
                    trialBoard[m.y][m.x] = m.color;
                    // 直接使用记录的被提棋子坐标来移除棋子，而不是重新检测
                    if (m.capturedPositions) {
                        for (const cap of m.capturedPositions) {
                            trialBoard[cap.y][cap.x] = null;
                        }
                    }
                }
                // 收集试下棋子的位置
                const trialPositions = new Set();
                for (let i = 0; i < trialIndex && i < state.trialMoves.length; i++) {
                    trialPositions.add(`${state.trialMoves[i].x},${state.trialMoves[i].y}`);
                }
                // 绘制所有棋子
                for (let y = 0; y < BOARD_SIZE; y++) {
                    for (let x = 0; x < BOARD_SIZE; x++) {
                        if (trialBoard[y][x]) {
                            const isTrial = trialPositions.has(`${x},${y}`);
                            if (isTrial) {
                                // 试下棋子 - 强制显示手数
                                let moveIdx = -1;
                                for (let i = 0; i < trialIndex && i < state.trialMoves.length; i++) {
                                    if (state.trialMoves[i].x === x && state.trialMoves[i].y === y) {
                                        moveIdx = i;
                                        break;
                                    }
                                }
                                const isLast = moveIdx === trialIndex - 1;
                                drawStone(x, y, trialBoard[y][x], isLast, moveIdx + 1);
                            } else {
                                // 主分支棋子
                                drawStone(x, y, trialBoard[y][x], false, null);
                            }
                        }
                    }
                }
            } else {
                const inMainBranch = isInMainBranch();
                // 找到最后一个非停一手的着法，用于标记最后一手
                const lastNonPassMove = moves.length > 0 && !moves[moves.length - 1].isPass ? moves[moves.length - 1] : null;
                if (inMainBranch) {
                    // 主分支：根据手数开关决定是否显示
                    for (let y = 0; y < BOARD_SIZE; y++) {
                        for (let x = 0; x < BOARD_SIZE; x++) {
                            if (board[y][x]) {
                                const isLast = lastNonPassMove &&
                                    lastNonPassMove.x === x &&
                                    lastNonPassMove.y === y;
                                const moveNum = showMoveNumbers ? (moves.findIndex(m => m.x === x && m.y === y) + 1) : null;
                                drawStone(x, y, board[y][x], isLast, moveNum);
                            }
                        }
                    }
                } else {
                    // 变化图：强制显示手数
                    for (let y = 0; y < BOARD_SIZE; y++) {
                        for (let x = 0; x < BOARD_SIZE; x++) {
                            if (board[y][x]) {
                                const isLast = lastNonPassMove &&
                                    lastNonPassMove.x === x &&
                                    lastNonPassMove.y === y;
                                let moveNum = null;
                                const idx = moves.findIndex(m => m.x === x && m.y === y);
                                if (idx >= state.variationBaseMove) {
                                    moveNum = idx - state.variationBaseMove + 1;
                                }
                                drawStone(x, y, board[y][x], isLast, moveNum);
                            }
                        }
                    }
                }
            }
            // 更新全局提子数
            currentBlackCaptured = blackCaptured;
            currentWhiteCaptured = whiteCaptured;
            
            updateStatus(moves.length, blackCaptured, whiteCaptured);
            updateControls();
            updateVariationPanel();
        }
        
        function updateStatus(moveCount, blackCaptured, whiteCaptured) {
            const moveInfo = document.getElementById('moveInfo');
            const capturedInfo = document.getElementById('capturedInfo');

            // 试下模式时不修改状态栏，保持原有内容
            if (state.trialMode) return;

            // 获取当前着法信息，检查是否是停一手
            const moves = getMovesToCurrent();
            const lastMove = moves.length > 0 ? moves[moves.length - 1] : null;
            const isLastPass = lastMove && lastMove.isPass;

            if (state.currentPath.length === 0 && state.displayIndex === 0 && state.trialMoves.length === 0) {
                moveInfo.textContent = '第 0 手';
            } else {
                let statusText = '第 ' + moveCount + ' 手';
                if (isLastPass) {
                    statusText += ' (' + (lastMove.color === 'black' ? '黑' : '白') + '停一手)';
                }
                moveInfo.textContent = statusText;
            }
            if (blackCaptured > 0 || whiteCaptured > 0) {
                capturedInfo.textContent = '提子: 黑 ' + blackCaptured + ' 子, 白 ' + whiteCaptured + ' 子';
            } else {
                capturedInfo.textContent = '';
            }
        }
        
        function isInMainBranch() {
            for (const idx of state.currentPath) {
                if (idx !== 0) return false;
            }
            return true;
        }

        function backToParent() {
            if (state.currentPath.length > 0) {
                state.currentPath.pop();
                
                // 检查是否全是0
                const allZeros = state.currentPath.every(idx => idx === 0);
                if (allZeros) {
                    state.displayIndex = state.currentPath.length;
                    state.currentPath = [];
                } else {
                    state.displayIndex = 0;
                }
                
                playUndoSound();
                updateDisplay();
            }
        }

        function updateControls() {
            const prevBtn = document.getElementById('prevBtn');
            const nextBtn = document.getElementById('nextBtn');
            const slider = document.getElementById('moveSlider');
            const sliderContainer = slider.parentElement;  // 获取 slider-container
            const backBtn = document.getElementById('backToParentBtn');
            const variationComment = document.getElementById('variationComment');
            prevBtn.disabled = state.displayIndex === 0 && state.currentPath.length === 0;
            const children = getCurrentChildren();
            nextBtn.disabled = children.length === 0;
            const inMainBranch = isInMainBranch();
            // 非主分支时隐藏滑条及其容器
            if (inMainBranch) {
                const currentNode = getCurrentNode();
                const currentDepth = (state.currentPath.length > 0 || state.displayIndex > 0) ? getMovesToCurrent().length : 0;
                const remainingDepth = getDefaultPathDepth(currentNode);
                slider.max = currentDepth + remainingDepth;
                slider.value = currentDepth;
                slider.disabled = false;  // 确保明确设置为可滑动
                slider.style.display = 'block';
                sliderContainer.style.display = 'block';  // 显示容器
                // 主分支时清空注释
                variationComment.textContent = '';
            } else {
                slider.style.display = 'none';
                sliderContainer.style.display = 'none';  // 隐藏容器
                slider.disabled = false;  // 修复：返回主分支后需要重新启用
                // 非主分支时显示当前分支的注释
                const currentNode = getNodeAtPathIndex();
                if (currentNode && currentNode.properties && currentNode.properties.C) {
                    variationComment.textContent = currentNode.properties.C;
                } else {
                    variationComment.textContent = '';
                }
            }
            // 返回上级按钮仅在非主分支时显示
            backBtn.style.display = !inMainBranch ? 'flex' : 'none';
            // 更多按钮及其面板只在主分支显示
            const moreBtn = document.getElementById('moreBtn');
            const extraButtons = document.getElementById('extraButtons');
            const mainControls = document.getElementById('mainControlsGroup');
            moreBtn.style.display = inMainBranch ? 'flex' : 'none';
            if (!inMainBranch) {
                if (extraButtons.classList.contains('visible')) {
                    extraButtons.classList.remove('visible');
                    moreBtn.classList.remove('active');
                    mainControls.classList.remove('expanded');
                }
            }
        }
        
        function extractWinRate(comment) {
            // 匹配 "jueyi白99.8%" 或 "jueyi黑0.4%"
            const match = comment.match(/jueyi(黑|白)(\d+\.?\d*)%/);
            if (match) {
                return match[1] + ' ' + match[2] + '%';
            }
            return null;
        }
        
        function updateVariationPanel() {
            const panel = document.getElementById('variationPanel');
            const list = document.getElementById('variationList');
            const children = getCurrentChildren();
            list.innerHTML = '';
            if (state.trialMode) {
                panel.classList.remove('visible');
                return;
            }
            if (children.length <= 1) {
                panel.classList.remove('visible');
                return;
            }
            
            panel.classList.add('visible');
            
            for (let index = 1; index < children.length; index++) {
                const child = children[index];
                const btn = document.createElement('button');
                btn.className = 'btn btn-variation';
                
                // 标签逻辑：优先N属性，其次C属性中的胜率，最后默认
                let label;
                if (child.properties && child.properties.N) {
                    label = child.properties.N;
                } else if (child.properties && child.properties.C) {
                    const winRate = extractWinRate(child.properties.C);
                    label = winRate || '变化' + index;
                } else {
                    label = '变化' + index;
                }
                
                // 判断标签是否包含"黑"或"白"
                const hasColorText = label.includes('黑') || label.includes('白');
                
                // 只有标签不包含颜色文字时才显示图标
                if (!hasColorText) {
                    const stoneIcon = document.createElement('span');
                    stoneIcon.className = 'stone-icon';
                    if (child.color === 'B') stoneIcon.classList.add('black');
                    else if (child.color === 'W') stoneIcon.classList.add('white');
                    else stoneIcon.classList.add('black');
                    btn.appendChild(stoneIcon);
                }
                
                const textSpan = document.createElement('span');
                textSpan.textContent = label;
                btn.appendChild(textSpan);
                btn.onclick = () => enterVariation(index);
                list.appendChild(btn);
            }
        }
        
        function updateTrialInfo() {
            if (!state.trialMode) return;
            const trialMoveInfo = document.getElementById('trialMoveInfo');
            const nextColor = trialIndex % 2 === 0 ? 
                (getMovesToCurrent().length % 2 === 0 ? '黑' : '白') :
                (state.trialMoves[trialIndex - 1].color === 'black' ? '白' : '黑');
            trialMoveInfo.textContent = `试下 ${trialIndex + 1} - ${nextColor}棋`;
        }
        
        function enterVariation(variationIndex) {
            const children = getCurrentChildren();
            if (variationIndex >= children.length) return;
            
            // 记录进入变化图前的手数（用于手数显示）
            const baseMovesBefore = getMovesToCurrent();
            state.variationBaseMove = baseMovesBefore.length;
            
            // 关键修复：先把 displayIndex 对应的默认路径加入 currentPath
            for (let i = 0; i < state.displayIndex; i++) {
                state.currentPath.push(0);
            }
            
            // 然后添加选择的子分支
            state.currentPath.push(variationIndex);
            state.displayIndex = 0;

            if (isPlaying) togglePlay();
            playStoneSound();
            updateDisplay();
        }
        
        function prevMove() {
            if (state.displayIndex > 0) {
                state.displayIndex--;
                playUndoSound();
                updateDisplay();
            } else if (state.currentPath.length > 0) {
                state.currentPath.pop();
                
                // 检查是否全是0
                const allZeros = state.currentPath.every(idx => idx === 0);
                if (allZeros) {
                    state.displayIndex = state.currentPath.length;
                    state.currentPath = [];
                } else {
                    state.displayIndex = 0;
                }
                
                if (state.currentPath.length === 0) {
                    state.variationBaseMove = 0;
                }
                playUndoSound();
                updateDisplay();
            }
        }
        
        function nextMove() {
            const children = getCurrentChildren();
            if (children.length === 0) return;
            
            const beforeBlack = currentBlackCaptured;
            const beforeWhite = currentWhiteCaptured;
            
            state.displayIndex++;
            updateDisplay();
            
            // 如果提子数增加了，说明有提子
            if (currentBlackCaptured > beforeBlack || currentWhiteCaptured > beforeWhite) {
                playCaptureSound();
            } else {
                playStoneSound();
            }
        }
        
        function goToMove(moveNum) {
            const target = parseInt(moveNum);
            const currentNode = getNodeAt(tree, state.currentPath);
            const maxDepth = getDefaultPathDepth(currentNode);
            const currentDepth = getMovesToCurrent().length;
            if (target >= 0 && target <= maxDepth) {
                const beforeBlack = currentBlackCaptured;
                const beforeWhite = currentWhiteCaptured;
                
                state.displayIndex = target;
                updateDisplay();
                
                // 根据方向播放声效
                if (target > currentDepth) {
                    // 向前且有提子
                    if (currentBlackCaptured > beforeBlack || currentWhiteCaptured > beforeWhite) {
                        playCaptureSound();
                    } else {
                        playStoneSound();
                    }
                } else if (target < currentDepth) {
                    playUndoSound();
                }
            }
        }
        
        function togglePlay() {
            const btn = document.getElementById('playBtn');
            if (isPlaying) {
                clearInterval(playInterval);
                isPlaying = false;
                btn.textContent = '播';
            } else {
                const children = getCurrentChildren();
                if (children.length === 0) return;
                isPlaying = true;
                btn.textContent = '停';
                playInterval = setInterval(() => {
                    const currentChildren = getCurrentChildren();
                    if (currentChildren.length > 0) {
                        state.displayIndex++;
                        playStoneSound();
                        updateDisplay();
                    } else {
                        togglePlay();
                    }
                }, 800);
            }
        }
        
        function toggleSound() {
            soundEnabled = !soundEnabled;
            const btn = document.getElementById('soundToggleBtn');
            btn.textContent = soundEnabled ? '🔊' : '🔇';
        }
        
        function toggleNumbers() {
            showMoveNumbers = !showMoveNumbers;
            updateDisplay();
            const btn = document.getElementById('numToggleBtn');
            btn.style.background = showMoveNumbers ? '#667eea' : '#f0f0f0';
            btn.style.color = showMoveNumbers ? 'white' : '#333';
        }
        
        function toggleMorePanel() {
            const panel = document.getElementById('extraButtons');
            const btn = document.getElementById('moreBtn');
            const mainControls = document.getElementById('mainControlsGroup');
            panel.classList.toggle('visible');
            btn.classList.toggle('active');
            mainControls.classList.toggle('expanded');
        }
        
        // ==================== 试下功能相关函数 ====================
        
        function findGroupOnBoard(board, startX, startY, color) {
            const group = [];
            const visited = new Set();
            const stack = [{x: startX, y: startY}];
            while (stack.length > 0) {
                const {x, y} = stack.pop();
                const key = `${x},${y}`;
                if (visited.has(key)) continue;
                visited.add(key);
                if (board[y][x] === color) {
                    group.push({x, y});
                    for (const [nx, ny] of getNeighbors(x, y)) {
                        stack.push({x: nx, y: ny});
                    }
                }
            }
            return group;
        }
        
        function countLibertiesOnBoard(board, group) {
            const liberties = new Set();
            for (const stone of group) {
                const neighbors = getNeighbors(stone.x, stone.y);
                for (const [nx, ny] of neighbors) {
                    if (ny >= 0 && ny < BOARD_SIZE && nx >= 0 && nx < BOARD_SIZE) {
                        if (board[ny][nx] === null) {
                            liberties.add(`${nx},${ny}`);
                        }
                    }
                }
            }
            return liberties.size;
        }
        
        function removeDeadStonesOnBoard(board, x, y, color) {
            const opponent = color === 'black' ? 'white' : 'black';
            const captured = [];
            const checked = new Set();
            for (const [nx, ny] of getNeighbors(x, y)) {
                if (board[ny][nx] === opponent) {
                    const key = `${nx},${ny}`;
                    if (checked.has(key)) continue;
                    const group = findGroupOnBoard(board, nx, ny, opponent);
                    for (const s of group) checked.add(`${s.x},${s.y}`);
                    if (countLibertiesOnBoard(board, group) === 0) {
                        for (const s of group) {
                            captured.push({x: s.x, y: s.y});
                            board[s.y][s.x] = null;
                        }
                    }
                }
            }
            return captured;
        }
        
        function isSuicideOnBoard(board, x, y, color) {
            const opponent = color === 'black' ? 'white' : 'black';
            
            // 临时放置棋子
            board[y][x] = color;
            
            // 检查能否提对方棋子（落子后对方相邻棋子是否没气）
            let canCapture = false;
            const checked = new Set();
            const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]];
            
            for (const [dx, dy] of dirs) {
                const nx = x + dx, ny = y + dy;
                if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE) {
                    if (board[ny][nx] === opponent) {
                        const key = `${nx},${ny}`;
                        if (!checked.has(key)) {
                            const group = findGroupOnBoard(board, nx, ny, opponent);
                            for (const s of group) checked.add(`${s.x},${s.y}`);
                            if (countLibertiesOnBoard(board, group) === 0) {
                                canCapture = true;
                            }
                        }
                    }
                }
            }
            
            // 检查己方棋块的气（如果提了对方子，新棋子就有气了）
            const myGroup = findGroupOnBoard(board, x, y, color);
            const myLiberties = countLibertiesOnBoard(board, myGroup);
            
            // 恢复棋盘
            board[y][x] = null;
            
            // 禁入点判定：己方没气 且 不能提对方子
            return myLiberties === 0 && !canCapture;
        }
        
        function checkKoOnBoard(board, x, y, color, captured) {
            if (captured.length !== 1) return null;
            const opponent = color === 'black' ? 'white' : 'black';
            const capturedPos = captured[0];
            board[capturedPos.y][capturedPos.x] = opponent;
            const myGroup = findGroupOnBoard(board, x, y, color);
            const myLiberties = countLibertiesOnBoard(board, myGroup);
            const canBeCaptured = myLiberties === 0 && myGroup.length === 1;
            board[capturedPos.y][capturedPos.x] = null;
            if (canBeCaptured) return {x: capturedPos.x, y: capturedPos.y};
            return null;
        }
        
        function buildBoardFromMoves() {
            const board = createBoard();
            const moves = getMovesToCurrent();
            for (const move of moves) {
                board[move.y][move.x] = move.color;
                removeDeadStonesOnBoard(board, move.x, move.y, move.color);
            }
            return board;
        }
        
        let trialKoPosition = null;
        let trialCapturedBlack = 0;
        let trialCapturedWhite = 0;
        let trialIndex = 0;

        function enterTrial() {
            state.trialStartPath = [...state.currentPath];
            state.trialStartIndex = state.displayIndex;
            state.trialMode = true;
            state.trialMoves = [];
            trialIndex = 0;
            trialKoPosition = null;
            trialCapturedBlack = 0;
            trialCapturedWhite = 0;
            document.getElementById('mainControls').style.display = 'none';
            document.getElementById('trialPanel').classList.add('visible');
            document.getElementById('variationPanel').classList.remove('visible');
            updateTrialButtons();
            updateDisplay();
        }
        
        function exitTrial() {
            state.currentPath = [...state.trialStartPath];
            state.displayIndex = state.trialStartIndex;
            state.trialMode = false;
            state.trialMoves = [];
            trialIndex = 0;
            trialKoPosition = null;
            trialCapturedBlack = 0;
            trialCapturedWhite = 0;
            document.getElementById('mainControls').style.display = 'flex';
            document.getElementById('trialPanel').classList.remove('visible');
            updateDisplay();
        }
        
        function trialPrev() {
            if (trialIndex > 0) {
                trialIndex--;
                const move = state.trialMoves[trialIndex];
                if (move.color === 'black') trialCapturedWhite -= move.capturedCount || 0;
                else trialCapturedBlack -= move.capturedCount || 0;
                trialKoPosition = null;
                playUndoSound();
                updateTrialButtons();
                updateDisplay();
            }
        }
        
        function trialNext() {
            if (trialIndex < state.trialMoves.length) {
                const move = state.trialMoves[trialIndex];
                trialIndex++;
                if (move.color === 'black') trialCapturedWhite += move.capturedCount || 0;
                else trialCapturedBlack += move.capturedCount || 0;
                playStoneSound();
                updateTrialButtons();
                updateDisplay();
            }
        }
        
        function updateTrialButtons() {
            const prevBtn = document.getElementById('trialPrevBtn');
            const nextBtn = document.getElementById('trialNextBtn');
            prevBtn.disabled = trialIndex <= 0;
            nextBtn.disabled = trialIndex >= state.trialMoves.length;
        }
        
        function trialPlaceStone(x, y) {
            const board = buildBoardFromMoves();
            
            // 应用已有的试下着法
            for (let i = 0; i < trialIndex && i < state.trialMoves.length; i++) {
                const m = state.trialMoves[i];
                board[m.y][m.x] = m.color;
                removeDeadStonesOnBoard(board, m.x, m.y, m.color);
            }
            
            // 检查位置是否为空
            if (board[y][x] !== null) return false;
            
            // 检查劫
            if (trialKoPosition && trialKoPosition.x === x && trialKoPosition.y === y) return false;
            
            // 判断当前执子方
            const currentColor = trialIndex % 2 === 0 ? 
                (getMovesToCurrent().length % 2 === 0 ? 'black' : 'white') :
                (state.trialMoves[trialIndex - 1].color === 'black' ? 'white' : 'black');
            
            // 检查自杀（禁入点）
            if (isSuicideOnBoard(board, x, y, currentColor)) return false;
            // 落子
            board[y][x] = currentColor;
            const captured = removeDeadStonesOnBoard(board, x, y, currentColor);
            trialKoPosition = checkKoOnBoard(board, x, y, currentColor, captured);
            if (currentColor === 'black') trialCapturedWhite += captured.length;
            else trialCapturedBlack += captured.length;
            // 添加到试下记录
            state.trialMoves = state.trialMoves.slice(0, trialIndex);
            state.trialMoves.push({
                x, y,
                color: currentColor,
                capturedCount: captured.length,
                capturedPositions: captured.map(c => ({x: c.x, y: c.y}))  // 记录被提棋子的坐标
            });
            trialIndex++;
            // 有提子时播放提子声，否则播放落子声
            if (captured.length > 0) {
                playCaptureSound();
            } else {
                playStoneSound();
            }
            updateTrialButtons();
            updateDisplay();
            return true;
        }
        
        canvas.addEventListener('click', (e) => {
            const pos = getBoardPosition(e);
            
            // 检查是否已有棋子（hasStoneAt 已经正确处理了提子后的空位）
            if (hasStoneAt(pos.x, pos.y)) return;
            
            // 如果不在试下模式，先进入试下模式
            if (!state.trialMode) {
                enterTrial();
            }
            
            // 试下放子（包含提子逻辑）
            trialPlaceStone(pos.x, pos.y);
        });
        
        function downloadSGF() {
            let sgf = '(;GM[1]FF[4]SZ[' + BOARD_SIZE + ']';
            sgf += 'PB[' + (tree.properties.PB || '黑棋') + ']';
            sgf += 'PW[' + (tree.properties.PW || '白棋') + ']';
            if (handicapStones && handicapStones.length > 0) {
                sgf += 'HA[' + handicapStones.length + ']';
                for (const s of handicapStones) {
                    sgf += 'AB[' + String.fromCharCode(97 + s.x) + String.fromCharCode(97 + s.y) + ']';
                }
            }
            
            const moves = getMovesToCurrent();
            
            if (moves.length === 0 && !state.trialMode) {
                // 第0手且不在试下模式，下载整个棋谱（包含所有变化分支）
                function appendMoves(node) {
                    if (node.color && node.coord) {
                        sgf += ';' + node.color + '[' + node.coord + ']';
                    }
                    if (node.children && node.children.length > 0) {
                        if (node.children.length === 1) {
                            appendMoves(node.children[0]);
                        } else {
                            for (const child of node.children) {
                                sgf += '(';
                                appendMoves(child);
                                sgf += ')';
                            }
                        }
                    }
                }
                appendMoves(tree);
            } else {
                // 下载当前局面的着法序列
                for (const move of moves) {
                    const color = move.color === 'black' ? 'B' : 'W';
                    sgf += ';' + color + '[' + move.coord + ']';
                }
                // 如果是试下模式，追加试下着法
                if (state.trialMode) {
                    for (let i = 0; i < trialIndex; i++) {
                        const m = state.trialMoves[i];
                        const color = m.color === 'black' ? 'B' : 'W';
                        sgf += ';' + color + '[' + String.fromCharCode(97 + m.x) + String.fromCharCode(97 + m.y) + ']';
                    }
                }
            }
            
            sgf += ')';
            const blob = new Blob([sgf], { type: 'application/x-go-sgf' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = '{{DOWNLOAD_FILENAME}}';
            a.click();
            URL.revokeObjectURL(url);
        }
        
        document.addEventListener('keydown', (e) => {
            if (e.key === 'ArrowLeft') prevMove();
            else if (e.key === 'ArrowRight') nextMove();
            else if (e.key === ' ') togglePlay();
        });
        window.addEventListener('resize', resizeCanvas);
        resizeCanvas();
        
        // 页面加载后，如果设置了默认手数，跳转到该手数
        // DEFAULT_MOVE = -1 表示跳转到最后一手（主分支终点）
        if (DEFAULT_MOVE > 0 || DEFAULT_MOVE === -1) {
            setTimeout(() => {
                const currentNode = getNodeAt(tree, state.currentPath);
                const maxDepth = getDefaultPathDepth(currentNode);
                const targetMove = DEFAULT_MOVE === -1 ? maxDepth : Math.min(DEFAULT_MOVE, maxDepth);
                if (targetMove > 0) {
                    // 只设置 displayIndex，不修改 currentPath
                    // 这样滑条控制才能正常工作
                    state.displayIndex = targetMove;
                    updateDisplay();
                }
            }, 100);
        }
    </script>
</body>
</html>


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)