From 0ada1588ee04a43dff925042acd6c8fd01473a26 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Sun, 4 Oct 2026 23:49:09 +0800 Subject: [PATCH] Add the console game loop and entry point Add show_round, ask_choice and play_game with injectable input and output, and make python -m higher_lower play a game. Each account is shown once per game; a wrong guess or running out of accounts ends it. Invalid input is re-asked. Ctrl+C and Ctrl+D leave with a goodbye. Cover the loop with scripted tests (36 passing). Task: MIL-003#1 Task: MIL-003#2 Task: MIL-003#3 Task: MIL-003#4 Task: MIL-003#5 Closes #12 Closes #13 Closes #14 Closes #15 Closes #16 --- src/higher_lower/__main__.py | 13 ++- src/higher_lower/constants.py | 1 + src/higher_lower/game.py | 122 ++++++++++++++++++++++++++++ tests/test_game.py | 144 ++++++++++++++++++++++++++++++++++ tests/test_smoke.py | 4 +- 5 files changed, 278 insertions(+), 6 deletions(-) create mode 100644 src/higher_lower/game.py create mode 100644 tests/test_game.py diff --git a/src/higher_lower/__main__.py b/src/higher_lower/__main__.py index 7f1c898..e0433b1 100644 --- a/src/higher_lower/__main__.py +++ b/src/higher_lower/__main__.py @@ -2,12 +2,17 @@ @brief Entry point for `python -m higher_lower`. """ +from higher_lower.constants import MSG_GOODBYE +from higher_lower.game import play_game + def main() -> None: - """@brief Start the game. - - Placeholder: the game loop is implemented in MIL-003. - """ + """@brief Play a game; leave quietly if the player quits with Ctrl+C or Ctrl+D.""" + try: + play_game() + except (EOFError, KeyboardInterrupt): + print() + print(MSG_GOODBYE) if __name__ == "__main__": diff --git a/src/higher_lower/constants.py b/src/higher_lower/constants.py index 22e9ffc..90574fc 100644 --- a/src/higher_lower/constants.py +++ b/src/higher_lower/constants.py @@ -26,3 +26,4 @@ MSG_INVALID_CHOICE = "Please type 'A' or 'B'." MSG_CORRECT = "You're right! Current score: {score}." MSG_WRONG = "Sorry, that's wrong. Final score: {score}" MSG_WIN = "You went through every account! Final score: {score}" +MSG_GOODBYE = "Goodbye!" diff --git a/src/higher_lower/game.py b/src/higher_lower/game.py new file mode 100644 index 0000000..736c49b --- /dev/null +++ b/src/higher_lower/game.py @@ -0,0 +1,122 @@ +"""@file game.py +@brief The interactive game: what the player sees, what they type, and the game loop. + +Input and output go through callables so tests can script a whole game. +""" + +import random +from collections.abc import Callable, Sequence + +from higher_lower.art import logo, vs +from higher_lower.constants import ( + CHOICE_A, + CHOICE_B, + KEY_FOLLOWER_COUNT, + KEY_NAME, + MSG_AGAINST_B, + MSG_COMPARE_A, + MSG_CORRECT, + MSG_INVALID_CHOICE, + MSG_WIN, + MSG_WRONG, + PROMPT_GUESS, + START_SCORE, +) +from higher_lower.game_data import Account, data +from higher_lower.game_logic import ( + check_answer, + format_data, + get_random_account, + pick_pair, +) + +InputFunc = Callable[[str], str] +OutputFunc = Callable[[str], None] + + +def show_round( + account_a: Account, + account_b: Account, + output: OutputFunc = print, +) -> None: + """@brief Show the two accounts the player compares. + + @param account_a The account shown as A. + @param account_b The account shown as B. + @param output Where the text goes. + """ + output(MSG_COMPARE_A.format(account=format_data(account_a))) + output(vs) + output(MSG_AGAINST_B.format(account=format_data(account_b))) + + +def ask_choice(input_func: InputFunc = input, output: OutputFunc = print) -> str: + """@brief Ask for A or B until the player gives a valid answer. + + @param input_func Reads one line of input, given the prompt. + @param output Where the invalid-answer message goes. + @return "a" or "b", in lower case. + """ + while True: + answer = input_func(PROMPT_GUESS).strip().lower() + if answer in (CHOICE_A, CHOICE_B): + return answer + output(MSG_INVALID_CHOICE) + + +def _pick_next_account( + accounts: Sequence[Account], + shown_names: set[str], + rng: random.Random | None, +) -> Account | None: + """@brief Draw an account that has not been shown in this game. + + @param accounts All accounts of the game. + @param shown_names Names of the accounts already shown. + @param rng Random generator, or None for the default. + @return A new account, or None if every account has been shown. + """ + unused = [account for account in accounts if account[KEY_NAME] not in shown_names] + if not unused: + return None + return get_random_account(unused, rng) + + +def play_game( + accounts: Sequence[Account] = data, + rng: random.Random | None = None, + input_func: InputFunc = input, + output: OutputFunc = print, +) -> int: + """@brief Play one game, from the logo to the final score. + + Each account is shown once per game. A right guess adds a point and + makes B the next A; a wrong guess, or running out of accounts, ends + the game. + + @param accounts The accounts to play with; needs at least two entries. + @param rng Random generator; pass a seeded one for a repeatable game. + @param input_func Reads one line of input, given the prompt. + @param output Where the text goes. + @return The final score. + """ + output(logo) + score = START_SCORE + account_a, account_b = pick_pair(accounts, rng) + shown_names = {account_a[KEY_NAME], account_b[KEY_NAME]} + while True: + show_round(account_a, account_b, output) + guess = ask_choice(input_func, output) + if not check_answer( + guess, account_a[KEY_FOLLOWER_COUNT], account_b[KEY_FOLLOWER_COUNT] + ): + output(MSG_WRONG.format(score=score)) + return score + score += 1 + output(MSG_CORRECT.format(score=score)) + next_account = _pick_next_account(accounts, shown_names, rng) + if next_account is None: + output(MSG_WIN.format(score=score)) + return score + account_a, account_b = account_b, next_account + shown_names.add(next_account[KEY_NAME]) diff --git a/tests/test_game.py b/tests/test_game.py new file mode 100644 index 0000000..427f9d1 --- /dev/null +++ b/tests/test_game.py @@ -0,0 +1,144 @@ +"""@file test_game.py +@brief Tests for the game loop, run with scripted input and no real console. +""" + +import io +import random +from collections.abc import Callable, Iterable, Sequence + +import pytest + +from higher_lower import __main__ as entry_point +from higher_lower.art import logo +from higher_lower.game import ask_choice, play_game, show_round +from higher_lower.game_data import Account + +ACCOUNTS: list[Account] = [ + {"name": "First", "follower_count": 30, "description": "Test one", "country": "Denmark"}, + {"name": "Second", "follower_count": 20, "description": "Test two", "country": "Norway"}, + {"name": "Third", "follower_count": 10, "description": "Test three", "country": "Sweden"}, +] + + +class FirstRng(random.Random): + """@brief Random generator that always takes the first entries, so a game is fixed.""" + + def sample(self, population: Sequence, k: int, **kwargs: object) -> list: # type: ignore[override] + """@brief Return the first k entries.""" + return list(population)[:k] + + def choice(self, seq: Sequence) -> Account: # type: ignore[override] + """@brief Return the first entry.""" + return seq[0] + + +def scripted(answers: Iterable[str]) -> Callable[[str], str]: + """@brief Make an input function that gives the answers one by one. + + @param answers The answers to give. + @return A function with the signature of input(). + """ + iterator = iter(answers) + return lambda _prompt: next(iterator) + + +def run_game(answers: Iterable[str]) -> tuple[int, str]: + """@brief Play a fixed game and return the score and everything shown. + + @param answers The answers the player gives. + @return The final score and the output joined with newlines. + """ + lines: list[str] = [] + score = play_game(ACCOUNTS, FirstRng(), scripted(answers), lines.append) + return score, "\n".join(lines) + + +def test_show_round_shows_both_accounts() -> None: + """@brief Both accounts are described, with the vs art between them.""" + lines: list[str] = [] + show_round(ACCOUNTS[0], ACCOUNTS[1], lines.append) + assert lines[0] == "Compare A: First, a Test one, from Denmark." + assert lines[2] == "Against B: Second, a Test two, from Norway." + + +@pytest.mark.parametrize("typed", ["a", "A", " a "]) +def test_ask_choice_accepts_a(typed: str) -> None: + """@brief A is accepted in any case and with spaces around it.""" + assert ask_choice(scripted([typed]), lambda _line: None) == "a" + + +def test_ask_choice_asks_again_after_an_invalid_answer() -> None: + """@brief Invalid answers are rejected and the player is asked again.""" + lines: list[str] = [] + assert ask_choice(scripted(["x", "", "b"]), lines.append) == "b" + assert len(lines) == 2 + + +def test_correct_streak_adds_a_point_per_round() -> None: + """@brief Each right guess adds one to the score and B becomes the next A.""" + score, shown = run_game(["a", "a"]) + assert score == 2 + assert "Current score: 1." in shown + assert "Compare A: Second" in shown + + +def test_wrong_first_guess_ends_the_game_with_zero() -> None: + """@brief A wrong guess ends the game and shows the final score.""" + score, shown = run_game(["b"]) + assert score == 0 + assert "Sorry, that's wrong. Final score: 0" in shown + + +def test_wrong_guess_after_a_point_keeps_the_score() -> None: + """@brief The final score is the number of right guesses.""" + score, shown = run_game(["a", "b"]) + assert score == 1 + assert "Final score: 1" in shown + + +def test_invalid_input_does_not_change_the_round() -> None: + """@brief An invalid answer is re-asked without ending the game or scoring.""" + score, shown = run_game(["x", "a", "a"]) + assert score == 2 + assert shown.count("Compare A: First") == 1 + + +def test_game_ends_with_a_win_when_the_accounts_run_out() -> None: + """@brief Using every account ends the game instead of failing.""" + score, shown = run_game(["a", "a"]) + assert score == 2 + assert "You went through every account!" in shown + + +def test_game_starts_with_the_logo() -> None: + """@brief The logo is shown first.""" + _score, shown = run_game(["b"]) + assert shown.startswith(logo) + + +def test_full_game_with_the_assignment_data_ends() -> None: + """@brief A game with the real data set ends with a final score.""" + lines: list[str] = [] + score = play_game( + rng=random.Random(5), input_func=lambda _prompt: "a", output=lines.append + ) + assert score >= 0 + assert "Final score" in lines[-1] + + +def test_main_plays_a_game( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """@brief main() runs a game on the console.""" + monkeypatch.setattr("sys.stdin", io.StringIO("a\n" * 100)) + entry_point.main() + assert "Final score" in capsys.readouterr().out + + +def test_main_leaves_quietly_on_end_of_input( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """@brief Ctrl+D ends the game with a goodbye, not a traceback.""" + monkeypatch.setattr("sys.stdin", io.StringIO("")) + entry_point.main() + assert "Goodbye!" in capsys.readouterr().out diff --git a/tests/test_smoke.py b/tests/test_smoke.py index c045c44..507c30c 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -12,5 +12,5 @@ def test_package_imports() -> None: def test_entry_point_is_callable() -> None: - """@brief The entry point exists and can be called.""" - assert entry_point.main() is None + """@brief The entry point exists; importing it does not start a game.""" + assert callable(entry_point.main)