Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion app/game_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
]
33 changes: 32 additions & 1 deletion app/game_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,24 @@
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
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]:
Expand All @@ -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:
Expand All @@ -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
Expand Down
24 changes: 21 additions & 3 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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},
)


Expand All @@ -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)
Expand All @@ -54,14 +63,23 @@ 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)
session.reset_game()
return templates.TemplateResponse(
request,
"components/start_screen.html",
{"session": session, "GameState": GameState},
{"session": session, "GameState": GameState, "GameMode": GameMode},
)


Expand Down
15 changes: 15 additions & 0 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand All @@ -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
6 changes: 6 additions & 0 deletions app/static/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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; }
Expand Down Expand Up @@ -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; }
Expand Down
52 changes: 52 additions & 0 deletions app/templates/components/scavenger_screen.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<div id="game-container">
<div class="flex flex-col min-h-full bg-gray-50">
<!-- Header -->
<header class="flex items-center justify-between p-3 bg-white border-b border-gray-200">
<button
hx-post="/reset"
hx-target="#game-container"
hx-swap="outerHTML"
class="text-gray-500 text-sm px-3 py-1.5 rounded active:bg-gray-100">
← Back
</button>
<h1 class="font-bold text-gray-900">Scavenger Hunt</h1>
<div class="w-16"></div>
</header>

<!-- Progress bar -->
<div class="px-4 py-3 bg-white border-b border-gray-200">
<div class="flex items-center justify-between text-sm text-gray-600 mb-2">
<span>Progress</span>
<span class="font-semibold">{{ session.checked_count }} / {{ session.checklist | length }}</span>
</div>
<div class="w-full bg-gray-200 rounded h-2">
<div class="bg-accent rounded h-2 transition-all duration-150"
style="width: {{ session.progress_percent }}%"></div>
</div>
</div>

<!-- Checklist -->
<div class="flex-1 overflow-auto p-4">
<ul class="space-y-2 max-w-md mx-auto">
{% for item in session.checklist %}
<li>
<button
hx-post="/toggle-item/{{ item.id }}"
hx-target="#game-container"
hx-swap="outerHTML"
class="w-full flex items-center p-3 rounded-lg border text-left transition-all duration-150
{%- if item.is_checked %} bg-marked border-marked-border text-green-800
{%- else %} bg-white border-gray-200 text-gray-700 active:bg-gray-100{% endif %}">
<span class="flex-shrink-0 w-6 h-6 rounded border-2 flex items-center justify-center mr-3 text-sm
{%- if item.is_checked %} bg-accent border-accent text-white
{%- else %} border-gray-300{% endif %}">
{% if item.is_checked %}✓{% endif %}
</span>
<span class="text-sm leading-tight">{{ item.text }}</span>
</button>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
23 changes: 16 additions & 7 deletions app/templates/components/start_screen.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,22 @@ <h2 class="font-semibold text-gray-800 mb-3">How to play</h2>
</ul>
</div>

<button
hx-post="/start"
hx-target="#game-container"
hx-swap="outerHTML"
class="w-full bg-accent text-white font-semibold py-4 px-8 rounded-lg text-lg active:bg-accent-light transition-colors">
Start Game
</button>
<div class="space-y-2">
<button
hx-post="/start"
hx-target="#game-container"
hx-swap="outerHTML"
class="w-full bg-accent text-white font-semibold py-4 px-8 rounded-lg text-lg active:bg-accent-light transition-colors">
🎲 Play Bingo
</button>
<button
hx-post="/start-scavenger-hunt"
hx-target="#game-container"
hx-swap="outerHTML"
class="w-full bg-white text-gray-700 font-semibold py-4 px-8 rounded-lg text-lg border border-gray-300 active:bg-gray-100 transition-colors">
🔍 Scavenger Hunt
</button>
</div>
</div>
</div>
</div>
39 changes: 37 additions & 2 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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


Expand All @@ -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
Loading