Author SHA1 Message Date
TirsvadandClaude Sonnet 5.5 037900df94 Resolve pending commit links for MIL-002 acceptance
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 23:35:18 +08:00
Tirsvad ee58fcdc1c Add game data, art, constants and game logic
Add the assignment data set (50 accounts), the logo and vs art, the
constants module, and the pure functions get_random_account,
pick_pair, format_data and check_answer, all with Doxygen comments.
pick_pair never returns the same account twice; check_answer accepts
either choice on a tie. Cover them with pytest tests (22 passing).

The art is built from concatenated raw lines so the backslashes survive
and Doxygen does not read the backticks as markup.

Accept MIL-002 after review RC-005 and add it to the Traceability
Matrix.

Task: MIL-002#1
Task: MIL-002#2
Task: MIL-002#3
Task: MIL-002#4
Task: MIL-002#5
Task: MIL-002#6
Closes #6
Closes #7
Closes #8
Closes #9
Closes #10
Closes #11
2026-10-04 23:34:48 +08:00
Tirsvad b1f4116bc1 Merge pull request 'MIL-001 Project setup: pyproject, skeleton and Doxyfile' (#23) from mil-001-project-setup into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 7s
Reviewed-on: #23
2026-10-04 17:30:02 +02:00
Tirsvad 115e543ee3 Add project configuration, skeleton and Doxyfile
Add pyproject.toml (Python >=3.13, no runtime dependencies, pytest as a
dev extra), the src/higher_lower package with an empty constants module
and an entry-point stub, a smoke test, and the Doxyfile for the Doxygen
comments in the source. Ignore the Doxygen output in .gitignore.

Verified in a fresh .venv: pip install -e ".[dev]", pytest (2 passed),
python -m higher_lower and doxygen Doxyfile without warnings.

Task: MIL-001#1
Task: MIL-001#2
Task: MIL-001#3
Task: MIL-001#4
Closes #1
Closes #2
Closes #3
Closes #4
2026-10-04 23:29:03 +08:00
16 changed files with 3677 additions and 4 deletions
+3
View File
@@ -174,3 +174,6 @@ cython_debug/
# PyPI configuration file
.pypirc
# Doxygen output
docs/doxygen/
+2967
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -15,7 +15,7 @@ document of a type. `Primary File` may contain a glob (e.g.
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
| PP | Project Plan | docs/project-plan.md | 002 |
| MIL | Milestone / Gateway | docs/milestones/*.md | 005 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 005 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 006 |
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
## Languages
+5 -2
View File
@@ -4,12 +4,13 @@
| Key | Value |
| --- | --- |
| ID | MIL-002 |
| CrossReference | [BC-001], [PP-001] |
| CrossReference | [BC-001], [PP-001], [RC-005] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-04 | Proposed | Jens Tirsvad Nielsen | S01 | Initial version | [ddfe96f] |
| 2026-10-04 | Deprecated | Jens Tirsvad Nielsen | S01 | Initial version | [ddfe96f] |
| 2026-10-04 | Accepted | Jens Tirsvad Nielsen | S01 | Accepted after review RC-005 | [ee58fcd] |
---
@@ -72,3 +73,5 @@ The assignment data set and art as modules, constants in `constants.py`, and the
[PP-001]: ../project-plan.md
[MIL-001]: ./mil-001-project-setup.md
[ddfe96f]: https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/014-higher_lower/commit/ddfe96ffa459d5e4b2e30d71bd53878aba2cfc5d
[RC-005]: ../sqa/reviews/rc-005-mil-002.md
[ee58fcd]: https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/014-higher_lower/commit/ee58fcdc1c9c83b0721022d623e4989591a47987
+46
View File
@@ -0,0 +1,46 @@
# SQA Review Record: MIL-002 Game logic
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-005 |
| CrossReference | [MIL-002], [QC-MIL-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-04 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version | [ee58fcd] |
---
## Artifact Under Review
- Instance reviewed: [MIL-002]
- Checklist used: [QC-MIL-001]
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | A concrete deliverable is defined for every gate | Pass | Data and art modules, constants, four named functions, Doxygen comments, pytest tests. |
| 2 | Explicit Go/No-Go criteria are stated for each gate | Pass | Six objectively checkable criteria. |
| 3 | Dependencies on other milestones are explicitly mapped (Optional) | Pass | Depends on MIL-001, with the reason. |
| 4 | Each milestone is traceable to a Business Case objective or KPI | Pass | O1, O2, O3. |
| 5 | Milestone owner and approving reviewer are identified | Pass | S01 and S01. |
| 6 | Milestone has a target date consistent with project constraints | Pass | 2026-10-09, inside the 2026-10-14 constraint in BC-001. |
## Overall Verdict
Go — all criteria pass. Code under `src/` and `tests/` for MIL-002 may start once the MIL-001 deliverables exist (its dependency). MIL-003 and MIL-004 stay Proposed. Reviewer and author are both S01: the project has a single participant, so the framework's author/reviewer separation cannot be met. S01 accepted this deviation when requesting acceptance (2026-10-04).
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| Deliver MIL-001 (issues #1-#4) before starting MIL-002 tasks | S01 | 2026-10-06 |
---
[MIL-002]: ../../milestones/mil-002-game-logic.md
[QC-MIL-001]: ../../../framework/qc/qc-milestones-gateways.md
[ee58fcd]: https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/014-higher_lower/commit/ee58fcdc1c9c83b0721022d623e4989591a47987
+4 -1
View File
@@ -10,6 +10,7 @@
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-04 | Proposed | Jens Tirsvad Nielsen | S01 | Initial version | [a1ff735] |
| 2026-10-04 | Proposed | Jens Tirsvad Nielsen | S01 | Added RC-005 review of MIL-002 | [ee58fcd] |
---
@@ -26,7 +27,7 @@ target is measurable. A row is added or updated whenever an artifact instance is
| [SA-001] | SA | [BC-001] | [PP-001] | [RC-002] |
| [PP-001] | PP | [BC-001], [SA-001] | [MIL-001], [MIL-002], [MIL-003], [MIL-004] | [RC-003] |
| [MIL-001] | MIL | [BC-001], [PP-001] | - | [RC-004] |
| [MIL-002] | MIL | [BC-001], [PP-001] | - | - |
| [MIL-002] | MIL | [BC-001], [PP-001], [MIL-001] | - | [RC-005] |
| [MIL-003] | MIL | [BC-001], [PP-001] | - | - |
| [MIL-004] | MIL | [BC-001], [PP-001] | - | - |
@@ -49,3 +50,5 @@ target is measurable. A row is added or updated whenever an artifact instance is
[RC-003]: ./reviews/rc-003-project-plan.md
[RC-004]: ./reviews/rc-004-mil-001.md
[a1ff735]: https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/014-higher_lower/commit/a1ff73580fdcfad5bbb3854298fdfa0c16ada853
[RC-005]: ./reviews/rc-005-mil-002.md
[ee58fcd]: https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/014-higher_lower/commit/ee58fcdc1c9c83b0721022d623e4989591a47987
+22
View File
@@ -0,0 +1,22 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "higher-lower"
version = "0.1.0"
description = "Higher Lower console game: guess which Instagram account has more followers (Udemy 100 Days of Code, day 14)."
readme = "README.md"
requires-python = ">=3.13"
license = { file = "LICENSE" }
dependencies = []
[project.optional-dependencies]
dev = ["pytest>=8"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
+3
View File
@@ -0,0 +1,3 @@
"""@package higher_lower
@brief Higher Lower console game: guess which Instagram account has more followers.
"""
+14
View File
@@ -0,0 +1,14 @@
"""@file __main__.py
@brief Entry point for `python -m higher_lower`.
"""
def main() -> None:
"""@brief Start the game.
Placeholder: the game loop is implemented in MIL-003.
"""
if __name__ == "__main__":
main()
+31
View File
@@ -0,0 +1,31 @@
"""@file art.py
@brief ASCII art shown by the game, as given by the assignment.
The lines are raw strings so the backslashes in the art survive unchanged.
They are concatenated line by line, rather than written as one multi-line
string, so Doxygen does not read the backticks in the art as markup.
"""
## The game logo.
logo = (
"\n"
r" __ ___ __ " "\n"
r" / / / (_)___ _/ /_ ___ _____" "\n"
r" / /_/ / / __ `/ __ \/ _ \/ ___/" "\n"
r" / __ / / /_/ / / / / __/ / " "\n"
r"/_/ ///_/\__, /_/ /_/\___/_/ " "\n"
r" / / /____/_ _____ _____" "\n"
r" / / / __ \ | /| / / _ \/ ___/" "\n"
r" / /___/ /_/ / |/ |/ / __/ / " "\n"
r"/_____/\____/|__/|__/\___/_/ " "\n"
)
## The "vs" shown between the two accounts.
vs = (
"\n"
r" _ __ " "\n"
r"| | / /____" "\n"
r"| | / / ___/" "\n"
r"| |/ (__ ) " "\n"
r"|___/____(_)" "\n"
)
+28
View File
@@ -0,0 +1,28 @@
"""@file constants.py
@brief Constants shared by the game modules.
"""
## Keys of an account dictionary.
KEY_NAME = "name"
KEY_FOLLOWER_COUNT = "follower_count"
KEY_DESCRIPTION = "description"
KEY_COUNTRY = "country"
## The two choices the player can type, in lower case.
CHOICE_A = "a"
CHOICE_B = "b"
## Score at the start of a game.
START_SCORE = 0
## How an account is described to the player.
ACCOUNT_TEMPLATE = "{name}, a {description}, from {country}"
## Texts shown to the player.
MSG_COMPARE_A = "Compare A: {account}."
MSG_AGAINST_B = "Against B: {account}."
PROMPT_GUESS = "Who has more followers? Type 'A' or 'B': "
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}"
+324
View File
@@ -0,0 +1,324 @@
"""@file game_data.py
@brief The accounts the player compares, as given by the assignment.
The order is kept as in the assignment; the game compares follower counts,
never list positions.
"""
from typing import TypedDict
class Account(TypedDict):
"""@brief One account the player can be asked about.
follower_count is in millions of Instagram followers.
"""
name: str
follower_count: int
description: str
country: str
data: list[Account] = [
{
"name": "Instagram",
"follower_count": 346,
"description": "Social media platform",
"country": "United States",
},
{
"name": "Cristiano Ronaldo",
"follower_count": 215,
"description": "Footballer",
"country": "Portugal",
},
{
"name": "Ariana Grande",
"follower_count": 183,
"description": "Musician and actress",
"country": "United States",
},
{
"name": "Dwayne Johnson",
"follower_count": 181,
"description": "Actor and professional wrestler",
"country": "United States",
},
{
"name": "Selena Gomez",
"follower_count": 174,
"description": "Musician and actress",
"country": "United States",
},
{
"name": "Kylie Jenner",
"follower_count": 172,
"description": "Reality TV personality and businesswoman and Self-Made Billionaire",
"country": "United States",
},
{
"name": "Kim Kardashian",
"follower_count": 167,
"description": "Reality TV personality and businesswoman",
"country": "United States",
},
{
"name": "Lionel Messi",
"follower_count": 149,
"description": "Footballer",
"country": "Argentina",
},
{
"name": "Beyoncé",
"follower_count": 145,
"description": "Musician",
"country": "United States",
},
{
"name": "Neymar",
"follower_count": 138,
"description": "Footballer",
"country": "Brasil",
},
{
"name": "National Geographic",
"follower_count": 135,
"description": "Magazine",
"country": "United States",
},
{
"name": "Justin Bieber",
"follower_count": 133,
"description": "Musician",
"country": "Canada",
},
{
"name": "Taylor Swift",
"follower_count": 131,
"description": "Musician",
"country": "United States",
},
{
"name": "Kendall Jenner",
"follower_count": 127,
"description": "Reality TV personality and Model",
"country": "United States",
},
{
"name": "Jennifer Lopez",
"follower_count": 119,
"description": "Musician and actress",
"country": "United States",
},
{
"name": "Nicki Minaj",
"follower_count": 113,
"description": "Musician",
"country": "Trinidad and Tobago",
},
{
"name": "Nike",
"follower_count": 109,
"description": "Sportswear multinational",
"country": "United States",
},
{
"name": "Khloé Kardashian",
"follower_count": 108,
"description": "Reality TV personality and businesswoman",
"country": "United States",
},
{
"name": "Miley Cyrus",
"follower_count": 107,
"description": "Musician and actress",
"country": "United States",
},
{
"name": "Katy Perry",
"follower_count": 94,
"description": "Musician",
"country": "United States",
},
{
"name": "Kourtney Kardashian",
"follower_count": 90,
"description": "Reality TV personality",
"country": "United States",
},
{
"name": "Kevin Hart",
"follower_count": 89,
"description": "Comedian and actor",
"country": "United States",
},
{
"name": "Ellen DeGeneres",
"follower_count": 87,
"description": "Comedian",
"country": "United States",
},
{
"name": "Real Madrid CF",
"follower_count": 86,
"description": "Football club",
"country": "Spain",
},
{
"name": "FC Barcelona",
"follower_count": 85,
"description": "Football club",
"country": "Spain",
},
{
"name": "Rihanna",
"follower_count": 81,
"description": "Musician and businesswoman",
"country": "Barbados",
},
{
"name": "Demi Lovato",
"follower_count": 80,
"description": "Musician and actress",
"country": "United States",
},
{
"name": "Victoria's Secret",
"follower_count": 69,
"description": "Lingerie brand",
"country": "United States",
},
{
"name": "Zendaya",
"follower_count": 68,
"description": "Actress and musician",
"country": "United States",
},
{
"name": "Shakira",
"follower_count": 66,
"description": "Musician",
"country": "Colombia",
},
{
"name": "Drake",
"follower_count": 65,
"description": "Musician",
"country": "Canada",
},
{
"name": "Chris Brown",
"follower_count": 64,
"description": "Musician",
"country": "United States",
},
{
"name": "LeBron James",
"follower_count": 63,
"description": "Basketball player",
"country": "United States",
},
{
"name": "Vin Diesel",
"follower_count": 62,
"description": "Actor",
"country": "United States",
},
{
"name": "Cardi B",
"follower_count": 67,
"description": "Musician",
"country": "United States",
},
{
"name": "David Beckham",
"follower_count": 82,
"description": "Footballer",
"country": "United Kingdom",
},
{
"name": "Billie Eilish",
"follower_count": 61,
"description": "Musician",
"country": "United States",
},
{
"name": "Justin Timberlake",
"follower_count": 59,
"description": "Musician and actor",
"country": "United States",
},
{
"name": "UEFA Champions League",
"follower_count": 58,
"description": "Club football competition",
"country": "Europe",
},
{
"name": "NASA",
"follower_count": 56,
"description": "Space agency",
"country": "United States",
},
{
"name": "Emma Watson",
"follower_count": 56,
"description": "Actress",
"country": "United Kingdom",
},
{
"name": "Shawn Mendes",
"follower_count": 57,
"description": "Musician",
"country": "Canada",
},
{
"name": "Virat Kohli",
"follower_count": 55,
"description": "Cricketer",
"country": "India",
},
{
"name": "Gigi Hadid",
"follower_count": 54,
"description": "Model",
"country": "United States",
},
{
"name": "Priyanka Chopra Jonas",
"follower_count": 53,
"description": "Actress and musician",
"country": "India",
},
{
"name": "9GAG",
"follower_count": 52,
"description": "Social media platform",
"country": "China",
},
{
"name": "Ronaldinho",
"follower_count": 51,
"description": "Footballer",
"country": "Brasil",
},
{
"name": "Maluma",
"follower_count": 50,
"description": "Musician",
"country": "Colombia",
},
{
"name": "Camila Cabello",
"follower_count": 49,
"description": "Musician",
"country": "Cuba",
},
{
"name": "NBA",
"follower_count": 47,
"description": "Club Basketball Competition",
"country": "United States",
},
]
+78
View File
@@ -0,0 +1,78 @@
"""@file game_logic.py
@brief Pure functions of the game: drawing accounts, describing them and checking guesses.
"""
import random
from collections.abc import Sequence
from higher_lower.constants import (
ACCOUNT_TEMPLATE,
CHOICE_A,
CHOICE_B,
KEY_DESCRIPTION,
KEY_NAME,
KEY_COUNTRY,
)
from higher_lower.game_data import Account, data
_DEFAULT_RNG = random.Random()
def get_random_account(
accounts: Sequence[Account] = data,
rng: random.Random | None = None,
) -> Account:
"""@brief Return one random account.
@param accounts The accounts to draw from.
@param rng Random generator; pass a seeded one to make the draw repeatable.
@return A random entry of accounts.
"""
return (rng or _DEFAULT_RNG).choice(accounts)
def pick_pair(
accounts: Sequence[Account] = data,
rng: random.Random | None = None,
) -> tuple[Account, Account]:
"""@brief Return two different random accounts.
@param accounts The accounts to draw from; needs at least two entries.
@param rng Random generator; pass a seeded one to make the draw repeatable.
@return The accounts A and B, never the same entry.
@throws ValueError If accounts has fewer than two entries.
"""
first, second = (rng or _DEFAULT_RNG).sample(accounts, 2)
return first, second
def format_data(account: Account) -> str:
"""@brief Describe an account for the player.
@param account The account to describe.
@return Text such as "Nike, a Sportswear multinational, from United States".
"""
return ACCOUNT_TEMPLATE.format(
name=account[KEY_NAME],
description=account[KEY_DESCRIPTION],
country=account[KEY_COUNTRY],
)
def check_answer(guess: str, a_followers: int, b_followers: int) -> bool:
"""@brief Tell whether the guess names the account with more followers.
Equal follower counts make either choice correct.
@param guess "a" or "b", in any case.
@param a_followers Follower count of account A.
@param b_followers Follower count of account B.
@return True if the guess is right.
@throws ValueError If guess is neither "a" nor "b".
"""
choice = guess.lower()
if choice == CHOICE_A:
return a_followers >= b_followers
if choice == CHOICE_B:
return b_followers >= a_followers
raise ValueError(f"guess must be {CHOICE_A!r} or {CHOICE_B!r}, got {guess!r}")
+46
View File
@@ -0,0 +1,46 @@
"""@file test_game_data.py
@brief Tests for the assignment data set and art.
"""
from higher_lower.art import logo, vs
from higher_lower.constants import (
KEY_COUNTRY,
KEY_DESCRIPTION,
KEY_FOLLOWER_COUNT,
KEY_NAME,
)
from higher_lower.game_data import data
EXPECTED_KEYS = {KEY_NAME, KEY_FOLLOWER_COUNT, KEY_DESCRIPTION, KEY_COUNTRY}
def test_data_has_fifty_accounts() -> None:
"""@brief The assignment data set has 50 entries."""
assert len(data) == 50
def test_every_account_has_the_required_keys() -> None:
"""@brief Every entry has exactly the four assignment keys."""
assert all(set(account) == EXPECTED_KEYS for account in data)
def test_follower_counts_are_positive_integers() -> None:
"""@brief Follower counts are positive integers."""
assert all(
isinstance(account[KEY_FOLLOWER_COUNT], int) and account[KEY_FOLLOWER_COUNT] > 0
for account in data
)
def test_account_names_are_unique() -> None:
"""@brief No account appears twice."""
names = [account[KEY_NAME] for account in data]
assert len(names) == len(set(names))
def test_art_is_available() -> None:
"""@brief The logo and the vs art keep their shape, including backslashes."""
assert len(logo.splitlines()) == 10
assert "\\" in logo
assert len(vs.splitlines()) == 6
assert "|___/____(_)" in vs
+89
View File
@@ -0,0 +1,89 @@
"""@file test_game_logic.py
@brief Tests for the pure game functions.
"""
import random
import pytest
from higher_lower.constants import KEY_NAME
from higher_lower.game_data import Account, data
from higher_lower.game_logic import (
check_answer,
format_data,
get_random_account,
pick_pair,
)
def test_get_random_account_returns_an_entry_of_the_data() -> None:
"""@brief The drawn account comes from the data set."""
assert get_random_account(rng=random.Random(1)) in data
def test_get_random_account_is_repeatable_with_a_seed() -> None:
"""@brief The same seed draws the same account."""
assert get_random_account(rng=random.Random(7)) == get_random_account(
rng=random.Random(7)
)
def test_pick_pair_never_returns_the_same_account() -> None:
"""@brief A and B are always different entries."""
rng = random.Random(0)
for _ in range(200):
first, second = pick_pair(rng=rng)
assert first[KEY_NAME] != second[KEY_NAME]
def test_pick_pair_with_two_accounts_returns_both() -> None:
"""@brief With only two accounts, the pair holds both of them."""
two = data[:2]
first, second = pick_pair(two, random.Random(3))
assert {first[KEY_NAME], second[KEY_NAME]} == {a[KEY_NAME] for a in two}
def test_pick_pair_with_one_account_raises() -> None:
"""@brief A pair cannot be drawn from a single account."""
with pytest.raises(ValueError):
pick_pair(data[:1])
def test_format_data_describes_the_account() -> None:
"""@brief The description holds name, description and country."""
account: Account = {
"name": "Nike",
"follower_count": 109,
"description": "Sportswear multinational",
"country": "United States",
}
assert (
format_data(account)
== "Nike, a Sportswear multinational, from United States"
)
@pytest.mark.parametrize(
("guess", "a_followers", "b_followers", "expected"),
[
("a", 200, 100, True),
("a", 100, 200, False),
("b", 100, 200, True),
("b", 200, 100, False),
("A", 200, 100, True),
("B", 100, 200, True),
("a", 100, 100, True),
("b", 100, 100, True),
],
)
def test_check_answer(
guess: str, a_followers: int, b_followers: int, expected: bool
) -> None:
"""@brief The answer is right for the higher account, and for either on a tie."""
assert check_answer(guess, a_followers, b_followers) is expected
def test_check_answer_rejects_an_unknown_choice() -> None:
"""@brief A guess other than a or b is an error."""
with pytest.raises(ValueError):
check_answer("c", 1, 2)
+16
View File
@@ -0,0 +1,16 @@
"""@file test_smoke.py
@brief Smoke test: the package and its entry point can be imported.
"""
import higher_lower
from higher_lower import __main__ as entry_point
def test_package_imports() -> None:
"""@brief The package imports without side effects."""
assert higher_lower.__doc__
def test_entry_point_is_callable() -> None:
"""@brief The entry point exists and can be called."""
assert entry_point.main() is None