- commit
- 47eccbc190abca038664f8d5bbb528762b4d8c99
- author
- Dami <dami.etoile@gmail.com>
| A | Makefile | 45 | + |
| A | README.md | 199 | +++ |
| A | select-mate.sh | 1857 | ++++++++++++++++++++++++++++++ |
@@ -0,0 +1,45 @@
1+SHELL := /bin/bash2+3+TEST_ROOT ?= /tmp/select-mate-live4+TEST_BOARD_PATH := $(TEST_ROOT)/shared/game.db5+TEST_BOARD_URI := file:$(TEST_BOARD_PATH)?mode=rwc6+TEST_CONFIG_ROOT := $(TEST_ROOT)/config7+TEST_ALICE_CONFIG := $(TEST_CONFIG_ROOT)/alice8+TEST_BOB_CONFIG := $(TEST_CONFIG_ROOT)/bob9+10+11+12+help:13+ @printf '%s\n' \14+ 'make alice Open the shared live-test board as alice.' \15+ 'make bob Open the shared live-test board as bob.' \16+ 'make test-prepare Create the shared board/config layout.' \17+ 'make test-reset Remove the shared live-test board and configs.' \18+ 'make test-board-uri Print the shared board URI.'19+20+test-prepare:21+ @mkdir -p "$(dir $(TEST_BOARD_PATH))" "$(TEST_ALICE_CONFIG)" "$(TEST_BOB_CONFIG)"22+ @printf 'identity=alice\n' > "$(TEST_ALICE_CONFIG)/select-mate.conf"23+ @printf 'identity=bob\n' > "$(TEST_BOB_CONFIG)/select-mate.conf"24+ @printf '%s\n' "$(TEST_BOARD_URI)" | ./select-mate.sh --whoami >/dev/null25+ @printf 'Shared board: %s\n' "$(TEST_BOARD_URI)"26+ @printf 'Alice config: %s\n' "$(TEST_ALICE_CONFIG)/select-mate.conf"27+ @printf 'Bob config: %s\n' "$(TEST_BOB_CONFIG)/select-mate.conf"28+ @printf '%s\n' 'Open one terminal with `make alice` and another with `make bob`.'29+30+test-reset:31+ @rm -rf "$(TEST_ROOT)"32+ @printf 'Removed %s\n' "$(TEST_ROOT)"33+34+test-board-uri:35+ @printf '%s\n' "$(TEST_BOARD_URI)"36+37+alice: test-prepare38+ @printf '%s\n' "$(TEST_BOARD_URI)" | XDG_CONFIG_HOME="$(TEST_ALICE_CONFIG)" ./select-mate.sh --identity alice39+40+bob: test-prepare41+ @printf '%s\n' "$(TEST_BOARD_URI)" | XDG_CONFIG_HOME="$(TEST_BOB_CONFIG)" ./select-mate.sh --identity bob42+43+test-alice: alice44+45+test-bob: bob@@ -0,0 +1,199 @@
1+# select-mate.sh2+3+`select-mate.sh` is a terminal chess game backed by SQLite. It uses [`gum`](https://github.com/charmbracelet/gum) for the interactive picker UI, stores game state on disk, supports named identities, and can point at a board/database URI from stdin. The fun part is that two players can open the same `game.db` from different terminals, or even different machines, as long as they both see the same shared filesystem path.4+5+## Requirements6+7+- `bash`8+- `sqlite3`9+- `gum`10+11+On macOS with Homebrew:12+13+```bash14+brew install sqlite gum15+```16+17+## Quick Start18+19+Make sure the script is executable:20+21+```bash22+chmod +x ./select-mate.sh23+```24+25+Start or resume the active game:26+27+```bash28+./select-mate.sh29+```30+31+Set a default identity in standard XDG config:32+33+```bash34+mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}"35+printf 'identity=alice\n' > "${XDG_CONFIG_HOME:-$HOME/.config}/select-mate.conf"36+```37+38+Print the currently selected identity:39+40+```bash41+./select-mate.sh --whoami42+```43+44+Start a fresh multiplayer game:45+46+```bash47+./select-mate.sh --new48+```49+50+Start a fresh local game where one identity controls both sides:51+52+```bash53+./select-mate.sh --local54+```55+56+Show the move history for the active game:57+58+```bash59+./select-mate.sh --moves60+```61+62+Target a specific board/database by piping a URI on stdin:63+64+```bash65+printf 'file:/tmp/select-mate.db?mode=rwc\n' | ./select-mate.sh --whoami66+```67+68+Show CLI help:69+70+```bash71+./select-mate.sh --help72+```73+74+For a shared live-test setup with two terminals:75+76+```bash77+make alice78+make bob79+```80+81+## Identity Selection82+83+Identity comes from the first available source in this order:84+85+- `--identity NAME`86+- `${XDG_CONFIG_HOME:-$HOME/.config}/select-mate.conf`87+- the selected identity already remembered in the active board database88+89+When an identity is provided explicitly or via config, it is used for the current client session without rewriting the board's remembered identity. If you use the in-app identity picker, that choice is remembered in the board database and reused on the next launch when no `--identity` or config override is supplied.90+91+Config format is a single key/value entry:92+93+```bash94+identity=alice95+```96+97+Game state is stored here by default:98+99+```bash100+${XDG_STATE_HOME:-$HOME/.local/state}/select-mate/game.db101+```102+103+If you want an isolated game state for testing, point `XDG_STATE_HOME` somewhere else:104+105+```bash106+XDG_STATE_HOME="$(mktemp -d)" ./select-mate.sh107+```108+109+## Multiplayer110+111+Multiplayer is board/database based: Alice and Bob can both point at the same SQLite `game.db` and watch turn ownership switch live.112+113+The main flow to show off is a shared database on a network-mounted folder. This is still SQLite, not a separate server process, so both machines need the same shared filesystem and working SQLite file locks.114+115+Example shared location:116+117+```bash118+/Volumes/chess-share/select-mate/game.db119+```120+121+Alice on one machine:122+123+```bash124+mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}"125+printf 'identity=alice\n' > "${XDG_CONFIG_HOME:-$HOME/.config}/select-mate.conf"126+printf 'file:/Volumes/chess-share/select-mate/game.db?mode=rwc\n' | ./select-mate.sh --new127+```128+129+Bob on another machine with the same network share mounted:130+131+```bash132+mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}"133+printf 'identity=bob\n' > "${XDG_CONFIG_HOME:-$HOME/.config}/select-mate.conf"134+printf 'file:/Volumes/chess-share/select-mate/game.db?mode=rwc\n' | ./select-mate.sh135+```136+137+What that looks like in practice:138+139+- Alice starts a new multiplayer game and assigns `alice` / `bob`.140+- Bob opens the same shared `game.db` and immediately lands in the same match.141+- The side to move gets the writable action list.142+- The other player sees a read-only waiting screen, but can refresh and inspect moves.143+- After each move, the other terminal can refresh and take over from the same shared board state.144+- You can keep both terminals open the whole time, which makes the turn handoff very visible.145+146+If you want to demo the same idea on one machine first, the repo includes a `Makefile` that pins both players to the same board URI under `/tmp/select-mate-live`:147+148+```bash149+make alice150+make bob151+```152+153+Recommended live-test flow:154+155+- Run `make alice` in one terminal.156+- Run `make bob` in a second terminal.157+- Start a new multiplayer game from one side and assign `alice` / `bob`.158+- Keep both terminals open to see writable vs read-only mode change live after each move.159+160+Helper targets:161+162+- `make test-prepare` creates the shared board/config layout.163+- `make test-board-uri` prints the shared board URI.164+- `make test-reset` removes the shared `/tmp/select-mate-live` test area.165+166+Example using a shared local folder:167+168+```bash169+mkdir -p /tmp/select-mate-shared170+export XDG_STATE_HOME=/tmp/select-mate-shared171+./select-mate.sh172+```173+174+On a second terminal or second machine with access to the same shared directory, choose another identity:175+176+```bash177+export XDG_STATE_HOME=/tmp/select-mate-shared178+./select-mate.sh --identity bob179+```180+181+Recommended flow:182+183+- One player starts the game with `./select-mate.sh --new` and assigns white/black identities.184+- Both players open the same board path.185+- Only the identity assigned to the side to move gets write actions.186+- The other identity sees a read-only waiting view and can still refresh and inspect moves.187+- Choose the player identity up front with `--identity NAME` before joining a multiplayer board.188+189+If you prefer a URI-based board target instead of `XDG_STATE_HOME`, pipe the board URI on stdin:190+191+```bash192+printf 'file:/tmp/select-mate-shared/game.db?mode=rwc\n' | ./select-mate.sh --identity alice193+printf 'file:/tmp/select-mate-shared/game.db?mode=rwc\n' | ./select-mate.sh --identity bob194+```195+196+Current limitations:197+198+- Avoid trying to make moves at the same time from two terminals.199+- The stdin contract is one raw board URI line, not JSON or key/value input.@@ -0,0 +1,1857 @@
1+2+3+set -euo pipefail4+5+APP_NAME="select-mate"6+APP_TITLE="select-mate.sh"7+STATE_ROOT="${XDG_STATE_HOME:-$HOME/.local/state}"8+APP_STATE_DIR="$STATE_ROOT/$APP_NAME"9+DEFAULT_DB_PATH="$APP_STATE_DIR/game.db"10+DB_PATH="$DEFAULT_DB_PATH"11+CONFIG_PATH="${XDG_CONFIG_HOME:-$HOME/.config}/select-mate.conf"12+SESSION_IDENTITY_ID=""13+SESSION_IDENTITY_NAME=""14+15+usage() {16+ cat <<'EOF'17+Usage: select-mate.sh [--identity NAME] [--new | --local | --moves | --whoami | --help]18+19+Commands:20+ --identity NAME Select or create an identity before running the command.21+ --new Start a fresh multiplayer game after confirmation.22+ --local Start a fresh local-only game using the selected identity for both sides.23+ --moves Show move history for the active game and exit.24+ --whoami Print the currently selected identity and exit.25+ --help Show this help text and exit.26+27+Startup defaults:28+ - Identity config: ${XDG_CONFIG_HOME:-$HOME/.config}/select-mate.conf29+ Example: identity=alice30+ - Board URI from stdin when stdin is not a TTY31+ Example: printf 'file:/tmp/select-mate.db?mode=rwc\n' | ./select-mate.sh --whoami32+EOF33+}34+35+sql_quote() {36+ printf "%s" "$1" | sed "s/'/''/g"37+}38+39+trim_string() {40+ printf '%s' "$1" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'41+}42+43+dbq() {44+ sqlite3 -batch -noheader "$DB_PATH" "$1"45+}46+47+dbqt() {48+ sqlite3 -batch -noheader -tabs "$DB_PATH" "$1"49+}50+51+require_sqlite() {52+ if ! command -v sqlite3 >/dev/null 2>&1; then53+ printf 'sqlite3 is required.\n' >&254+ exit 155+ fi56+}57+58+require_gum() {59+ if ! command -v gum >/dev/null 2>&1; then60+ printf 'gum is required to play interactively. Install it from https://github.com/charmbracelet/gum.\n' >&261+ exit 162+ fi63+}64+65+has_full_tty() {66+ [[ -t 0 && -t 1 && "${TERM:-}" != "dumb" ]]67+}68+69+clear_screen() {70+ if has_full_tty && command -v clear >/dev/null 2>&1; then71+ clear || true72+ fi73+}74+75+show_in_pager() {76+ local content=$177+78+ # `less` behaves better than `gum pager` in tight terminals and falls back79+ # cleanly when the command is used from pipes or automation.80+ if has_full_tty && command -v less >/dev/null 2>&1; then81+ less -RFX <<<"$content"82+ return 083+ fi84+85+ printf '%s\n' "$content"86+}87+88+db_parent_dir() {89+ local target=$190+ local path_without_query91+92+ if [[ "$target" == file:* ]]; then93+ path_without_query=${target#file:}94+ path_without_query=${path_without_query%%\?*}95+ if [[ -z "$path_without_query" || "$path_without_query" == ':memory:' ]]; then96+ return 197+ fi98+ dirname "$path_without_query"99+ return 0100+ fi101+102+ dirname "$target"103+}104+105+ensure_state_dir() {106+ local parent_dir107+ if parent_dir=$(db_parent_dir "$DB_PATH"); then108+ mkdir -p "$parent_dir"109+ fi110+}111+112+init_db() {113+ ensure_state_dir114+ if [[ "$DB_PATH" == file:* ]]; then115+ sqlite3 "$DB_PATH" <<'SQL'116+PRAGMA foreign_keys = ON;117+118+CREATE TABLE IF NOT EXISTS games (119+ id INTEGER PRIMARY KEY AUTOINCREMENT,120+ status TEXT NOT NULL DEFAULT 'active',121+ turn TEXT NOT NULL CHECK (turn IN ('white', 'black')),122+ winner TEXT,123+ note TEXT,124+ en_passant_file INTEGER,125+ en_passant_rank INTEGER,126+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,127+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP128+);129+130+CREATE TABLE IF NOT EXISTS pieces (131+ id INTEGER PRIMARY KEY AUTOINCREMENT,132+ game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE,133+ file INTEGER NOT NULL CHECK (file BETWEEN 1 AND 8),134+ rank INTEGER NOT NULL CHECK (rank BETWEEN 1 AND 8),135+ piece TEXT NOT NULL,136+ color TEXT NOT NULL CHECK (color IN ('white', 'black')),137+ kind TEXT NOT NULL CHECK (kind IN ('king', 'queen', 'rook', 'bishop', 'knight', 'pawn')),138+ has_moved INTEGER NOT NULL DEFAULT 0 CHECK (has_moved IN (0, 1)),139+ UNIQUE (game_id, file, rank)140+);141+142+CREATE INDEX IF NOT EXISTS idx_pieces_game_color ON pieces(game_id, color);143+CREATE INDEX IF NOT EXISTS idx_pieces_game_square ON pieces(game_id, file, rank);144+145+CREATE TABLE IF NOT EXISTS moves (146+ id INTEGER PRIMARY KEY AUTOINCREMENT,147+ game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE,148+ ply INTEGER NOT NULL,149+ from_sq TEXT NOT NULL,150+ to_sq TEXT NOT NULL,151+ piece TEXT NOT NULL,152+ capture TEXT,153+ promotion TEXT,154+ notation TEXT NOT NULL,155+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,156+ UNIQUE (game_id, ply)157+);158+159+CREATE INDEX IF NOT EXISTS idx_moves_game_ply ON moves(game_id, ply);160+161+CREATE TABLE IF NOT EXISTS identities (162+ id INTEGER PRIMARY KEY AUTOINCREMENT,163+ name TEXT NOT NULL UNIQUE,164+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,165+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,166+ last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP167+);168+169+CREATE TABLE IF NOT EXISTS game_players (170+ game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE,171+ color TEXT NOT NULL CHECK (color IN ('white', 'black')),172+ identity_id INTEGER NOT NULL REFERENCES identities(id) ON DELETE CASCADE,173+ PRIMARY KEY (game_id, color)174+);175+176+CREATE TABLE IF NOT EXISTS app_state (177+ key TEXT PRIMARY KEY,178+ value TEXT NOT NULL179+);180+181+CREATE INDEX IF NOT EXISTS idx_identities_last_used ON identities(last_used_at DESC, name ASC);182+CREATE INDEX IF NOT EXISTS idx_game_players_identity ON game_players(identity_id);183+SQL184+ return185+ fi186+187+ sqlite3 "$DB_PATH" <<'SQL'188+PRAGMA foreign_keys = ON;189+190+CREATE TABLE IF NOT EXISTS games (191+ id INTEGER PRIMARY KEY AUTOINCREMENT,192+ status TEXT NOT NULL DEFAULT 'active',193+ turn TEXT NOT NULL CHECK (turn IN ('white', 'black')),194+ winner TEXT,195+ note TEXT,196+ en_passant_file INTEGER,197+ en_passant_rank INTEGER,198+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,199+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP200+);201+202+CREATE TABLE IF NOT EXISTS pieces (203+ id INTEGER PRIMARY KEY AUTOINCREMENT,204+ game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE,205+ file INTEGER NOT NULL CHECK (file BETWEEN 1 AND 8),206+ rank INTEGER NOT NULL CHECK (rank BETWEEN 1 AND 8),207+ piece TEXT NOT NULL,208+ color TEXT NOT NULL CHECK (color IN ('white', 'black')),209+ kind TEXT NOT NULL CHECK (kind IN ('king', 'queen', 'rook', 'bishop', 'knight', 'pawn')),210+ has_moved INTEGER NOT NULL DEFAULT 0 CHECK (has_moved IN (0, 1)),211+ UNIQUE (game_id, file, rank)212+);213+214+CREATE INDEX IF NOT EXISTS idx_pieces_game_color ON pieces(game_id, color);215+CREATE INDEX IF NOT EXISTS idx_pieces_game_square ON pieces(game_id, file, rank);216+217+CREATE TABLE IF NOT EXISTS moves (218+ id INTEGER PRIMARY KEY AUTOINCREMENT,219+ game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE,220+ ply INTEGER NOT NULL,221+ from_sq TEXT NOT NULL,222+ to_sq TEXT NOT NULL,223+ piece TEXT NOT NULL,224+ capture TEXT,225+ promotion TEXT,226+ notation TEXT NOT NULL,227+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,228+ UNIQUE (game_id, ply)229+);230+231+CREATE INDEX IF NOT EXISTS idx_moves_game_ply ON moves(game_id, ply);232+233+CREATE TABLE IF NOT EXISTS identities (234+ id INTEGER PRIMARY KEY AUTOINCREMENT,235+ name TEXT NOT NULL UNIQUE,236+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,237+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,238+ last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP239+);240+241+CREATE TABLE IF NOT EXISTS game_players (242+ game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE,243+ color TEXT NOT NULL CHECK (color IN ('white', 'black')),244+ identity_id INTEGER NOT NULL REFERENCES identities(id) ON DELETE CASCADE,245+ PRIMARY KEY (game_id, color)246+);247+248+CREATE TABLE IF NOT EXISTS app_state (249+ key TEXT PRIMARY KEY,250+ value TEXT NOT NULL251+);252+253+CREATE INDEX IF NOT EXISTS idx_identities_last_used ON identities(last_used_at DESC, name ASC);254+CREATE INDEX IF NOT EXISTS idx_game_players_identity ON game_players(identity_id);255+SQL256+}257+258+piece_symbol() {259+ local color=$1260+ local kind=$2261+ case "$color:$kind" in262+ white:king) printf 'K' ;;263+ white:queen) printf 'Q' ;;264+ white:rook) printf 'R' ;;265+ white:bishop) printf 'B' ;;266+ white:knight) printf 'N' ;;267+ white:pawn) printf 'P' ;;268+ black:king) printf 'k' ;;269+ black:queen) printf 'q' ;;270+ black:rook) printf 'r' ;;271+ black:bishop) printf 'b' ;;272+ black:knight) printf 'n' ;;273+ black:pawn) printf 'p' ;;274+ *) printf '?' ;;275+ esac276+}277+278+opposite_color() {279+ if [[ "$1" == "white" ]]; then280+ printf 'black'281+ else282+ printf 'white'283+ fi284+}285+286+file_to_letter() {287+ case "$1" in288+ 1) printf 'a' ;;289+ 2) printf 'b' ;;290+ 3) printf 'c' ;;291+ 4) printf 'd' ;;292+ 5) printf 'e' ;;293+ 6) printf 'f' ;;294+ 7) printf 'g' ;;295+ 8) printf 'h' ;;296+ *) return 1 ;;297+ esac298+}299+300+letter_to_file() {301+ case "$1" in302+ a|A) printf '1' ;;303+ b|B) printf '2' ;;304+ c|C) printf '3' ;;305+ d|D) printf '4' ;;306+ e|E) printf '5' ;;307+ f|F) printf '6' ;;308+ g|G) printf '7' ;;309+ h|H) printf '8' ;;310+ *) return 1 ;;311+ esac312+}313+314+coords_to_square() {315+ printf '%s%s' "$(file_to_letter "$1")" "$2"316+}317+318+square_to_coords() {319+ local square=$1320+ local file_letter rank321+ file_letter=${square%${square#?}}322+ rank=${square#?}323+ printf '%s|%s' "$(letter_to_file "$file_letter")" "$rank"324+}325+326+is_on_board() {327+ local file=$1328+ local rank=$2329+ [[ "$file" -ge 1 && "$file" -le 8 && "$rank" -ge 1 && "$rank" -le 8 ]]330+}331+332+current_game_id() {333+ dbq "SELECT id FROM games WHERE status = 'active' ORDER BY updated_at DESC, id DESC LIMIT 1;"334+}335+336+game_turn() {337+ dbq "SELECT turn FROM games WHERE id = $1;"338+}339+340+game_status() {341+ dbq "SELECT status FROM games WHERE id = $1;"342+}343+344+game_note() {345+ dbq "SELECT COALESCE(note, '') FROM games WHERE id = $1;"346+}347+348+game_is_local() {349+ dbq "350+ SELECT CASE WHEN COUNT(DISTINCT identity_id) = 1 THEN 1 ELSE 0 END351+ FROM game_players352+ WHERE game_id = $1;"353+}354+355+game_en_passant() {356+ dbq "SELECT COALESCE(en_passant_file, 0) || '|' || COALESCE(en_passant_rank, 0) FROM games WHERE id = $1;"357+}358+359+piece_info() {360+ dbqt "SELECT id, file, rank, color, kind, has_moved FROM pieces WHERE id = $1;"361+}362+363+piece_id_at() {364+ dbq "SELECT id FROM pieces WHERE game_id = $1 AND file = $2 AND rank = $3;"365+}366+367+occupant_data() {368+ dbqt "SELECT id, color, kind, piece FROM pieces WHERE game_id = $1 AND file = $2 AND rank = $3;"369+}370+371+occupied_color() {372+ dbq "SELECT color FROM pieces WHERE game_id = $1 AND file = $2 AND rank = $3;"373+}374+375+count_lines() {376+ local count=0 line377+ while IFS= read -r line; do378+ [[ -n "$line" ]] || continue379+ count=$((count + 1))380+ done381+ printf '%s' "$count"382+}383+384+confirm_action() {385+ local prompt=$1386+ if command -v gum >/dev/null 2>&1; then387+ gum confirm "$prompt"388+ return $?389+ fi390+ printf '%s [y/N] ' "$prompt"391+ read -r reply392+ [[ "$reply" == "y" || "$reply" == "Y" ]]393+}394+395+list_identity_names() {396+ dbq "SELECT name FROM identities ORDER BY last_used_at DESC, name ASC;"397+}398+399+identity_name_by_id() {400+ dbq "SELECT name FROM identities WHERE id = $1;"401+}402+403+selected_identity_id() {404+ if [[ -n "$SESSION_IDENTITY_ID" ]]; then405+ printf '%s' "$SESSION_IDENTITY_ID"406+ return 0407+ fi408+ dbq "SELECT value FROM app_state WHERE key = 'selected_identity_id' LIMIT 1;"409+}410+411+selected_identity_name() {412+ if [[ -n "$SESSION_IDENTITY_NAME" ]]; then413+ printf '%s' "$SESSION_IDENTITY_NAME"414+ return 0415+ fi416+ local identity_id417+ identity_id=$(selected_identity_id)418+ if [[ -z "$identity_id" ]]; then419+ return 0420+ fi421+ identity_name_by_id "$identity_id"422+}423+424+config_identity_name() {425+ local line trimmed_line key value426+427+ if [[ ! -f "$CONFIG_PATH" ]]; then428+ return 0429+ fi430+431+ while IFS= read -r line || [[ -n "$line" ]]; do432+ trimmed_line=$(trim_string "$line")433+ [[ -n "$trimmed_line" ]] || continue434+ [[ "${trimmed_line#\#}" != "$trimmed_line" ]] && continue435+ key=${trimmed_line%%=*}436+ value=${trimmed_line#*=}437+ key=$(trim_string "$key")438+ value=$(trim_string "$value")439+ if [[ "$key" == "identity" && -n "$value" ]]; then440+ printf '%s' "$value"441+ return 0442+ fi443+ done <"$CONFIG_PATH"444+}445+446+stdin_board_uri() {447+ local uri=''448+449+ if [[ -t 0 ]]; then450+ return 0451+ fi452+453+ IFS= read -r uri || true454+ trim_string "$uri"455+}456+457+ensure_identity() {458+ local raw_name=$1459+ local name safe_name460+ name=$(trim_string "$raw_name")461+ if [[ -z "$name" ]]; then462+ return 1463+ fi464+ safe_name=$(sql_quote "$name")465+ if [[ "$DB_PATH" == file:* ]]; then466+ sqlite3 -batch -noheader "$DB_PATH" <<SQL467+PRAGMA foreign_keys = ON;468+BEGIN;469+INSERT INTO identities(name, created_at, updated_at, last_used_at)470+SELECT '$safe_name', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP471+WHERE NOT EXISTS (472+ SELECT 1 FROM identities WHERE name = '$safe_name'473+);474+UPDATE identities475+SET updated_at = CURRENT_TIMESTAMP,476+ last_used_at = CURRENT_TIMESTAMP477+WHERE name = '$safe_name';478+SELECT id FROM identities WHERE name = '$safe_name';479+COMMIT;480+SQL481+ return482+ fi483+484+ sqlite3 -batch -noheader "$DB_PATH" <<SQL485+PRAGMA foreign_keys = ON;486+BEGIN;487+INSERT INTO identities(name, created_at, updated_at, last_used_at)488+SELECT '$safe_name', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP489+WHERE NOT EXISTS (490+ SELECT 1 FROM identities WHERE name = '$safe_name'491+);492+UPDATE identities493+SET updated_at = CURRENT_TIMESTAMP,494+ last_used_at = CURRENT_TIMESTAMP495+WHERE name = '$safe_name';496+SELECT id FROM identities WHERE name = '$safe_name';497+COMMIT;498+SQL499+}500+501+set_selected_identity_id() {502+ dbq "503+ INSERT INTO app_state(key, value)504+ VALUES('selected_identity_id', '$1')505+ ON CONFLICT(key) DO UPDATE SET value = excluded.value;"506+}507+508+set_selected_identity() {509+ local identity_id510+ local persist_selection=${2:-0}511+ identity_id=$(ensure_identity "$1") || return 1512+ SESSION_IDENTITY_ID=$identity_id513+ SESSION_IDENTITY_NAME=$(identity_name_by_id "$identity_id")514+ if [[ "$persist_selection" -eq 1 ]]; then515+ set_selected_identity_id "$identity_id"516+ fi517+ printf '%s' "$identity_id"518+}519+520+turn_identity_id() {521+ local game_id=$1522+ dbq "523+ SELECT gp.identity_id524+ FROM games g525+ LEFT JOIN game_players gp526+ ON gp.game_id = g.id527+ AND gp.color = g.turn528+ WHERE g.id = $game_id529+ LIMIT 1;"530+}531+532+turn_identity_name() {533+ local identity_id534+ identity_id=$(turn_identity_id "$1")535+ if [[ -z "$identity_id" ]]; then536+ return 0537+ fi538+ identity_name_by_id "$identity_id"539+}540+541+can_selected_identity_write() {542+ local game_id=${1:-}543+ local selected_id turn_id544+545+ if [[ -z "$game_id" ]]; then546+ printf '1'547+ return 0548+ fi549+550+ selected_id=$(selected_identity_id)551+ turn_id=$(turn_identity_id "$game_id")552+ if [[ -n "$selected_id" && -n "$turn_id" && "$selected_id" == "$turn_id" ]]; then553+ printf '1'554+ else555+ printf '0'556+ fi557+}558+559+can_selected_identity_move() {560+ can_selected_identity_write "$1"561+}562+563+require_write_access() {564+ if [[ "$(can_selected_identity_write "${1:-}")" -eq 1 ]]; then565+ return 0566+ fi567+ return 1568+}569+570+prompt_text_input() {571+ local prompt=$1572+ local default_value=${2:-}573+ local reply574+575+ if command -v gum >/dev/null 2>&1; then576+ if [[ -n "$default_value" ]]; then577+ reply=$(gum input --prompt "$prompt: " --value "$default_value") || return 1578+ else579+ reply=$(gum input --prompt "$prompt: ") || return 1580+ fi581+ printf '%s' "$reply"582+ return 0583+ fi584+585+ printf '%s: ' "$prompt"586+ read -r reply || return 1587+ if [[ -z "$reply" && -n "$default_value" ]]; then588+ reply=$default_value589+ fi590+ printf '%s' "$reply"591+}592+593+pick_option() {594+ local header=$1595+ shift596+ if [[ "$#" -eq 0 ]]; then597+ return 1598+ fi599+ printf '%s\n' "$@" | gum choose --header "$header"600+}601+602+prompt_for_identity_name() {603+ local prompt=$1604+ local default_name=${2:-}605+ local choice name606+ local options=()607+608+ if command -v gum >/dev/null 2>&1; then609+ while IFS= read -r line; do610+ [[ -n "$line" ]] || continue611+ options[${#options[@]}]=$line612+ done <<EOF613+$(list_identity_names)614+EOF615+ if [[ "${#options[@]}" -gt 0 ]]; then616+ options[${#options[@]}]='create new identity'617+ choice=$(pick_option "$prompt" "${options[@]}") || return 1618+ if [[ "$choice" != 'create new identity' ]]; then619+ printf '%s' "$choice"620+ return 0621+ fi622+ fi623+ fi624+625+ while :; do626+ name=$(prompt_text_input "$prompt" "$default_name") || return 1627+ name=$(trim_string "$name")628+ if [[ -n "$name" ]]; then629+ printf '%s' "$name"630+ return 0631+ fi632+ printf 'Identity name cannot be empty.\n' >&2633+ done634+}635+636+switch_identity_interactively() {637+ local game_id638+ local name639+640+ game_id=$(current_game_id)641+ if [[ -n "$game_id" && "$(game_is_local "$game_id")" -ne 1 ]]; then642+ printf 'Identity switching is disabled after a multiplayer game starts. Use --identity before joining, or use --local for shared-device play.\n' >&2643+ return 1644+ fi645+646+ name=$(prompt_for_identity_name "Select identity" "$(selected_identity_name)") || return 1647+ set_selected_identity "$name" 1 >/dev/null648+}649+650+ensure_selected_identity_for_interactive() {651+ local current_name652+ current_name=$(selected_identity_name)653+ if [[ -n "$current_name" ]]; then654+ return 0655+ fi656+ switch_identity_interactively657+}658+659+seed_new_game() {660+ local white_identity_id=$1661+ local black_identity_id=$2662+ dbq "663+ BEGIN;664+ UPDATE games SET status = 'archived', updated_at = CURRENT_TIMESTAMP665+ WHERE status = 'active';666+ INSERT INTO games(status, turn, winner, note, en_passant_file, en_passant_rank)667+ VALUES('active', 'white', NULL, NULL, NULL, NULL);668+ INSERT INTO pieces(game_id, file, rank, piece, color, kind, has_moved)669+ SELECT game_id, file, rank, piece, color, kind, has_moved670+ FROM (671+ 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_moved672+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 2, 1, 'N', 'white', 'knight', 0673+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 3, 1, 'B', 'white', 'bishop', 0674+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 4, 1, 'Q', 'white', 'queen', 0675+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 5, 1, 'K', 'white', 'king', 0676+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 6, 1, 'B', 'white', 'bishop', 0677+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 7, 1, 'N', 'white', 'knight', 0678+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 8, 1, 'R', 'white', 'rook', 0679+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 1, 2, 'P', 'white', 'pawn', 0680+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 2, 2, 'P', 'white', 'pawn', 0681+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 3, 2, 'P', 'white', 'pawn', 0682+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 4, 2, 'P', 'white', 'pawn', 0683+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 5, 2, 'P', 'white', 'pawn', 0684+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 6, 2, 'P', 'white', 'pawn', 0685+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 7, 2, 'P', 'white', 'pawn', 0686+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 8, 2, 'P', 'white', 'pawn', 0687+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 1, 8, 'r', 'black', 'rook', 0688+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 2, 8, 'n', 'black', 'knight', 0689+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 3, 8, 'b', 'black', 'bishop', 0690+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 4, 8, 'q', 'black', 'queen', 0691+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 5, 8, 'k', 'black', 'king', 0692+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 6, 8, 'b', 'black', 'bishop', 0693+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 7, 8, 'n', 'black', 'knight', 0694+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 8, 8, 'r', 'black', 'rook', 0695+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 1, 7, 'p', 'black', 'pawn', 0696+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 2, 7, 'p', 'black', 'pawn', 0697+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 3, 7, 'p', 'black', 'pawn', 0698+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 4, 7, 'p', 'black', 'pawn', 0699+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 5, 7, 'p', 'black', 'pawn', 0700+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 6, 7, 'p', 'black', 'pawn', 0701+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 7, 7, 'p', 'black', 'pawn', 0702+ UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 8, 7, 'p', 'black', 'pawn', 0703+ );704+ INSERT INTO game_players(game_id, color, identity_id)705+ VALUES706+ ((SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 'white', $white_identity_id),707+ ((SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 'black', $black_identity_id);708+ COMMIT;"709+}710+711+start_new_game_flow() {712+ local mode=$1713+ local selected_name white_name black_name white_identity_id black_identity_id game_id714+715+ selected_name=$(selected_identity_name)716+ if [[ "$mode" == "local" ]]; then717+ if [[ -z "$selected_name" ]]; then718+ return 1719+ fi720+ white_identity_id=$(ensure_identity "$selected_name") || return 1721+ black_identity_id=$white_identity_id722+ else723+ while :; do724+ white_name=$(prompt_for_identity_name "White player" "$selected_name") || return 1725+ black_name=$(prompt_for_identity_name "Black player") || return 1726+ white_identity_id=$(ensure_identity "$white_name") || continue727+ black_identity_id=$(ensure_identity "$black_name") || continue728+ if [[ "$white_identity_id" == "$black_identity_id" ]]; then729+ printf 'Use --local if one identity should control both colors.\n' >&2730+ continue731+ fi732+ break733+ done734+ fi735+736+ seed_new_game "$white_identity_id" "$black_identity_id"737+ game_id=$(current_game_id)738+ if [[ -n "$game_id" ]]; then739+ update_game_state "$game_id"740+ fi741+}742+743+ensure_active_game() {744+ current_game_id745+}746+747+attack_query_for_square() {748+ local game_id=$1749+ local attacker_color=$2750+ local target_file=$3751+ local target_rank=$4752+ cat <<SQL753+WITH RECURSIVE754+occupied AS (755+ SELECT file, rank, color, kind756+ FROM pieces757+ WHERE game_id = $game_id758+),759+attackers AS (760+ SELECT id, file, rank, color, kind761+ FROM pieces762+ WHERE game_id = $game_id763+ AND color = '$attacker_color'764+),765+dirs(df, dr) AS (766+ VALUES767+ (1, 0), (-1, 0), (0, 1), (0, -1),768+ (1, 1), (1, -1), (-1, 1), (-1, -1)769+),770+ray(id, kind, file, rank, df, dr) AS (771+ SELECT a.id, a.kind, a.file + d.df, a.rank + d.dr, d.df, d.dr772+ FROM attackers a773+ JOIN dirs d774+ ON (775+ (a.kind = 'bishop' AND abs(d.df) = 1 AND abs(d.dr) = 1) OR776+ (a.kind = 'rook' AND ((abs(d.df) = 1 AND d.dr = 0) OR (d.df = 0 AND abs(d.dr) = 1))) OR777+ (a.kind = 'queen')778+ )779+ WHERE a.kind IN ('bishop', 'rook', 'queen')780+ AND a.file + d.df BETWEEN 1 AND 8781+ AND a.rank + d.dr BETWEEN 1 AND 8782+783+ UNION ALL784+785+ SELECT id, kind, file + df, rank + dr, df, dr786+ FROM ray787+ WHERE NOT EXISTS (788+ SELECT 1789+ FROM occupied o790+ WHERE o.file = ray.file791+ AND o.rank = ray.rank792+ )793+ AND file + df BETWEEN 1 AND 8794+ AND rank + dr BETWEEN 1 AND 8795+),796+hits AS (797+ SELECT 1798+ FROM attackers a799+ WHERE a.kind = 'knight'800+ AND (801+ (a.file + 1 = $target_file AND a.rank + 2 = $target_rank) OR802+ (a.file + 2 = $target_file AND a.rank + 1 = $target_rank) OR803+ (a.file + 2 = $target_file AND a.rank - 1 = $target_rank) OR804+ (a.file + 1 = $target_file AND a.rank - 2 = $target_rank) OR805+ (a.file - 1 = $target_file AND a.rank - 2 = $target_rank) OR806+ (a.file - 2 = $target_file AND a.rank - 1 = $target_rank) OR807+ (a.file - 2 = $target_file AND a.rank + 1 = $target_rank) OR808+ (a.file - 1 = $target_file AND a.rank + 2 = $target_rank)809+ )810+811+ UNION ALL812+813+ SELECT 1814+ FROM attackers a815+ WHERE a.kind = 'king'816+ AND abs(a.file - $target_file) <= 1817+ AND abs(a.rank - $target_rank) <= 1818+819+ UNION ALL820+821+ SELECT 1822+ FROM attackers a823+ WHERE a.kind = 'pawn'824+ AND (825+ (a.color = 'white' AND (826+ (a.file + 1 = $target_file AND a.rank + 1 = $target_rank) OR827+ (a.file - 1 = $target_file AND a.rank + 1 = $target_rank)828+ )) OR829+ (a.color = 'black' AND (830+ (a.file + 1 = $target_file AND a.rank - 1 = $target_rank) OR831+ (a.file - 1 = $target_file AND a.rank - 1 = $target_rank)832+ ))833+ )834+835+ UNION ALL836+837+ SELECT 1838+ FROM ray839+ WHERE ray.file = $target_file840+ AND ray.rank = $target_rank841+)842+SELECT CASE WHEN EXISTS(SELECT 1 FROM hits) THEN 1 ELSE 0 END;843+SQL844+}845+846+square_attacked_by() {847+ local game_id=$1 attacker_color=$2 target_file=$3 target_rank=$4848+ dbq "$(attack_query_for_square "$game_id" "$attacker_color" "$target_file" "$target_rank")"849+}850+851+is_in_check() {852+ local game_id=$1 color=$2 attacker king_square king_file king_rank853+ attacker=$(opposite_color "$color")854+ king_square=$(dbqt "SELECT file, rank FROM pieces WHERE game_id = $game_id AND color = '$color' AND kind = 'king' LIMIT 1;")855+ if [[ -z "$king_square" ]]; then856+ printf '1'857+ return858+ fi859+ IFS=$'\t' read -r king_file king_rank <<EOF860+$king_square861+EOF862+ square_attacked_by "$game_id" "$attacker" "$king_file" "$king_rank"863+}864+865+simulate_move_batch() {866+ local game_id=$1867+ local piece_id=$2868+ local src_file=$3869+ local src_rank=$4870+ local color=$5871+ local kind=$6872+ local dest_file=$7873+ local dest_rank=$8874+ local special=$9875+ local capture_rank=$src_rank876+ local promoted_symbol877+878+ promoted_symbol=$(piece_symbol "$color" "queen")879+880+ case "$special" in881+ normal|capture)882+ cat <<SQL883+DELETE FROM pieces WHERE game_id = $game_id AND file = $dest_file AND rank = $dest_rank;884+UPDATE pieces885+SET file = $dest_file, rank = $dest_rank, has_moved = 1886+WHERE id = $piece_id;887+SQL888+ ;;889+ promotion|promotion_capture)890+ cat <<SQL891+DELETE FROM pieces WHERE game_id = $game_id AND file = $dest_file AND rank = $dest_rank;892+UPDATE pieces893+SET file = $dest_file,894+ rank = $dest_rank,895+ has_moved = 1,896+ kind = 'queen',897+ piece = '$promoted_symbol'898+WHERE id = $piece_id;899+SQL900+ ;;901+ en_passant)902+ if [[ "$color" == "white" ]]; then903+ capture_rank=$((dest_rank - 1))904+ else905+ capture_rank=$((dest_rank + 1))906+ fi907+ cat <<SQL908+DELETE FROM pieces WHERE game_id = $game_id AND file = $dest_file AND rank = $capture_rank;909+UPDATE pieces910+SET file = $dest_file, rank = $dest_rank, has_moved = 1911+WHERE id = $piece_id;912+SQL913+ ;;914+ castle_kingside)915+ cat <<SQL916+UPDATE pieces917+SET file = 7, rank = $src_rank, has_moved = 1918+WHERE id = $piece_id;919+UPDATE pieces920+SET file = 6, rank = $src_rank, has_moved = 1921+WHERE game_id = $game_id AND color = '$color' AND kind = 'rook' AND file = 8 AND rank = $src_rank;922+SQL923+ ;;924+ castle_queenside)925+ cat <<SQL926+UPDATE pieces927+SET file = 3, rank = $src_rank, has_moved = 1928+WHERE id = $piece_id;929+UPDATE pieces930+SET file = 4, rank = $src_rank, has_moved = 1931+WHERE game_id = $game_id AND color = '$color' AND kind = 'rook' AND file = 1 AND rank = $src_rank;932+SQL933+ ;;934+ *)935+ return 1936+ ;;937+ esac938+}939+940+is_move_safe() {941+ local game_id=$1 piece_id=$2 src_file=$3 src_rank=$4 color=$5 kind=$6 dest_file=$7 dest_rank=$8 special=$9942+ local opponent943+ opponent=$(opposite_color "$color")944+ dbq "$(cat <<SQL945+BEGIN;946+$(simulate_move_batch "$game_id" "$piece_id" "$src_file" "$src_rank" "$color" "$kind" "$dest_file" "$dest_rank" "$special")947+WITH king_square AS (948+ SELECT file, rank949+ FROM pieces950+ WHERE game_id = $game_id951+ AND color = '$color'952+ AND kind = 'king'953+ LIMIT 1954+),955+occupied AS (956+ SELECT file, rank, color, kind957+ FROM pieces958+ WHERE game_id = $game_id959+),960+attackers AS (961+ SELECT id, file, rank, color, kind962+ FROM pieces963+ WHERE game_id = $game_id964+ AND color = '$opponent'965+),966+dirs(df, dr) AS (967+ VALUES968+ (1, 0), (-1, 0), (0, 1), (0, -1),969+ (1, 1), (1, -1), (-1, 1), (-1, -1)970+),971+ray(id, kind, file, rank, df, dr) AS (972+ SELECT a.id, a.kind, a.file + d.df, a.rank + d.dr, d.df, d.dr973+ FROM attackers a974+ JOIN dirs d975+ ON (976+ (a.kind = 'bishop' AND abs(d.df) = 1 AND abs(d.dr) = 1) OR977+ (a.kind = 'rook' AND ((abs(d.df) = 1 AND d.dr = 0) OR (d.df = 0 AND abs(d.dr) = 1))) OR978+ (a.kind = 'queen')979+ )980+ WHERE a.kind IN ('bishop', 'rook', 'queen')981+ AND a.file + d.df BETWEEN 1 AND 8982+ AND a.rank + d.dr BETWEEN 1 AND 8983+984+ UNION ALL985+986+ SELECT id, kind, file + df, rank + dr, df, dr987+ FROM ray988+ WHERE NOT EXISTS (989+ SELECT 1990+ FROM occupied o991+ WHERE o.file = ray.file992+ AND o.rank = ray.rank993+ )994+ AND file + df BETWEEN 1 AND 8995+ AND rank + dr BETWEEN 1 AND 8996+),997+hits AS (998+ SELECT 1999+ FROM attackers a, king_square k1000+ WHERE a.kind = 'knight'1001+ AND (1002+ (a.file + 1 = k.file AND a.rank + 2 = k.rank) OR1003+ (a.file + 2 = k.file AND a.rank + 1 = k.rank) OR1004+ (a.file + 2 = k.file AND a.rank - 1 = k.rank) OR1005+ (a.file + 1 = k.file AND a.rank - 2 = k.rank) OR1006+ (a.file - 1 = k.file AND a.rank - 2 = k.rank) OR1007+ (a.file - 2 = k.file AND a.rank - 1 = k.rank) OR1008+ (a.file - 2 = k.file AND a.rank + 1 = k.rank) OR1009+ (a.file - 1 = k.file AND a.rank + 2 = k.rank)1010+ )1011+1012+ UNION ALL1013+1014+ SELECT 11015+ FROM attackers a, king_square k1016+ WHERE a.kind = 'king'1017+ AND abs(a.file - k.file) <= 11018+ AND abs(a.rank - k.rank) <= 11019+1020+ UNION ALL1021+1022+ SELECT 11023+ FROM attackers a, king_square k1024+ WHERE a.kind = 'pawn'1025+ AND (1026+ (a.color = 'white' AND (1027+ (a.file + 1 = k.file AND a.rank + 1 = k.rank) OR1028+ (a.file - 1 = k.file AND a.rank + 1 = k.rank)1029+ )) OR1030+ (a.color = 'black' AND (1031+ (a.file + 1 = k.file AND a.rank - 1 = k.rank) OR1032+ (a.file - 1 = k.file AND a.rank - 1 = k.rank)1033+ ))1034+ )1035+1036+ UNION ALL1037+1038+ SELECT 11039+ FROM ray, king_square k1040+ WHERE ray.file = k.file1041+ AND ray.rank = k.rank1042+)1043+SELECT CASE WHEN EXISTS(SELECT 1 FROM hits) THEN 0 ELSE 1 END;1044+ROLLBACK;1045+SQL1046+)"1047+}1048+1049+sliding_moves() {1050+ local game_id=$1 src_file=$2 src_rank=$3 color=$4 directions=$51051+ local dir token df dr file rank occupant1052+ for dir in $directions; do1053+ df=${dir%:*}1054+ dr=${dir#*:}1055+ file=$((src_file + df))1056+ rank=$((src_rank + dr))1057+ while is_on_board "$file" "$rank"; do1058+ occupant=$(occupant_data "$game_id" "$file" "$rank")1059+ if [[ -z "$occupant" ]]; then1060+ printf '%s|%s|normal\n' "$file" "$rank"1061+ else1062+ IFS=$'\t' read -r _ target_color _ _ <<EOF1063+$occupant1064+EOF1065+ if [[ "$target_color" != "$color" ]]; then1066+ printf '%s|%s|capture\n' "$file" "$rank"1067+ fi1068+ break1069+ fi1070+ file=$((file + df))1071+ rank=$((rank + dr))1072+ done1073+ done1074+}1075+1076+pseudo_moves_for_piece() {1077+ local game_id=$1 piece_id=$21078+ local piece_data src_file src_rank color kind has_moved1079+ local dir start_rank last_rank next_rank two_rank file rank occupant target_color1080+ local en_passant_data en_passant_file en_passant_rank1081+1082+ piece_data=$(piece_info "$piece_id")1083+ if [[ -z "$piece_data" ]]; then1084+ return 01085+ fi1086+1087+ IFS=$'\t' read -r _ src_file src_rank color kind has_moved <<EOF1088+$piece_data1089+EOF1090+1091+ en_passant_data=$(game_en_passant "$game_id")1092+ IFS='|' read -r en_passant_file en_passant_rank <<EOF1093+$en_passant_data1094+EOF1095+1096+ case "$kind" in1097+ pawn)1098+ if [[ "$color" == "white" ]]; then1099+ dir=11100+ start_rank=21101+ last_rank=81102+ else1103+ dir=-11104+ start_rank=71105+ last_rank=11106+ fi1107+1108+ next_rank=$((src_rank + dir))1109+ if is_on_board "$src_file" "$next_rank" && [[ -z "$(occupant_data "$game_id" "$src_file" "$next_rank")" ]]; then1110+ if [[ "$next_rank" -eq "$last_rank" ]]; then1111+ printf '%s|%s|promotion\n' "$src_file" "$next_rank"1112+ else1113+ printf '%s|%s|normal\n' "$src_file" "$next_rank"1114+ fi1115+ two_rank=$((src_rank + (2 * dir)))1116+ if [[ "$src_rank" -eq "$start_rank" ]] && [[ -z "$(occupant_data "$game_id" "$src_file" "$two_rank")" ]]; then1117+ printf '%s|%s|normal\n' "$src_file" "$two_rank"1118+ fi1119+ fi1120+1121+ for file in $((src_file - 1)) $((src_file + 1)); do1122+ rank=$((src_rank + dir))1123+ if ! is_on_board "$file" "$rank"; then1124+ continue1125+ fi1126+ occupant=$(occupant_data "$game_id" "$file" "$rank")1127+ if [[ -n "$occupant" ]]; then1128+ IFS=$'\t' read -r _ target_color _ _ <<EOF1129+$occupant1130+EOF1131+ if [[ "$target_color" != "$color" ]]; then1132+ if [[ "$rank" -eq "$last_rank" ]]; then1133+ printf '%s|%s|promotion_capture\n' "$file" "$rank"1134+ else1135+ printf '%s|%s|capture\n' "$file" "$rank"1136+ fi1137+ fi1138+ elif [[ "$file" -eq "$en_passant_file" && "$rank" -eq "$en_passant_rank" ]]; then1139+ printf '%s|%s|en_passant\n' "$file" "$rank"1140+ fi1141+ done1142+ ;;1143+ knight)1144+ for token in "1:2" "2:1" "2:-1" "1:-2" "-1:-2" "-2:-1" "-2:1" "-1:2"; do1145+ file=$((src_file + ${token%:*}))1146+ rank=$((src_rank + ${token#*:}))1147+ if ! is_on_board "$file" "$rank"; then1148+ continue1149+ fi1150+ occupant=$(occupant_data "$game_id" "$file" "$rank")1151+ if [[ -z "$occupant" ]]; then1152+ printf '%s|%s|normal\n' "$file" "$rank"1153+ else1154+ IFS=$'\t' read -r _ target_color _ _ <<EOF1155+$occupant1156+EOF1157+ if [[ "$target_color" != "$color" ]]; then1158+ printf '%s|%s|capture\n' "$file" "$rank"1159+ fi1160+ fi1161+ done1162+ ;;1163+ bishop)1164+ sliding_moves "$game_id" "$src_file" "$src_rank" "$color" "1:1 1:-1 -1:1 -1:-1"1165+ ;;1166+ rook)1167+ sliding_moves "$game_id" "$src_file" "$src_rank" "$color" "1:0 -1:0 0:1 0:-1"1168+ ;;1169+ queen)1170+ 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"1171+ ;;1172+ king)1173+ for token in "1:0" "1:1" "0:1" "-1:1" "-1:0" "-1:-1" "0:-1" "1:-1"; do1174+ file=$((src_file + ${token%:*}))1175+ rank=$((src_rank + ${token#*:}))1176+ if ! is_on_board "$file" "$rank"; then1177+ continue1178+ fi1179+ occupant=$(occupant_data "$game_id" "$file" "$rank")1180+ if [[ -z "$occupant" ]]; then1181+ printf '%s|%s|normal\n' "$file" "$rank"1182+ else1183+ IFS=$'\t' read -r _ target_color _ _ <<EOF1184+$occupant1185+EOF1186+ if [[ "$target_color" != "$color" ]]; then1187+ printf '%s|%s|capture\n' "$file" "$rank"1188+ fi1189+ fi1190+ done1191+ if [[ "$has_moved" -eq 0 ]] && [[ "$(is_in_check "$game_id" "$color")" -eq 0 ]]; then1192+ local home_rank rook_data rook_moved enemy1193+ home_rank=$src_rank1194+ enemy=$(opposite_color "$color")1195+1196+ 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;")1197+ if [[ -n "$rook_data" ]] && [[ -z "$(occupant_data "$game_id" 6 "$home_rank")" ]] && [[ -z "$(occupant_data "$game_id" 7 "$home_rank")" ]]; then1198+ IFS=$'\t' read -r _ rook_moved <<EOF1199+$rook_data1200+EOF1201+ 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 ]]; then1202+ printf '7|%s|castle_kingside\n' "$home_rank"1203+ fi1204+ fi1205+1206+ 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;")1207+ 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")" ]]; then1208+ IFS=$'\t' read -r _ rook_moved <<EOF1209+$rook_data1210+EOF1211+ 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 ]]; then1212+ printf '3|%s|castle_queenside\n' "$home_rank"1213+ fi1214+ fi1215+ fi1216+ ;;1217+ esac1218+}1219+1220+legal_moves_for_piece() {1221+ local game_id=$1 piece_id=$21222+ local piece_data src_file src_rank color kind has_moved1223+ local move_line dest_file dest_rank special1224+ piece_data=$(piece_info "$piece_id")1225+ [[ -n "$piece_data" ]] || return 01226+ IFS=$'\t' read -r _ src_file src_rank color kind has_moved <<EOF1227+$piece_data1228+EOF1229+ while IFS='|' read -r dest_file dest_rank special; do1230+ [[ -n "$dest_file" ]] || continue1231+ if [[ "$(is_move_safe "$game_id" "$piece_id" "$src_file" "$src_rank" "$color" "$kind" "$dest_file" "$dest_rank" "$special")" -eq 1 ]]; then1232+ printf '%s|%s|%s\n' "$dest_file" "$dest_rank" "$special"1233+ fi1234+ done <<EOF1235+$(pseudo_moves_for_piece "$game_id" "$piece_id")1236+EOF1237+}1238+1239+list_source_options() {1240+ local game_id=$1 turn pieces piece_row piece_id file rank color kind symbol moves move_count square1241+ turn=$(game_turn "$game_id")1242+ pieces=$(dbqt "SELECT id, file, rank, color, kind FROM pieces WHERE game_id = $game_id AND color = '$turn' ORDER BY rank, file;")1243+ while IFS=$'\t' read -r piece_id file rank color kind; do1244+ [[ -n "$piece_id" ]] || continue1245+ moves=$(legal_moves_for_piece "$game_id" "$piece_id")1246+ move_count=$(count_lines <<EOF1247+$moves1248+EOF1249+)1250+ if [[ "$move_count" -gt 0 ]]; then1251+ square=$(coords_to_square "$file" "$rank")1252+ symbol=$(piece_symbol "$color" "$kind")1253+ printf '%s %s %s move(s)\n' "$square" "$symbol" "$move_count"1254+ fi1255+ done <<EOF1256+$pieces1257+EOF1258+}1259+1260+move_description() {1261+ case "$1" in1262+ normal) printf 'move' ;;1263+ capture) printf 'capture' ;;1264+ promotion) printf 'promote to queen' ;;1265+ promotion_capture) printf 'capture + promote to queen' ;;1266+ en_passant) printf 'en passant' ;;1267+ castle_kingside) printf 'castle kingside' ;;1268+ castle_queenside) printf 'castle queenside' ;;1269+ *) printf 'move' ;;1270+ esac1271+}1272+1273+list_destination_options() {1274+ local game_id=$1 piece_id=$21275+ local legal_moves dest_file dest_rank special1276+ legal_moves=$(legal_moves_for_piece "$game_id" "$piece_id")1277+ while IFS='|' read -r dest_file dest_rank special; do1278+ [[ -n "$dest_file" ]] || continue1279+ printf '%s %s\n' "$(coords_to_square "$dest_file" "$dest_rank")" "$(move_description "$special")"1280+ done <<EOF1281+$legal_moves1282+EOF1283+}1284+1285+special_for_destination() {1286+ local game_id=$1 piece_id=$2 target_square=$31287+ local coords dest_file dest_rank special file rank1288+ coords=$(square_to_coords "$target_square")1289+ IFS='|' read -r file rank <<EOF1290+$coords1291+EOF1292+ while IFS='|' read -r dest_file dest_rank special; do1293+ [[ -n "$dest_file" ]] || continue1294+ if [[ "$dest_file" -eq "$file" && "$dest_rank" -eq "$rank" ]]; then1295+ printf '%s' "$special"1296+ return 01297+ fi1298+ done <<EOF1299+$(legal_moves_for_piece "$game_id" "$piece_id")1300+EOF1301+ return 11302+}1303+1304+render_board_text() {1305+ local game_id=$1 rank line file piece_char piece_data1306+ printf ' a b c d e f g h\n'1307+ printf ' +-----------------+\n'1308+ for rank in 8 7 6 5 4 3 2 1; do1309+ line=''1310+ for file in 1 2 3 4 5 6 7 8; do1311+ piece_data=$(dbq "SELECT piece FROM pieces WHERE game_id = $game_id AND file = $file AND rank = $rank;")1312+ if [[ -z "$piece_data" ]]; then1313+ piece_char='.'1314+ else1315+ piece_char=$piece_data1316+ fi1317+ if [[ -z "$line" ]]; then1318+ line=$piece_char1319+ else1320+ line="$line $piece_char"1321+ fi1322+ done1323+ printf '%s | %s | %s\n' "$rank" "$line" "$rank"1324+ done1325+ printf ' +-----------------+\n'1326+ printf ' a b c d e f g h\n'1327+}1328+1329+moves_text() {1330+ local game_id=$11331+ dbq "SELECT printf('%3d. %s', ply, notation) FROM moves WHERE game_id = $game_id ORDER BY ply;"1332+}1333+1334+show_moves() {1335+ local game_id moves1336+ game_id=$(current_game_id)1337+ if [[ -z "$game_id" ]]; then1338+ printf 'No active game.\n'1339+ return 01340+ fi1341+ moves=$(moves_text "$game_id")1342+ if [[ -z "$moves" ]]; then1343+ moves='No moves yet.'1344+ fi1345+ show_in_pager "$moves"1346+}1347+1348+notation_for_move() {1349+ local src_square=$1 dest_square=$2 special=$3 capture_symbol=$41350+ case "$special" in1351+ castle_kingside) printf 'O-O' ;;1352+ castle_queenside) printf 'O-O-O' ;;1353+ *)1354+ if [[ -n "$capture_symbol" ]]; then1355+ printf '%s x %s' "$src_square" "$dest_square"1356+ else1357+ printf '%s - %s' "$src_square" "$dest_square"1358+ fi1359+ if [[ "$special" == "en_passant" ]]; then1360+ printf ' e.p.'1361+ fi1362+ if [[ "$special" == "promotion" || "$special" == "promotion_capture" ]]; then1363+ printf ' =Q'1364+ fi1365+ ;;1366+ esac1367+}1368+1369+apply_move() {1370+ local game_id=$1 piece_id=$2 target_square=$31371+ local piece_data src_file src_rank color kind has_moved1372+ local coords dest_file dest_rank special capture_data capture_symbol next_turn1373+ local src_square dest_square notation promotion_value en_passant_file en_passant_rank1374+1375+ piece_data=$(piece_info "$piece_id")1376+ IFS=$'\t' read -r _ src_file src_rank color kind has_moved <<EOF1377+$piece_data1378+EOF1379+1380+ coords=$(square_to_coords "$target_square")1381+ IFS='|' read -r dest_file dest_rank <<EOF1382+$coords1383+EOF1384+1385+ special=$(special_for_destination "$game_id" "$piece_id" "$target_square")1386+ if [[ -z "$special" ]]; then1387+ return 11388+ fi1389+1390+ capture_symbol=''1391+ if [[ "$special" == "capture" || "$special" == "promotion_capture" ]]; then1392+ capture_data=$(occupant_data "$game_id" "$dest_file" "$dest_rank")1393+ if [[ -n "$capture_data" ]]; then1394+ IFS=$'\t' read -r _ _ _ capture_symbol <<EOF1395+$capture_data1396+EOF1397+ fi1398+ elif [[ "$special" == "en_passant" ]]; then1399+ if [[ "$color" == "white" ]]; then1400+ capture_data=$(occupant_data "$game_id" "$dest_file" $((dest_rank - 1)))1401+ else1402+ capture_data=$(occupant_data "$game_id" "$dest_file" $((dest_rank + 1)))1403+ fi1404+ if [[ -n "$capture_data" ]]; then1405+ IFS=$'\t' read -r _ _ _ capture_symbol <<EOF1406+$capture_data1407+EOF1408+ fi1409+ fi1410+1411+ src_square=$(coords_to_square "$src_file" "$src_rank")1412+ dest_square=$(coords_to_square "$dest_file" "$dest_rank")1413+ notation=$(notation_for_move "$src_square" "$dest_square" "$special" "$capture_symbol")1414+ promotion_value='NULL'1415+ if [[ "$special" == "promotion" || "$special" == "promotion_capture" ]]; then1416+ promotion_value="'queen'"1417+ fi1418+1419+ en_passant_file='NULL'1420+ en_passant_rank='NULL'1421+ if [[ "$kind" == "pawn" && $((dest_rank - src_rank)) -eq 2 ]]; then1422+ en_passant_file=$src_file1423+ en_passant_rank=$((src_rank + 1))1424+ elif [[ "$kind" == "pawn" && $((dest_rank - src_rank)) -eq -2 ]]; then1425+ en_passant_file=$src_file1426+ en_passant_rank=$((src_rank - 1))1427+ fi1428+1429+ next_turn=$(opposite_color "$color")1430+1431+ if [[ "$DB_PATH" == file:* ]]; then1432+ sqlite3 "$DB_PATH" <<SQL1433+PRAGMA foreign_keys = ON;1434+BEGIN;1435+$(simulate_move_batch "$game_id" "$piece_id" "$src_file" "$src_rank" "$color" "$kind" "$dest_file" "$dest_rank" "$special")1436+UPDATE games1437+SET turn = '$next_turn',1438+ status = 'active',1439+ winner = NULL,1440+ note = NULL,1441+ en_passant_file = $en_passant_file,1442+ en_passant_rank = $en_passant_rank,1443+ updated_at = CURRENT_TIMESTAMP1444+WHERE id = $game_id;1445+INSERT INTO moves(game_id, ply, from_sq, to_sq, piece, capture, promotion, notation, created_at)1446+SELECT1447+ $game_id,1448+ COALESCE(MAX(ply), 0) + 1,1449+ '$src_square',1450+ '$dest_square',1451+ '$(piece_symbol "$color" "$kind")',1452+ $(if [[ -n "$capture_symbol" ]]; then printf "'%s'" "$(sql_quote "$capture_symbol")"; else printf 'NULL'; fi),1453+ $promotion_value,1454+ '$(sql_quote "$notation")',1455+ CURRENT_TIMESTAMP1456+FROM moves1457+WHERE game_id = $game_id;1458+COMMIT;1459+SQL1460+ return1461+ fi1462+1463+ sqlite3 "$DB_PATH" <<SQL1464+PRAGMA foreign_keys = ON;1465+BEGIN;1466+$(simulate_move_batch "$game_id" "$piece_id" "$src_file" "$src_rank" "$color" "$kind" "$dest_file" "$dest_rank" "$special")1467+UPDATE games1468+SET turn = '$next_turn',1469+ status = 'active',1470+ winner = NULL,1471+ note = NULL,1472+ en_passant_file = $en_passant_file,1473+ en_passant_rank = $en_passant_rank,1474+ updated_at = CURRENT_TIMESTAMP1475+WHERE id = $game_id;1476+INSERT INTO moves(game_id, ply, from_sq, to_sq, piece, capture, promotion, notation, created_at)1477+SELECT1478+ $game_id,1479+ COALESCE(MAX(ply), 0) + 1,1480+ '$src_square',1481+ '$dest_square',1482+ '$(piece_symbol "$color" "$kind")',1483+ $(if [[ -n "$capture_symbol" ]]; then printf "'%s'" "$(sql_quote "$capture_symbol")"; else printf 'NULL'; fi),1484+ $promotion_value,1485+ '$(sql_quote "$notation")',1486+ CURRENT_TIMESTAMP1487+FROM moves1488+WHERE game_id = $game_id;1489+COMMIT;1490+SQL1491+}1492+1493+update_game_state() {1494+ local game_id=$1 turn sources winner1495+ turn=$(game_turn "$game_id")1496+ sources=$(list_source_options "$game_id")1497+ if [[ -n "$sources" ]]; then1498+ if [[ "$(is_in_check "$game_id" "$turn")" -eq 1 ]]; then1499+ dbq "UPDATE games SET note = '$turn is in check', updated_at = CURRENT_TIMESTAMP WHERE id = $game_id;"1500+ else1501+ dbq "UPDATE games SET note = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $game_id;"1502+ fi1503+ return 01504+ fi1505+1506+ if [[ "$(is_in_check "$game_id" "$turn")" -eq 1 ]]; then1507+ winner=$(opposite_color "$turn")1508+ dbq "UPDATE games1509+ SET status = 'checkmate',1510+ winner = '$winner',1511+ note = '$winner wins by checkmate',1512+ updated_at = CURRENT_TIMESTAMP1513+ WHERE id = $game_id;"1514+ else1515+ dbq "UPDATE games1516+ SET status = 'stalemate',1517+ winner = NULL,1518+ note = 'Draw by stalemate',1519+ updated_at = CURRENT_TIMESTAMP1520+ WHERE id = $game_id;"1521+ fi1522+}1523+1524+resign_game() {1525+ local game_id=$1 turn winner1526+ turn=$(game_turn "$game_id")1527+ winner=$(opposite_color "$turn")1528+ dbq "UPDATE games1529+ SET status = 'resigned',1530+ winner = '$winner',1531+ note = '$turn resigned',1532+ updated_at = CURRENT_TIMESTAMP1533+ WHERE id = $game_id;"1534+}1535+1536+status_line() {1537+ local game_id=${1:-}1538+ local identity_name turn status note turn_name mode1539+ identity_name=$(selected_identity_name)1540+ if [[ -z "$identity_name" ]]; then1541+ identity_name='(none)'1542+ fi1543+1544+ if [[ -z "$game_id" ]]; then1545+ printf 'Identity: %s\nTurn: none\nMode: writable setup\nStatus: no active game\n' "$identity_name"1546+ return 01547+ fi1548+1549+ turn=$(game_turn "$game_id")1550+ status=$(game_status "$game_id")1551+ note=$(game_note "$game_id")1552+ turn_name=$(turn_identity_name "$game_id")1553+ if [[ -z "$turn_name" ]]; then1554+ turn_name='unassigned'1555+ fi1556+1557+ if [[ "$(can_selected_identity_write "$game_id")" -eq 1 ]]; then1558+ mode='writable'1559+ elif [[ "$turn_name" == 'unassigned' ]]; then1560+ mode='read-only (turn owner unassigned)'1561+ else1562+ mode="read-only waiting for $turn_name"1563+ fi1564+1565+ printf 'Identity: %s\nTurn: %s (%s)\nMode: %s\nStatus: %s\n' "$identity_name" "$turn" "$turn_name" "$mode" "$status"1566+ if [[ -n "$note" ]]; then1567+ printf 'Note: %s\n' "$note"1568+ fi1569+}1570+1571+interactive_game() {1572+ local game_id board screen source_options source_choice source_square piece_coords piece_id1573+ local destination_options destination_choice target_square action status1574+ local piece_file piece_rank allow_switch_identity1575+1576+ require_gum1577+ ensure_selected_identity_for_interactive || return 01578+1579+ while :; do1580+ game_id=$(current_game_id)1581+ allow_switch_identity=11582+ if [[ -n "$game_id" && "$(game_is_local "$game_id")" -ne 1 ]]; then1583+ allow_switch_identity=01584+ fi1585+1586+ if [[ -n "$game_id" ]]; then1587+ status=$(game_status "$game_id")1588+ board=$(render_board_text "$game_id")1589+ screen=$(printf '%s\n\n%s\n' "$(status_line "$game_id")" "$board")1590+ else1591+ status='none'1592+ screen=$(printf '%s\n' "$(status_line)")1593+ fi1594+1595+ clear_screen1596+ gum style --border rounded --padding "1 2" --margin "1 0" --border-foreground 212 "$(printf '%s\n' "$APP_TITLE")"1597+ gum style --border normal --padding "1 2" "$screen"1598+1599+ if [[ -z "$game_id" ]]; then1600+ action=$(pick_option "No active game." "new game" "new local game" "switch identity" "quit") || return 01601+ case "$action" in1602+ "new game")1603+ if confirm_action "Start a new game?"; then1604+ start_new_game_flow "multiplayer"1605+ fi1606+ ;;1607+ "new local game")1608+ if confirm_action "Start a new local game?"; then1609+ start_new_game_flow "local"1610+ fi1611+ ;;1612+ "switch identity")1613+ switch_identity_interactively || true1614+ ;;1615+ quit)1616+ return 01617+ ;;1618+ esac1619+ continue1620+ fi1621+1622+ if [[ "$status" != "active" ]]; then1623+ if require_write_access "$game_id"; then1624+ if [[ "$allow_switch_identity" -eq 1 ]]; then1625+ action=$(pick_option "Game over." "new game" "new local game" "show moves" "switch identity" "quit") || return 01626+ else1627+ action=$(pick_option "Game over." "new game" "new local game" "show moves" "quit") || return 01628+ fi1629+ else1630+ if [[ "$allow_switch_identity" -eq 1 ]]; then1631+ action=$(pick_option "Read-only mode." "refresh" "show moves" "switch identity" "quit") || return 01632+ else1633+ action=$(pick_option "Read-only mode." "refresh" "show moves" "quit") || return 01634+ fi1635+ fi1636+ case "$action" in1637+ "new game")1638+ if confirm_action "Start a new game?"; then1639+ start_new_game_flow "multiplayer"1640+ fi1641+ ;;1642+ "new local game")1643+ if confirm_action "Start a new local game?"; then1644+ start_new_game_flow "local"1645+ fi1646+ ;;1647+ "show moves")1648+ show_moves1649+ ;;1650+ "switch identity")1651+ switch_identity_interactively || true1652+ ;;1653+ refresh)1654+ ;;1655+ quit)1656+ return 01657+ ;;1658+ esac1659+ continue1660+ fi1661+1662+ if ! require_write_access "$game_id"; then1663+ if [[ "$allow_switch_identity" -eq 1 ]]; then1664+ action=$(pick_option "Read-only mode." "refresh" "show moves" "switch identity" "quit") || return 01665+ else1666+ action=$(pick_option "Read-only mode." "refresh" "show moves" "quit") || return 01667+ fi1668+ case "$action" in1669+ refresh)1670+ ;;1671+ "show moves")1672+ show_moves1673+ ;;1674+ "switch identity")1675+ switch_identity_interactively || true1676+ ;;1677+ quit)1678+ return 01679+ ;;1680+ esac1681+ continue1682+ fi1683+1684+ if [[ "$allow_switch_identity" -eq 1 ]]; then1685+ action=$(pick_option "Choose an action" "move" "show moves" "switch identity" "resign" "new game" "new local game" "quit") || return 01686+ else1687+ action=$(pick_option "Choose an action" "move" "show moves" "resign" "new game" "new local game" "quit") || return 01688+ fi1689+ case "$action" in1690+ move)1691+ source_options=()1692+ while IFS= read -r line; do1693+ [[ -n "$line" ]] || continue1694+ source_options[${#source_options[@]}]=$line1695+ done <<EOF1696+$(list_source_options "$game_id")1697+EOF1698+ if [[ "${#source_options[@]}" -eq 0 ]]; then1699+ continue1700+ fi1701+1702+ source_choice=$(pick_option "Select a piece" "${source_options[@]}") || continue1703+ source_square=${source_choice%% *}1704+ piece_coords=$(square_to_coords "$source_square")1705+ IFS='|' read -r piece_file piece_rank <<EOF1706+$piece_coords1707+EOF1708+ piece_id=$(piece_id_at "$game_id" "$piece_file" "$piece_rank")1709+1710+ destination_options=()1711+ while IFS= read -r line; do1712+ [[ -n "$line" ]] || continue1713+ destination_options[${#destination_options[@]}]=$line1714+ done <<EOF1715+$(list_destination_options "$game_id" "$piece_id")1716+EOF1717+ if [[ "${#destination_options[@]}" -eq 0 ]]; then1718+ continue1719+ fi1720+1721+ destination_choice=$(pick_option "Select a destination for $source_square" "${destination_options[@]}") || continue1722+ target_square=${destination_choice%% *}1723+ apply_move "$game_id" "$piece_id" "$target_square"1724+ update_game_state "$game_id"1725+ ;;1726+ "show moves")1727+ show_moves1728+ ;;1729+ "switch identity")1730+ switch_identity_interactively || true1731+ ;;1732+ resign)1733+ if confirm_action "Resign the current game?"; then1734+ resign_game "$game_id"1735+ fi1736+ ;;1737+ "new game")1738+ if confirm_action "Start a new game?"; then1739+ start_new_game_flow "multiplayer"1740+ fi1741+ ;;1742+ "new local game")1743+ if confirm_action "Start a new local game?"; then1744+ start_new_game_flow "local"1745+ fi1746+ ;;1747+ quit)1748+ return 01749+ ;;1750+ esac1751+ done1752+}1753+1754+main() {1755+ local action=''1756+ local identity_arg=''1757+ local selected_name=''1758+ local config_identity=''1759+ local board_uri=''1760+1761+ require_sqlite1762+1763+ while [[ "$#" -gt 0 ]]; do1764+ case "$1" in1765+ --identity)1766+ shift1767+ if [[ "$#" -eq 0 ]]; then1768+ printf '%s\n' '--identity requires a name.' >&21769+ exit 11770+ fi1771+ identity_arg=$11772+ ;;1773+ --new|--local|--moves|--whoami|--help|-h)1774+ if [[ -n "$action" ]]; then1775+ printf 'Only one command may be used at a time.\n' >&21776+ exit 11777+ fi1778+ action=$11779+ ;;1780+ *)1781+ printf 'Unknown option: %s\n\n' "$1" >&21782+ usage >&21783+ exit 11784+ ;;1785+ esac1786+ shift1787+ done1788+1789+ case "$action" in1790+ --help|-h)1791+ usage1792+ return 01793+ ;;1794+ esac1795+1796+ if [[ "$action" != "--help" && "$action" != "-h" ]]; then1797+ board_uri=$(stdin_board_uri)1798+ if [[ -n "$board_uri" ]]; then1799+ DB_PATH=$board_uri1800+ fi1801+ fi1802+1803+ init_db1804+1805+ config_identity=$(config_identity_name)1806+ selected_name=$(selected_identity_name)1807+1808+ if [[ -n "$identity_arg" ]]; then1809+ set_selected_identity "$identity_arg" >/dev/null1810+ elif [[ -n "$config_identity" ]]; then1811+ set_selected_identity "$config_identity" >/dev/null1812+ fi1813+1814+ case "$action" in1815+ --help|-h)1816+ usage1817+ ;;1818+ --moves)1819+ show_moves1820+ ;;1821+ --whoami)1822+ if [[ -n "$(selected_identity_name)" ]]; then1823+ selected_identity_name1824+ else1825+ printf 'No identity selected.\n'1826+ fi1827+ ;;1828+ --new)1829+ require_gum1830+ ensure_selected_identity_for_interactive || exit 01831+ if confirm_action "Start a new game?"; then1832+ start_new_game_flow "multiplayer"1833+ fi1834+ interactive_game1835+ ;;1836+ --local)1837+ require_gum1838+ ensure_selected_identity_for_interactive || exit 01839+ if confirm_action "Start a new local game?"; then1840+ start_new_game_flow "local"1841+ fi1842+ interactive_game1843+ ;;1844+ "")1845+ interactive_game1846+ ;;1847+ *)1848+ printf 'Unknown option: %s\n\n' "$action" >&21849+ usage >&21850+ exit 11851+ ;;1852+ esac1853+}1854+1855+if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then1856+ main "$@"1857+fi