Skip to content

Commit 7afdff3

Browse files
committed
Address PR review comments
1 parent fce4522 commit 7afdff3

12 files changed

Lines changed: 294 additions & 302 deletions

File tree

backend/games/generator.py

Lines changed: 35 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,30 +10,29 @@
1010
if not zip_file_path.exists():
1111
raise FileNotFoundError(f"words.txt.zip not found at {zip_file_path}")
1212

13-
with zipfile.ZipFile(zip_file_path, "r") as zip_ref:
14-
try:
15-
with zip_ref.open("words.txt") as file:
16-
ws = [line.decode("utf-8").strip() for line in file if line.strip()]
17-
except KeyError as ex:
18-
raise FileNotFoundError(f"words.txt is not in {zip_file_path}") from ex
19-
20-
MIN_LEN, MAX_LEN = 3, 8
13+
MAX_LEN = 8
2114
MIN_TOTAL_WORDS = 175
2215

2316
WORD_SET = set()
2417
PREFIX_SET = set()
2518
SIX_LETTER_WORDS = []
2619

27-
for w in ws:
28-
w = w.lower()
29-
if not w.isalpha():
30-
continue
31-
if len(w) == 6:
32-
SIX_LETTER_WORDS.append(w)
33-
if MIN_LEN <= len(w) <= MAX_LEN:
34-
WORD_SET.add(w)
35-
for i in range(1, len(w) + 1):
36-
PREFIX_SET.add(w[:i])
20+
with zipfile.ZipFile(zip_file_path, "r") as zip_ref:
21+
try:
22+
with zip_ref.open("words.txt") as file:
23+
for line in file:
24+
if line.strip():
25+
w = line.decode("utf-8").strip().lower()
26+
if not w.isalpha():
27+
continue
28+
if len(w) == 6:
29+
SIX_LETTER_WORDS.append(w)
30+
if len(w) <= MAX_LEN:
31+
WORD_SET.add(w)
32+
for i in range(1, len(w) + 1):
33+
PREFIX_SET.add(w[:i])
34+
except KeyError as ex:
35+
raise FileNotFoundError(f"words.txt is not in {zip_file_path}") from ex
3736

3837

3938
def neighbors(n, x, y):
@@ -69,35 +68,31 @@ def generate_game():
6968
return game, seed_word
7069

7170

72-
def solve_game(game, min_len=MIN_LEN, max_len=MAX_LEN):
71+
def solve_game(game, min_len=3, max_len=MAX_LEN):
7372
n = len(game)
7473
dirs = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
7574

7675
found = set()
77-
visited = [[False] * n for _ in range(n)]
78-
79-
def dfs(r, c, s):
80-
s += game[r][c]
81-
if s not in PREFIX_SET:
82-
return
83-
if len(s) >= min_len and s in WORD_SET:
84-
found.add(s)
85-
if len(s) == max_len:
86-
return
87-
88-
visited[r][c] = True
89-
for dr, dc in dirs:
90-
rr, cc = r + dr, c + dc
91-
if 0 <= rr < n and 0 <= cc < n and not visited[rr][cc]:
92-
dfs(rr, cc, s)
93-
visited[r][c] = False
9476

9577
for r in range(n):
9678
for c in range(n):
97-
dfs(r, c, "")
98-
99-
results = sorted(found, key=lambda w: (-len(w), w))
100-
return results
79+
stack = [(r, c, "", frozenset())]
80+
while stack:
81+
cr, cc, s, vis = stack.pop()
82+
s = s + game[cr][cc]
83+
if s not in PREFIX_SET:
84+
continue
85+
if len(s) >= min_len and s in WORD_SET:
86+
found.add(s)
87+
if len(s) == max_len:
88+
continue
89+
vis = vis | {(cr, cc)}
90+
for dr, dc in dirs:
91+
rr, nc = cr + dr, cc + dc
92+
if 0 <= rr < n and 0 <= nc < n and (rr, nc) not in vis:
93+
stack.append((rr, nc, s, vis))
94+
95+
return sorted(found, key=lambda w: (-len(w), w))
10196

10297

10398
def generate_good_game(min_total_words=MIN_TOTAL_WORDS):

backend/games/management/commands/generate_game.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def handle(self, *args, **kwargs):
2323
"board": game,
2424
"possible_words": sols,
2525
"seed": seed,
26-
"freqs": word_freqs,
26+
"word_length_freq": word_freqs,
2727
},
2828
)
2929

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Generated by Django 5.0.2 on 2026-03-30 02:51
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("games", "0002_game_freqs"),
10+
]
11+
12+
operations = [
13+
migrations.AlterModelOptions(
14+
name="leaderboardentry",
15+
options={"ordering": ["-score"]},
16+
),
17+
migrations.RenameField(
18+
model_name="game",
19+
old_name="freqs",
20+
new_name="word_length_freq",
21+
),
22+
migrations.RenameField(
23+
model_name="leaderboardentry",
24+
old_name="words_found",
25+
new_name="num_words_found",
26+
),
27+
]

backend/games/models.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class Game(models.Model):
1111
board = models.JSONField()
1212
possible_words = models.JSONField()
1313
seed = models.CharField(max_length=32)
14-
freqs = models.JSONField(default=dict)
14+
word_length_freq = models.JSONField(default=dict)
1515

1616
def __str__(self):
1717
return str(self.date)
@@ -26,12 +26,12 @@ class LeaderboardEntry(models.Model):
2626
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="word_hunt_scores")
2727

2828
score = models.PositiveIntegerField(db_index=True)
29-
words_found = models.PositiveIntegerField()
29+
num_words_found = models.PositiveIntegerField()
3030

3131
submitted_at = models.DateTimeField(auto_now_add=True)
3232

3333
class Meta:
3434
constraints = [
3535
models.UniqueConstraint(fields=["game", "user"], name="unique_entry_per_user_per_game")
3636
]
37-
ordering = ["-score", "words_found", "submitted_at"]
37+
ordering = ["-score"]

backend/games/serializers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,4 @@ class LeaderboardEntrySerializer(serializers.ModelSerializer):
2020

2121
class Meta:
2222
model = LeaderboardEntry
23-
fields = ["username", "score", "words_found", "submitted_at"]
23+
fields = ["username", "score", "num_words_found", "submitted_at"]

backend/games/urls.py

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
11
from django.urls import path
22

3-
from games.views import (
4-
GameByDateView,
5-
LeaderboardByDateView,
6-
SubmitScoreView,
7-
TodayGameView,
8-
ValidateGameLogView,
9-
)
3+
from games.views import GameByDateView, LeaderboardByDateView, SubmitScoreView, TodayGameView
104

115

126
urlpatterns = [
13-
path("today/", TodayGameView.as_view(), name="game-today"),
14-
path("<date>/", GameByDateView.as_view(), name="game-by-date"),
15-
path("<date>/leaderboard/", LeaderboardByDateView.as_view(), name="game-leaderboard-by-date"),
16-
path("<date>/submit/", SubmitScoreView.as_view(), name="submit-score"),
17-
path("<date>/validate/", ValidateGameLogView.as_view(), name="validate-game-log"),
7+
path("word-hunt/today/", TodayGameView.as_view(), name="word-hunt-today"),
8+
path("word-hunt/<date>/", GameByDateView.as_view(), name="word-hunt-by-date"),
9+
path(
10+
"word-hunt/<date>/leaderboard/",
11+
LeaderboardByDateView.as_view(),
12+
name="word-hunt-leaderboard-by-date",
13+
),
14+
path("word-hunt/<date>/submit/", SubmitScoreView.as_view(), name="word-hunt-submit-score"),
1815
]

backend/games/views.py

Lines changed: 18 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
from games.models import Game, LeaderboardEntry
88
from games.serializers import GameSerializer, LeaderboardEntrySerializer
9-
from games.validators import validate_words_for_game
109
from pennmobile.analytics import LabsAnalytics
1110

1211

@@ -63,57 +62,39 @@ def get(self, request, date):
6362
)
6463
class SubmitScoreView(APIView):
6564
"""
66-
POST: submits the authenticated user's score for a specific date
65+
POST: validates submitted words, computes score, and saves leaderboard entry
6766
"""
6867

6968
permission_classes = [IsAuthenticated]
7069

7170
def post(self, request, date):
7271
game = get_object_or_404(Game, date=date)
73-
score = request.data.get("score")
74-
words_found = request.data.get("words_found")
75-
76-
if score is None or words_found is None:
77-
return Response({"error": "score and words_found are required."}, status=400)
78-
79-
if (
80-
not isinstance(score, int)
81-
or not isinstance(words_found, int)
82-
or score < 0
83-
or words_found < 0
84-
):
72+
submitted_words = request.data.get("words")
73+
74+
if not isinstance(submitted_words, list):
75+
return Response({"error": "words must be a list."}, status=400)
76+
77+
normalized = [w.lower().strip() for w in submitted_words]
78+
79+
if len(normalized) != len(set(normalized)):
80+
return Response({"error": "Duplicate words submitted."}, status=400)
81+
82+
legal_words = set(game.possible_words)
83+
invalid = [w for w in normalized if w not in legal_words]
84+
if invalid:
8585
return Response(
86-
{"error": "score and words_found must be non-negative integers."}, status=400
86+
{"error": "Invalid words submitted.", "invalid_words": invalid}, status=400
8787
)
8888

8989
if LeaderboardEntry.objects.filter(game=game, user=request.user).exists():
9090
return Response({"error": "Score already submitted for this game."}, status=400)
9191

92+
score = sum((len(w) - 2) ** 2 * 100 for w in normalized)
93+
9294
entry = LeaderboardEntry.objects.create(
9395
game=game,
9496
user=request.user,
9597
score=score,
96-
words_found=words_found,
98+
num_words_found=len(normalized),
9799
)
98100
return Response(LeaderboardEntrySerializer(entry).data, status=201)
99-
100-
101-
@LabsAnalytics.record_apiview(
102-
ViewEntry(name="validate-game-log"),
103-
)
104-
class ValidateGameLogView(APIView):
105-
"""
106-
POST: returns whether the words chosen are valid
107-
"""
108-
109-
permission_classes = [IsAuthenticated]
110-
111-
def post(self, request, date):
112-
game = get_object_or_404(Game, date=date)
113-
submitted_words = request.data.get("words", [])
114-
115-
result = validate_words_for_game(game, submitted_words)
116-
if not result["valid"]:
117-
return Response(result, status=400)
118-
119-
return Response({"valid": True}, status=200)

backend/test_games.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import json
2+
import os
3+
4+
import django
5+
6+
7+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pennmobile.settings")
8+
django.setup()
9+
10+
from django.contrib.auth import get_user_model # noqa: E402
11+
from rest_framework.test import APIClient # noqa: E402
12+
13+
from games.generator import generate_good_game # noqa: E402
14+
from games.models import Game, LeaderboardEntry # noqa: E402
15+
16+
17+
User = get_user_model()
18+
client = APIClient()
19+
20+
print("\n" + "=" * 50)
21+
22+
# TASK 1 ----------------------------------------------------------------------
23+
print("--- Testing Task 1: Generate Games & Store in DB ---")
24+
try:
25+
game_board, seed, sols = generate_good_game(
26+
min_total_words=50
27+
) # Using a smaller number so it tests quickly
28+
print("✅ Game generation script successful! Seed word generated:", seed)
29+
30+
game = Game.get_today()
31+
if game:
32+
print(f"✅ Django DB Game model successfully loaded for {game.date}.")
33+
print(" Board format:", type(game.board), "->", game.board[0], "...")
34+
else:
35+
print("❌ No game for today found. Ensure you ran 'python manage.py generate_game'")
36+
except Exception as e:
37+
print("❌ Generation/Model script failed:", str(e))
38+
39+
40+
# TASK 2 ----------------------------------------------------------------------
41+
print("\n--- Testing Task 2: Models for storing game results (User+Day) ---")
42+
user, _ = User.objects.get_or_create(username="testuser")
43+
dummy_user, _ = User.objects.get_or_create(username="dummyuser2")
44+
45+
try:
46+
entry, created = LeaderboardEntry.objects.get_or_create(
47+
game=game, user=dummy_user, defaults={"score": 100, "num_words_found": 5}
48+
)
49+
print(
50+
f"✅ LeaderboardEntry model works for {dummy_user.username}. "
51+
f"Associated with Game {game.date}."
52+
)
53+
print(f"✅ Relational lookup successful: game.scores.count() = {game.scores.count()}")
54+
55+
except Exception as e:
56+
print("❌ Leaderboard model creation failed:", str(e))
57+
58+
59+
client.force_authenticate(user=user)
60+
61+
# TASK 3 ----------------------------------------------------------------------
62+
print("\n--- Testing Task 3: Fetch Game Route ---")
63+
response = client.get("/games/word-hunt/today/", format="json")
64+
print(f"✅ Route Status Code: {response.status_code}")
65+
print("✅ JSON Data Payload Extract:")
66+
print(json.dumps(response.data, indent=2)[:200] + "...\n")
67+
68+
69+
# TASK 4 ----------------------------------------------------------------------
70+
print("--- Testing Task 4: Submit Score Route (valid words) ---")
71+
valid_words = game.possible_words[:3] if game and game.possible_words else []
72+
data = {"words": valid_words}
73+
response = client.post(f"/games/word-hunt/{game.date}/submit/", data, format="json")
74+
print(f"✅ Route Status Code: {response.status_code}")
75+
print("✅ Submit Route Response:")
76+
print(json.dumps(response.data, indent=2) + "\n")
77+
78+
79+
# TASK 5 ----------------------------------------------------------------------
80+
print("--- Testing Task 5: Submit Score Route (invalid words) ---")
81+
data = {"words": [valid_words[0] if valid_words else "cat", "notarealword123"]}
82+
response2_user, _ = User.objects.get_or_create(username="testuser2")
83+
client2 = APIClient()
84+
client2.force_authenticate(user=response2_user)
85+
response = client2.post(f"/games/word-hunt/{game.date}/submit/", data, format="json")
86+
print(f"✅ Route Status Code: {response.status_code}")
87+
print("✅ Validation Error Response:")
88+
print(json.dumps(response.data, indent=2) + "\n")
89+
90+
91+
# TASK 6 ----------------------------------------------------------------------
92+
print("--- Testing Task 6: Leaderboard Route ---")
93+
response = client.get(f"/games/word-hunt/{game.date}/leaderboard/", format="json")
94+
print(f"✅ Route Status Code: {response.status_code}")
95+
print("✅ Leaderboard JSON Output (Ranked):")
96+
print(json.dumps(response.data, indent=2))
97+
98+
print("=" * 50 + "\n")

backend/tests/games/test_commands.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def test_computes_word_freqs_by_length(self):
5252
expected = {"3": 0, "4": 0, "5": 0, "6": 0, "7": 0, "8": 0}
5353
for w in MOCK_SOLS:
5454
expected[str(len(w))] += 1
55-
self.assertEqual(expected, game.freqs)
55+
self.assertEqual(expected, game.word_length_freq)
5656

5757
@mock.patch(
5858
"games.management.commands.generate_game.generate_good_game", mock_generate_good_game

0 commit comments

Comments
 (0)