MIL-002 Game core logic #26

Merged
Tirsvad merged 2 commits from mil-002-game-core into main 2026-10-13 09:48:21 +02:00
8 changed files with 366 additions and 2 deletions
@@ -0,0 +1,52 @@
# Read the states with the csv module
## Metadata
| Key | Value |
| --- | --- |
| ID | ADR-0001 |
| CrossReference | [BC-001], [SA-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-13 | Accepted | Claude (AI) for S01 | AI as stand-in for S01 | Initial version; review RC-008: Go | [bb8730c] |
Allowed Status values: `Proposed`, `Accepted`, `Rejected`, `Deprecated`, `Superseded by ADR-NNNN`.
---
## Context
The lectures read `50_states.csv` with pandas (`pandas.read_csv`, a DataFrame, then `DataFrame.to_csv` for `states_to_learn.csv`). The file has 50 rows and three columns (`state`, `x`, `y`). [BC-001] sets the constraint "avoid adding runtime dependencies unless they are needed", and [SA-001] records that S03 wants no runtime dependencies while S02 wants the assignment's function and variable names.
Options evaluated:
1. **pandas**: matches the lectures literally; adds a large runtime dependency (with NumPy) for 50 rows and makes installation and CI slower.
2. **Standard library `csv` module**: no dependency; a few more lines to read and write; the assignment's names (`data`, `states`, `guessed_states`, `missing_states`) can be kept.
## Decision
Read and write the CSV files with the standard library `csv` module, and keep the assignment's names for variables and functions. pandas is not a runtime dependency.
## Consequences
**Positive:**
- The game has no runtime dependencies, so S03 installs nothing and CI is fast.
- The game logic is plain functions over `str` and `int`, easy to test without a display.
**Negative:**
- The code differs from the lecture's pandas calls, so S02 compares by names and behaviour, not by line.
- Reading and writing the CSV takes a few more lines than `read_csv` and `to_csv`.
## Affected Artifacts
- [BC-001] - Success criterion 6 (no runtime dependency beyond the standard library) is met without exception.
- [SA-001] - Resolves the S02 and S03 conflict in favour of no runtime dependency.
---
[BC-001]: ../business-case.md
[SA-001]: ../stakeholder-analysis.md
[bb8730c]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/025-us-states-game/commit/bb8730c30761f885491abeb764690e0b53d2459b
+2 -2
View File
@@ -15,8 +15,8 @@ document of a type. `Primary File` may contain a glob (e.g.
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 | | SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
| PP | Project Plan | docs/project-plan.md | 002 | | PP | Project Plan | docs/project-plan.md | 002 |
| MIL | Milestone / Gateway | docs/milestones/*.md | 005 | | MIL | Milestone / Gateway | docs/milestones/*.md | 005 |
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0001 | | ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0002 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 008 | | RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 010 |
## Languages ## Languages
+58
View File
@@ -0,0 +1,58 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-008 |
| CrossReference | [QC-ADR-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-13 | Proposed | Claude (AI) for S01 | AI as stand-in for S01 | Initial review record: Go | [bb8730c] |
---
## Artifact Under Review
- Instance reviewed: [ADR-0001]
- Checklist used: [QC-ADR-001]
- Scope: full review
- Language and domain: n/a (technical type)
- Language reviewer: none
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`; the author is the same AI)
## Checklist Results
### QC-ADR-001
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | The ID (`ADR-NNNN`, 4-digit) matches the filename number and is unique and sequential (Mandatory) | Pass | ID ADR-0001 matches file adr-0001-...; first ADR. |
| 2 | Context states the problem, the forces (cost, risk, constraints) and the options evaluated (Mandatory) | Pass | Context states problem, constraints from BC-001 and SA-001, and two options. |
| 3 | At least two options were considered, or the absence of alternatives is justified (Mandatory) | Pass | Two options: pandas and the csv module. |
| 4 | Decision is stated in one or two unhedged sentences and matches one of the evaluated options (Mandatory) | Pass | Decision is two unhedged sentences and equals option 2. |
| 5 | Consequences list both Positive and Negative outcomes (Mandatory) | Pass | Positive and Negative lists present. |
| 6 | Quality-attribute impacts (performance, security, portability, etc.) are named where the decision affects them (Optional) | Pass | Portability and installation effect named (no runtime dependency). |
| 7 | Affected Artifacts are listed as links, and each is also in CrossReference (Mandatory) | Pass | BC-001 and SA-001 are linked and in CrossReference. |
| 8 | Status changes are recorded as new Version History rows with a Change summary and Commit link; retained rows are never rewritten (Mandatory) | Pass | One Version History row; to be set Accepted on this Go. |
| 9 | A superseded or deprecated ADR names its successor, and the successor links back (Optional) | N-A | Not superseded or deprecated. |
| 10 | Written in business or technical language of the domain, with no unexplained abbreviations (Optional) | Pass | Abbreviations spelled out or defined (CSV file name, CI named as continuous integration in BC-001). |
| 11 | Status is one of `Proposed`, `Accepted`, `Rejected`, `Deprecated` or `Superseded by ADR-NNNN`, and every Version History row uses only these (Mandatory) | Pass | Status values are from the allowed list. |
## Overall Verdict
Go - ADR-0001 meets every Mandatory criterion.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | S01 | |
---
[QC-ADR-001]: ../../../framework/qc/artifacts/qc-adr.md
[ADR-0001]: ../../adr/adr-0001-csv-module-over-pandas.md
[bb8730c]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/025-us-states-game/commit/bb8730c30761f885491abeb764690e0b53d2459b
+61
View File
@@ -0,0 +1,61 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-009 |
| CrossReference | [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-13 | Proposed | Claude (AI) for S01 | AI as stand-in for S01 | Initial review record: Go | [bb8730c] |
---
## Artifact Under Review
- Instance reviewed: source and tests delivered by MIL-002 (branch mil-002-game-core)
- Checklist used: [QC-PY-001]
- Scope: full review
- Language and domain: n/a (technical type)
- Language reviewer: none
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`; the author is the same AI)
## Checklist Results
### QC-PY-001
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) (Mandatory) | Pass | snake_case functions, PascalCase State; ruff N rules pass. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes (Mandatory) | Pass | find_state, get_missing_states, save_states_to_learn state purpose; assignment names kept. |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions (Mandatory) | Pass | ruff format and check clean, no suppressions. |
| 4 | Every function and method signature is type-annotated, including `-> None` (Mandatory) | Pass | All signatures annotated; mypy --strict passes. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) (Mandatory) | N-A | No exception handling; a missing file raises FileNotFoundError to the caller. |
| 6 | No mutable default arguments and no shadowed builtins (Mandatory) | Pass | Default arguments are immutable (Path values); no builtin shadowed. |
| 7 | Files, locks and connections are managed with context managers (Mandatory) | Pass | storage.py opens files with `with`. |
| 8 | Public modules, classes and functions have docstrings that say what, not how (Optional) | Pass | Docstrings with Doxygen @param/@return on all public items. |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output (Mandatory) | Pass | No print and no logging in the logic. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded (Mandatory) | N-A | No Design Class Diagram exists; State is a plain value object. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network (Mandatory) | Pass | 15 tests named for behaviour, tmp_path for files, no network or order dependence. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment (Optional) | Pass | mypy strict reports no issues. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused (Optional) | Pass | No runtime dependencies (ADR-0001). |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike (Mandatory) | Pass | Column names and paths defined once in constants.py; matching rule in find_state only. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles (Mandatory) | Pass | states.py imports no IO or UI; storage.py imports inward (states, constants); no cycles. |
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations (Mandatory) | Pass | states.py has one reason to change (rules), storage.py another (file format); no inheritance. |
## Overall Verdict
Go - Mandatory criteria pass for states.py, storage.py and their tests (ruff, mypy --strict, 17 pytest tests, doxygen without warnings).
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | S01 | |
---
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[bb8730c]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/025-us-states-game/commit/bb8730c30761f885491abeb764690e0b53d2459b
+54
View File
@@ -0,0 +1,54 @@
"""Pure domain logic of the U.S. States Game: states, answers and misses.
@file states.py
@brief State model, answer matching and the list of missing states.
Nothing here reads files or touches the screen, so the rules run under test
with no display and no file system.
"""
from collections.abc import Iterable
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class State:
"""A U.S. state and the position of its name on the map.
@param name Name of the state, for example "Ohio".
@param x Horizontal map coordinate of the state.
@param y Vertical map coordinate of the state.
"""
name: str
x: int
y: int
def find_state(answer: str, states: Iterable[State]) -> State | None:
"""Find the state the player named, ignoring case and surrounding spaces.
@param answer Text the player typed.
@param states The states to search.
@return The matching state, or None if the answer matches no state.
"""
wanted = answer.strip().casefold()
if not wanted:
return None
for state in states:
if state.name.casefold() == wanted:
return state
return None
def get_missing_states(
states: Iterable[State], guessed_states: Iterable[str]
) -> list[str]:
"""List the names of the states the player did not guess.
@param states All states of the game, in the order to report them.
@param guessed_states Names of the states the player guessed.
@return Names of the states not guessed, in the order of `states`.
"""
guessed = set(guessed_states)
return [state.name for state in states if state.name not in guessed]
+48
View File
@@ -0,0 +1,48 @@
"""CSV input and output of the U.S. States Game, using the csv module.
@file storage.py
@brief Loads the states from CSV and saves the states to learn.
Uses the standard library `csv` module, not pandas (see ADR-0001).
"""
import csv
from collections.abc import Iterable
from pathlib import Path
from us_states_game.constants import (
STATE_COLUMN,
STATES_CSV_PATH,
STATES_TO_LEARN_FILENAME,
X_COLUMN,
Y_COLUMN,
)
from us_states_game.states import State
def load_states(path: Path = STATES_CSV_PATH) -> list[State]:
"""Read the states and their map coordinates from a CSV file.
@param path CSV file with the state, x and y columns.
@return The states, in file order.
"""
with path.open(newline="", encoding="utf-8") as file:
return [
State(row[STATE_COLUMN], int(row[X_COLUMN]), int(row[Y_COLUMN]))
for row in csv.DictReader(file)
]
def save_states_to_learn(
missing_states: Iterable[str],
path: Path = Path(STATES_TO_LEARN_FILENAME),
) -> None:
"""Write the missing states to a one-column CSV file.
@param missing_states Names of the states the player missed.
@param path File to write; an existing file is replaced.
"""
with path.open("w", newline="", encoding="utf-8") as file:
writer = csv.writer(file)
writer.writerow([STATE_COLUMN])
writer.writerows([name] for name in missing_states)
+41
View File
@@ -0,0 +1,41 @@
"""Tests of the pure game logic in states.py."""
from us_states_game.states import State, find_state, get_missing_states
STATES = [State("Alabama", 139, -77), State("Ohio", 100, 20), State("Texas", -50, -120)]
def test_find_state_matches_exact_name() -> None:
assert find_state("Ohio", STATES) == State("Ohio", 100, 20)
def test_find_state_ignores_case() -> None:
assert find_state("oHiO", STATES) == State("Ohio", 100, 20)
def test_find_state_ignores_surrounding_whitespace() -> None:
assert find_state(" ohio \n", STATES) == State("Ohio", 100, 20)
def test_find_state_returns_none_for_unknown_answer() -> None:
assert find_state("Atlantis", STATES) is None
def test_find_state_returns_none_for_empty_answer() -> None:
assert find_state("", STATES) is None
def test_find_state_returns_none_for_blank_answer() -> None:
assert find_state(" ", STATES) is None
def test_get_missing_states_keeps_order_of_states() -> None:
assert get_missing_states(STATES, ["Ohio"]) == ["Alabama", "Texas"]
def test_get_missing_states_is_empty_when_all_guessed() -> None:
assert get_missing_states(STATES, ["Texas", "Ohio", "Alabama"]) == []
def test_get_missing_states_returns_all_when_none_guessed() -> None:
assert get_missing_states(STATES, []) == ["Alabama", "Ohio", "Texas"]
+50
View File
@@ -0,0 +1,50 @@
"""Tests of the CSV input and output in storage.py."""
import csv
from pathlib import Path
from us_states_game.constants import NUMBER_OF_STATES, STATE_COLUMN
from us_states_game.states import State, get_missing_states
from us_states_game.storage import load_states, save_states_to_learn
def _read_rows(path: Path) -> list[list[str]]:
with path.open(newline="", encoding="utf-8") as file:
return list(csv.reader(file))
def test_load_states_returns_all_states() -> None:
assert len(load_states()) == NUMBER_OF_STATES
def test_load_states_reads_alabama_coordinates() -> None:
assert load_states()[0] == State("Alabama", 139, -77)
def test_load_states_reads_custom_path(tmp_path: Path) -> None:
path = tmp_path / "states.csv"
path.write_text("state,x,y\nOhio,10,-20\n", encoding="utf-8")
assert load_states(path) == [State("Ohio", 10, -20)]
def test_save_states_to_learn_writes_header_and_states(tmp_path: Path) -> None:
path = tmp_path / "learn.csv"
save_states_to_learn(["Ohio", "Texas"], path)
assert _read_rows(path) == [[STATE_COLUMN], ["Ohio"], ["Texas"]]
def test_save_states_to_learn_writes_only_header_when_nothing_missing(
tmp_path: Path,
) -> None:
path = tmp_path / "learn.csv"
save_states_to_learn([], path)
assert _read_rows(path) == [[STATE_COLUMN]]
def test_save_states_to_learn_lists_47_rows_after_three_guesses(
tmp_path: Path,
) -> None:
path = tmp_path / "learn.csv"
missing = get_missing_states(load_states(), ["Alabama", "Ohio", "Texas"])
save_states_to_learn(missing, path)
assert len(_read_rows(path)) - 1 == NUMBER_OF_STATES - 3