Files
Paolo Di Lorenzo 022086aa94 Codable conformance (#71)
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.
2025-11-12 18:28:41 -05:00

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
}
}
}