From 986ccc2eb810a8b0595b26b45762faac7a4da947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A1s=20B=20Nagy?= <20251272+BNAndras@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:58:59 -0700 Subject: [PATCH] Add `camicia` --- config.json | 8 + .../practice/camicia/.docs/instructions.md | 84 ++++ .../practice/camicia/.docs/introduction.md | 24 + exercises/practice/camicia/.meta/config.json | 21 + exercises/practice/camicia/.meta/example.gd | 63 +++ exercises/practice/camicia/.meta/template.j2 | 10 + exercises/practice/camicia/.meta/tests.toml | 94 ++++ exercises/practice/camicia/camicia.gd | 2 + exercises/practice/camicia/camicia_test.gd | 250 ++++++++++ exercises/practice/camicia/lib/test_runner.gd | 467 ++++++++++++++++++ exercises/practice/camicia/run_tests | 6 + 11 files changed, 1029 insertions(+) create mode 100644 exercises/practice/camicia/.docs/instructions.md create mode 100644 exercises/practice/camicia/.docs/introduction.md create mode 100644 exercises/practice/camicia/.meta/config.json create mode 100644 exercises/practice/camicia/.meta/example.gd create mode 100644 exercises/practice/camicia/.meta/template.j2 create mode 100644 exercises/practice/camicia/.meta/tests.toml create mode 100644 exercises/practice/camicia/camicia.gd create mode 100644 exercises/practice/camicia/camicia_test.gd create mode 100644 exercises/practice/camicia/lib/test_runner.gd create mode 100755 exercises/practice/camicia/run_tests diff --git a/config.json b/config.json index 349ea37..8f5848c 100644 --- a/config.json +++ b/config.json @@ -357,6 +357,14 @@ "prerequisites": [], "difficulty": 4 }, + { + "slug": "camicia", + "name": "Camicia", + "uuid": "54146929-8c06-4377-85cc-a13319583241", + "practices": [], + "prerequisites": [], + "difficulty": 5 + }, { "slug": "prism", "name": "Prism", diff --git a/exercises/practice/camicia/.docs/instructions.md b/exercises/practice/camicia/.docs/instructions.md new file mode 100644 index 0000000..db62fce --- /dev/null +++ b/exercises/practice/camicia/.docs/instructions.md @@ -0,0 +1,84 @@ +# Instructions + +In this exercise, you will simulate a game very similar to the classic card game **Camicia**. +Your program will receive the initial configuration of two players' decks and must simulate the game until it ends (or detect that it will never end). + +## Rules + +- The deck is split between **two players**. + The player's cards are read from left to right, where the leftmost card is the top of the deck. +- A round consists of both players playing at least one card. +- Players take turns placing the **top card** of their deck onto a central pile. +- If the card is a **number card** (2-10), play simply passes to the other player. +- If the card is a **payment card**, a penalty must be paid: + - **J** → opponent must pay 1 card + - **Q** → opponent must pay 2 cards + - **K** → opponent must pay 3 cards + - **A** → opponent must pay 4 cards +- If the player paying a penalty reveals another payment card, that player stops paying the penalty. + The other player must then pay a penalty based on the new payment card. +- If the penalty is fully paid without interruption, the player who placed the **last payment card** collects the central pile and places it at the bottom of their deck. + That player then starts the next round. +- If a player runs out of cards and is unable to play a card (either while paying a penalty or when it is their turn), the other player collects the central pile. +- The moment when a player collects cards from the central pile is called a **trick**. +- If a player has all the cards in their possession after a trick, the game **ends**. +- The game **enters a loop** as soon as the decks are identical to what they were earlier during the game, **not** counting number cards! + +## Examples + +A small example of a match that ends. + +| Round | Player A | Player B | Pile | Penalty Due | +| :---- | :----------- | :------------------------- | :------------------------- | :---------- | +| 1 | 2 A 7 8 Q 10 | 3 4 5 6 K 9 J | | - | +| 1 | A 7 8 Q 10 | 3 4 5 6 K 9 J | 2 | - | +| 1 | A 7 8 Q 10 | 4 5 6 K 9 J | 2 3 | - | +| 1 | 7 8 Q 10 | 4 5 6 K 9 J | 2 3 A | Player B: 4 | +| 1 | 7 8 Q 10 | 5 6 K 9 J | 2 3 A 4 | Player B: 3 | +| 1 | 7 8 Q 10 | 6 K 9 J | 2 3 A 4 5 | Player B: 2 | +| 1 | 7 8 Q 10 | K 9 J | 2 3 A 4 5 6 | Player B: 1 | +| 1 | 7 8 Q 10 | 9 J | 2 3 A 4 5 6 K | Player A: 3 | +| 1 | 8 Q 10 | 9 J | 2 3 A 4 5 6 K 7 | Player A: 2 | +| 1 | Q 10 | 9 J | 2 3 A 4 5 6 K 7 8 | Player A: 1 | +| 1 | 10 | 9 J | 2 3 A 4 5 6 K 7 8 Q | Player B: 2 | +| 1 | 10 | J | 2 3 A 4 5 6 K 7 8 Q 9 | Player B: 1 | +| 1 | 10 | - | 2 3 A 4 5 6 K 7 8 Q 9 J | Player A: 1 | +| 1 | - | - | 2 3 A 4 5 6 K 7 8 Q 9 J 10 | - | +| 2 | - | 2 3 A 4 5 6 K 7 8 Q 9 J 10 | - | - | + +status: `"finished"`, cards: 13, tricks: 1 + +This is a small example of a match that loops. + +| Round | Player A | Player B | Pile | Penalty Due | +| :---- | :------- | :------- | :---- | :---------- | +| 1 | J 2 3 | 4 J 5 | - | - | +| 1 | 2 3 | 4 J 5 | J | Player B: 1 | +| 1 | 2 3 | J 5 | J 4 | - | +| 2 | 2 3 J 4 | J 5 | - | - | +| 2 | 3 J 4 | J 5 | 2 | - | +| 2 | 3 J 4 | 5 | 2 J | Player A: 1 | +| 2 | J 4 | 5 | 2 J 3 | - | +| 3 | J 4 | 5 2 J 3 | - | - | +| 3 | J 4 | 2 J 3 | 5 | - | +| 3 | 4 | 2 J 3 | 5 J | Player B: 1 | +| 3 | 4 | J 3 | 5 J 2 | - | +| 4 | 4 5 J 2 | J 3 | - | - | + +The start of round 4 matches the start of round 2. +Recall, the value of the number cards does not matter. + +status: `"loop"`, cards: 8, tricks: 3 + +## Your Task + +- Using the input, simulate the game following the rules above. +- Determine the following information regarding the game: + - **Status**: `"finished"` or `"loop"` + - **Cards**: total number of cards played throughout the game + - **Tricks**: number of times the central pile was collected + +~~~~exercism/advanced +For those who want to take on a more exciting challenge, the hunt for other records for the longest game with an end is still open. +There are 653,534,134,886,878,245,000 (approximately 654 quintillion) possibilities, and we haven't calculated them all yet! +~~~~ diff --git a/exercises/practice/camicia/.docs/introduction.md b/exercises/practice/camicia/.docs/introduction.md new file mode 100644 index 0000000..761d8a8 --- /dev/null +++ b/exercises/practice/camicia/.docs/introduction.md @@ -0,0 +1,24 @@ +# Introduction + +One rainy afternoon, you sit at the kitchen table playing cards with your grandmother. +The game is her take on [Camicia][bmn]. + +At first it feels like just another friendly match: cards slapped down, laughter across the table, the occasional victorious grin from Nonna. +But as the game stretches on, something strange happens. +The same cards keep cycling back. +You play card after card, yet the end never seems to come. + +You start to wonder. +_Will this game ever finish? +Or could we keep playing forever?_ + +Later, driven by curiosity, you search online and to your surprise you discover that what happened wasn't just bad luck. +You and your grandmother may have stumbled upon one of the longest possible sequences! +Suddenly, you're hooked. +What began as a casual game has turned into a quest: _how long can such a game really last?_ +_Can you find a sequence even longer than the one you played at the kitchen table?_ +_Perhaps even long enough to set a new world record?_ + +And so, armed with nothing but a deck of cards and some algorithmic ingenuity, you decide to investigate... + +[bmn]: https://en.wikipedia.org/wiki/Beggar-my-neighbour diff --git a/exercises/practice/camicia/.meta/config.json b/exercises/practice/camicia/.meta/config.json new file mode 100644 index 0000000..71b6d21 --- /dev/null +++ b/exercises/practice/camicia/.meta/config.json @@ -0,0 +1,21 @@ +{ + "authors": [ + "BNAndras" + ], + "files": { + "solution": [ + "camicia.gd" + ], + "test": [ + "camicia_test.gd", + "run_tests", + "lib/test_runner.gd" + ], + "example": [ + ".meta/example.gd" + ] + }, + "blurb": "Simulate the card game and determine whether the match ends or enters an infinite loop.", + "source": "Beggar-My-Neighbour", + "source_url": "https://www.richardpmann.com/beggar-my-neighbour-records.html" +} diff --git a/exercises/practice/camicia/.meta/example.gd b/exercises/practice/camicia/.meta/example.gd new file mode 100644 index 0000000..eea1ac1 --- /dev/null +++ b/exercises/practice/camicia/.meta/example.gd @@ -0,0 +1,63 @@ +func simulate_game(player_a: Array, player_b: Array) -> Dictionary: + var hand_a := values(player_a) + var hand_b := values(player_b) + var turn_a := true + var pile: Array[int] = [] + var seen: Dictionary = {} + var tricks := 0 + var cards_played := 0 + var debt := 0 + + while true: + if pile.is_empty(): + var state := "%s|%s|%s" % [hand_a, hand_b, turn_a] + if seen.has(state): + return {"status": "loop", "tricks": tricks, "cards": cards_played} + seen[state] = true + + var active_hand: Array[int] = hand_a if turn_a else hand_b + var other_hand: Array[int] = hand_b if turn_a else hand_a + + if active_hand.is_empty(): + return { + "status": "finished", + "tricks": tricks + (1 if not pile.is_empty() else 0), + "cards": cards_played, + } + + var card_value: int = active_hand.pop_front() + pile.append(card_value) + cards_played += 1 + + if card_value > 0: + debt = card_value + turn_a = not turn_a + elif debt > 0: + debt -= 1 + if debt == 0: + other_hand.append_array(pile) + pile.clear() + tricks += 1 + if hand_a.is_empty() or hand_b.is_empty(): + return {"status": "finished", "tricks": tricks, "cards": cards_played} + turn_a = not turn_a + else: + turn_a = not turn_a + + return {} + + +func values(cards: Array) -> Array[int]: + var values: Array[int] = [] + for card in cards: + values.append(value(card)) + return values + + +func value(card: String) -> int: + match card: + "J": return 1 + "Q": return 2 + "K": return 3 + "A": return 4 + _: return 0 diff --git a/exercises/practice/camicia/.meta/template.j2 b/exercises/practice/camicia/.meta/template.j2 new file mode 100644 index 0000000..8acd492 --- /dev/null +++ b/exercises/practice/camicia/.meta/template.j2 @@ -0,0 +1,10 @@ +{% for case in cases %} +func test_{{ case.func_name }}(solution_script): + var want = {{ case.expected | togdscript }} + var got = solution_script.{{ case.property | camel_to_snake }}( + {{ case.input.playerA | togdscript }}, + {{ case.input.playerB | togdscript }} + ) + return [got, want] + +{% endfor %} diff --git a/exercises/practice/camicia/.meta/tests.toml b/exercises/practice/camicia/.meta/tests.toml new file mode 100644 index 0000000..18d3fdd --- /dev/null +++ b/exercises/practice/camicia/.meta/tests.toml @@ -0,0 +1,94 @@ +# This is an auto-generated file. +# +# Regenerating this file via `configlet sync` will: +# - Recreate every `description` key/value pair +# - Recreate every `reimplements` key/value pair, where they exist in problem-specifications +# - Remove any `include = true` key/value pair (an omitted `include` key implies inclusion) +# - Preserve any other key/value pair +# +# As user-added comments (using the # character) will be removed when this file +# is regenerated, comments can be added via a `comment` key. + +[0b7f737c-3ecd-4a55-b34d-e65c62a85c28] +description = "two cards, one trick" + +[27c19d75-53a5-48e5-b33b-232c3884d4f3] +description = "three cards, one trick" + +[9b02dd49-efaf-4b71-adca-a05c18a7c5b0] +description = "four cards, one trick" + +[fa3f4479-466a-4734-a001-ab79bfe27260] +description = "the ace reigns supreme" + +[07629689-f589-4f54-a6d1-8ce22776ce72] +description = "the king beats ace" + +[54d4a1c5-76fb-4d1e-8358-0e0296ac0601] +description = "the queen seduces the king" + +[c875500c-ff3d-47a4-bd1e-b60b90da80aa] +description = "the jack betrays the queen" + +[436875da-96ca-4149-be22-0b78173b8125] +description = "the 10 just wants to put on a show" + +[5be39bb6-1b34-4ce6-a1cd-0fcc142bb272] +description = "simple loop with decks of 3 cards" + +[2795dc21-0a2a-4c38-87c2-5a42e1ff15eb] +description = "the story is starting to get a bit complicated" + +[6999dfac-3fdc-41e2-b64b-38f4be228712] +description = "two tricks" + +[83dcd4f3-e089-4d54-855a-73f5346543a3] +description = "more tricks" + +[3107985a-f43e-486a-9ce8-db51547a9941] +description = "simple loop with decks of 4 cards" + +[dca32c31-11ed-49f6-b078-79ab912c1f7b] +description = "easy card combination" + +[1f8488d0-48d3-45ae-b819-59cedad0a5f4] +description = "easy card combination, inverted decks" + +[98878d35-623a-4d05-b81a-7bdc569eb88d] +description = "mirrored decks" + +[3e0ba597-ca10-484b-87a3-31a7df7d6da3] +description = "opposite decks" + +[92334ddb-aaa7-47fa-ab36-e928a8a6a67c] +description = "random decks #1" + +[30477523-9651-4860-84a3-e1ac461bb7fa] +description = "random decks #2" + +[20967de8-9e94-4e0e-9010-14bc1c157432] +description = "Kleber 1999" + +[9f2fdfe8-27f3-4323-816d-6bce98a9c6f7] +description = "Collins 2006" + +[c90b6f8d-7013-49f3-b5cb-14ea006cca1d] +description = "Mann and Wu 2007" + +[a3f1fbc5-1d0b-499a-92a5-22932dfc6bc8] +description = "Nessler 2012" + +[9cefb1ba-e6d1-4ab7-9d8f-76d8e0976d5f] +description = "Anderson 2013" + +[d37c0318-5be6-48d0-ab72-a7aaaff86179] +description = "Rucklidge 2014" + +[4305e479-ba87-432f-8a29-cd2bd75d2f05] +description = "Nessler 2021" + +[252f5cc3-b86d-4251-87ce-f920b7a6a559] +description = "Nessler 2022" + +[b9efcfa4-842f-4542-8112-8389c714d958] +description = "Casella 2024, first infinite game found" diff --git a/exercises/practice/camicia/camicia.gd b/exercises/practice/camicia/camicia.gd new file mode 100644 index 0000000..0ad2c9c --- /dev/null +++ b/exercises/practice/camicia/camicia.gd @@ -0,0 +1,2 @@ +func simulate_game(player_a: Array, player_b: Array) -> Dictionary: + pass diff --git a/exercises/practice/camicia/camicia_test.gd b/exercises/practice/camicia/camicia_test.gd new file mode 100644 index 0000000..845c18c --- /dev/null +++ b/exercises/practice/camicia/camicia_test.gd @@ -0,0 +1,250 @@ +func test_two_cards_one_trick(solution_script): + var want = {"status": "finished", "cards": 2, "tricks": 1} + var got = solution_script.simulate_game( + ["2"], + ["3"] + ) + return [got, want] + + +func test_three_cards_one_trick(solution_script): + var want = {"status": "finished", "cards": 3, "tricks": 1} + var got = solution_script.simulate_game( + ["2", "4"], + ["3"] + ) + return [got, want] + + +func test_four_cards_one_trick(solution_script): + var want = {"status": "finished", "cards": 4, "tricks": 1} + var got = solution_script.simulate_game( + ["2", "4"], + ["3", "5", "6"] + ) + return [got, want] + + +func test_the_ace_reigns_supreme(solution_script): + var want = {"status": "finished", "cards": 7, "tricks": 1} + var got = solution_script.simulate_game( + ["2", "A"], + ["3", "4", "5", "6", "7"] + ) + return [got, want] + + +func test_the_king_beats_ace(solution_script): + var want = {"status": "finished", "cards": 7, "tricks": 1} + var got = solution_script.simulate_game( + ["2", "A"], + ["3", "4", "5", "6", "K"] + ) + return [got, want] + + +func test_the_queen_seduces_the_king(solution_script): + var want = {"status": "finished", "cards": 10, "tricks": 1} + var got = solution_script.simulate_game( + ["2", "A", "7", "8", "Q"], + ["3", "4", "5", "6", "K"] + ) + return [got, want] + + +func test_the_jack_betrays_the_queen(solution_script): + var want = {"status": "finished", "cards": 12, "tricks": 1} + var got = solution_script.simulate_game( + ["2", "A", "7", "8", "Q"], + ["3", "4", "5", "6", "K", "9", "J"] + ) + return [got, want] + + +func test_the_10_just_wants_to_put_on_a_show(solution_script): + var want = {"status": "finished", "cards": 13, "tricks": 1} + var got = solution_script.simulate_game( + ["2", "A", "7", "8", "Q", "10"], + ["3", "4", "5", "6", "K", "9", "J"] + ) + return [got, want] + + +func test_simple_loop_with_decks_of_3_cards(solution_script): + var want = {"status": "loop", "cards": 8, "tricks": 3} + var got = solution_script.simulate_game( + ["J", "2", "3"], + ["4", "J", "5"] + ) + return [got, want] + + +func test_the_story_is_starting_to_get_a_bit_complicated(solution_script): + var want = {"status": "finished", "cards": 361, "tricks": 1} + var got = solution_script.simulate_game( + ["2", "6", "6", "J", "4", "K", "Q", "10", "K", "J", "Q", "2", "3", "K", "5", "6", "Q", "Q", "A", "A", "6", "9", "K", "A", "8", "K", "2", "A", "9", "A", "Q", "4", "K", "K", "K", "3", "5", "K", "8", "Q", "3", "Q", "7", "J", "K", "J", "9", "J", "3", "3", "K", "K", "Q", "A", "K", "7", "10", "A", "Q", "7", "10", "J", "4", "5", "J", "9", "10", "Q", "J", "J", "K", "6", "10", "J", "6", "Q", "J", "5", "J", "Q", "Q", "8", "3", "8", "A", "2", "6", "9", "K", "7", "J", "K", "K", "8", "K", "Q", "6", "10", "J", "10", "J", "Q", "J", "10", "3", "8", "K", "A", "6", "9", "K", "2", "A", "A", "10", "J", "6", "A", "4", "J", "A", "J", "J", "6", "2", "J", "3", "K", "2", "5", "9", "J", "9", "6", "K", "A", "5", "Q", "J", "2", "Q", "K", "A", "3", "K", "J", "K", "2", "5", "6", "Q", "J", "Q", "Q", "J", "2", "J", "9", "Q", "7", "7", "A", "Q", "7", "Q", "J", "K", "J", "A", "7", "7", "8", "Q", "10", "J", "10", "J", "J", "9", "2", "A", "2"], + ["7", "2", "10", "K", "8", "2", "J", "9", "A", "5", "6", "J", "Q", "6", "K", "6", "5", "A", "4", "Q", "7", "J", "7", "10", "2", "Q", "8", "2", "2", "K", "J", "A", "5", "5", "A", "4", "Q", "6", "Q", "K", "10", "8", "Q", "2", "10", "J", "A", "Q", "8", "Q", "Q", "J", "J", "A", "A", "9", "10", "J", "K", "4", "Q", "10", "10", "J", "K", "10", "2", "J", "7", "A", "K", "K", "J", "A", "J", "10", "8", "K", "A", "7", "Q", "Q", "J", "3", "Q", "4", "A", "3", "A", "Q", "Q", "Q", "5", "4", "K", "J", "10", "A", "Q", "J", "6", "J", "A", "10", "A", "5", "8", "3", "K", "5", "9", "Q", "8", "7", "7", "J", "7", "Q", "Q", "Q", "A", "7", "8", "9", "A", "Q", "A", "K", "8", "A", "A", "J", "8", "4", "8", "K", "J", "A", "10", "Q", "8", "J", "8", "6", "10", "Q", "J", "J", "A", "A", "J", "5", "Q", "6", "J", "K", "Q", "8", "K", "4", "Q", "Q", "6", "J", "K", "4", "7", "J", "J", "9", "9", "A", "Q", "Q", "K", "A", "6", "5", "K"] + ) + return [got, want] + + +func test_two_tricks(solution_script): + var want = {"status": "finished", "cards": 5, "tricks": 2} + var got = solution_script.simulate_game( + ["J"], + ["3", "J"] + ) + return [got, want] + + +func test_more_tricks(solution_script): + var want = {"status": "finished", "cards": 12, "tricks": 4} + var got = solution_script.simulate_game( + ["J", "2", "4"], + ["3", "J", "A"] + ) + return [got, want] + + +func test_simple_loop_with_decks_of_4_cards(solution_script): + var want = {"status": "loop", "cards": 16, "tricks": 4} + var got = solution_script.simulate_game( + ["2", "3", "J", "6"], + ["K", "5", "J", "7"] + ) + return [got, want] + + +func test_easy_card_combination(solution_script): + var want = {"status": "finished", "cards": 40, "tricks": 4} + var got = solution_script.simulate_game( + ["4", "8", "7", "5", "4", "10", "3", "9", "7", "3", "10", "10", "6", "8", "2", "8", "5", "4", "5", "9", "6", "5", "2", "8", "10", "9"], + ["6", "9", "4", "7", "2", "2", "3", "6", "7", "3", "A", "A", "A", "A", "K", "K", "K", "K", "Q", "Q", "Q", "Q", "J", "J", "J", "J"] + ) + return [got, want] + + +func test_easy_card_combination_inverted_decks(solution_script): + var want = {"status": "finished", "cards": 40, "tricks": 4} + var got = solution_script.simulate_game( + ["3", "3", "5", "7", "3", "2", "10", "7", "6", "7", "A", "A", "A", "A", "K", "K", "K", "K", "Q", "Q", "Q", "Q", "J", "J", "J", "J"], + ["5", "10", "8", "2", "6", "7", "2", "4", "9", "2", "6", "10", "10", "5", "4", "8", "4", "8", "6", "9", "8", "5", "9", "3", "4", "9"] + ) + return [got, want] + + +func test_mirrored_decks(solution_script): + var want = {"status": "finished", "cards": 59, "tricks": 4} + var got = solution_script.simulate_game( + ["2", "A", "3", "A", "3", "K", "4", "K", "2", "Q", "2", "Q", "10", "J", "5", "J", "6", "10", "2", "9", "10", "7", "3", "9", "6", "9"], + ["6", "A", "4", "A", "7", "K", "4", "K", "7", "Q", "7", "Q", "5", "J", "8", "J", "4", "5", "8", "9", "10", "6", "8", "3", "8", "5"] + ) + return [got, want] + + +func test_opposite_decks(solution_script): + var want = {"status": "finished", "cards": 151, "tricks": 21} + var got = solution_script.simulate_game( + ["4", "A", "9", "A", "4", "K", "9", "K", "6", "Q", "8", "Q", "8", "J", "10", "J", "9", "8", "4", "6", "3", "6", "5", "2", "4", "3"], + ["10", "7", "3", "2", "9", "2", "7", "8", "7", "5", "J", "7", "J", "10", "Q", "10", "Q", "3", "K", "5", "K", "6", "A", "2", "A", "5"] + ) + return [got, want] + + +func test_random_decks_1(solution_script): + var want = {"status": "finished", "cards": 542, "tricks": 76} + var got = solution_script.simulate_game( + ["K", "10", "9", "8", "J", "8", "6", "9", "7", "A", "K", "5", "4", "4", "J", "5", "J", "4", "3", "5", "8", "6", "7", "7", "4", "9"], + ["6", "3", "K", "A", "Q", "10", "A", "2", "Q", "8", "2", "10", "10", "2", "Q", "3", "K", "9", "7", "A", "3", "Q", "5", "J", "2", "6"] + ) + return [got, want] + + +func test_random_decks_2(solution_script): + var want = {"status": "finished", "cards": 327, "tricks": 42} + var got = solution_script.simulate_game( + ["8", "A", "4", "8", "5", "Q", "J", "2", "6", "2", "9", "7", "K", "A", "8", "10", "K", "8", "10", "9", "K", "6", "7", "3", "K", "9"], + ["10", "5", "2", "6", "Q", "J", "A", "9", "5", "5", "3", "7", "3", "J", "A", "2", "Q", "3", "J", "Q", "4", "10", "4", "7", "4", "6"] + ) + return [got, want] + + +func test_kleber_1999(solution_script): + var want = {"status": "finished", "cards": 5790, "tricks": 805} + var got = solution_script.simulate_game( + ["4", "8", "9", "J", "Q", "8", "5", "5", "K", "2", "A", "9", "8", "5", "10", "A", "4", "J", "3", "K", "6", "9", "2", "Q", "K", "7"], + ["10", "J", "3", "2", "4", "10", "4", "7", "5", "3", "6", "6", "7", "A", "J", "Q", "A", "7", "2", "10", "3", "K", "9", "6", "8", "Q"] + ) + return [got, want] + + +func test_collins_2006(solution_script): + var want = {"status": "finished", "cards": 6913, "tricks": 960} + var got = solution_script.simulate_game( + ["A", "8", "Q", "K", "9", "10", "3", "7", "4", "2", "Q", "3", "2", "10", "9", "K", "A", "8", "7", "7", "4", "5", "J", "9", "2", "10"], + ["4", "J", "A", "K", "8", "5", "6", "6", "A", "6", "5", "Q", "4", "6", "10", "8", "J", "2", "5", "7", "Q", "J", "3", "3", "K", "9"] + ) + return [got, want] + + +func test_mann_and_wu_2007(solution_script): + var want = {"status": "finished", "cards": 7157, "tricks": 1007} + var got = solution_script.simulate_game( + ["K", "2", "K", "K", "3", "3", "6", "10", "K", "6", "A", "2", "5", "5", "7", "9", "J", "A", "A", "3", "4", "Q", "4", "8", "J", "6"], + ["4", "5", "2", "Q", "7", "9", "9", "Q", "7", "J", "9", "8", "10", "3", "10", "J", "4", "10", "8", "6", "8", "7", "A", "Q", "5", "2"] + ) + return [got, want] + + +func test_nessler_2012(solution_script): + var want = {"status": "finished", "cards": 7207, "tricks": 1015} + var got = solution_script.simulate_game( + ["10", "3", "6", "7", "Q", "2", "9", "8", "2", "8", "4", "A", "10", "6", "K", "2", "10", "A", "5", "A", "2", "4", "Q", "J", "K", "4"], + ["10", "Q", "4", "6", "J", "9", "3", "J", "9", "3", "3", "Q", "K", "5", "9", "5", "K", "6", "5", "7", "8", "J", "A", "7", "8", "7"] + ) + return [got, want] + + +func test_anderson_2013(solution_script): + var want = {"status": "finished", "cards": 7225, "tricks": 1016} + var got = solution_script.simulate_game( + ["6", "7", "A", "3", "Q", "3", "5", "J", "3", "2", "J", "7", "4", "5", "Q", "10", "5", "A", "J", "2", "K", "8", "9", "9", "K", "3"], + ["4", "J", "6", "9", "8", "5", "10", "7", "9", "Q", "2", "7", "10", "8", "4", "10", "A", "6", "4", "A", "6", "8", "Q", "K", "K", "2"] + ) + return [got, want] + + +func test_rucklidge_2014(solution_script): + var want = {"status": "finished", "cards": 7959, "tricks": 1122} + var got = solution_script.simulate_game( + ["8", "J", "2", "9", "4", "4", "5", "8", "Q", "3", "9", "3", "6", "2", "8", "A", "A", "A", "9", "4", "7", "2", "5", "Q", "Q", "3"], + ["K", "7", "10", "6", "3", "J", "A", "7", "6", "5", "5", "8", "10", "9", "10", "4", "2", "7", "K", "Q", "10", "K", "6", "J", "J", "K"] + ) + return [got, want] + + +func test_nessler_2021(solution_script): + var want = {"status": "finished", "cards": 7972, "tricks": 1106} + var got = solution_script.simulate_game( + ["7", "2", "3", "4", "K", "9", "6", "10", "A", "8", "9", "Q", "7", "A", "4", "8", "J", "J", "A", "4", "3", "2", "5", "6", "6", "J"], + ["3", "10", "8", "9", "8", "K", "K", "2", "5", "5", "7", "6", "4", "3", "5", "7", "A", "9", "J", "K", "2", "Q", "10", "Q", "10", "Q"] + ) + return [got, want] + + +func test_nessler_2022(solution_script): + var want = {"status": "finished", "cards": 8344, "tricks": 1164} + var got = solution_script.simulate_game( + ["2", "10", "10", "A", "J", "3", "8", "Q", "2", "5", "5", "5", "9", "2", "4", "3", "10", "Q", "A", "K", "Q", "J", "J", "9", "Q", "K"], + ["10", "7", "6", "3", "6", "A", "8", "9", "4", "3", "K", "J", "6", "K", "4", "9", "7", "8", "5", "7", "8", "2", "A", "7", "4", "6"] + ) + return [got, want] + + +func test_casella_2024_first_infinite_game_found(solution_script): + var want = {"status": "loop", "cards": 474, "tricks": 66} + var got = solution_script.simulate_game( + ["2", "8", "4", "K", "5", "2", "3", "Q", "6", "K", "Q", "A", "J", "3", "5", "9", "8", "3", "A", "A", "J", "4", "4", "J", "7", "5"], + ["7", "7", "8", "6", "10", "10", "6", "10", "7", "2", "Q", "6", "3", "2", "4", "K", "Q", "10", "J", "5", "9", "8", "9", "9", "K", "A"] + ) + return [got, want] diff --git a/exercises/practice/camicia/lib/test_runner.gd b/exercises/practice/camicia/lib/test_runner.gd new file mode 100644 index 0000000..9274583 --- /dev/null +++ b/exercises/practice/camicia/lib/test_runner.gd @@ -0,0 +1,467 @@ +class_name TestRunner +extends SceneTree + +class ErrorReader: + """ErrorReader reads from a STDERR file (cmd 2>/file). + + It filters out the less useful Godot output and handles retrieving lines iteratively. + """ + + const EXPECTED_GODOT_ERRORS = [ + "ERROR: Could not create directory: /root/.local\n at: make_dir_recursive (core/io/dir_access.cpp:180)\n", + "ERROR: Error attempting to create data dir: /root/.local/share/godot/app_userdata/[unnamed project].\n at: ensure_user_data_dir (core/os/os.cpp:344)\n", + "ERROR: Could not create editor data directory: /root/.local/share/godot\n at: EditorPaths (editor/editor_paths.cpp:183)\n", + "ERROR: Could not create directory: /root/.config\n at: make_dir_recursive (core/io/dir_access.cpp:180)\n", + "ERROR: Could not create editor config directory: /root/.config/godot\n at: EditorPaths (editor/editor_paths.cpp:198)\n", + "ERROR: Could not create directory: /root/.cache\n at: make_dir_recursive (core/io/dir_access.cpp:180)\n", + "ERROR: Could not create editor cache directory: /root/.cache/godot\n at: EditorPaths (editor/editor_paths.cpp:219)\n", + "ERROR: Can't save resource to empty path. Provide non-empty path or a Resource with non-empty resource_path.\n at: save (core/io/resource_saver.cpp:105)\n", + "ERROR: Error saving editor settings to \n at: save (editor/editor_settings.cpp:1013)\n", + ] + var already_processed_bytes: int = 0 + var filename: String = "" + var color_sequence = RegEx.create_from_string("\\e\\[\\d[\\d;]*m") + + func _init(filename: String) -> void: + self.already_processed_bytes = 0 + self.filename = filename + if not self.filename.is_empty() and ResourceLoader.exists(self.filename): + self.get_error_message() + + func check() -> Error: + if not FileAccess.file_exists(self.filename): + push_error("STDERR must be redirected to '%s' when using '--json'" % self.filename) + return ERR_FILE_NOT_FOUND + if OS.get_stderr_type() != OS.STD_HANDLE_FILE: + push_error("STDERR must be redirected to '%s' when using '--json'" % self.filename) + return ERR_FILE_NOT_FOUND + return OK + + func get_error_message() -> String: + """ + Checks the contents of the STDERR file, where error output from the + currently running test suite should be stored. If there is any relevant output + there, this method will return it. If the file is empty, an empty string is returned. + + This method will remove previously read output from the returned message. It should + be called after executing every test, to ensure that the message contains only output + relevant for the given test. + + NOTE: running a Godot instance without full access to certain folders in the + home directory (`~/.config`, `~/.local`, `~/.cache`) will result in additional + errors. This happens when GDScript test runner is executed in Docker. However, + these errors do not stop the execution of the program, so they can be ignored. + This method filters them out, leaving only the errors caused by the actual tests. + """ + if self.filename.is_empty(): + return "" + + var error_output = FileAccess.get_file_as_string(self.filename) + error_output = error_output.erase(0, self.already_processed_bytes) + self.already_processed_bytes += len(error_output) + + # By default, Godot's error messages are printed with colors. To include the output in the + # `results.json` file, color markers need to be removed first. + error_output = self.color_sequence.sub(error_output, "", true) + + # Filter out the expected error messages + for expected_error in EXPECTED_GODOT_ERRORS: + error_output = error_output.replace(expected_error, "") + + return error_output + + +class ResultWriter: + + var json_file: String = "" + var stderr: ErrorReader = null + + func _init(json_file: String, stderr: ErrorReader) -> void: + self.json_file = json_file + self.stderr = stderr + + func delete_existing() -> Error: + """ + Removes the previous `results.json` file, to ensure that the current test suite will not use it. + """ + if self.json_file.is_empty(): + return OK + if FileAccess.file_exists(self.json_file): + DirAccess.remove_absolute(self.json_file) + var error_message: String = self.stderr.get_error_message() + if not error_message.is_empty(): + push_error(error_message) + return FAILED + return OK + + func write(results: Dictionary) -> Error: + """ + If output_dir_path is empty, outputs friendly-formatted test results to + stdout. + + Otherwise, writes JSON results to results.json in the given path. + """ + if self.json_file.is_empty(): + return print_friendly_results(results) + else: + return write_results_file(results) + + func print_friendly_results(results: Dictionary) -> Error: + """ + Prints rich results to stdout. + """ + var rich_results = [""] + if results.status == "pass": + rich_results.push_back("[color=yellow]Exercise passed! Nicely done![/color]") + else: + rich_results.push_back("Exercise has at least one failed test:") + for test in results.tests: + if test.status == "pass": + rich_results.push_back(" %s [color=yellow]passed ✔[/color]" % test.name) + else: + rich_results.push_back(" %s [color=pink]failed: %s[/color]" % [test.name, test.message]) + print_rich("\n".join(rich_results)) + return OK + + func write_results_file(results: Dictionary) -> Error: + """ + Saves a dictionary as a `results.json` file in the output directory. The file + is indented with 4 spaces, keys are not sorted. + + The `results` dictionary represents the full output of a single exercise's + test suite, according to the Test Runner Interface documentation: + + https://exercism.org/docs/building/tooling/test-runners/interface + + This method automatically adds `'version' = 2` to the results. + """ + # Due to keys not being sorted, `version` should be inserted first + var full_results = {"version": 2} + full_results.merge(results) + + var pretty_results = JSON.stringify(full_results, " ", false) + + var results_json = FileAccess.open(self.json_file, FileAccess.WRITE) + if results_json == null: + var err = FileAccess.get_open_error() + push_error("Failed to write the file: %s (%s)" % [self.json_file, error_string(err)]) + return err + + results_json.store_string(pretty_results + "\n") + return OK + + +class ArgParser: + """ArgParser reads the cmdline and parses inputs and options.""" + + var solution_script_path: String = "" + var test_suite_script_path: String = "" + var run_all: bool = false + var json: bool = false + var json_file: String = "" + + func usage(message: String) -> void: + print("Usage:") + print("test_runner [--all] [--json filename.json] ") + if not message.is_empty(): + print() + print(message) + + func parse_args() -> Error: + """ + Validates the number of args passed to the script and sets values of the following + global variables, based on the given args: + * `solution_script_path` + * `test_suite_script_path` + """ + var args = OS.get_cmdline_user_args() + var positional: Array = [] + + var idx := 0 + while idx < len(args): + var arg = args[idx] + if arg == "--all": + run_all = true + elif arg.begins_with("--json"): + if arg.contains("="): + var parts = arg.split("=", true, 1) + if parts[0] == "--json": + self.json = true + self.json_file = parts[1] + else: + usage("Invalid flag %s" % arg) + return ERR_INVALID_PARAMETER + elif idx + 1 < len(args): + self.json = true + self.json_file = args[idx + 1] + idx += 1 + else: + usage("Flag %s is missing a filename" % arg) + return ERR_INVALID_PARAMETER + elif arg.begins_with("--"): + usage("Invalid flag %s" % arg) + return ERR_INVALID_PARAMETER + else: + positional.append(arg) + idx += 1 + + if len(positional) != 2: + usage("Expecting 2 positional args but got %d" % len(positional)) + return ERR_INVALID_PARAMETER + + var slug = positional[0] + var solution_dir_path = positional[1] + + # Test folders use dashes, but test files use underscores + var gdscript_path = solution_dir_path.path_join(slug.replace("-", "_")) + + self.solution_script_path = gdscript_path + ".gd" + self.test_suite_script_path = gdscript_path + "_test.gd" + + return OK + + func files() -> Array: + return [self.solution_script_path, self.test_suite_script_path] + + +class Runner: + + var test_suite: Object = null + var solution: Object = null + var stderr: ErrorReader = null + var result_writer: ResultWriter = null + + func _init(test_suite: Object, solution: Object, stderr: ErrorReader, result_writer: ResultWriter) -> void: + self.test_suite = test_suite + self.solution = solution + self.stderr = stderr + self.result_writer = result_writer + + func extract_methods() -> Dictionary: + """Extract the source code of a method from a script.""" + var in_func: bool = false + var parts = [] + var methods: Dictionary = {} + var name: String = "" + var func_re: RegEx = RegEx.create_from_string('^func ([^(]+)\\(') + for line in self.test_suite.get_script().source_code.split("\n"): + if not in_func: + var matched = func_re.search(line) + if matched: + in_func = true + name = matched.get_strings()[1] + parts = [] + elif not line.begins_with("\t") and not line.begins_with(" "): + if in_func: + in_func = false + methods[name] = "\n".join(parts) + else: + parts.append(line) + + return methods + + func get_failed_test_message(actual, expected) -> String: + """ + Generates a message for a failed test. If given values are strings, + this method will wrap them in single quotes, to distinguish from + other types, like integers (e.g. '3' vs 3). + + This method should be used if the actual and expected values for a + given test are different, or if they have different types. + """ + var values = [] + + for value in [actual, expected]: + var formatted_value = str(value) + if typeof(value) == TYPE_STRING: + formatted_value = "'{0}'".format([formatted_value]) + values.append(formatted_value) + + return "Expected output was {1}, actual output was {0}.".format(values) + + func run_tests() -> Array: + """ + Runs the given test suite agains the given solution script. Returns an Array + representing the results of each test case, in the same order that they are defined + in the test suite. Each test result is a Dictionary containing 3 values: + + * `name`: the name of the test case + * `status`: 'pass', 'fail', or 'error' + * `message`: null if the test passed, otherwise it contains the details of the failure/error + + This method will look for methods starting with `test_` in the test script, and + then call them with the solution script as the only argument. The `test_` method's + name will be used as the `name` value for each test result. + + If any stderr output was generated during the execution of the `test_` method, + `status` will be set to 'error'. The test will also receive an 'error' status + if calling the `test_` method returned null (which indicates execution issues). + + Otherwise, the `test_` method should return an Array of 2 elements: the actual value, + and the expected value. The values will be compared and if they are the same, the test will + pass. Otherwise, it will fail, with a relevant message being set by this method. + """ + var test_results = [] + var test_codes = self.extract_methods() + + for method in self.test_suite.get_method_list(): + if not method["name"].begins_with("test_"): + continue + var test_method_name = method["name"] + print("-> " + test_method_name) + var test_method = self.test_suite.get(test_method_name) + + var output = test_method.call(self.solution) + var error_message = self.stderr.get_error_message() + + if output == null: + test_results.append({ + "name": test_method_name, + "status": "error", + "message": error_message, + }) + else: + var actual = output[0] + var expected = output[1] + + var passed = ( + typeof(actual) == typeof(expected) and + actual == expected and + error_message.is_empty() + ) + + var status = "error" + if error_message.is_empty(): + status = "pass" if passed else "fail" + error_message = null if passed else self.get_failed_test_message( + actual, expected + ) + + var test_code = test_codes[method["name"]] + # The code would be cleaner if the test suite actually checked the values. + # Instead, the test suite returns an array with two values which are compared (above). + # This `replace()` hides some of that implementation detail by communicating that the + # array elements are being compared for equality. + test_code = test_code.replace("return [", "assert_equal [") + test_code = test_code.replace("\t", " ") + test_code = test_code.dedent().strip_edges() + + test_results.append({ + "name": test_method_name, + "status": status, + "message": error_message, + "test_code": test_code, + }) + + return test_results + + func run(run_all: bool) -> void: + """ + Executes the current suite against the current solution. Stores the results in the + `results.json` file in the output dir. + """ + var test_results = run_tests() + var results = {} + + # Check if any tests were executed + if len(test_results) == 0: + results = {"status": "error", "message": "No tests were executed.", "tests": []} + else: + results = {"status": "pass", "tests": test_results} + + # If any of the tests failed, change the global status to `fail` + for test_result in test_results: + if test_result["status"] != "pass": + results["status"] = "fail" + break + + if not run_all and results["status"] != "pass": + var success_count: int = 0 + for test_result in test_results: + if test_result["status"] == "pass": + success_count += 1 + + var has_failure = false + var filtered_results = [] + for test_result in test_results: + if test_result["status"] == "pass": + filtered_results.append(test_result) + elif not has_failure: + has_failure = true + test_result["message"] = "Passes %d/%d tests. First failure: %s" % [ + success_count, len(test_results), test_result["message"], + ] + filtered_results.append(test_result) + results["tests"] = filtered_results + + self.result_writer.write(results) + + +func load_script(solution_script_path: String) -> Object: + """Load a script. This helper is needed to catch failures without aborting the error handling.""" + return load(solution_script_path).new() + + +func load_solution_script(solution_script_path: String, result_writer: ResultWriter, stderr: ErrorReader) -> Array: + """ + Loads the solution script and saves it in a global variable. In case of any + issues, this method will save the `results.json` file with `error` status. + """ + # Make sure the STDERR file is empty. + stderr.get_error_message() + var solution_script = load_script(solution_script_path) + + if solution_script == null: + var message: String = "The solution file could not be parsed." + # Extract the error message, up until the "Failed to load" line + # which is specific to the test runner, not the user's solution. + var error_message: String = stderr.get_error_message() + if not error_message.is_empty(): + var msg = [] + var stop = 'ERROR: Failed to load script "%s" with error "Parse error".' % solution_script_path + for line in error_message.split("\n"): + if line == stop: + break + msg.append(line) + message += "\n" + "\n".join(msg) + var results = {"status": "error", "message": message, "tests": []} + result_writer.write(results) + return [null, ERR_PARSE_ERROR] + + return [solution_script, OK] + + +func _init(): + # Calling `quit(1)` doesn't stop the program immediately, so a `return` is necessary. + # That's why errors are checked directly in `_init()`, instead of calling `quit(1)` + # in each method. + var args = ArgParser.new() + if args.parse_args() != OK: + quit(1) + return + for file in args.files(): + if not ResourceLoader.exists(file): + push_error("File %s not found." % file) + quit(1) + return + + var stderr = ErrorReader.new("/tmp/stderr") + if args.json and stderr.check() != OK: + quit(1) + return + + var result_writer = ResultWriter.new(args.json_file, stderr) + if result_writer.delete_existing() != OK: + quit(1) + return + + var r = load_solution_script(args.solution_script_path, result_writer, stderr) + if r[1] != OK: + quit(1) + return + var solution = r[0] + + var test_suite = load(args.test_suite_script_path).new() + + Runner.new(test_suite, solution, stderr, result_writer).run(args.run_all) + quit() diff --git a/exercises/practice/camicia/run_tests b/exercises/practice/camicia/run_tests new file mode 100755 index 0000000..ed18a90 --- /dev/null +++ b/exercises/practice/camicia/run_tests @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +slug=${PWD##*/} +[[ -f "${slug//-/_}.gd" ]] || die "Error! solution file is missing" +[[ -f "${slug//-/_}_test.gd" ]] || die "Error! test file is missing" +exec godot --headless --script ./lib/test_runner.gd -- "${slug}" "${PWD}"