From 390d0bc8a8081240827ad66bd784b5b4af42bde6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:55:17 +0000 Subject: [PATCH 1/2] Initial plan From f99a09ecede9a05fa4813fd8d6bd8db3825a3119 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:03:13 +0000 Subject: [PATCH 2/2] feat: add Scavenger Hunt game mode - Add GameMode enum and ScavengerItem model to models.py - Add generate_checklist() and toggle_item() to game_logic.py - Update GameSession with scavenger hunt support (start_scavenger_hunt, handle_item_click, checked_count, progress_percent) - Add /start-scavenger-hunt and /toggle-item/{item_id} routes - Update start screen with mode selection (Bingo vs Scavenger Hunt) - Add scavenger_screen.html template with checklist and progress bar - Add CSS utility classes (w-6, h-2, h-6, flex-shrink-0, overflow-auto) - Add tests for new game logic and API routes (39 total, all passing) Agent-Logs-Url: https://github.com/prashantchahar/my-soc-ops-python/sessions/da4dff2d-850b-4db2-9521-3171e26f4256 Co-authored-by: prashantchahar <19215720+prashantchahar@users.noreply.github.com> --- app/game_logic.py | 20 ++++++- app/game_service.py | 33 ++++++++++- app/main.py | 24 +++++++- app/models.py | 15 +++++ app/static/css/app.css | 6 ++ .../components/scavenger_screen.html | 52 ++++++++++++++++++ app/templates/components/start_screen.html | 23 +++++--- tests/test_api.py | 39 ++++++++++++- tests/test_game_logic.py | 55 +++++++++++++++++++ 9 files changed, 253 insertions(+), 14 deletions(-) create mode 100644 app/templates/components/scavenger_screen.html diff --git a/app/game_logic.py b/app/game_logic.py index 85c262a..6824531 100644 --- a/app/game_logic.py +++ b/app/game_logic.py @@ -2,7 +2,7 @@ import random from app.data import FREE_SPACE, QUESTIONS -from app.models import BingoLine, BingoSquareData +from app.models import BingoLine, BingoSquareData, ScavengerItem BOARD_SIZE = 5 CENTER_INDEX = 12 # 5x5 grid, center is index 12 (row 2, col 2) @@ -67,3 +67,21 @@ def check_bingo(board: list[BingoSquareData]) -> BingoLine | None: def get_winning_square_ids(line: BingoLine | None) -> set[int]: """Get the square IDs that are part of a winning line.""" return set(line.squares) if line else set() + + +def generate_checklist() -> list[ScavengerItem]: + """Generate a shuffled checklist from all available questions.""" + questions = random.sample(QUESTIONS, len(QUESTIONS)) + return [ScavengerItem(id=i, text=q) for i, q in enumerate(questions)] + + +def toggle_item( + items: list[ScavengerItem], item_id: int +) -> list[ScavengerItem]: + """Toggle a checklist item's checked state. Returns a new list.""" + return [ + item.model_copy(update={"is_checked": not item.is_checked}) + if item.id == item_id + else item + for item in items + ] diff --git a/app/game_service.py b/app/game_service.py index dbd9729..0c7787d 100644 --- a/app/game_service.py +++ b/app/game_service.py @@ -3,10 +3,12 @@ from app.game_logic import ( check_bingo, generate_board, + generate_checklist, get_winning_square_ids, + toggle_item, toggle_square, ) -from app.models import BingoLine, BingoSquareData, GameState +from app.models import BingoLine, BingoSquareData, GameMode, GameState, ScavengerItem @dataclass @@ -14,9 +16,11 @@ class GameSession: """Holds the state for a single game session.""" game_state: GameState = GameState.START + game_mode: GameMode = GameMode.BINGO board: list[BingoSquareData] = field(default_factory=list) winning_line: BingoLine | None = None show_bingo_modal: bool = False + checklist: list[ScavengerItem] = field(default_factory=list) @property def winning_square_ids(self) -> set[int]: @@ -26,10 +30,30 @@ def winning_square_ids(self) -> set[int]: def has_bingo(self) -> bool: return self.game_state == GameState.BINGO + @property + def checked_count(self) -> int: + return sum(1 for item in self.checklist if item.is_checked) + + @property + def progress_percent(self) -> int: + if not self.checklist: + return 0 + return round(self.checked_count / len(self.checklist) * 100) + def start_game(self) -> None: self.board = generate_board() self.winning_line = None self.game_state = GameState.PLAYING + self.game_mode = GameMode.BINGO + self.show_bingo_modal = False + self.checklist = [] + + def start_scavenger_hunt(self) -> None: + self.checklist = generate_checklist() + self.board = [] + self.winning_line = None + self.game_state = GameState.PLAYING + self.game_mode = GameMode.SCAVENGER_HUNT self.show_bingo_modal = False def handle_square_click(self, square_id: int) -> None: @@ -44,11 +68,18 @@ def handle_square_click(self, square_id: int) -> None: self.game_state = GameState.BINGO self.show_bingo_modal = True + def handle_item_click(self, item_id: int) -> None: + if self.game_state != GameState.PLAYING: + return + self.checklist = toggle_item(self.checklist, item_id) + def reset_game(self) -> None: self.game_state = GameState.START + self.game_mode = GameMode.BINGO self.board = [] self.winning_line = None self.show_bingo_modal = False + self.checklist = [] def dismiss_modal(self) -> None: self.show_bingo_modal = False diff --git a/app/main.py b/app/main.py index 4f7dcec..44147f9 100644 --- a/app/main.py +++ b/app/main.py @@ -8,7 +8,7 @@ from starlette.middleware.sessions import SessionMiddleware from app.game_service import GameSession, get_session -from app.models import GameState +from app.models import GameMode, GameState BASE_DIR = Path(__file__).resolve().parent @@ -32,7 +32,7 @@ async def home(request: Request) -> Response: return templates.TemplateResponse( request, "home.html", - {"session": session, "GameState": GameState}, + {"session": session, "GameState": GameState, "GameMode": GameMode}, ) @@ -45,6 +45,15 @@ async def start_game(request: Request) -> Response: ) +@app.post("/start-scavenger-hunt", response_class=HTMLResponse) +async def start_scavenger_hunt(request: Request) -> Response: + session = _get_game_session(request) + session.start_scavenger_hunt() + return templates.TemplateResponse( + request, "components/scavenger_screen.html", {"session": session} + ) + + @app.post("/toggle/{square_id}", response_class=HTMLResponse) async def toggle_square(request: Request, square_id: int) -> Response: session = _get_game_session(request) @@ -54,6 +63,15 @@ async def toggle_square(request: Request, square_id: int) -> Response: ) +@app.post("/toggle-item/{item_id}", response_class=HTMLResponse) +async def toggle_item(request: Request, item_id: int) -> Response: + session = _get_game_session(request) + session.handle_item_click(item_id) + return templates.TemplateResponse( + request, "components/scavenger_screen.html", {"session": session} + ) + + @app.post("/reset", response_class=HTMLResponse) async def reset_game(request: Request) -> Response: session = _get_game_session(request) @@ -61,7 +79,7 @@ async def reset_game(request: Request) -> Response: return templates.TemplateResponse( request, "components/start_screen.html", - {"session": session, "GameState": GameState}, + {"session": session, "GameState": GameState, "GameMode": GameMode}, ) diff --git a/app/models.py b/app/models.py index 9f8e8f8..14d74af 100644 --- a/app/models.py +++ b/app/models.py @@ -10,6 +10,11 @@ class GameState(StrEnum): BINGO = "bingo" +class GameMode(StrEnum): + BINGO = "bingo" + SCAVENGER_HUNT = "scavenger_hunt" + + class BingoSquareData(BaseModel): """A single square on the bingo board.""" @@ -29,3 +34,13 @@ class BingoLine(BaseModel): type: Literal["row", "column", "diagonal"] = "row" index: int = 0 squares: list[int] = [] + + +class ScavengerItem(BaseModel): + """A single item in the scavenger hunt checklist.""" + + model_config = ConfigDict(frozen=True) + + id: int + text: str + is_checked: bool = False diff --git a/app/static/css/app.css b/app/static/css/app.css index dfa6b51..71d8c27 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -14,6 +14,9 @@ body { .h-full { height: 100%; } .min-h-full { min-height: 100%; } .w-full { width: 100%; } +.w-6 { width: 1.5rem; } +.h-2 { height: 0.5rem; } +.h-6 { height: 1.5rem; } .w-16 { width: 4rem; } .max-w-sm { max-width: 24rem; } .max-w-md { max-width: 28rem; } @@ -22,6 +25,7 @@ body { .flex { display: flex; } .flex-col { flex-direction: column; } .flex-1 { flex: 1 1 0%; } +.flex-shrink-0 { flex-shrink: 0; } .items-center { align-items: center; } .justify-center { justify-content: center; } .justify-between { justify-content: space-between; } @@ -109,6 +113,8 @@ body { .z-50 { z-index: 50; } +.overflow-auto { overflow: auto; } + .aspect-square { aspect-ratio: 1 / 1; } .select-none { user-select: none; } diff --git a/app/templates/components/scavenger_screen.html b/app/templates/components/scavenger_screen.html new file mode 100644 index 0000000..a564968 --- /dev/null +++ b/app/templates/components/scavenger_screen.html @@ -0,0 +1,52 @@ +
+
+ +
+ +

Scavenger Hunt

+
+
+ + +
+
+ Progress + {{ session.checked_count }} / {{ session.checklist | length }} +
+
+
+
+
+ + +
+
    + {% for item in session.checklist %} +
  • + +
  • + {% endfor %} +
+
+
+
diff --git a/app/templates/components/start_screen.html b/app/templates/components/start_screen.html index 14a938a..9c923fc 100644 --- a/app/templates/components/start_screen.html +++ b/app/templates/components/start_screen.html @@ -13,13 +13,22 @@

How to play

- +
+ + +
diff --git a/tests/test_api.py b/tests/test_api.py index 477b9b3..a65adb9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -17,7 +17,7 @@ def test_home_returns_200(self, client: TestClient): def test_home_contains_start_screen(self, client: TestClient): response = client.get("/") assert "Soc Ops" in response.text - assert "Start Game" in response.text + assert "Play Bingo" in response.text assert "How to play" in response.text def test_home_sets_session_cookie(self, client: TestClient): @@ -57,7 +57,7 @@ def test_reset_returns_start_screen(self, client: TestClient): client.post("/start") response = client.post("/reset") assert response.status_code == 200 - assert "Start Game" in response.text + assert "Play Bingo" in response.text assert "How to play" in response.text @@ -68,3 +68,38 @@ def test_dismiss_returns_game_screen(self, client: TestClient): response = client.post("/dismiss-modal") assert response.status_code == 200 assert "FREE SPACE" in response.text + + +class TestScavengerHunt: + def test_start_scavenger_hunt_returns_200(self, client: TestClient): + client.get("/") + response = client.post("/start-scavenger-hunt") + assert response.status_code == 200 + + def test_scavenger_hunt_shows_checklist(self, client: TestClient): + client.get("/") + response = client.post("/start-scavenger-hunt") + assert "Scavenger Hunt" in response.text + assert "Progress" in response.text + + def test_scavenger_hunt_has_items(self, client: TestClient): + client.get("/") + response = client.post("/start-scavenger-hunt") + assert 'hx-post="/toggle-item/' in response.text + + def test_toggle_item_updates_checklist(self, client: TestClient): + client.get("/") + client.post("/start-scavenger-hunt") + response = client.post("/toggle-item/0") + assert response.status_code == 200 + assert "Scavenger Hunt" in response.text + + def test_reset_from_scavenger_hunt_returns_start_screen( + self, client: TestClient + ): + client.get("/") + client.post("/start-scavenger-hunt") + response = client.post("/reset") + assert response.status_code == 200 + assert "Play Bingo" in response.text + assert "Scavenger Hunt" in response.text diff --git a/tests/test_game_logic.py b/tests/test_game_logic.py index 52eb682..0c5771a 100644 --- a/tests/test_game_logic.py +++ b/tests/test_game_logic.py @@ -3,7 +3,9 @@ CENTER_INDEX, check_bingo, generate_board, + generate_checklist, get_winning_square_ids, + toggle_item, toggle_square, ) from app.models import BingoLine, BingoSquareData @@ -132,3 +134,56 @@ def test_none_line_returns_empty_set(self): def test_returns_square_ids(self): line = BingoLine(type="row", index=0, squares=[0, 1, 2, 3, 4]) assert get_winning_square_ids(line) == {0, 1, 2, 3, 4} + + +class TestGenerateChecklist: + def test_checklist_has_all_questions(self): + checklist = generate_checklist() + assert len(checklist) == len(QUESTIONS) + + def test_checklist_items_have_sequential_ids(self): + checklist = generate_checklist() + for i, item in enumerate(checklist): + assert item.id == i + + def test_checklist_items_are_from_questions_pool(self): + checklist = generate_checklist() + texts = {item.text for item in checklist} + assert texts == set(QUESTIONS) + + def test_checklist_items_start_unchecked(self): + checklist = generate_checklist() + assert all(not item.is_checked for item in checklist) + + def test_checklist_is_shuffled(self): + checklist1 = generate_checklist() + checklist2 = generate_checklist() + texts1 = [item.text for item in checklist1] + texts2 = [item.text for item in checklist2] + assert texts1 != texts2 + + +class TestToggleItem: + def test_toggle_checks_unchecked_item(self): + checklist = generate_checklist() + assert checklist[0].is_checked is False + new_checklist = toggle_item(checklist, 0) + assert new_checklist[0].is_checked is True + + def test_toggle_unchecks_checked_item(self): + checklist = generate_checklist() + checklist = toggle_item(checklist, 0) + assert checklist[0].is_checked is True + checklist = toggle_item(checklist, 0) + assert checklist[0].is_checked is False + + def test_toggle_only_affects_target_item(self): + checklist = generate_checklist() + new_checklist = toggle_item(checklist, 0) + for item in new_checklist[1:]: + assert item.is_checked is False + + def test_toggle_returns_new_list(self): + checklist = generate_checklist() + new_checklist = toggle_item(checklist, 0) + assert checklist is not new_checklist