#!/usr/bin/env bash if [ -z "${BASH_VERSION:-}" ]; then if command -v bash >/dev/null 2>&1; then case "${0##*/}" in sh|dash|ash) exec bash /dev/fd/0 "$@" ;; *) exec bash "$0" "$@" ;; esac fi printf 'bash is required.\n' >&2 exit 1 fi set -euo pipefail APP_NAME="select-mate" APP_TITLE="select-mate.sh" GUM_THEME_ACCENT="#cc8844" GUM_THEME_ACCENT_TEXT="#e4e4e4" GUM_THEME_TEXT_FG="#d0d0d0" GUM_THEME_TEXT_DIM="#949494" GUM_THEME_SURFACE_BG="#3a3a3a" GUM_THEME_BORDER="#585858" CONFIG_PATH="${XDG_CONFIG_HOME:-$HOME/.config}/select-mate.conf" DEFAULT_DB_PATH="$(dirname "$CONFIG_PATH")/game.db" DB_PATH="$DEFAULT_DB_PATH" SESSION_IDENTITY_ID="" SESSION_IDENTITY_NAME="" has_gum() { command -v gum >/dev/null 2>&1 } prepare_interactive_stdin() { if [[ -t 0 || -r /dev/tty ]]; then return 0 fi printf 'An interactive terminal is required for prompts.\n' >&2 return 1 } read_user_line() { local __reply_var=$1 if [[ -t 0 ]]; then IFS= read -r "$__reply_var" || return 1 return 0 fi IFS= read -r "$__reply_var" /dev/null 2>&1; then printf 'sqlite3 is required.\n' >&2 exit 1 fi } has_full_tty() { [[ -t 0 && -t 1 && "${TERM:-}" != "dumb" ]] } clear_screen() { if has_full_tty && command -v clear >/dev/null 2>&1; then clear || true fi } show_in_pager() { local content=$1 # `less` behaves better than `gum pager` in tight terminals and falls back # cleanly when the command is used from pipes or automation. if has_full_tty && command -v less >/dev/null 2>&1; then less -RFX <<<"$content" return 0 fi printf '%s\n' "$content" } db_parent_dir() { local target=$1 local path_without_query if [[ "$target" == file:* ]]; then path_without_query=${target#file:} path_without_query=${path_without_query%%\?*} if [[ -z "$path_without_query" || "$path_without_query" == ':memory:' ]]; then return 1 fi dirname "$path_without_query" return 0 fi dirname "$target" } ensure_state_dir() { local parent_dir if parent_dir=$(db_parent_dir "$DB_PATH"); then mkdir -p "$parent_dir" fi } init_db() { ensure_state_dir if [[ "$DB_PATH" == file:* ]]; then sqlite3 "$DB_PATH" <<'SQL' PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS games ( id INTEGER PRIMARY KEY AUTOINCREMENT, status TEXT NOT NULL DEFAULT 'active', turn TEXT NOT NULL CHECK (turn IN ('white', 'black')), winner TEXT, note TEXT, en_passant_file INTEGER, en_passant_rank INTEGER, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS pieces ( id INTEGER PRIMARY KEY AUTOINCREMENT, game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE, file INTEGER NOT NULL CHECK (file BETWEEN 1 AND 8), rank INTEGER NOT NULL CHECK (rank BETWEEN 1 AND 8), piece TEXT NOT NULL, color TEXT NOT NULL CHECK (color IN ('white', 'black')), kind TEXT NOT NULL CHECK (kind IN ('king', 'queen', 'rook', 'bishop', 'knight', 'pawn')), has_moved INTEGER NOT NULL DEFAULT 0 CHECK (has_moved IN (0, 1)), UNIQUE (game_id, file, rank) ); CREATE INDEX IF NOT EXISTS idx_pieces_game_color ON pieces(game_id, color); CREATE INDEX IF NOT EXISTS idx_pieces_game_square ON pieces(game_id, file, rank); CREATE TABLE IF NOT EXISTS moves ( id INTEGER PRIMARY KEY AUTOINCREMENT, game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE, ply INTEGER NOT NULL, from_sq TEXT NOT NULL, to_sq TEXT NOT NULL, piece TEXT NOT NULL, capture TEXT, promotion TEXT, notation TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE (game_id, ply) ); CREATE INDEX IF NOT EXISTS idx_moves_game_ply ON moves(game_id, ply); CREATE TABLE IF NOT EXISTS identities ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS game_players ( game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE, color TEXT NOT NULL CHECK (color IN ('white', 'black')), identity_id INTEGER NOT NULL REFERENCES identities(id) ON DELETE CASCADE, PRIMARY KEY (game_id, color) ); CREATE TABLE IF NOT EXISTS app_state ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_identities_last_used ON identities(last_used_at DESC, name ASC); CREATE INDEX IF NOT EXISTS idx_game_players_identity ON game_players(identity_id); SQL return fi sqlite3 "$DB_PATH" <<'SQL' PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS games ( id INTEGER PRIMARY KEY AUTOINCREMENT, status TEXT NOT NULL DEFAULT 'active', turn TEXT NOT NULL CHECK (turn IN ('white', 'black')), winner TEXT, note TEXT, en_passant_file INTEGER, en_passant_rank INTEGER, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS pieces ( id INTEGER PRIMARY KEY AUTOINCREMENT, game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE, file INTEGER NOT NULL CHECK (file BETWEEN 1 AND 8), rank INTEGER NOT NULL CHECK (rank BETWEEN 1 AND 8), piece TEXT NOT NULL, color TEXT NOT NULL CHECK (color IN ('white', 'black')), kind TEXT NOT NULL CHECK (kind IN ('king', 'queen', 'rook', 'bishop', 'knight', 'pawn')), has_moved INTEGER NOT NULL DEFAULT 0 CHECK (has_moved IN (0, 1)), UNIQUE (game_id, file, rank) ); CREATE INDEX IF NOT EXISTS idx_pieces_game_color ON pieces(game_id, color); CREATE INDEX IF NOT EXISTS idx_pieces_game_square ON pieces(game_id, file, rank); CREATE TABLE IF NOT EXISTS moves ( id INTEGER PRIMARY KEY AUTOINCREMENT, game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE, ply INTEGER NOT NULL, from_sq TEXT NOT NULL, to_sq TEXT NOT NULL, piece TEXT NOT NULL, capture TEXT, promotion TEXT, notation TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE (game_id, ply) ); CREATE INDEX IF NOT EXISTS idx_moves_game_ply ON moves(game_id, ply); CREATE TABLE IF NOT EXISTS identities ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS game_players ( game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE, color TEXT NOT NULL CHECK (color IN ('white', 'black')), identity_id INTEGER NOT NULL REFERENCES identities(id) ON DELETE CASCADE, PRIMARY KEY (game_id, color) ); CREATE TABLE IF NOT EXISTS app_state ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_identities_last_used ON identities(last_used_at DESC, name ASC); CREATE INDEX IF NOT EXISTS idx_game_players_identity ON game_players(identity_id); SQL } piece_symbol() { local color=$1 local kind=$2 case "$color:$kind" in white:king) printf 'K' ;; white:queen) printf 'Q' ;; white:rook) printf 'R' ;; white:bishop) printf 'B' ;; white:knight) printf 'N' ;; white:pawn) printf 'P' ;; black:king) printf 'k' ;; black:queen) printf 'q' ;; black:rook) printf 'r' ;; black:bishop) printf 'b' ;; black:knight) printf 'n' ;; black:pawn) printf 'p' ;; *) printf '?' ;; esac } opposite_color() { if [[ "$1" == "white" ]]; then printf 'black' else printf 'white' fi } file_to_letter() { case "$1" in 1) printf 'a' ;; 2) printf 'b' ;; 3) printf 'c' ;; 4) printf 'd' ;; 5) printf 'e' ;; 6) printf 'f' ;; 7) printf 'g' ;; 8) printf 'h' ;; *) return 1 ;; esac } letter_to_file() { case "$1" in a|A) printf '1' ;; b|B) printf '2' ;; c|C) printf '3' ;; d|D) printf '4' ;; e|E) printf '5' ;; f|F) printf '6' ;; g|G) printf '7' ;; h|H) printf '8' ;; *) return 1 ;; esac } coords_to_square() { printf '%s%s' "$(file_to_letter "$1")" "$2" } square_to_coords() { local square=$1 local file_letter rank file_letter=${square%${square#?}} rank=${square#?} printf '%s|%s' "$(letter_to_file "$file_letter")" "$rank" } is_on_board() { local file=$1 local rank=$2 [[ "$file" -ge 1 && "$file" -le 8 && "$rank" -ge 1 && "$rank" -le 8 ]] } current_game_id() { dbq "SELECT id FROM games WHERE status = 'active' ORDER BY updated_at DESC, id DESC LIMIT 1;" } game_turn() { dbq "SELECT turn FROM games WHERE id = $1;" } game_status() { dbq "SELECT status FROM games WHERE id = $1;" } game_note() { dbq "SELECT COALESCE(note, '') FROM games WHERE id = $1;" } game_is_local() { dbq " SELECT CASE WHEN COUNT(DISTINCT identity_id) = 1 THEN 1 ELSE 0 END FROM game_players WHERE game_id = $1;" } game_en_passant() { dbq "SELECT COALESCE(en_passant_file, 0) || '|' || COALESCE(en_passant_rank, 0) FROM games WHERE id = $1;" } piece_info() { dbqt "SELECT id, file, rank, color, kind, has_moved FROM pieces WHERE id = $1;" } piece_id_at() { dbq "SELECT id FROM pieces WHERE game_id = $1 AND file = $2 AND rank = $3;" } occupant_data() { dbqt "SELECT id, color, kind, piece FROM pieces WHERE game_id = $1 AND file = $2 AND rank = $3;" } occupied_color() { dbq "SELECT color FROM pieces WHERE game_id = $1 AND file = $2 AND rank = $3;" } count_lines() { local count=0 line while IFS= read -r line; do [[ -n "$line" ]] || continue count=$((count + 1)) done printf '%s' "$count" } confirm_action() { local prompt=$1 local reply if has_gum; then gum_confirm_interactive "$prompt" return $? fi printf '%s [y/N] ' "$prompt" >&2 read_user_line reply || return 1 [[ "$reply" == "y" || "$reply" == "Y" ]] } list_identity_names() { dbq "SELECT name FROM identities ORDER BY last_used_at DESC, name ASC;" } identity_name_by_id() { dbq "SELECT name FROM identities WHERE id = $1;" } selected_identity_id() { if [[ -n "$SESSION_IDENTITY_ID" ]]; then printf '%s' "$SESSION_IDENTITY_ID" return 0 fi dbq "SELECT value FROM app_state WHERE key = 'selected_identity_id' LIMIT 1;" } selected_identity_name() { if [[ -n "$SESSION_IDENTITY_NAME" ]]; then printf '%s' "$SESSION_IDENTITY_NAME" return 0 fi local identity_id identity_id=$(selected_identity_id) if [[ -z "$identity_id" ]]; then return 0 fi identity_name_by_id "$identity_id" } config_identity_name() { local line trimmed_line key value if [[ ! -f "$CONFIG_PATH" ]]; then return 0 fi while IFS= read -r line || [[ -n "$line" ]]; do trimmed_line=$(trim_string "$line") [[ -n "$trimmed_line" ]] || continue [[ "${trimmed_line#\#}" != "$trimmed_line" ]] && continue key=${trimmed_line%%=*} value=${trimmed_line#*=} key=$(trim_string "$key") value=$(trim_string "$value") if [[ "$key" == "identity" && -n "$value" ]]; then printf '%s' "$value" return 0 fi done <"$CONFIG_PATH" } stdin_board_uri() { local uri='' if [[ -t 0 ]]; then return 0 fi IFS= read -r uri || true trim_string "$uri" } ensure_identity() { local raw_name=$1 local name safe_name name=$(trim_string "$raw_name") if [[ -z "$name" ]]; then return 1 fi safe_name=$(sql_quote "$name") if [[ "$DB_PATH" == file:* ]]; then sqlite3 -batch -noheader "$DB_PATH" <&2 read_user_line reply || return 1 if [[ -z "$reply" && -n "$default_value" ]]; then reply=$default_value fi printf '%s' "$reply" } pick_option() { local header=$1 local options=() local option_count reply i option shift options=("$@") option_count=${#options[@]} if [[ "$option_count" -eq 0 ]]; then return 1 fi if has_gum; then gum_choose_interactive --header "$header" "${options[@]}" return 0 fi printf '%s\n' "$header" >&2 for ((i = 0; i < option_count; i += 1)); do printf ' %d) %s\n' "$((i + 1))" "${options[$i]}" >&2 done while :; do reply=$(prompt_text_input "Choose 1-$option_count") || return 1 reply=$(trim_string "$reply") if [[ "$reply" =~ ^[0-9]+$ ]] && ((reply >= 1 && reply <= option_count)); then printf '%s' "${options[$((reply - 1))]}" return 0 fi for option in "${options[@]}"; do if [[ "$reply" == "$option" ]]; then printf '%s' "$option" return 0 fi done printf 'Choose one of the listed options.\n' >&2 done } prompt_for_identity_name() { local prompt=$1 local default_name=${2:-} local choice name local options=() if has_gum; then while IFS= read -r line; do [[ -n "$line" ]] || continue options[${#options[@]}]=$line done <&2 done } switch_identity_interactively() { local game_id local name game_id=$(current_game_id) if [[ -n "$game_id" && "$(game_is_local "$game_id")" -ne 1 ]]; then printf 'Identity switching is disabled after a multiplayer game starts. Use --identity before joining, or use --local for shared-device play.\n' >&2 return 1 fi name=$(prompt_for_identity_name "Select identity" "$(selected_identity_name)") || return 1 set_selected_identity "$name" 1 >/dev/null } ensure_selected_identity_for_interactive() { local current_name current_name=$(selected_identity_name) if [[ -n "$current_name" ]]; then return 0 fi switch_identity_interactively } seed_new_game() { local white_identity_id=$1 local black_identity_id=$2 dbq " BEGIN; UPDATE games SET status = 'archived', updated_at = CURRENT_TIMESTAMP WHERE status = 'active'; INSERT INTO games(status, turn, winner, note, en_passant_file, en_passant_rank) VALUES('active', 'white', NULL, NULL, NULL, NULL); INSERT INTO pieces(game_id, file, rank, piece, color, kind, has_moved) SELECT game_id, file, rank, piece, color, kind, has_moved FROM ( 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_moved UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 2, 1, 'N', 'white', 'knight', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 3, 1, 'B', 'white', 'bishop', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 4, 1, 'Q', 'white', 'queen', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 5, 1, 'K', 'white', 'king', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 6, 1, 'B', 'white', 'bishop', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 7, 1, 'N', 'white', 'knight', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 8, 1, 'R', 'white', 'rook', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 1, 2, 'P', 'white', 'pawn', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 2, 2, 'P', 'white', 'pawn', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 3, 2, 'P', 'white', 'pawn', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 4, 2, 'P', 'white', 'pawn', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 5, 2, 'P', 'white', 'pawn', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 6, 2, 'P', 'white', 'pawn', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 7, 2, 'P', 'white', 'pawn', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 8, 2, 'P', 'white', 'pawn', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 1, 8, 'r', 'black', 'rook', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 2, 8, 'n', 'black', 'knight', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 3, 8, 'b', 'black', 'bishop', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 4, 8, 'q', 'black', 'queen', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 5, 8, 'k', 'black', 'king', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 6, 8, 'b', 'black', 'bishop', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 7, 8, 'n', 'black', 'knight', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 8, 8, 'r', 'black', 'rook', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 1, 7, 'p', 'black', 'pawn', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 2, 7, 'p', 'black', 'pawn', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 3, 7, 'p', 'black', 'pawn', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 4, 7, 'p', 'black', 'pawn', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 5, 7, 'p', 'black', 'pawn', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 6, 7, 'p', 'black', 'pawn', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 7, 7, 'p', 'black', 'pawn', 0 UNION ALL SELECT (SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 8, 7, 'p', 'black', 'pawn', 0 ); INSERT INTO game_players(game_id, color, identity_id) VALUES ((SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 'white', $white_identity_id), ((SELECT id FROM games WHERE status = 'active' ORDER BY id DESC LIMIT 1), 'black', $black_identity_id); COMMIT;" } start_new_game_flow() { local mode=$1 local selected_name white_name black_name white_identity_id black_identity_id game_id selected_name=$(selected_identity_name) if [[ "$mode" == "local" ]]; then if [[ -z "$selected_name" ]]; then return 1 fi white_identity_id=$(ensure_identity "$selected_name") || return 1 black_identity_id=$white_identity_id else while :; do white_name=$(prompt_for_identity_name "White player" "$selected_name") || return 1 black_name=$(prompt_for_identity_name "Black player") || return 1 white_identity_id=$(ensure_identity "$white_name") || continue black_identity_id=$(ensure_identity "$black_name") || continue if [[ "$white_identity_id" == "$black_identity_id" ]]; then printf 'Use --local if one identity should control both colors.\n' >&2 continue fi break done fi seed_new_game "$white_identity_id" "$black_identity_id" game_id=$(current_game_id) if [[ -n "$game_id" ]]; then update_game_state "$game_id" fi } ensure_active_game() { current_game_id } attack_query_for_square() { local game_id=$1 local attacker_color=$2 local target_file=$3 local target_rank=$4 cat <&2 exit 1 fi identity_arg=$1 ;; --new|--local|--moves|--whoami|--help|-h) if [[ -n "$action" ]]; then printf 'Only one command may be used at a time.\n' >&2 exit 1 fi action=$1 ;; *) printf 'Unknown option: %s\n\n' "$1" >&2 usage >&2 exit 1 ;; esac shift done case "$action" in --help|-h) usage return 0 ;; esac if [[ "$action" != "--help" && "$action" != "-h" ]]; then board_uri=$(stdin_board_uri) if [[ -n "$board_uri" ]]; then DB_PATH=$board_uri fi fi init_db config_identity=$(config_identity_name) selected_name=$(selected_identity_name) if [[ -n "$identity_arg" ]]; then set_selected_identity "$identity_arg" >/dev/null elif [[ -n "$config_identity" ]]; then set_selected_identity "$config_identity" >/dev/null fi case "$action" in --help|-h) usage ;; --moves) show_moves ;; --whoami) if [[ -n "$(selected_identity_name)" ]]; then selected_identity_name else printf 'No identity selected.\n' fi ;; --new) prepare_interactive_stdin || exit 1 ensure_selected_identity_for_interactive || exit 0 if confirm_action "Start a new game?"; then start_new_game_flow "multiplayer" fi interactive_game ;; --local) prepare_interactive_stdin || exit 1 ensure_selected_identity_for_interactive || exit 0 if confirm_action "Start a new local game?"; then start_new_game_flow "local" fi interactive_game ;; "") interactive_game ;; *) printf 'Unknown option: %s\n\n' "$action" >&2 usage >&2 exit 1 ;; esac } if [[ "${BASH_SOURCE[0]-$0}" == "$0" ]]; then main "$@" fi