mirror of
https://github.com/chesskit-app/chesskit-swift.git
synced 2026-06-15 11:54:29 +00:00
Enabled `Codable` conformance for all public types (except `Board`). This allows objects derived from `ChessKit` to be encoded/decoded for storage or network transmission. #### `Board` conformance to Codable `Board` conformance will be enabled once `BoardDelegate` is removed completely. Until then, `Board.position` and `Board.state` can be encoded/decoded instead as a reasonable proxy. > [!note] Using `position`/`state` alone will lack the position count information used to calculate threefold repetition.
431 lines
12 KiB
Swift
431 lines
12 KiB
Swift
//
|
|
// MoveTree.swift
|
|
// ChessKit
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// A tree-like data structure that represents the moves of a chess game.
|
|
///
|
|
/// The tree maintains the move order including variations and
|
|
/// provides index-based access for any element in the tree.
|
|
public struct MoveTree: Codable, Hashable, Sendable {
|
|
|
|
/// The index of the root of the move tree.
|
|
///
|
|
/// Defaults to `MoveTree.Index.minimum`.
|
|
var minimumIndex: Index = .minimum
|
|
|
|
/// The last index of the main variation of the move tree.
|
|
private(set) var lastMainVariationIndex: Index = .minimum
|
|
|
|
/// Dictionary representation of the tree for faster access.
|
|
private(set) var dictionary: [Index: Node] = [:]
|
|
/// The root node of the tree.
|
|
private var root: Node?
|
|
|
|
/// A set containing the indices of all the moves stored in the tree.
|
|
public var indices: [Index] {
|
|
Array(dictionary.keys)
|
|
}
|
|
|
|
/// Lock to restrict modification of tree nodes
|
|
/// to ensure `Sendable` conformance for ``Node``.
|
|
private static let nodeLock = NSLock()
|
|
|
|
/// Adds a move to the move tree.
|
|
///
|
|
/// - parameter move: The move to add to the tree.
|
|
/// - parameter moveIndex: The `MoveIndex` of the parent move, if applicable.
|
|
/// If `moveIndex` is `nil`, the move tree is cleared and the provided
|
|
/// move is set to the `head` of the move tree.
|
|
///
|
|
/// - returns: The move index resulting from the addition of the move.
|
|
///
|
|
@discardableResult
|
|
public mutating func add(
|
|
move: Move,
|
|
toParentIndex moveIndex: Index? = nil
|
|
) -> Index {
|
|
let newNode = Node(move: move)
|
|
|
|
guard let root, let moveIndex else {
|
|
let index = minimumIndex.next
|
|
|
|
newNode.index = index
|
|
self.root = newNode
|
|
|
|
dictionary = [index: newNode]
|
|
|
|
if index.variation == Index.mainVariation {
|
|
lastMainVariationIndex = index
|
|
}
|
|
return index
|
|
}
|
|
|
|
let parent = dictionary[moveIndex] ?? root
|
|
newNode.previous = parent
|
|
|
|
var newIndex = moveIndex.next
|
|
|
|
if parent.next == nil {
|
|
parent.next = newNode
|
|
} else {
|
|
parent.children.append(newNode)
|
|
while indices.contains(newIndex) {
|
|
newIndex.variation += 1
|
|
}
|
|
}
|
|
|
|
Self.nodeLock.withLock {
|
|
dictionary[newIndex] = newNode
|
|
}
|
|
newNode.index = newIndex
|
|
|
|
if newIndex.variation == Index.mainVariation {
|
|
lastMainVariationIndex = newIndex
|
|
}
|
|
|
|
return newIndex
|
|
}
|
|
|
|
/// Returns the index matching `move` in the next or child moves of the
|
|
/// move contained at `index`.
|
|
public func nextIndex(containing move: Move, for index: Index) -> Index? {
|
|
guard let node = dictionary[index] else {
|
|
if index == minimumIndex, let root, root.move == move {
|
|
return root.index
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
if let next = node.next, next.move == move {
|
|
return next.index
|
|
} else {
|
|
return node.children.filter { $0.move == move }.first?.index
|
|
}
|
|
}
|
|
|
|
/// Provides a single history for a given index.
|
|
///
|
|
/// - parameter index: The index from which to generate the history.
|
|
/// - returns: An array of move indices sorted from beginning to end with
|
|
/// the end being the provided `index`.
|
|
///
|
|
/// For chess this would represent an array of all the move indices
|
|
/// from the starting move until the move defined by `index`, accounting
|
|
/// for any branching variations in between.
|
|
public func history(for index: Index) -> [Index] {
|
|
let index = index == .minimum ? .minimum.next : index
|
|
var currentNode = dictionary[index]
|
|
var history: [Index] = []
|
|
|
|
while currentNode != nil {
|
|
if let node = currentNode {
|
|
history.append(node.index)
|
|
}
|
|
|
|
currentNode = currentNode?.previous
|
|
}
|
|
|
|
return history.reversed()
|
|
}
|
|
|
|
/// Provides a single future for a given index.
|
|
///
|
|
/// - parameter index: The index from which to generate the future.
|
|
/// - returns: An array of move indices sorted from beginning to end.
|
|
///
|
|
/// For chess this would represent an array of all the move indices
|
|
/// from the move after the move defined by `index` to the last move
|
|
/// of the variation.
|
|
public func future(for index: Index) -> [Index] {
|
|
let index = index == .minimum ? .minimum.next : index
|
|
var currentNode = dictionary[index]
|
|
var future: [Index] = []
|
|
|
|
while currentNode != nil {
|
|
currentNode = currentNode?.next
|
|
|
|
if let node = currentNode {
|
|
future.append(node.index)
|
|
}
|
|
}
|
|
|
|
return future
|
|
}
|
|
|
|
/// Returns the full variation for a move at the provided `index`.
|
|
///
|
|
/// This returns the sum of `history(for:)` and `future(for:)`.
|
|
public func fullVariation(for index: Index) -> [Index] {
|
|
history(for: index) + future(for: index)
|
|
}
|
|
|
|
private func indices(between start: Index, and end: Index) -> [Index] {
|
|
var result = [Index]()
|
|
|
|
let endNode = dictionary[end]
|
|
var currentNode = dictionary[start]
|
|
|
|
while currentNode != endNode {
|
|
if let currentNode {
|
|
result.append(currentNode.index)
|
|
}
|
|
|
|
currentNode = currentNode?.previous
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/// Provides the shortest path through the move tree
|
|
/// from the given start and end indices.
|
|
///
|
|
/// - parameter startIndex: The starting index of the path.
|
|
/// - parameter endIndex: The ending index of the path.
|
|
/// - returns: An array of indices starting with the index after `startIndex`
|
|
/// and ending with `endIndex`. If `startIndex` and `endIndex`
|
|
/// are the same, an empty array is returned.
|
|
///
|
|
/// The purpose of this path is return the indices of the moves required to
|
|
/// go from the current position at `startIndex` and end up with the
|
|
/// final position at `endIndex`, so `startIndex` is included in the returned
|
|
/// array, but `endIndex` is not. The path direction included with the index
|
|
/// indicates the direction to move to get to the next index.
|
|
public func path(
|
|
from startIndex: Index,
|
|
to endIndex: Index
|
|
) -> [(direction: PathDirection, index: Index)] {
|
|
var results = [(PathDirection, Index)]()
|
|
let startHistory = history(for: startIndex)
|
|
let endHistory = history(for: endIndex)
|
|
|
|
if startIndex == endIndex {
|
|
// keep results array empty
|
|
} else if startHistory.contains(endIndex) {
|
|
results = indices(between: startIndex, and: endIndex)
|
|
.map { (.reverse, $0) }
|
|
} else if endHistory.contains(startIndex) {
|
|
results = indices(between: endIndex, and: startIndex)
|
|
.map { (.forward, $0) }
|
|
.reversed()
|
|
} else {
|
|
// lowest common ancestor
|
|
guard
|
|
let lca = zip(startHistory, endHistory).filter({ $0 == $1 }).last?.0,
|
|
let startLCAIndex = startHistory.firstIndex(where: { $0 == lca }),
|
|
let endLCAIndex = endHistory.firstIndex(where: { $0 == lca })
|
|
else {
|
|
return []
|
|
}
|
|
|
|
let startToLCAPath = startHistory[startLCAIndex...]
|
|
.reversed() // reverse since history is in ascending order
|
|
.dropLast() // drop LCA; to be included in the next array
|
|
.map { (PathDirection.reverse, $0) }
|
|
|
|
let LCAtoEndPath = endHistory[endLCAIndex...]
|
|
.map { (PathDirection.forward, $0) }
|
|
|
|
results = startToLCAPath + LCAtoEndPath
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
/// The direction of the ``MoveTree`` path.
|
|
public enum PathDirection: Sendable {
|
|
/// Move forward (i.e. perform a move).
|
|
case forward
|
|
/// Move backward (i.e. undo a move).
|
|
case reverse
|
|
}
|
|
|
|
/// Whether the tree is empty or not.
|
|
public var isEmpty: Bool {
|
|
root == nil
|
|
}
|
|
|
|
/// Annotates the move at the provided index.
|
|
///
|
|
/// - parameter index: The index of the move to annotate.
|
|
/// - parameter assessment: The assessment to annotate the move with.
|
|
/// - parameter comment: The comment to annotate the move with.
|
|
///
|
|
/// - returns: The move updated with the given annotations.
|
|
///
|
|
@discardableResult
|
|
public mutating func annotate(
|
|
moveAt index: Index,
|
|
assessment: Move.Assessment = .null,
|
|
comment: String = ""
|
|
) -> Move? {
|
|
Self.nodeLock.withLock {
|
|
dictionary[index]?.move.assessment = assessment
|
|
dictionary[index]?.move.comment = comment
|
|
}
|
|
return dictionary[index]?.move
|
|
}
|
|
|
|
/// Annotates the position at the provided index.
|
|
///
|
|
/// - parameter index: The index of the position to annotate.
|
|
/// - parameter assessment: The assessment to annotate the position with.
|
|
///
|
|
/// This value is stored in the move tree to generate an accurate
|
|
/// PGN representation with `MoveTree.pgnRepresentation`.
|
|
///
|
|
public mutating func annotate(
|
|
positionAt index: Index,
|
|
assessment: Position.Assessment
|
|
) {
|
|
Self.nodeLock.withLock {
|
|
dictionary[index]?.positionAssessment = assessment
|
|
}
|
|
}
|
|
|
|
// MARK: - PGN
|
|
|
|
/// An element for representing the ``MoveTree`` in
|
|
/// PGN (Portable Game Notation) format.
|
|
public enum PGNElement: Hashable, Sendable {
|
|
/// e.g. `1.`
|
|
case whiteNumber(Int)
|
|
/// e.g. `1...`
|
|
case blackNumber(Int)
|
|
/// e.g. `e4`
|
|
case move(Move, Index)
|
|
/// e.g. `$10`
|
|
case positionAssessment(Position.Assessment)
|
|
/// e.g. `(`
|
|
case variationStart
|
|
/// e.g. `)`
|
|
case variationEnd
|
|
}
|
|
|
|
private func pgn(for node: Node?) -> [PGNElement] {
|
|
guard let node else { return [] }
|
|
var result: [PGNElement] = []
|
|
|
|
switch node.index.color {
|
|
case .white:
|
|
result.append(.whiteNumber(node.index.number))
|
|
case .black:
|
|
result.append(.blackNumber(node.index.number))
|
|
}
|
|
|
|
result.append(.move(node.move, node.index))
|
|
if node.positionAssessment != .null {
|
|
result.append(.positionAssessment(node.positionAssessment))
|
|
}
|
|
|
|
var iterator = node.next?.makeIterator()
|
|
var previousIndex = node.index
|
|
|
|
while let currentNode = iterator?.next() {
|
|
let currentIndex = currentNode.index
|
|
|
|
switch (previousIndex.number, currentIndex.number) {
|
|
case let (x, y) where x < y:
|
|
result.append(.whiteNumber(currentIndex.number))
|
|
default:
|
|
break
|
|
}
|
|
|
|
result.append(.move(currentNode.move, currentIndex))
|
|
|
|
if currentNode.positionAssessment != .null {
|
|
result.append(.positionAssessment(currentNode.positionAssessment))
|
|
}
|
|
|
|
// recursively generate PGN for all child nodes
|
|
currentNode.previous?.children.forEach { child in
|
|
result.append(.variationStart)
|
|
result.append(contentsOf: pgn(for: child))
|
|
result.append(.variationEnd)
|
|
}
|
|
|
|
previousIndex = currentIndex
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/// Returns the ``MoveTree`` as an array of PGN
|
|
/// (Portable Game Format) elements.
|
|
public var pgnRepresentation: [PGNElement] {
|
|
pgn(for: root)
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Equatable
|
|
extension MoveTree: Equatable {
|
|
|
|
public static func == (lhs: MoveTree, rhs: MoveTree) -> Bool {
|
|
lhs.dictionary == rhs.dictionary
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Node
|
|
extension MoveTree {
|
|
|
|
/// Object that represents a node in the move tree.
|
|
class Node: Codable, Hashable, @unchecked Sendable, Sequence {
|
|
|
|
/// The move for this node.
|
|
var move: Move
|
|
/// The position assessment for this node.
|
|
var positionAssessment = Position.Assessment.null
|
|
/// The index for this node.
|
|
fileprivate(set) var index = Index.minimum
|
|
/// The previous node.
|
|
fileprivate(set) var previous: Node?
|
|
/// The next node.
|
|
fileprivate(set) weak var next: Node?
|
|
/// Children nodes (i.e. variation moves).
|
|
fileprivate var children: [Node] = []
|
|
|
|
fileprivate init(move: Move) {
|
|
self.move = move
|
|
}
|
|
|
|
// MARK: Equatable
|
|
static func == (lhs: Node, rhs: Node) -> Bool {
|
|
lhs.index == rhs.index && lhs.move == rhs.move
|
|
}
|
|
|
|
// MARK: Hashable
|
|
func hash(into hasher: inout Hasher) {
|
|
hasher.combine(move)
|
|
hasher.combine(index)
|
|
hasher.combine(previous)
|
|
hasher.combine(next)
|
|
hasher.combine(children)
|
|
}
|
|
|
|
// MARK: Sequence
|
|
func makeIterator() -> NodeIterator {
|
|
.init(start: self)
|
|
}
|
|
|
|
}
|
|
|
|
struct NodeIterator: IteratorProtocol {
|
|
private var current: Node?
|
|
|
|
init(start: Node?) {
|
|
current = start
|
|
}
|
|
|
|
mutating func next() -> Node? {
|
|
defer { current = current?.next }
|
|
return current
|
|
}
|
|
}
|
|
|
|
}
|