clone

two-player chess in the terminal, kept in a shared sqlite file and played with gum

main
select-mate.sh / select-mate.sh
select-mate.sh 56.4K executable
1#!/usr/bin/env bash23if [ -z "${BASH_VERSION:-}" ]; then4 if command -v bash >/dev/null 2>&1; then5 case "${0##*/}" in6 sh|dash|ash)7 exec bash /dev/fd/0 "$@"8 ;;9 *)10 exec bash "$0" "$@"11 ;;12 esac13 fi14 printf 'bash is required.\n' >&215 exit 116fi1718set -euo pipefail1920APP_NAME="select-mate"21APP_TITLE="select-mate.sh"22GUM_THEME_ACCENT="#cc8844"23GUM_THEME_ACCENT_TEXT="#e4e4e4"24GUM_THEME_TEXT_FG="#d0d0d0"25GUM_THEME_TEXT_DIM="#949494"26GUM_THEME_SURFACE_BG="#3a3a3a"27GUM_THEME_BORDER="#585858"28CONFIG_PATH="${XDG_CONFIG_HOME:-$HOME/.config}/select-mate.conf"29DEFAULT_DB_PATH="$(dirname "$CONFIG_PATH")/game.db"30DB_PATH="$DEFAULT_DB_PATH"31SESSION_IDENTITY_ID=""32SESSION_IDENTITY_NAME=""3334has_gum() {35 command -v gum >/dev/null 2>&136}3738prepare_interactive_stdin() {39 if [[ -t 0 || -r /dev/tty ]]; then40 return 041 fi42 printf 'An interactive terminal is required for prompts.\n' >&243 return 144}4546read_user_line() {47 local __reply_var=$14849 if [[ -t 0 ]]; then50 IFS= read -r "$__reply_var" || return 151 return 052 fi5354 IFS= read -r "$__reply_var" </dev/tty || return 155}5657gum_confirm_interactive() {58 local style_args=(59 --prompt.foreground "$GUM_THEME_ACCENT"60 --selected.foreground "$GUM_THEME_ACCENT_TEXT"61 --selected.background "$GUM_THEME_ACCENT"62 --unselected.foreground "$GUM_THEME_TEXT_FG"63 --unselected.background "$GUM_THEME_SURFACE_BG"64 --padding "0 1"65 )66 if [[ -t 0 ]]; then67 gum confirm "${style_args[@]}" "$@"68 return $?69 fi70 gum confirm "${style_args[@]}" "$@" </dev/tty71}7273gum_input_interactive() {74 local style_args=(75 --prompt.foreground "$GUM_THEME_ACCENT"76 --placeholder.foreground "$GUM_THEME_TEXT_DIM"77 --cursor.foreground "$GUM_THEME_ACCENT"78 --header.foreground "$GUM_THEME_ACCENT"79 --padding "0 1"80 )81 if [[ -t 0 ]]; then82 gum input "${style_args[@]}" "$@"83 return $?84 fi85 gum input "${style_args[@]}" "$@" </dev/tty86}8788gum_choose_interactive() {89 local style_args=(90 --cursor.foreground "$GUM_THEME_ACCENT"91 --header.foreground "$GUM_THEME_ACCENT"92 --item.foreground "$GUM_THEME_TEXT_FG"93 --selected.foreground "$GUM_THEME_ACCENT"94 --padding "0 1"95 )96 if [[ -t 0 ]]; then97 gum choose "${style_args[@]}" "$@"98 return $?99 fi100 gum choose "${style_args[@]}" "$@" </dev/tty101}102103usage() {104 cat <<'EOF'105Usage: select-mate.sh [--identity NAME] [--new | --local | --moves | --whoami | --help]106107Commands:108 --identity NAME Select or create an identity before running the command.109 --new Start a fresh multiplayer game after confirmation.110 --local Start a fresh local-only game using the selected identity for both sides.111 --moves Show move history for the active game and exit.112 --whoami Print the currently selected identity and exit.113 --help Show this help text and exit.114115Startup defaults:116 - Identity config: ${XDG_CONFIG_HOME:-$HOME/.config}/select-mate.conf117 Example: identity=alice118 - Board path when stdin does not provide a URI: ${XDG_CONFIG_HOME:-$HOME/.config}/game.db119 - Board URI from stdin when stdin is not a TTY120 Example: printf 'file:/tmp/select-mate.db?mode=rwc\n' | ./select-mate.sh --whoami121EOF122}123124sql_quote() {125 printf "%s" "$1" | sed "s/'/''/g"126}127128trim_string() {129 printf '%s' "$1" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'130}131132dbq() {133 sqlite3 -batch -noheader "$DB_PATH" "$1"134}135136dbqt() {137 sqlite3 -batch -noheader -tabs "$DB_PATH" "$1"138}139140require_sqlite() {141 if ! command -v sqlite3 >/dev/null 2>&1; then142 printf 'sqlite3 is required.\n' >&2143 exit 1144 fi145}146147has_full_tty() {148 [[ -t 0 && -t 1 && "${TERM:-}" != "dumb" ]]149}150151clear_screen() {152 if has_full_tty && command -v clear >/dev/null 2>&1; then153 clear || true154 fi155}156157show_in_pager() {158 local content=$1159160 # `less` behaves better than `gum pager` in tight terminals and falls back161 # cleanly when the command is used from pipes or automation.162 if has_full_tty && command -v less >/dev/null 2>&1; then163 less -RFX <<<"$content"164 return 0165 fi166167 printf '%s\n' "$content"168}169170db_parent_dir() {171 local target=$1172 local path_without_query173174 if [[ "$target" == file:* ]]; then175 path_without_query=${target#file:}176 path_without_query=${path_without_query%%\?*}177 if [[ -z "$path_without_query" || "$path_without_query" == ':memory:' ]]; then178 return 1179 fi180 dirname "$path_without_query"181 return 0182 fi183184 dirname "$target"185}186187ensure_state_dir() {188 local parent_dir189 if parent_dir=$(db_parent_dir "$DB_PATH"); then190 mkdir -p "$parent_dir"191 fi192}193194init_db() {195 ensure_state_dir196 if [[ "$DB_PATH" == file:* ]]; then197 sqlite3 "$DB_PATH" <<'SQL'198PRAGMA foreign_keys = ON;199200CREATE TABLE IF NOT EXISTS games (201 id INTEGER PRIMARY KEY AUTOINCREMENT,202 status TEXT NOT NULL DEFAULT 'active',203 turn TEXT NOT NULL CHECK (turn IN ('white', 'black')),204 winner TEXT,205 note TEXT,206 en_passant_file INTEGER,207 en_passant_rank INTEGER,208 created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,209 updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP210);211212CREATE TABLE IF NOT EXISTS pieces (213 id INTEGER PRIMARY KEY AUTOINCREMENT,214 game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE,215 file INTEGER NOT NULL CHECK (file BETWEEN 1 AND 8),216 rank INTEGER NOT NULL CHECK (rank BETWEEN 1 AND 8),217 piece TEXT NOT NULL,218 color TEXT NOT NULL CHECK (color IN ('white', 'black')),219 kind TEXT NOT NULL CHECK (kind IN ('king', 'queen', 'rook', 'bishop', 'knight', 'pawn')),220 has_moved INTEGER NOT NULL DEFAULT 0 CHECK (has_moved IN (0, 1)),221 UNIQUE (game_id, file, rank)222);223224CREATE INDEX IF NOT EXISTS idx_pieces_game_color ON pieces(game_id, color);225CREATE INDEX IF NOT EXISTS idx_pieces_game_square ON pieces(game_id, file, rank);226227CREATE TABLE IF NOT EXISTS moves (228 id INTEGER PRIMARY KEY AUTOINCREMENT,229 game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE,230 ply INTEGER NOT NULL,231 from_sq TEXT NOT NULL,232 to_sq TEXT NOT NULL,233 piece TEXT NOT NULL,234 capture TEXT,235 promotion TEXT,236 notation TEXT NOT NULL,237 created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,238 UNIQUE (game_id, ply)239);240241CREATE INDEX IF NOT EXISTS idx_moves_game_ply ON moves(game_id, ply);242243CREATE TABLE IF NOT EXISTS identities (244 id INTEGER PRIMARY KEY AUTOINCREMENT,245 name TEXT NOT NULL UNIQUE,246 created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,247 updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,248 last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP249);250251CREATE TABLE IF NOT EXISTS game_players (252 game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE,253 color TEXT NOT NULL CHECK (color IN ('white', 'black')),254 identity_id INTEGER NOT NULL REFERENCES identities(id) ON DELETE CASCADE,255 PRIMARY KEY (game_id, color)256);257258CREATE TABLE IF NOT EXISTS app_state (259 key TEXT PRIMARY KEY,260 value TEXT NOT NULL261);262263CREATE INDEX IF NOT EXISTS idx_identities_last_used ON identities(last_used_at DESC, name ASC);264CREATE INDEX IF NOT EXISTS idx_game_players_identity ON game_players(identity_id);265SQL266 return267 fi268269 sqlite3 "$DB_PATH" <<'SQL'270PRAGMA foreign_keys = ON;271272CREATE TABLE IF NOT EXISTS games (273 id INTEGER PRIMARY KEY AUTOINCREMENT,274 status TEXT NOT NULL DEFAULT 'active',275 turn TEXT NOT NULL CHECK (turn IN ('white', 'black')),276 winner TEXT,277 note TEXT,278 en_passant_file INTEGER,279 en_passant_rank INTEGER,280 created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,281 updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP282);283284CREATE TABLE IF NOT EXISTS pieces (285 id INTEGER PRIMARY KEY AUTOINCREMENT,286 game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE,287 file INTEGER NOT NULL CHECK (file BETWEEN 1 AND 8),288 rank INTEGER NOT NULL CHECK (rank BETWEEN 1 AND 8),289 piece TEXT NOT NULL,290 color TEXT NOT NULL CHECK (color IN ('white', 'black')),291 kind TEXT NOT NULL CHECK (kind IN ('king', 'queen', 'rook', 'bishop', 'knight', 'pawn')),292 has_moved INTEGER NOT NULL DEFAULT 0 CHECK (has_moved IN (0, 1)),293 UNIQUE (game_id, file, rank)294);295296CREATE INDEX IF NOT EXISTS idx_pieces_game_color ON pieces(game_id, color);297CREATE INDEX IF NOT EXISTS idx_pieces_game_square ON pieces(game_id, file, rank);298299CREATE TABLE IF NOT EXISTS moves (300 id INTEGER PRIMARY KEY AUTOINCREMENT,301 game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE,302 ply INTEGER NOT NULL,303 from_sq TEXT NOT NULL,304 to_sq TEXT NOT NULL,305 piece TEXT NOT NULL,306 capture TEXT,307 promotion TEXT,308 notation TEXT NOT NULL,309 created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,310 UNIQUE (game_id, ply)311);312313CREATE INDEX IF NOT EXISTS idx_moves_game_ply ON moves(game_id, ply);314315CREATE TABLE IF NOT EXISTS identities (316 id INTEGER PRIMARY KEY AUTOINCREMENT,317 name TEXT NOT NULL UNIQUE,318 created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,319 updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,320 last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP321);322323CREATE TABLE IF NOT EXISTS game_players (324 game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE,325 color TEXT NOT NULL CHECK (color IN ('white', 'black')),326 identity_id INTEGER NOT NULL REFERENCES identities(id) ON DELETE CASCADE,327 PRIMARY KEY (game_id, color)328);329330CREATE TABLE IF NOT EXISTS app_state (331 key TEXT PRIMARY KEY,332 value TEXT NOT NULL333);334335CREATE INDEX IF NOT EXISTS idx_identities_last_used ON identities(last_used_at DESC, name ASC);336CREATE INDEX IF NOT EXISTS idx_game_players_identity ON game_players(identity_id);337SQL338}339340piece_symbol() {341 local color=$1342 local kind=$2343 case "$color:$kind" in344 white:king) printf 'K' ;;345 white:queen) printf 'Q' ;;346 white:rook) printf 'R' ;;347 white:bishop) printf 'B' ;;348 white:knight) printf 'N' ;;349 white:pawn) printf 'P' ;;350 black:king) printf 'k' ;;351 black:queen) printf 'q' ;;352 black:rook) printf 'r' ;;353 black:bishop) printf 'b' ;;354 black:knight) printf 'n' ;;355 black:pawn) printf 'p' ;;356 *) printf '?' ;;357 esac358}359360opposite_color() {361 if [[ "$1" == "white" ]]; then362 printf 'black'363 else364 printf 'white'365 fi366}367368file_to_letter() {369 case "$1" in370 1) printf 'a' ;;371 2) printf 'b' ;;372 3) printf 'c' ;;373 4) printf 'd' ;;374 5) printf 'e' ;;375 6) printf 'f' ;;376 7) printf 'g' ;;377 8) printf 'h' ;;378 *) return 1 ;;379 esac380}381382letter_to_file() {383 case "$1" in384 a|A) printf '1' ;;385 b|B) printf '2' ;;386 c|C) printf '3' ;;387 d|D) printf '4' ;;388 e|E) printf '5' ;;389 f|F) printf '6' ;;390 g|G) printf '7' ;;391 h|H) printf '8' ;;392 *) return 1 ;;393 esac394}395396coords_to_square() {397 printf '%s%s' "$(file_to_letter "$1")" "$2"398}399400square_to_coords() {401 local square=$1402 local file_letter rank403 file_letter=${square%${square#?}}404 rank=${square#?}405 printf '%s|%s' "$(letter_to_file "$file_letter")" "$rank"406}407408is_on_board() {409 local file=$1410 local rank=$2411 [[ "$file" -ge 1 && "$file" -le 8 && "$rank" -ge 1 && "$rank" -le 8 ]]412}413414current_game_id() {415 dbq "SELECT id FROM games WHERE status = 'active' ORDER BY updated_at DESC, id DESC LIMIT 1;"416}417418game_turn() {419 dbq "SELECT turn FROM games WHERE id = $1;"420}421422game_status() {423 dbq "SELECT status FROM games WHERE id = $1;"424}425426game_note() {427 dbq "SELECT COALESCE(note, '') FROM games WHERE id = $1;"428}429430game_is_local() {431 dbq "432 SELECT CASE WHEN COUNT(DISTINCT identity_id) = 1 THEN 1 ELSE 0 END433 FROM game_players434 WHERE game_id = $1;"435}436437game_en_passant() {438 dbq "SELECT COALESCE(en_passant_file, 0) || '|' || COALESCE(en_passant_rank, 0) FROM games WHERE id = $1;"439}440441piece_info() {442 dbqt "SELECT id, file, rank, color, kind, has_moved FROM pieces WHERE id = $1;"443}444445piece_id_at() {446 dbq "SELECT id FROM pieces WHERE game_id = $1 AND file = $2 AND rank = $3;"447}448449occupant_data() {450 dbqt "SELECT id, color, kind, piece FROM pieces WHERE game_id = $1 AND file = $2 AND rank = $3;"451}452453occupied_color() {454 dbq "SELECT color FROM pieces WHERE game_id = $1 AND file = $2 AND rank = $3;"455}456457count_lines() {458 local count=0 line459 while IFS= read -r line; do460 [[ -n "$line" ]] || continue461 count=$((count + 1))462 done463 printf '%s' "$count"464}465466confirm_action() {467 local prompt=$1468 local reply469470 if has_gum; then471 gum_confirm_interactive "$prompt"472 return $?473 fi474 printf '%s [y/N] ' "$prompt" >&2475 read_user_line reply || return 1476 [[ "$reply" == "y" || "$reply" == "Y" ]]477}478479list_identity_names() {480 dbq "SELECT name FROM identities ORDER BY last_used_at DESC, name ASC;"481}482483identity_name_by_id() {484 dbq "SELECT name FROM identities WHERE id = $1;"485}486487selected_identity_id() {488 if [[ -n "$SESSION_IDENTITY_ID" ]]; then489 printf '%s' "$SESSION_IDENTITY_ID"490 return 0491 fi492 dbq "SELECT value FROM app_state WHERE key = 'selected_identity_id' LIMIT 1;"493}494495selected_identity_name() {496 if [[ -n "$SESSION_IDENTITY_NAME" ]]; then497 printf '%s' "$SESSION_IDENTITY_NAME"498 return 0499 fi500 local identity_id501 identity_id=$(selected_identity_id)502 if [[ -z "$identity_id" ]]; then503 return 0504 fi505 identity_name_by_id "$identity_id"506}507508config_identity_name() {509 local line trimmed_line key value510511 if [[ ! -f "$CONFIG_PATH" ]]; then512 return 0513 fi514515 while IFS= read -r line || [[ -n "$line" ]]; do516 trimmed_line=$(trim_string "$line")517 [[ -n "$trimmed_line" ]] || continue518 [[ "${trimmed_line#\#}" != "$trimmed_line" ]] && continue519 key=${trimmed_line%%=*}520 value=${trimmed_line#*=}521 key=$(trim_string "$key")522 value=$(trim_string "$value")523 if [[ "$key" == "identity" && -n "$value" ]]; then524 printf '%s' "$value"525 return 0526 fi527 done <"$CONFIG_PATH"528}529530stdin_board_uri() {531 local uri=''532533 if [[ -t 0 ]]; then534 return 0535 fi536537 IFS= read -r uri || true538 trim_string "$uri"539}540541ensure_identity() {542 local raw_name=$1543 local name safe_name544 name=$(trim_string "$raw_name")545 if [[ -z "$name" ]]; then546 return 1547 fi548 safe_name=$(sql_quote "$name")549 if [[ "$DB_PATH" == file:* ]]; then550 sqlite3 -batch -noheader "$DB_PATH" <<SQL551PRAGMA foreign_keys = ON;552BEGIN;553INSERT INTO identities(name, created_at, updated_at, last_used_at)554SELECT '$safe_name', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP555WHERE NOT EXISTS (556 SELECT 1 FROM identities WHERE name = '$safe_name'557);558UPDATE identities559SET updated_at = CURRENT_TIMESTAMP,560 last_used_at = CURRENT_TIMESTAMP561WHERE name = '$safe_name';562SELECT id FROM identities WHERE name = '$safe_name';563COMMIT;564SQL565 return566 fi567568 sqlite3 -batch -noheader "$DB_PATH" <<SQL569PRAGMA foreign_keys = ON;570BEGIN;571INSERT INTO identities(name, created_at, updated_at, last_used_at)572SELECT '$safe_name', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP573WHERE NOT EXISTS (574 SELECT 1 FROM identities WHERE name = '$safe_name'575);576UPDATE identities577SET updated_at = CURRENT_TIMESTAMP,578 last_used_at = CURRENT_TIMESTAMP579WHERE name = '$safe_name';580SELECT id FROM identities WHERE name = '$safe_name';581COMMIT;582SQL583}584585set_selected_identity_id() {586 dbq "587 INSERT INTO app_state(key, value)588 VALUES('selected_identity_id', '$1')589 ON CONFLICT(key) DO UPDATE SET value = excluded.value;"590}591592set_selected_identity() {593 local identity_id594 local persist_selection=${2:-0}595 identity_id=$(ensure_identity "$1") || return 1596 SESSION_IDENTITY_ID=$identity_id597 SESSION_IDENTITY_NAME=$(identity_name_by_id "$identity_id")598 if [[ "$persist_selection" -eq 1 ]]; then599 set_selected_identity_id "$identity_id"600 fi601 printf '%s' "$identity_id"602}603604turn_identity_id() {605 local game_id=$1606 dbq "607 SELECT gp.identity_id608 FROM games g609 LEFT JOIN game_players gp610 ON gp.game_id = g.id611 AND gp.color = g.turn612 WHERE g.id = $game_id613 LIMIT 1;"614}615616turn_identity_name() {617 local identity_id618 identity_id=$(turn_identity_id "$1")619 if [[ -z "$identity_id" ]]; then620 return 0621 fi622 identity_name_by_id "$identity_id"623}624625can_selected_identity_write() {626 local game_id=${1:-}627 local selected_id turn_id628629 if [[ -z "$game_id" ]]; then630 printf '1'631 return 0632 fi633634 selected_id=$(selected_identity_id)635 turn_id=$(turn_identity_id "$game_id")636 if [[ -n "$selected_id" && -n "$turn_id" && "$selected_id" == "$turn_id" ]]; then637 printf '1'638 else639 printf '0'640 fi641}642643can_selected_identity_move() {644 can_selected_identity_write "$1"645}646647require_write_access() {648 if [[ "$(can_selected_identity_write "${1:-}")" -eq 1 ]]; then649 return 0650 fi651 return 1652}653654prompt_text_input() {655 local prompt=$1656 local default_value=${2:-}657 local reply658659 if has_gum; then660 if [[ -n "$default_value" ]]; then661 reply=$(gum_input_interactive --prompt "$prompt: " --value "$default_value") || return 1662 else663 reply=$(gum_input_interactive --prompt "$prompt: ") || return 1664 fi665 printf '%s' "$reply"666 return 0667 fi668669 printf '%s: ' "$prompt" >&2670 read_user_line reply || return 1671 if [[ -z "$reply" && -n "$default_value" ]]; then672 reply=$default_value673 fi674 printf '%s' "$reply"675}676677pick_option() {678 local header=$1679 local options=()680 local option_count reply i option681 shift682 options=("$@")683 option_count=${#options[@]}684685 if [[ "$option_count" -eq 0 ]]; then686 return 1687 fi688689 if has_gum; then690 gum_choose_interactive --header "$header" "${options[@]}"691 return 0692 fi693694 printf '%s\n' "$header" >&2695 for ((i = 0; i < option_count; i += 1)); do696 printf ' %d) %s\n' "$((i + 1))" "${options[$i]}" >&2697 done698699 while :; do700 reply=$(prompt_text_input "Choose 1-$option_count") || return 1701 reply=$(trim_string "$reply")702 if [[ "$reply" =~ ^[0-9]+$ ]] && ((reply >= 1 && reply <= option_count)); then703 printf '%s' "${options[$((reply - 1))]}"704 return 0705 fi706 for option in "${options[@]}"; do707 if [[ "$reply" == "$option" ]]; then708 printf '%s' "$option"709 return 0710 fi711 done712 printf 'Choose one of the listed options.\n' >&2713 done714}715716prompt_for_identity_name() {717 local prompt=$1718 local default_name=${2:-}719 local choice name720 local options=()721722 if has_gum; then723 while IFS= read -r line; do724 [[ -n "$line" ]] || continue725 options[${#options[@]}]=$line726 done <<EOF727$(list_identity_names)728EOF729 if [[ "${#options[@]}" -gt 0 ]]; then730 options[${#options[@]}]='create new identity'731 choice=$(pick_option "$prompt" "${options[@]}") || return 1732 if [[ "$choice" != 'create new identity' ]]; then733 printf '%s' "$choice"734 return 0735 fi736 fi737 fi738739 while :; do740 name=$(prompt_text_input "$prompt" "$default_name") || return 1741 name=$(trim_string "$name")742 if [[ -n "$name" ]]; then743 printf '%s' "$name"744 return 0745 fi746 printf 'Identity name cannot be empty.\n' >&2747 done748}749750switch_identity_interactively() {751 local game_id752 local name753754 game_id=$(current_game_id)755 if [[ -n "$game_id" && "$(game_is_local "$game_id")" -ne 1 ]]; then756 printf 'Identity switching is disabled after a multiplayer game starts. Use --identity before joining, or use --local for shared-device play.\n' >&2757 return 1758 fi759760 name=$(prompt_for_identity_name "Select identity" "$(selected_identity_name)") || return 1761 set_selected_identity "$name" 1 >/dev/null762}763764ensure_selected_identity_for_interactive() {765 local current_name766 current_name=$(selected_identity_name)767 if [[ -n "$current_name" ]]; then768 return 0769 fi770 switch_identity_interactively771}772773seed_new_game() {774 local white_identity_id=$1775 local black_identity_id=$2776 dbq "777 BEGIN;778 UPDATE games SET status = 'archived', updated_at = CURRENT_TIMESTAMP779 WHERE status = 'active';780 INSERT INTO games(status, turn, winner, note, en_passant_file, en_passant_rank)781 VALUES('active', 'white', NULL, NULL, NULL, NULL);782 INSERT INTO pieces(game_id, file, rank, piece, color, kind, has_moved)783 SELECT game_id, file, rank, piece, color, kind, has_moved784 FROM (785 SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1) AS game_id, 1 AS file, 1 AS rank, 'R' AS piece, 'white' AS color, 'rook' AS kind, 0 AS has_moved786 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 2, 1, 'N', 'white', 'knight', 0787 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 3, 1, 'B', 'white', 'bishop', 0788 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 4, 1, 'Q', 'white', 'queen', 0789 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 5, 1, 'K', 'white', 'king', 0790 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 6, 1, 'B', 'white', 'bishop', 0791 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 7, 1, 'N', 'white', 'knight', 0792 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 8, 1, 'R', 'white', 'rook', 0793 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 1, 2, 'P', 'white', 'pawn', 0794 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 2, 2, 'P', 'white', 'pawn', 0795 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 3, 2, 'P', 'white', 'pawn', 0796 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 4, 2, 'P', 'white', 'pawn', 0797 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 5, 2, 'P', 'white', 'pawn', 0798 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 6, 2, 'P', 'white', 'pawn', 0799 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 7, 2, 'P', 'white', 'pawn', 0800 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 8, 2, 'P', 'white', 'pawn', 0801 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 1, 8, 'r', 'black', 'rook', 0802 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 2, 8, 'n', 'black', 'knight', 0803 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 3, 8, 'b', 'black', 'bishop', 0804 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 4, 8, 'q', 'black', 'queen', 0805 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 5, 8, 'k', 'black', 'king', 0806 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 6, 8, 'b', 'black', 'bishop', 0807 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 7, 8, 'n', 'black', 'knight', 0808 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 8, 8, 'r', 'black', 'rook', 0809 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 1, 7, 'p', 'black', 'pawn', 0810 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 2, 7, 'p', 'black', 'pawn', 0811 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 3, 7, 'p', 'black', 'pawn', 0812 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 4, 7, 'p', 'black', 'pawn', 0813 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 5, 7, 'p', 'black', 'pawn', 0814 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 6, 7, 'p', 'black', 'pawn', 0815 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 7, 7, 'p', 'black', 'pawn', 0816 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 8, 7, 'p', 'black', 'pawn', 0817 );818 INSERT INTO game_players(game_id, color, identity_id)819 VALUES820 ((SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 'white', $white_identity_id),821 ((SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 'black', $black_identity_id);822 COMMIT;"823}824825start_new_game_flow() {826 local mode=$1827 local selected_name white_name black_name white_identity_id black_identity_id game_id828829 selected_name=$(selected_identity_name)830 if [[ "$mode" == "local" ]]; then831 if [[ -z "$selected_name" ]]; then832 return 1833 fi834 white_identity_id=$(ensure_identity "$selected_name") || return 1835 black_identity_id=$white_identity_id836 else837 while :; do838 white_name=$(prompt_for_identity_name "White player" "$selected_name") || return 1839 black_name=$(prompt_for_identity_name "Black player") || return 1840 white_identity_id=$(ensure_identity "$white_name") || continue841 black_identity_id=$(ensure_identity "$black_name") || continue842 if [[ "$white_identity_id" == "$black_identity_id" ]]; then843 printf 'Use --local if one identity should control both colors.\n' >&2844 continue845 fi846 break847 done848 fi849850 seed_new_game "$white_identity_id" "$black_identity_id"851 game_id=$(current_game_id)852 if [[ -n "$game_id" ]]; then853 update_game_state "$game_id"854 fi855}856857ensure_active_game() {858 current_game_id859}860861attack_query_for_square() {862 local game_id=$1863 local attacker_color=$2864 local target_file=$3865 local target_rank=$4866 cat <<SQL867WITH RECURSIVE868occupied AS (869 SELECT file, rank, color, kind870 FROM pieces871 WHERE game_id = $game_id872),873attackers AS (874 SELECT id, file, rank, color, kind875 FROM pieces876 WHERE game_id = $game_id877 AND color = '$attacker_color'878),879dirs(df, dr) AS (880 VALUES881 (1, 0), (-1, 0), (0, 1), (0, -1),882 (1, 1), (1, -1), (-1, 1), (-1, -1)883),884ray(id, kind, file, rank, df, dr) AS (885 SELECT a.id, a.kind, a.file + d.df, a.rank + d.dr, d.df, d.dr886 FROM attackers a887 JOIN dirs d888 ON (889 (a.kind = 'bishop' AND abs(d.df) = 1 AND abs(d.dr) = 1) OR890 (a.kind = 'rook' AND ((abs(d.df) = 1 AND d.dr = 0) OR (d.df = 0 AND abs(d.dr) = 1))) OR891 (a.kind = 'queen')892 )893 WHERE a.kind IN ('bishop', 'rook', 'queen')894 AND a.file + d.df BETWEEN 1 AND 8895 AND a.rank + d.dr BETWEEN 1 AND 8896897 UNION ALL898899 SELECT id, kind, file + df, rank + dr, df, dr900 FROM ray901 WHERE NOT EXISTS (902 SELECT 1903 FROM occupied o904 WHERE o.file = ray.file905 AND o.rank = ray.rank906 )907 AND file + df BETWEEN 1 AND 8908 AND rank + dr BETWEEN 1 AND 8909),910hits AS (911 SELECT 1912 FROM attackers a913 WHERE a.kind = 'knight'914 AND (915 (a.file + 1 = $target_file AND a.rank + 2 = $target_rank) OR916 (a.file + 2 = $target_file AND a.rank + 1 = $target_rank) OR917 (a.file + 2 = $target_file AND a.rank - 1 = $target_rank) OR918 (a.file + 1 = $target_file AND a.rank - 2 = $target_rank) OR919 (a.file - 1 = $target_file AND a.rank - 2 = $target_rank) OR920 (a.file - 2 = $target_file AND a.rank - 1 = $target_rank) OR921 (a.file - 2 = $target_file AND a.rank + 1 = $target_rank) OR922 (a.file - 1 = $target_file AND a.rank + 2 = $target_rank)923 )924925 UNION ALL926927 SELECT 1928 FROM attackers a929 WHERE a.kind = 'king'930 AND abs(a.file - $target_file) <= 1931 AND abs(a.rank - $target_rank) <= 1932933 UNION ALL934935 SELECT 1936 FROM attackers a937 WHERE a.kind = 'pawn'938 AND (939 (a.color = 'white' AND (940 (a.file + 1 = $target_file AND a.rank + 1 = $target_rank) OR941 (a.file - 1 = $target_file AND a.rank + 1 = $target_rank)942 )) OR943 (a.color = 'black' AND (944 (a.file + 1 = $target_file AND a.rank - 1 = $target_rank) OR945 (a.file - 1 = $target_file AND a.rank - 1 = $target_rank)946 ))947 )948949 UNION ALL950951 SELECT 1952 FROM ray953 WHERE ray.file = $target_file954 AND ray.rank = $target_rank955)956SELECT CASE WHEN EXISTS(SELECT 1 FROM hits) THEN 1 ELSE 0 END;957SQL958}959960square_attacked_by() {961 local game_id=$1 attacker_color=$2 target_file=$3 target_rank=$4962 dbq "$(attack_query_for_square "$game_id" "$attacker_color" "$target_file" "$target_rank")"963}964965is_in_check() {966 local game_id=$1 color=$2 attacker king_square king_file king_rank967 attacker=$(opposite_color "$color")968 king_square=$(dbqt "SELECT file, rank FROM pieces WHERE game_id = $game_id AND color = '$color' AND kind = 'king' LIMIT 1;")969 if [[ -z "$king_square" ]]; then970 printf '1'971 return972 fi973 IFS=$'\t' read -r king_file king_rank <<EOF974$king_square975EOF976 square_attacked_by "$game_id" "$attacker" "$king_file" "$king_rank"977}978979simulate_move_batch() {980 local game_id=$1981 local piece_id=$2982 local src_file=$3983 local src_rank=$4984 local color=$5985 local kind=$6986 local dest_file=$7987 local dest_rank=$8988 local special=$9989 local capture_rank=$src_rank990 local promoted_symbol991992 promoted_symbol=$(piece_symbol "$color" "queen")993994 case "$special" in995 normal|capture)996 cat <<SQL997DELETE FROM pieces WHERE game_id = $game_id AND file = $dest_file AND rank = $dest_rank;998UPDATE pieces999SET file = $dest_file, rank = $dest_rank, has_moved = 11000WHERE id = $piece_id;1001SQL1002 ;;1003 promotion|promotion_capture)1004 cat <<SQL1005DELETE FROM pieces WHERE game_id = $game_id AND file = $dest_file AND rank = $dest_rank;1006UPDATE pieces1007SET file = $dest_file,1008 rank = $dest_rank,1009 has_moved = 1,1010 kind = 'queen',1011 piece = '$promoted_symbol'1012WHERE id = $piece_id;1013SQL1014 ;;1015 en_passant)1016 if [[ "$color" == "white" ]]; then1017 capture_rank=$((dest_rank - 1))1018 else1019 capture_rank=$((dest_rank + 1))1020 fi1021 cat <<SQL1022DELETE FROM pieces WHERE game_id = $game_id AND file = $dest_file AND rank = $capture_rank;1023UPDATE pieces1024SET file = $dest_file, rank = $dest_rank, has_moved = 11025WHERE id = $piece_id;1026SQL1027 ;;1028 castle_kingside)1029 cat <<SQL1030UPDATE pieces1031SET file = 7, rank = $src_rank, has_moved = 11032WHERE id = $piece_id;1033UPDATE pieces1034SET file = 6, rank = $src_rank, has_moved = 11035WHERE game_id = $game_id AND color = '$color' AND kind = 'rook' AND file = 8 AND rank = $src_rank;1036SQL1037 ;;1038 castle_queenside)1039 cat <<SQL1040UPDATE pieces1041SET file = 3, rank = $src_rank, has_moved = 11042WHERE id = $piece_id;1043UPDATE pieces1044SET file = 4, rank = $src_rank, has_moved = 11045WHERE game_id = $game_id AND color = '$color' AND kind = 'rook' AND file = 1 AND rank = $src_rank;1046SQL1047 ;;1048 *)1049 return 11050 ;;1051 esac1052}10531054is_move_safe() {1055 local game_id=$1 piece_id=$2 src_file=$3 src_rank=$4 color=$5 kind=$6 dest_file=$7 dest_rank=$8 special=$91056 local opponent1057 opponent=$(opposite_color "$color")1058 dbq "$(cat <<SQL1059BEGIN;1060$(simulate_move_batch "$game_id" "$piece_id" "$src_file" "$src_rank" "$color" "$kind" "$dest_file" "$dest_rank" "$special")1061WITH king_square AS (1062 SELECT file, rank1063 FROM pieces1064 WHERE game_id = $game_id1065 AND color = '$color'1066 AND kind = 'king'1067 LIMIT 11068),1069occupied AS (1070 SELECT file, rank, color, kind1071 FROM pieces1072 WHERE game_id = $game_id1073),1074attackers AS (1075 SELECT id, file, rank, color, kind1076 FROM pieces1077 WHERE game_id = $game_id1078 AND color = '$opponent'1079),1080dirs(df, dr) AS (1081 VALUES1082 (1, 0), (-1, 0), (0, 1), (0, -1),1083 (1, 1), (1, -1), (-1, 1), (-1, -1)1084),1085ray(id, kind, file, rank, df, dr) AS (1086 SELECT a.id, a.kind, a.file + d.df, a.rank + d.dr, d.df, d.dr1087 FROM attackers a1088 JOIN dirs d1089 ON (1090 (a.kind = 'bishop' AND abs(d.df) = 1 AND abs(d.dr) = 1) OR1091 (a.kind = 'rook' AND ((abs(d.df) = 1 AND d.dr = 0) OR (d.df = 0 AND abs(d.dr) = 1))) OR1092 (a.kind = 'queen')1093 )1094 WHERE a.kind IN ('bishop', 'rook', 'queen')1095 AND a.file + d.df BETWEEN 1 AND 81096 AND a.rank + d.dr BETWEEN 1 AND 810971098 UNION ALL10991100 SELECT id, kind, file + df, rank + dr, df, dr1101 FROM ray1102 WHERE NOT EXISTS (1103 SELECT 11104 FROM occupied o1105 WHERE o.file = ray.file1106 AND o.rank = ray.rank1107 )1108 AND file + df BETWEEN 1 AND 81109 AND rank + dr BETWEEN 1 AND 81110),1111hits AS (1112 SELECT 11113 FROM attackers a, king_square k1114 WHERE a.kind = 'knight'1115 AND (1116 (a.file + 1 = k.file AND a.rank + 2 = k.rank) OR1117 (a.file + 2 = k.file AND a.rank + 1 = k.rank) OR1118 (a.file + 2 = k.file AND a.rank - 1 = k.rank) OR1119 (a.file + 1 = k.file AND a.rank - 2 = k.rank) OR1120 (a.file - 1 = k.file AND a.rank - 2 = k.rank) OR1121 (a.file - 2 = k.file AND a.rank - 1 = k.rank) OR1122 (a.file - 2 = k.file AND a.rank + 1 = k.rank) OR1123 (a.file - 1 = k.file AND a.rank + 2 = k.rank)1124 )11251126 UNION ALL11271128 SELECT 11129 FROM attackers a, king_square k1130 WHERE a.kind = 'king'1131 AND abs(a.file - k.file) <= 11132 AND abs(a.rank - k.rank) <= 111331134 UNION ALL11351136 SELECT 11137 FROM attackers a, king_square k1138 WHERE a.kind = 'pawn'1139 AND (1140 (a.color = 'white' AND (1141 (a.file + 1 = k.file AND a.rank + 1 = k.rank) OR1142 (a.file - 1 = k.file AND a.rank + 1 = k.rank)1143 )) OR1144 (a.color = 'black' AND (1145 (a.file + 1 = k.file AND a.rank - 1 = k.rank) OR1146 (a.file - 1 = k.file AND a.rank - 1 = k.rank)1147 ))1148 )11491150 UNION ALL11511152 SELECT 11153 FROM ray, king_square k1154 WHERE ray.file = k.file1155 AND ray.rank = k.rank1156)1157SELECT CASE WHEN EXISTS(SELECT 1 FROM hits) THEN 0 ELSE 1 END;1158ROLLBACK;1159SQL1160)"1161}11621163sliding_moves() {1164 local game_id=$1 src_file=$2 src_rank=$3 color=$4 directions=$51165 local dir token df dr file rank occupant1166 for dir in $directions; do1167 df=${dir%:*}1168 dr=${dir#*:}1169 file=$((src_file + df))1170 rank=$((src_rank + dr))1171 while is_on_board "$file" "$rank"; do1172 occupant=$(occupant_data "$game_id" "$file" "$rank")1173 if [[ -z "$occupant" ]]; then1174 printf '%s|%s|normal\n' "$file" "$rank"1175 else1176 IFS=$'\t' read -r _ target_color _ _ <<EOF1177$occupant1178EOF1179 if [[ "$target_color" != "$color" ]]; then1180 printf '%s|%s|capture\n' "$file" "$rank"1181 fi1182 break1183 fi1184 file=$((file + df))1185 rank=$((rank + dr))1186 done1187 done1188}11891190pseudo_moves_for_piece() {1191 local game_id=$1 piece_id=$21192 local piece_data src_file src_rank color kind has_moved1193 local dir start_rank last_rank next_rank two_rank file rank occupant target_color1194 local en_passant_data en_passant_file en_passant_rank11951196 piece_data=$(piece_info "$piece_id")1197 if [[ -z "$piece_data" ]]; then1198 return 01199 fi12001201 IFS=$'\t' read -r _ src_file src_rank color kind has_moved <<EOF1202$piece_data1203EOF12041205 en_passant_data=$(game_en_passant "$game_id")1206 IFS='|' read -r en_passant_file en_passant_rank <<EOF1207$en_passant_data1208EOF12091210 case "$kind" in1211 pawn)1212 if [[ "$color" == "white" ]]; then1213 dir=11214 start_rank=21215 last_rank=81216 else1217 dir=-11218 start_rank=71219 last_rank=11220 fi12211222 next_rank=$((src_rank + dir))1223 if is_on_board "$src_file" "$next_rank" && [[ -z "$(occupant_data "$game_id" "$src_file" "$next_rank")" ]]; then1224 if [[ "$next_rank" -eq "$last_rank" ]]; then1225 printf '%s|%s|promotion\n' "$src_file" "$next_rank"1226 else1227 printf '%s|%s|normal\n' "$src_file" "$next_rank"1228 fi1229 two_rank=$((src_rank + (2 * dir)))1230 if [[ "$src_rank" -eq "$start_rank" ]] && [[ -z "$(occupant_data "$game_id" "$src_file" "$two_rank")" ]]; then1231 printf '%s|%s|normal\n' "$src_file" "$two_rank"1232 fi1233 fi12341235 for file in $((src_file - 1)) $((src_file + 1)); do1236 rank=$((src_rank + dir))1237 if ! is_on_board "$file" "$rank"; then1238 continue1239 fi1240 occupant=$(occupant_data "$game_id" "$file" "$rank")1241 if [[ -n "$occupant" ]]; then1242 IFS=$'\t' read -r _ target_color _ _ <<EOF1243$occupant1244EOF1245 if [[ "$target_color" != "$color" ]]; then1246 if [[ "$rank" -eq "$last_rank" ]]; then1247 printf '%s|%s|promotion_capture\n' "$file" "$rank"1248 else1249 printf '%s|%s|capture\n' "$file" "$rank"1250 fi1251 fi1252 elif [[ "$file" -eq "$en_passant_file" && "$rank" -eq "$en_passant_rank" ]]; then1253 printf '%s|%s|en_passant\n' "$file" "$rank"1254 fi1255 done1256 ;;1257 knight)1258 for token in "1:2" "2:1" "2:-1" "1:-2" "-1:-2" "-2:-1" "-2:1" "-1:2"; do1259 file=$((src_file + ${token%:*}))1260 rank=$((src_rank + ${token#*:}))1261 if ! is_on_board "$file" "$rank"; then1262 continue1263 fi1264 occupant=$(occupant_data "$game_id" "$file" "$rank")1265 if [[ -z "$occupant" ]]; then1266 printf '%s|%s|normal\n' "$file" "$rank"1267 else1268 IFS=$'\t' read -r _ target_color _ _ <<EOF1269$occupant1270EOF1271 if [[ "$target_color" != "$color" ]]; then1272 printf '%s|%s|capture\n' "$file" "$rank"1273 fi1274 fi1275 done1276 ;;1277 bishop)1278 sliding_moves "$game_id" "$src_file" "$src_rank" "$color" "1:1 1:-1 -1:1 -1:-1"1279 ;;1280 rook)1281 sliding_moves "$game_id" "$src_file" "$src_rank" "$color" "1:0 -1:0 0:1 0:-1"1282 ;;1283 queen)1284 sliding_moves "$game_id" "$src_file" "$src_rank" "$color" "1:0 -1:0 0:1 0:-1 1:1 1:-1 -1:1 -1:-1"1285 ;;1286 king)1287 for token in "1:0" "1:1" "0:1" "-1:1" "-1:0" "-1:-1" "0:-1" "1:-1"; do1288 file=$((src_file + ${token%:*}))1289 rank=$((src_rank + ${token#*:}))1290 if ! is_on_board "$file" "$rank"; then1291 continue1292 fi1293 occupant=$(occupant_data "$game_id" "$file" "$rank")1294 if [[ -z "$occupant" ]]; then1295 printf '%s|%s|normal\n' "$file" "$rank"1296 else1297 IFS=$'\t' read -r _ target_color _ _ <<EOF1298$occupant1299EOF1300 if [[ "$target_color" != "$color" ]]; then1301 printf '%s|%s|capture\n' "$file" "$rank"1302 fi1303 fi1304 done1305 if [[ "$has_moved" -eq 0 ]] && [[ "$(is_in_check "$game_id" "$color")" -eq 0 ]]; then1306 local home_rank rook_data rook_moved enemy1307 home_rank=$src_rank1308 enemy=$(opposite_color "$color")13091310 rook_data=$(dbqt "SELECT id, has_moved FROM pieces WHERE game_id = $game_id AND color = '$color' AND kind = 'rook' AND file = 8 AND rank = $home_rank;")1311 if [[ -n "$rook_data" ]] && [[ -z "$(occupant_data "$game_id" 6 "$home_rank")" ]] && [[ -z "$(occupant_data "$game_id" 7 "$home_rank")" ]]; then1312 IFS=$'\t' read -r _ rook_moved <<EOF1313$rook_data1314EOF1315 if [[ "$rook_moved" -eq 0 ]] && [[ "$(square_attacked_by "$game_id" "$enemy" 6 "$home_rank")" -eq 0 ]] && [[ "$(square_attacked_by "$game_id" "$enemy" 7 "$home_rank")" -eq 0 ]]; then1316 printf '7|%s|castle_kingside\n' "$home_rank"1317 fi1318 fi13191320 rook_data=$(dbqt "SELECT id, has_moved FROM pieces WHERE game_id = $game_id AND color = '$color' AND kind = 'rook' AND file = 1 AND rank = $home_rank;")1321 if [[ -n "$rook_data" ]] && [[ -z "$(occupant_data "$game_id" 2 "$home_rank")" ]] && [[ -z "$(occupant_data "$game_id" 3 "$home_rank")" ]] && [[ -z "$(occupant_data "$game_id" 4 "$home_rank")" ]]; then1322 IFS=$'\t' read -r _ rook_moved <<EOF1323$rook_data1324EOF1325 if [[ "$rook_moved" -eq 0 ]] && [[ "$(square_attacked_by "$game_id" "$enemy" 4 "$home_rank")" -eq 0 ]] && [[ "$(square_attacked_by "$game_id" "$enemy" 3 "$home_rank")" -eq 0 ]]; then1326 printf '3|%s|castle_queenside\n' "$home_rank"1327 fi1328 fi1329 fi1330 ;;1331 esac1332}13331334legal_moves_for_piece() {1335 local game_id=$1 piece_id=$21336 local piece_data src_file src_rank color kind has_moved1337 local move_line dest_file dest_rank special1338 piece_data=$(piece_info "$piece_id")1339 [[ -n "$piece_data" ]] || return 01340 IFS=$'\t' read -r _ src_file src_rank color kind has_moved <<EOF1341$piece_data1342EOF1343 while IFS='|' read -r dest_file dest_rank special; do1344 [[ -n "$dest_file" ]] || continue1345 if [[ "$(is_move_safe "$game_id" "$piece_id" "$src_file" "$src_rank" "$color" "$kind" "$dest_file" "$dest_rank" "$special")" -eq 1 ]]; then1346 printf '%s|%s|%s\n' "$dest_file" "$dest_rank" "$special"1347 fi1348 done <<EOF1349$(pseudo_moves_for_piece "$game_id" "$piece_id")1350EOF1351}13521353list_source_options() {1354 local game_id=$1 turn pieces piece_row piece_id file rank color kind symbol moves move_count square1355 turn=$(game_turn "$game_id")1356 pieces=$(dbqt "SELECT id, file, rank, color, kind FROM pieces WHERE game_id = $game_id AND color = '$turn' ORDER BY rank, file;")1357 while IFS=$'\t' read -r piece_id file rank color kind; do1358 [[ -n "$piece_id" ]] || continue1359 moves=$(legal_moves_for_piece "$game_id" "$piece_id")1360 move_count=$(count_lines <<EOF1361$moves1362EOF1363)1364 if [[ "$move_count" -gt 0 ]]; then1365 square=$(coords_to_square "$file" "$rank")1366 symbol=$(piece_symbol "$color" "$kind")1367 printf '%s %s %s move(s)\n' "$square" "$symbol" "$move_count"1368 fi1369 done <<EOF1370$pieces1371EOF1372}13731374move_description() {1375 case "$1" in1376 normal) printf 'move' ;;1377 capture) printf 'capture' ;;1378 promotion) printf 'promote to queen' ;;1379 promotion_capture) printf 'capture + promote to queen' ;;1380 en_passant) printf 'en passant' ;;1381 castle_kingside) printf 'castle kingside' ;;1382 castle_queenside) printf 'castle queenside' ;;1383 *) printf 'move' ;;1384 esac1385}13861387list_destination_options() {1388 local game_id=$1 piece_id=$21389 local legal_moves dest_file dest_rank special1390 legal_moves=$(legal_moves_for_piece "$game_id" "$piece_id")1391 while IFS='|' read -r dest_file dest_rank special; do1392 [[ -n "$dest_file" ]] || continue1393 printf '%s %s\n' "$(coords_to_square "$dest_file" "$dest_rank")" "$(move_description "$special")"1394 done <<EOF1395$legal_moves1396EOF1397}13981399special_for_destination() {1400 local game_id=$1 piece_id=$2 target_square=$31401 local coords dest_file dest_rank special file rank1402 coords=$(square_to_coords "$target_square")1403 IFS='|' read -r file rank <<EOF1404$coords1405EOF1406 while IFS='|' read -r dest_file dest_rank special; do1407 [[ -n "$dest_file" ]] || continue1408 if [[ "$dest_file" -eq "$file" && "$dest_rank" -eq "$rank" ]]; then1409 printf '%s' "$special"1410 return 01411 fi1412 done <<EOF1413$(legal_moves_for_piece "$game_id" "$piece_id")1414EOF1415 return 11416}14171418render_board_text() {1419 local game_id=$1 rank line file piece_char piece_data1420 printf ' a b c d e f g h\n'1421 printf ' +-----------------+\n'1422 for rank in 8 7 6 5 4 3 2 1; do1423 line=''1424 for file in 1 2 3 4 5 6 7 8; do1425 piece_data=$(dbq "SELECT piece FROM pieces WHERE game_id = $game_id AND file = $file AND rank = $rank;")1426 if [[ -z "$piece_data" ]]; then1427 piece_char='.'1428 else1429 piece_char=$piece_data1430 fi1431 if [[ -z "$line" ]]; then1432 line=$piece_char1433 else1434 line="$line $piece_char"1435 fi1436 done1437 printf '%s | %s | %s\n' "$rank" "$line" "$rank"1438 done1439 printf ' +-----------------+\n'1440 printf ' a b c d e f g h\n'1441}14421443moves_text() {1444 local game_id=$11445 dbq "SELECT printf('%3d. %s', ply, notation) FROM moves WHERE game_id = $game_id ORDER BY ply;"1446}14471448show_moves() {1449 local game_id moves1450 game_id=$(current_game_id)1451 if [[ -z "$game_id" ]]; then1452 printf 'No active game.\n'1453 return 01454 fi1455 moves=$(moves_text "$game_id")1456 if [[ -z "$moves" ]]; then1457 moves='No moves yet.'1458 fi1459 show_in_pager "$moves"1460}14611462notation_for_move() {1463 local src_square=$1 dest_square=$2 special=$3 capture_symbol=$41464 case "$special" in1465 castle_kingside) printf 'O-O' ;;1466 castle_queenside) printf 'O-O-O' ;;1467 *)1468 if [[ -n "$capture_symbol" ]]; then1469 printf '%s x %s' "$src_square" "$dest_square"1470 else1471 printf '%s - %s' "$src_square" "$dest_square"1472 fi1473 if [[ "$special" == "en_passant" ]]; then1474 printf ' e.p.'1475 fi1476 if [[ "$special" == "promotion" || "$special" == "promotion_capture" ]]; then1477 printf ' =Q'1478 fi1479 ;;1480 esac1481}14821483apply_move() {1484 local game_id=$1 piece_id=$2 target_square=$31485 local piece_data src_file src_rank color kind has_moved1486 local coords dest_file dest_rank special capture_data capture_symbol next_turn1487 local src_square dest_square notation promotion_value en_passant_file en_passant_rank14881489 piece_data=$(piece_info "$piece_id")1490 IFS=$'\t' read -r _ src_file src_rank color kind has_moved <<EOF1491$piece_data1492EOF14931494 coords=$(square_to_coords "$target_square")1495 IFS='|' read -r dest_file dest_rank <<EOF1496$coords1497EOF14981499 special=$(special_for_destination "$game_id" "$piece_id" "$target_square")1500 if [[ -z "$special" ]]; then1501 return 11502 fi15031504 capture_symbol=''1505 if [[ "$special" == "capture" || "$special" == "promotion_capture" ]]; then1506 capture_data=$(occupant_data "$game_id" "$dest_file" "$dest_rank")1507 if [[ -n "$capture_data" ]]; then1508 IFS=$'\t' read -r _ _ _ capture_symbol <<EOF1509$capture_data1510EOF1511 fi1512 elif [[ "$special" == "en_passant" ]]; then1513 if [[ "$color" == "white" ]]; then1514 capture_data=$(occupant_data "$game_id" "$dest_file" $((dest_rank - 1)))1515 else1516 capture_data=$(occupant_data "$game_id" "$dest_file" $((dest_rank + 1)))1517 fi1518 if [[ -n "$capture_data" ]]; then1519 IFS=$'\t' read -r _ _ _ capture_symbol <<EOF1520$capture_data1521EOF1522 fi1523 fi15241525 src_square=$(coords_to_square "$src_file" "$src_rank")1526 dest_square=$(coords_to_square "$dest_file" "$dest_rank")1527 notation=$(notation_for_move "$src_square" "$dest_square" "$special" "$capture_symbol")1528 promotion_value='NULL'1529 if [[ "$special" == "promotion" || "$special" == "promotion_capture" ]]; then1530 promotion_value="'queen'"1531 fi15321533 en_passant_file='NULL'1534 en_passant_rank='NULL'1535 if [[ "$kind" == "pawn" && $((dest_rank - src_rank)) -eq 2 ]]; then1536 en_passant_file=$src_file1537 en_passant_rank=$((src_rank + 1))1538 elif [[ "$kind" == "pawn" && $((dest_rank - src_rank)) -eq -2 ]]; then1539 en_passant_file=$src_file1540 en_passant_rank=$((src_rank - 1))1541 fi15421543 next_turn=$(opposite_color "$color")15441545 if [[ "$DB_PATH" == file:* ]]; then1546 sqlite3 "$DB_PATH" <<SQL1547PRAGMA foreign_keys = ON;1548BEGIN;1549$(simulate_move_batch "$game_id" "$piece_id" "$src_file" "$src_rank" "$color" "$kind" "$dest_file" "$dest_rank" "$special")1550UPDATE games1551SET turn = '$next_turn',1552 status = 'active',1553 winner = NULL,1554 note = NULL,1555 en_passant_file = $en_passant_file,1556 en_passant_rank = $en_passant_rank,1557 updated_at = CURRENT_TIMESTAMP1558WHERE id = $game_id;1559INSERT INTO moves(game_id, ply, from_sq, to_sq, piece, capture, promotion, notation, created_at)1560SELECT1561 $game_id,1562 COALESCE(MAX(ply), 0) + 1,1563 '$src_square',1564 '$dest_square',1565 '$(piece_symbol "$color" "$kind")',1566 $(if [[ -n "$capture_symbol" ]]; then printf "'%s'" "$(sql_quote "$capture_symbol")"; else printf 'NULL'; fi),1567 $promotion_value,1568 '$(sql_quote "$notation")',1569 CURRENT_TIMESTAMP1570FROM moves1571WHERE game_id = $game_id;1572COMMIT;1573SQL1574 return1575 fi15761577 sqlite3 "$DB_PATH" <<SQL1578PRAGMA foreign_keys = ON;1579BEGIN;1580$(simulate_move_batch "$game_id" "$piece_id" "$src_file" "$src_rank" "$color" "$kind" "$dest_file" "$dest_rank" "$special")1581UPDATE games1582SET turn = '$next_turn',1583 status = 'active',1584 winner = NULL,1585 note = NULL,1586 en_passant_file = $en_passant_file,1587 en_passant_rank = $en_passant_rank,1588 updated_at = CURRENT_TIMESTAMP1589WHERE id = $game_id;1590INSERT INTO moves(game_id, ply, from_sq, to_sq, piece, capture, promotion, notation, created_at)1591SELECT1592 $game_id,1593 COALESCE(MAX(ply), 0) + 1,1594 '$src_square',1595 '$dest_square',1596 '$(piece_symbol "$color" "$kind")',1597 $(if [[ -n "$capture_symbol" ]]; then printf "'%s'" "$(sql_quote "$capture_symbol")"; else printf 'NULL'; fi),1598 $promotion_value,1599 '$(sql_quote "$notation")',1600 CURRENT_TIMESTAMP1601FROM moves1602WHERE game_id = $game_id;1603COMMIT;1604SQL1605}16061607update_game_state() {1608 local game_id=$1 turn sources winner1609 turn=$(game_turn "$game_id")1610 sources=$(list_source_options "$game_id")1611 if [[ -n "$sources" ]]; then1612 if [[ "$(is_in_check "$game_id" "$turn")" -eq 1 ]]; then1613 dbq "UPDATE games SET note = '$turn is in check', updated_at = CURRENT_TIMESTAMP WHERE id = $game_id;"1614 else1615 dbq "UPDATE games SET note = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $game_id;"1616 fi1617 return 01618 fi16191620 if [[ "$(is_in_check "$game_id" "$turn")" -eq 1 ]]; then1621 winner=$(opposite_color "$turn")1622 dbq "UPDATE games1623 SET status = 'checkmate',1624 winner = '$winner',1625 note = '$winner wins by checkmate',1626 updated_at = CURRENT_TIMESTAMP1627 WHERE id = $game_id;"1628 else1629 dbq "UPDATE games1630 SET status = 'stalemate',1631 winner = NULL,1632 note = 'Draw by stalemate',1633 updated_at = CURRENT_TIMESTAMP1634 WHERE id = $game_id;"1635 fi1636}16371638resign_game() {1639 local game_id=$1 turn winner1640 turn=$(game_turn "$game_id")1641 winner=$(opposite_color "$turn")1642 dbq "UPDATE games1643 SET status = 'resigned',1644 winner = '$winner',1645 note = '$turn resigned',1646 updated_at = CURRENT_TIMESTAMP1647 WHERE id = $game_id;"1648}16491650status_line() {1651 local game_id=${1:-}1652 local identity_name turn status note turn_name mode1653 identity_name=$(selected_identity_name)1654 if [[ -z "$identity_name" ]]; then1655 identity_name='(none)'1656 fi16571658 if [[ -z "$game_id" ]]; then1659 printf 'Identity: %s\nTurn: none\nMode: writable setup\nStatus: no active game\n' "$identity_name"1660 return 01661 fi16621663 turn=$(game_turn "$game_id")1664 status=$(game_status "$game_id")1665 note=$(game_note "$game_id")1666 turn_name=$(turn_identity_name "$game_id")1667 if [[ -z "$turn_name" ]]; then1668 turn_name='unassigned'1669 fi16701671 if [[ "$(can_selected_identity_write "$game_id")" -eq 1 ]]; then1672 mode='writable'1673 elif [[ "$turn_name" == 'unassigned' ]]; then1674 mode='read-only (turn owner unassigned)'1675 else1676 mode="read-only waiting for $turn_name"1677 fi16781679 printf 'Identity: %s\nTurn: %s (%s)\nMode: %s\nStatus: %s\n' "$identity_name" "$turn" "$turn_name" "$mode" "$status"1680 if [[ -n "$note" ]]; then1681 printf 'Note: %s\n' "$note"1682 fi1683}16841685interactive_game() {1686 local game_id board screen source_options source_choice source_square piece_coords piece_id1687 local destination_options destination_choice target_square action status1688 local piece_file piece_rank allow_switch_identity16891690 prepare_interactive_stdin || return 11691 ensure_selected_identity_for_interactive || return 016921693 while :; do1694 game_id=$(current_game_id)1695 allow_switch_identity=11696 if [[ -n "$game_id" && "$(game_is_local "$game_id")" -ne 1 ]]; then1697 allow_switch_identity=01698 fi16991700 if [[ -n "$game_id" ]]; then1701 status=$(game_status "$game_id")1702 board=$(render_board_text "$game_id")1703 screen=$(printf '%s\n\n%s\n' "$(status_line "$game_id")" "$board")1704 else1705 status='none'1706 screen=$(printf '%s\n' "$(status_line)")1707 fi17081709 clear_screen1710 if has_gum; then1711 gum style --bold --foreground "$GUM_THEME_ACCENT_TEXT" --background "$GUM_THEME_ACCENT" --padding "0 1" --margin "1 0" "$(printf '%s\n' "$APP_TITLE")"1712 gum style --foreground "$GUM_THEME_TEXT_FG" --border normal --border-foreground "$GUM_THEME_BORDER" --padding "1 2" "$screen"1713 else1714 printf '%s\n\n%s\n' "$APP_TITLE" "$screen"1715 fi17161717 if [[ -z "$game_id" ]]; then1718 action=$(pick_option "No active game." "new game" "new local game" "switch identity" "quit") || return 01719 case "$action" in1720 "new game")1721 if confirm_action "Start a new game?"; then1722 start_new_game_flow "multiplayer"1723 fi1724 ;;1725 "new local game")1726 if confirm_action "Start a new local game?"; then1727 start_new_game_flow "local"1728 fi1729 ;;1730 "switch identity")1731 switch_identity_interactively || true1732 ;;1733 quit)1734 return 01735 ;;1736 esac1737 continue1738 fi17391740 if [[ "$status" != "active" ]]; then1741 if require_write_access "$game_id"; then1742 if [[ "$allow_switch_identity" -eq 1 ]]; then1743 action=$(pick_option "Game over." "new game" "new local game" "show moves" "switch identity" "quit") || return 01744 else1745 action=$(pick_option "Game over." "new game" "new local game" "show moves" "quit") || return 01746 fi1747 else1748 if [[ "$allow_switch_identity" -eq 1 ]]; then1749 action=$(pick_option "Read-only mode." "refresh" "show moves" "switch identity" "quit") || return 01750 else1751 action=$(pick_option "Read-only mode." "refresh" "show moves" "quit") || return 01752 fi1753 fi1754 case "$action" in1755 "new game")1756 if confirm_action "Start a new game?"; then1757 start_new_game_flow "multiplayer"1758 fi1759 ;;1760 "new local game")1761 if confirm_action "Start a new local game?"; then1762 start_new_game_flow "local"1763 fi1764 ;;1765 "show moves")1766 show_moves1767 ;;1768 "switch identity")1769 switch_identity_interactively || true1770 ;;1771 refresh)1772 ;;1773 quit)1774 return 01775 ;;1776 esac1777 continue1778 fi17791780 if ! require_write_access "$game_id"; then1781 if [[ "$allow_switch_identity" -eq 1 ]]; then1782 action=$(pick_option "Read-only mode." "refresh" "show moves" "switch identity" "quit") || return 01783 else1784 action=$(pick_option "Read-only mode." "refresh" "show moves" "quit") || return 01785 fi1786 case "$action" in1787 refresh)1788 ;;1789 "show moves")1790 show_moves1791 ;;1792 "switch identity")1793 switch_identity_interactively || true1794 ;;1795 quit)1796 return 01797 ;;1798 esac1799 continue1800 fi18011802 if [[ "$allow_switch_identity" -eq 1 ]]; then1803 action=$(pick_option "Choose an action" "move" "show moves" "switch identity" "resign" "new game" "new local game" "quit") || return 01804 else1805 action=$(pick_option "Choose an action" "move" "show moves" "resign" "new game" "new local game" "quit") || return 01806 fi1807 case "$action" in1808 move)1809 source_options=()1810 while IFS= read -r line; do1811 [[ -n "$line" ]] || continue1812 source_options[${#source_options[@]}]=$line1813 done <<EOF1814$(list_source_options "$game_id")1815EOF1816 if [[ "${#source_options[@]}" -eq 0 ]]; then1817 continue1818 fi18191820 source_choice=$(pick_option "Select a piece" "${source_options[@]}") || continue1821 source_square=${source_choice%% *}1822 piece_coords=$(square_to_coords "$source_square")1823 IFS='|' read -r piece_file piece_rank <<EOF1824$piece_coords1825EOF1826 piece_id=$(piece_id_at "$game_id" "$piece_file" "$piece_rank")18271828 destination_options=()1829 while IFS= read -r line; do1830 [[ -n "$line" ]] || continue1831 destination_options[${#destination_options[@]}]=$line1832 done <<EOF1833$(list_destination_options "$game_id" "$piece_id")1834EOF1835 if [[ "${#destination_options[@]}" -eq 0 ]]; then1836 continue1837 fi18381839 destination_choice=$(pick_option "Select a destination for $source_square" "${destination_options[@]}") || continue1840 target_square=${destination_choice%% *}1841 apply_move "$game_id" "$piece_id" "$target_square"1842 update_game_state "$game_id"1843 ;;1844 "show moves")1845 show_moves1846 ;;1847 "switch identity")1848 switch_identity_interactively || true1849 ;;1850 resign)1851 if confirm_action "Resign the current game?"; then1852 resign_game "$game_id"1853 fi1854 ;;1855 "new game")1856 if confirm_action "Start a new game?"; then1857 start_new_game_flow "multiplayer"1858 fi1859 ;;1860 "new local game")1861 if confirm_action "Start a new local game?"; then1862 start_new_game_flow "local"1863 fi1864 ;;1865 quit)1866 return 01867 ;;1868 esac1869 done1870}18711872main() {1873 local action=''1874 local identity_arg=''1875 local selected_name=''1876 local config_identity=''1877 local board_uri=''18781879 require_sqlite18801881 while [[ "$#" -gt 0 ]]; do1882 case "$1" in1883 --identity)1884 shift1885 if [[ "$#" -eq 0 ]]; then1886 printf '%s\n' '--identity requires a name.' >&21887 exit 11888 fi1889 identity_arg=$11890 ;;1891 --new|--local|--moves|--whoami|--help|-h)1892 if [[ -n "$action" ]]; then1893 printf 'Only one command may be used at a time.\n' >&21894 exit 11895 fi1896 action=$11897 ;;1898 *)1899 printf 'Unknown option: %s\n\n' "$1" >&21900 usage >&21901 exit 11902 ;;1903 esac1904 shift1905 done19061907 case "$action" in1908 --help|-h)1909 usage1910 return 01911 ;;1912 esac19131914 if [[ "$action" != "--help" && "$action" != "-h" ]]; then1915 board_uri=$(stdin_board_uri)1916 if [[ -n "$board_uri" ]]; then1917 DB_PATH=$board_uri1918 fi1919 fi19201921 init_db19221923 config_identity=$(config_identity_name)1924 selected_name=$(selected_identity_name)19251926 if [[ -n "$identity_arg" ]]; then1927 set_selected_identity "$identity_arg" >/dev/null1928 elif [[ -n "$config_identity" ]]; then1929 set_selected_identity "$config_identity" >/dev/null1930 fi19311932 case "$action" in1933 --help|-h)1934 usage1935 ;;1936 --moves)1937 show_moves1938 ;;1939 --whoami)1940 if [[ -n "$(selected_identity_name)" ]]; then1941 selected_identity_name1942 else1943 printf 'No identity selected.\n'1944 fi1945 ;;1946 --new)1947 prepare_interactive_stdin || exit 11948 ensure_selected_identity_for_interactive || exit 01949 if confirm_action "Start a new game?"; then1950 start_new_game_flow "multiplayer"1951 fi1952 interactive_game1953 ;;1954 --local)1955 prepare_interactive_stdin || exit 11956 ensure_selected_identity_for_interactive || exit 01957 if confirm_action "Start a new local game?"; then1958 start_new_game_flow "local"1959 fi1960 interactive_game1961 ;;1962 "")1963 interactive_game1964 ;;1965 *)1966 printf 'Unknown option: %s\n\n' "$action" >&21967 usage >&21968 exit 11969 ;;1970 esac1971}19721973if [[ "${BASH_SOURCE[0]-$0}" == "$0" ]]; then1974 main "$@"1975fi