diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 18ac36a..94a63de 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -14,10 +14,11 @@ jobs: strategy: matrix: include: - - name: Linux GCC + - name: Linux GCC x64 os: ubuntu-latest compiler_cc: gcc compiler_cpp: g++ + asset: caravan_linux_x64 steps: - name: Checkout @@ -37,10 +38,10 @@ jobs: - name: Rename Executable working-directory: ./build - run: mv caravan caravan_linux_x64 + run: mv caravan ${{matrix.asset}} - name: Upload Executable to Release uses: AButler/upload-release-assets@v3.0 with: - files: ./build/caravan_linux_x64 + files: ./build/${{matrix.asset}} repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml new file mode 100644 index 0000000..3daaef8 --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,47 @@ +name: MacOS + +on: + release: + types: [ created ] + +env: + BUILD_TYPE: Release + +jobs: + build: + name: Build + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - name: MacOS Clang x64 + os: macos-latest + compiler_cc: clang + compiler_cpp: clang++ + asset: caravan_macos_x64 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install CMake and Ninja + uses: lukka/get-cmake@latest + with: + cmakeVersion: "~3.27.0" # use most recent 3.27.x version + ninjaVersion: "^1.0" # use most recent 1.x version + + - name: CMake Setup + run: cmake -S . -B ${{github.workspace}}/build -G Ninja -D CMAKE_C_COMPILER=${{matrix.compiler_cc}} CMAKE_CXX_COMPILER=${{matrix.compiler_cpp}} -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} + + - name: CMake Build + run: cmake --build ${{github.workspace}}/build --config ${{env.BUILD_TYPE}} --target caravan + + - name: Rename Executable + working-directory: ./build + run: mv caravan ${{matrix.asset}} + + - name: Upload Executable to Release + uses: AButler/upload-release-assets@v3.0 + with: + files: ./build/${{matrix.asset}} + repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index dddccf9..e610af5 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: include: - - name: Linux GCC + - name: Linux GCC x64 os: ubuntu-latest compiler_cc: gcc compiler_cpp: g++ diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index d8f3a4d..88eff9e 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -14,10 +14,11 @@ jobs: strategy: matrix: include: - - name: Windows GCC + - name: Windows GCC x64 os: windows-latest compiler_cc: gcc compiler_cpp: g++ + asset: caravan_windows_x64.exe steps: - name: Checkout @@ -37,10 +38,10 @@ jobs: - name: Rename Executable working-directory: ./build - run: mv caravan.exe caravan_windows_x64.exe + run: mv caravan.exe ${{matrix.asset}} - name: Upload Executable to Release uses: AButler/upload-release-assets@v3.0 with: - files: ./build/caravan_windows_x64.exe + files: ./build/${{matrix.asset}} repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/CMakeLists.txt b/CMakeLists.txt index fa5c837..66c09dc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,7 +6,7 @@ set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) -set(PROJECT_VERSION 1.1.0) +set(PROJECT_VERSION 1.2.0) set(PROJECT_DESCRIPTION "A command-line version of the Caravan card game from Fallout: New Vegas.") set(PROJECT_COPYRIGHT "Copyright (c) 2022-2024 r3w0p") set(PROJECT_URL "https://github.com/r3w0p/caravan") diff --git a/README b/README index b79fd72..9d8edb2 100644 --- a/README +++ b/README @@ -3,7 +3,7 @@ | (_| (_| | | | (_| |\ \/ / (_| | | | | \___\__,_|_| \__,_| \__/ \__,_|_| |_| -| v1.1.0 | GPL-3.0 | (c) 2022-2024 r3w0p | +| v1.2.0 | GPL-3.0 | (c) 2022-2024 r3w0p | A command-line version of the Caravan card game from Fallout: New Vegas. diff --git a/include/caravan/view/view.h b/include/caravan/view/view.h index d6aecda..d705124 100644 --- a/include/caravan/view/view.h +++ b/include/caravan/view/view.h @@ -37,6 +37,9 @@ typedef struct ViewConfig { // Most recent command GameCommand command; + // Board highlight + GameCommand highlight; + // Colour support bool colour{}; diff --git a/src/caravan/main.cpp b/src/caravan/main.cpp index cdcaf31..96a1985 100644 --- a/src/caravan/main.cpp +++ b/src/caravan/main.cpp @@ -9,9 +9,25 @@ const std::string OPTS_HELP = "h,help"; const std::string OPTS_VERSION = "v,version"; +const std::string OPTS_PVP = "pvp"; +const std::string OPTS_BVB = "bvb"; +const std::string OPTS_BOT = "b,bot"; +const std::string OPTS_DELAY = "d,delay"; +const std::string OPTS_FIRST = "f,first"; +const std::string OPTS_CARDS = "c,cards"; +const std::string OPTS_SAMPLES = "s,samples"; +const std::string OPTS_IMBALANCED = "i,imbalanced"; const std::string KEY_HELP = "help"; const std::string KEY_VERSION = "version"; +const std::string KEY_PVP = "pvp"; +const std::string KEY_BVB = "bvb"; +const std::string KEY_BOT = "bot"; +const std::string KEY_DELAY = "delay"; +const std::string KEY_FIRST = "first"; +const std::string KEY_CARDS = "cards"; +const std::string KEY_SAMPLES = "samples"; +const std::string KEY_IMBALANCED = "imbalanced"; const uint8_t FIRST_ABC = 1; const uint8_t FIRST_DEF = 2; @@ -29,14 +45,14 @@ int main(int argc, char *argv[]) { options.add_options() (OPTS_HELP, "Print help instructions.") (OPTS_VERSION, "Print software version.") - ("pvp", "A Player vs Player game.") - ("bvb", "A Bot vs Bot game.") - ("b,bot", "Which bot to play with (normal, friendly).", cxxopts::value()->default_value("normal")) - ("d,delay", "Delay before bot makes its move (in seconds).", cxxopts::value()->default_value("1.0")) - ("f,first", "Which player goes first (1 or 2).", cxxopts::value()->default_value("1")) - ("c,cards", "Number of cards for each caravan deck (30-162, inclusive).", cxxopts::value()->default_value("54")) - ("s,samples", "Number of traditional decks to sample when building caravan decks (1-3, inclusive).", cxxopts::value()->default_value("1")) - ("i,imbalanced", + (OPTS_PVP, "A Player vs Player game.") + (OPTS_BVB, "A Bot vs Bot game.") + (OPTS_BOT, "Which bot to play with (normal, friendly).", cxxopts::value()->default_value("normal")) + (OPTS_DELAY, "Delay before bot makes its move (in seconds).", cxxopts::value()->default_value("1.0")) + (OPTS_FIRST, "Which player goes first (1 or 2).", cxxopts::value()->default_value("1")) + (OPTS_CARDS, "Number of cards for each caravan deck (30-162, inclusive).", cxxopts::value()->default_value("54")) + (OPTS_SAMPLES, "Number of traditional decks to sample when building caravan decks (1-3, inclusive).", cxxopts::value()->default_value("1")) + (OPTS_IMBALANCED, "An imbalanced caravan deck is built by taking as many " "cards from one shuffled sample deck before moving to the next. " "A balanced deck randomly samples cards across all sample decks.") @@ -59,37 +75,37 @@ int main(int argc, char *argv[]) { exit(EXIT_SUCCESS); } - bool pvp = result["pvp"].as(); - bool bots = result["bvb"].as(); - std::string bot = result["bot"].as(); - float delay = result["delay"].as(); - uint8_t first = result["first"].as(); - uint8_t cards = result["cards"].as(); - uint8_t samples = result["samples"].as(); - bool imbalanced = result["imbalanced"].as(); - - if (pvp && bots) { - printf("Game cannot be both Player vs Player and Bot vs Bot."); + bool pvp = result[KEY_PVP].as(); + bool bvb = result[KEY_BVB].as(); + std::string bot = result[KEY_BOT].as(); + float delay = result[KEY_DELAY].as(); + uint8_t first = result[KEY_FIRST].as(); + uint8_t cards = result[KEY_CARDS].as(); + uint8_t samples = result[KEY_SAMPLES].as(); + bool imbalanced = result[KEY_IMBALANCED].as(); + + if (pvp && bvb) { + printf("Game cannot be both Player vs Player and Bot vs Bot.\n"); exit(EXIT_FAILURE); } if(first < FIRST_ABC || first > FIRST_DEF) { - printf("First player must be either %d or %d.", FIRST_ABC, FIRST_DEF); + printf("First player must be either %d or %d.\n", FIRST_ABC, FIRST_DEF); exit(EXIT_FAILURE); } if (cards < DECK_CARAVAN_MIN || cards > DECK_CARAVAN_MAX) { - printf("Caravan decks must have between %d and %d cards (inclusive).", DECK_CARAVAN_MIN, DECK_CARAVAN_MAX); + printf("Caravan decks must have between %d and %d cards (inclusive).\n", DECK_CARAVAN_MIN, DECK_CARAVAN_MAX); exit(EXIT_FAILURE); } if (samples < SAMPLE_DECKS_MIN || samples > SAMPLE_DECKS_MAX) { - printf("Number of caravan deck samples must be between %d and %d (inclusive).", SAMPLE_DECKS_MIN, SAMPLE_DECKS_MIN); + printf("Number of caravan deck samples must be between %d and %d (inclusive).\n", SAMPLE_DECKS_MIN, SAMPLE_DECKS_MIN); exit(EXIT_FAILURE); } if(delay < 0) { - printf("Bot delay cannot be a negative number."); + printf("Bot delay cannot be a negative number.\n"); exit(EXIT_FAILURE); } @@ -97,7 +113,7 @@ int main(int argc, char *argv[]) { user_abc = new UserHuman(PLAYER_ABC); user_def = new UserHuman(PLAYER_DEF); - } else if (bots) { // bot vs bot + } else if (bvb) { // bot vs bot user_abc = BotFactory::get(bot, PLAYER_ABC); user_def = BotFactory::get(bot, PLAYER_DEF); @@ -122,11 +138,11 @@ int main(int argc, char *argv[]) { view = new ViewTUI(&vc, game); } catch (CaravanException &e) { - printf("%s", e.what().c_str()); + printf("%s\n", e.what().c_str()); exit(EXIT_FAILURE); } catch (std::exception &e) { - printf("%s", e.what()); + printf("%s\n", e.what()); exit(EXIT_FAILURE); } diff --git a/src/caravan/view/view_tui.cpp b/src/caravan/view/view_tui.cpp index 6ec0b4f..6223334 100644 --- a/src/caravan/view/view_tui.cpp +++ b/src/caravan/view/view_tui.cpp @@ -161,11 +161,13 @@ void push_card(ViewConfig *vc, ftxui::Elements *e, Card card, bool lead) { using namespace ftxui; if (card.rank == JOKER) { + // Push lead then "JO" to compensate for lack of suit if(lead) { e->push_back(text(L" ")); } - e->push_back(text(rank_to_wstr(card.rank, lead))); + e->push_back(text(rank_to_wstr(card.rank, lead)) | color(Color::Default)); } else { - e->push_back(text(rank_to_wstr(card.rank, lead))); + // Push rank then suit + e->push_back(text(rank_to_wstr(card.rank, lead)) | color(Color::Default)); e->push_back(suit_to_text(vc, card.suit)); } } @@ -371,47 +373,60 @@ GameCommand ViewTUI::parse_user_input(std::string input, bool confirmed) { if (closed) { return command; } if (input.empty()) { return command; } - if (!confirmed) { return command; } - - /* - * FIRST - * - COMMAND TYPE - */ - process_first(input, &command); - - /* - * SECOND - * - HAND POSITION or - * - CARAVAN NAME - */ - process_second(input, &command); - - /* - * THIRD - * - CARAVAN NAME - */ - process_third(input, &command); - - /* - * FOURTH - * - CARAVAN POSITION (used when selecting Face card only) - */ - process_fourth(input, &command); + + try { + /* + * FIRST + * - COMMAND TYPE + */ + process_first(input, &command); + + /* + * SECOND + * - HAND POSITION or + * - CARAVAN NAME + */ + process_second(input, &command); + + /* + * THIRD + * - CARAVAN NAME + */ + process_third(input, &command); + + /* + * FOURTH + * - CARAVAN POSITION (used when selecting Face card only) + */ + process_fourth(input, &command); + + } catch(CaravanInputException &e) { + if(confirmed) { + // For confirmed commands: throw to other handling that prints + // command errors to the player + throw; + } else { + // For unconfirmed commands: accept whatever was able to be parsed + // so that it can be used for highlighting the game board + return command; + } + } return command; } std::shared_ptr gen_position(uint8_t position, bool blank = false) { using namespace ftxui; - return text(blank ? "" : std::to_string(position)) | borderEmpty | size(WIDTH, EQUAL, WIDTH_POSITION) | size(HEIGHT, EQUAL, HEIGHT_POSITION); + return text(blank ? "" : std::to_string(position)) | borderEmpty | color(Color::Default) | size(WIDTH, EQUAL, WIDTH_POSITION) | size(HEIGHT, EQUAL, HEIGHT_POSITION); } std::shared_ptr gen_position_blank() { return gen_position(0, true); } -std::shared_ptr gen_card(ViewConfig *vc, Card card, bool hide, bool blank = false) { +std::shared_ptr gen_card(ViewConfig *vc, Card card, bool hide, bool highlight, bool blank = false) { using namespace ftxui; + std::shared_ptr ret; Elements value; if (!blank) { @@ -422,11 +437,23 @@ std::shared_ptr gen_card(ViewConfig *vc, Card card, bool hide, bool } } - return hbox(value) | (blank ? borderEmpty : borderDouble) | size(WIDTH, EQUAL, WIDTH_CARD) | size(HEIGHT, EQUAL, HEIGHT_CARD); + ret = hbox(value); + + if(blank) { + ret = ret | borderEmpty; + } else if(highlight) { + ret = ret | borderHeavy | (vc->colour ? color(Color::Palette16::MagentaLight) : color(Color::Default)); + } else { + ret = ret | borderDouble; + } + + ret = ret | size(WIDTH, EQUAL, WIDTH_CARD) | size(HEIGHT, EQUAL, HEIGHT_CARD); + + return ret; } std::shared_ptr gen_card_blank() { - return gen_card({}, {}, false, true); + return gen_card({}, {}, false, false, true); } std::shared_ptr gen_faces(ViewConfig *vc, Slot slot, bool blank = false) { @@ -451,30 +478,43 @@ std::shared_ptr gen_faces(ViewConfig *vc, Slot slot, bool blank = f } return vbox({ - text(ranks), - hbox(suits) - }) | borderEmpty | size(WIDTH, EQUAL, WIDTH_FACES) | size(HEIGHT, EQUAL, HEIGHT_FACES); + text(ranks) | color(Color::Default), + hbox(suits) + }) | borderEmpty | size(WIDTH, EQUAL, WIDTH_FACES) | size(HEIGHT, EQUAL, HEIGHT_FACES); } std::shared_ptr gen_faces_blank() { return gen_faces({}, {}, true); } -std::shared_ptr gen_caravan_slot(ViewConfig *vc, uint8_t position, Slot slot, bool blank = false) { +std::shared_ptr gen_caravan_slot(ViewConfig *vc, uint8_t position, Slot slot, bool highlight, bool blank = false) { using namespace ftxui; - return hbox({ - blank ? gen_position_blank() : gen_position(position), - blank ? gen_card_blank() : gen_card(vc, slot.card, false), - blank ? gen_faces_blank() : gen_faces(vc, slot), - }) | hcenter | size(WIDTH, EQUAL, WIDTH_CARAVAN_SLOT) | size(HEIGHT, EQUAL, HEIGHT_CARAVAN_SLOT); + std::shared_ptr ret; + Elements e; + e.push_back(blank ? gen_position_blank() : gen_position(position)); + + if(blank) { + e.push_back(gen_card_blank()); + } else if(highlight) { + e.push_back(gen_card(vc, slot.card, false, highlight)); + } else { + e.push_back(gen_card(vc, slot.card, false, highlight) | color(Color::Default)); + } + + e.push_back(blank ? gen_faces_blank() : gen_faces(vc, slot)); + + ret = hbox(e) | hcenter | size(WIDTH, EQUAL, WIDTH_CARAVAN_SLOT) | size(HEIGHT, EQUAL, HEIGHT_CARAVAN_SLOT); + + return ret; } std::shared_ptr gen_caravan_slot_blank() { - return gen_caravan_slot({}, 0, {}, true); + return gen_caravan_slot({}, 0, {}, false, true); } std::shared_ptr gen_caravan(ViewConfig *vc, Game *game, CaravanName cn, bool top) { using namespace ftxui; + std::shared_ptr ret; std::shared_ptr content; Elements e; Elements title; @@ -486,7 +526,13 @@ std::shared_ptr gen_caravan(ViewConfig *vc, Game *game, CaravanName if ((top && (TRACK_NUMERIC_MAX - i) <= caravan_size) || (!top && i + 1 <= caravan_size)) { uint8_t position = top ? TRACK_NUMERIC_MAX - i : i + 1; - e.push_back(gen_caravan_slot(vc, position, caravan->get_slot(position))); + // Highlight caravan slot if selected for placement of face card + bool highlight = + vc->highlight.option != NO_OPTION && + vc->highlight.caravan_name == cn && + vc->highlight.pos_caravan == position; + + e.push_back(gen_caravan_slot(vc, position, caravan->get_slot(position), highlight)); } else { e.push_back(gen_caravan_slot_blank()); @@ -512,7 +558,6 @@ std::shared_ptr gen_caravan(ViewConfig *vc, Game *game, CaravanName } } - title.push_back(text(L" ")); title.push_back(text(caravan_to_wstr(cn, true) + L" ") | maybe_colour); if (game->get_table()->get_caravan(cn)->get_size() > 0) { @@ -524,22 +569,33 @@ std::shared_ptr gen_caravan(ViewConfig *vc, Game *game, CaravanName title.push_back(text(L" ")); } - return window( + ret = window( hbox({title}) | hcenter | bold, content ) | center | size(WIDTH, EQUAL, WIDTH_CARAVAN) | size(HEIGHT, EQUAL, HEIGHT_CARAVAN); + + // Highlight card if caravan selected in unconfirmed command + bool highlight = + vc->highlight.option != NO_OPTION && + vc->highlight.caravan_name == cn; + + if(highlight) { + ret = ret | (vc->colour ? color(Color::Palette16::MagentaLight) : color(Color::Default)); + } + + return ret; } -std::shared_ptr gen_deck_card(ViewConfig *vc, Game *game, uint8_t position, Card card, bool hide, bool blank = false) { +std::shared_ptr gen_deck_card(ViewConfig *vc, Game *game, uint8_t position, Card card, bool hide, bool highlight, bool blank = false) { using namespace ftxui; return hbox({ blank || game->get_winner() != NO_PLAYER ? gen_position_blank() : gen_position(position), - blank ? gen_card_blank() : gen_card(vc, card, hide), + blank ? gen_card_blank() : gen_card(vc, card, hide, highlight), }) | size(HEIGHT, EQUAL, HEIGHT_CARAVAN_SLOT); } std::shared_ptr gen_deck_card_blank() { - return gen_deck_card({}, {}, 0, {}, false, true); + return gen_deck_card({}, {}, 0, {}, false, false, true); } std::shared_ptr gen_deck(ViewConfig *vc, Game *game, bool top) { @@ -588,7 +644,14 @@ std::shared_ptr gen_deck(ViewConfig *vc, Game *game, bool top) { uint8_t position = top ? hand_max - i : i + 1; Card card = player_this->get_hand()[position - 1]; - e.push_back(gen_deck_card(vc, game, position, card, hide)); + // Highlight card if it is this player's turn and unconfirmed + // command wants to use this hand card + bool highlight = + vc->user_turn->get_name() == player_this->get_name() && + vc->highlight.option != NO_OPTION && + vc->highlight.pos_hand == position; + + e.push_back(gen_deck_card(vc, game, position, card, hide, highlight)); } else if (i < HAND_SIZE_MAX_POST_START || equalise) { e.push_back(gen_deck_card_blank()); @@ -858,14 +921,17 @@ void ViewTUI::run() { // Tweak how the component tree is rendered: auto renderer = Renderer(component, [&] { - screen.SetCursor(Screen::Cursor(Screen::Cursor::Hidden)); + screen.SetCursor(Screen::Cursor({.shape=Screen::Cursor::Hidden})); try { if (closed) { return gen_closed(vc); } + // Reset values terminal_size = Terminal::Size(); set_current_turn(vc, game); confirmed = false; + vc->command = {}; + vc->highlight = {}; // Error screen if less than minimum terminal dimensions if (terminal_size.dimx < MIN_X || terminal_size.dimy < MIN_Y) { @@ -887,7 +953,9 @@ void ViewTUI::run() { if(vc->user_turn->is_human()) { // Create new command if ENTER key pressed (i.e., if newline) raw_command = user_input; + if (raw_command.ends_with('\n')) { + // A confirmed command ready to send to the game model raw_command.pop_back(); // remove newline confirmed = true; user_input = ""; @@ -915,8 +983,16 @@ void ViewTUI::run() { } try { - // Parse raw command to get usable command - vc->command = parse_user_input(raw_command, confirmed); + if(confirmed) { + // Parse raw command to get usable command + vc->command = parse_user_input(raw_command, confirmed); + } else { + // An incomplete command that can be used to highlight + // areas of the board as a hint to the player + vc->highlight = parse_user_input(raw_command, confirmed); + } + + raw_command = ""; switch (vc->command.option) { case NO_OPTION: