Skip to content

Commit 9f436c1

Browse files
authored
Backend for Penn Mobile Games Word Hunt
* Backend for Penn Mobile Games Word Hunt. * Tests for Penn Mobile Games backend. * Fixes for flake8 * Format code with Black * Fixes * Address PR review comments * Rename test_games.py to demo_games.py to prevent CI test runner from picking it up * Use any() with generator for invalid word check
1 parent deff44e commit 9f436c1

21 files changed

Lines changed: 1150 additions & 0 deletions

backend/demo_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/games/admin.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django.contrib import admin
2+
3+
from games.models import Game, LeaderboardEntry
4+
5+
6+
admin.site.register(Game)
7+
admin.site.register(LeaderboardEntry)

backend/games/generator.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import random
2+
import zipfile
3+
from pathlib import Path
4+
5+
6+
BASE_DIR = Path(__file__).resolve().parent
7+
zip_file_path = BASE_DIR / "words.txt.zip"
8+
extract_directory = BASE_DIR
9+
10+
if not zip_file_path.exists():
11+
raise FileNotFoundError(f"words.txt.zip not found at {zip_file_path}")
12+
13+
MAX_LEN = 8
14+
MIN_TOTAL_WORDS = 175
15+
16+
WORD_SET = set()
17+
PREFIX_SET = set()
18+
SIX_LETTER_WORDS = []
19+
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
36+
37+
38+
def neighbors(n, x, y):
39+
for dx in (-1, 0, 1):
40+
for dy in (-1, 0, 1):
41+
if dx == 0 and dy == 0:
42+
continue
43+
xx, yy = x + dx, y + dy
44+
if 0 <= xx <= n and 0 <= yy <= n:
45+
yield xx, yy
46+
47+
48+
def generate_game():
49+
letters = list("abcdefghijklmnopqrstuvwxyz")
50+
game = [[""] * 5 for _ in range(5)]
51+
n = 4
52+
53+
seed_word = random.choice(SIX_LETTER_WORDS)
54+
curr_x, curr_y = random.randint(0, n), random.randint(0, n)
55+
56+
for c in seed_word:
57+
game[curr_y][curr_x] = c
58+
options = [(xx, yy) for (xx, yy) in neighbors(n, curr_x, curr_y) if game[yy][xx] == ""]
59+
if not options:
60+
return generate_game()
61+
curr_x, curr_y = random.choice(options)
62+
63+
for y in range(5):
64+
for x in range(5):
65+
if game[y][x] == "":
66+
game[y][x] = random.choice(letters)
67+
68+
return game, seed_word
69+
70+
71+
def solve_game(game, min_len=3, max_len=MAX_LEN):
72+
n = len(game)
73+
dirs = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
74+
75+
found = set()
76+
77+
for r in range(n):
78+
for c in range(n):
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))
96+
97+
98+
def generate_good_game(min_total_words=MIN_TOTAL_WORDS):
99+
while True:
100+
game, seed = generate_game()
101+
sols = solve_game(game)
102+
if len(sols) >= min_total_words:
103+
return game, seed, sols
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from django.core.management.base import BaseCommand
2+
from django.utils import timezone
3+
4+
from games.generator import generate_good_game
5+
from games.models import Game
6+
7+
8+
class Command(BaseCommand):
9+
help = "Generate and save 100 future Word Hunt games"
10+
11+
def handle(self, *args, **kwargs):
12+
for i in range(100):
13+
future_date = timezone.localdate() + timezone.timedelta(days=i)
14+
game, seed, sols = generate_good_game()
15+
16+
word_freqs = {3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0}
17+
for w in sols:
18+
word_freqs[len(w)] += 1
19+
20+
Game.objects.update_or_create(
21+
date=future_date,
22+
defaults={
23+
"board": game,
24+
"possible_words": sols,
25+
"seed": seed,
26+
"word_length_freq": word_freqs,
27+
},
28+
)
29+
30+
self.stdout.write(f"Game for {future_date} generated successfully")
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Generated by Django 5.0.2 on 2026-02-27 23:06
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
initial = True
11+
12+
dependencies = [
13+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name="Game",
19+
fields=[
20+
("date", models.DateField(primary_key=True, serialize=False)),
21+
("board", models.JSONField()),
22+
("possible_words", models.JSONField()),
23+
("seed", models.CharField(max_length=32)),
24+
],
25+
),
26+
migrations.CreateModel(
27+
name="LeaderboardEntry",
28+
fields=[
29+
(
30+
"id",
31+
models.AutoField(
32+
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
33+
),
34+
),
35+
("score", models.PositiveIntegerField(db_index=True)),
36+
("words_found", models.PositiveIntegerField()),
37+
("submitted_at", models.DateTimeField(auto_now_add=True)),
38+
(
39+
"game",
40+
models.ForeignKey(
41+
on_delete=django.db.models.deletion.CASCADE,
42+
related_name="scores",
43+
to="games.game",
44+
),
45+
),
46+
(
47+
"user",
48+
models.ForeignKey(
49+
on_delete=django.db.models.deletion.CASCADE,
50+
related_name="word_hunt_scores",
51+
to=settings.AUTH_USER_MODEL,
52+
),
53+
),
54+
],
55+
options={
56+
"ordering": ["-score", "words_found", "submitted_at"],
57+
},
58+
),
59+
migrations.AddConstraint(
60+
model_name="leaderboardentry",
61+
constraint=models.UniqueConstraint(
62+
fields=("game", "user"), name="unique_entry_per_user_per_game"
63+
),
64+
),
65+
]
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.0.2 on 2026-03-01 18:41
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("games", "0001_initial"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="game",
15+
name="freqs",
16+
field=models.JSONField(default=dict),
17+
),
18+
]
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/migrations/__init__.py

Whitespace-only changes.

backend/games/models.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from django.contrib.auth import get_user_model
2+
from django.db import models
3+
from django.utils import timezone
4+
5+
6+
User = get_user_model()
7+
8+
9+
class Game(models.Model):
10+
date = models.DateField(primary_key=True)
11+
board = models.JSONField()
12+
possible_words = models.JSONField()
13+
seed = models.CharField(max_length=32)
14+
word_length_freq = models.JSONField(default=dict)
15+
16+
def __str__(self):
17+
return str(self.date)
18+
19+
@classmethod
20+
def get_today(cls):
21+
return cls.objects.filter(date=timezone.localdate()).first()
22+
23+
24+
class LeaderboardEntry(models.Model):
25+
game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name="scores")
26+
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="word_hunt_scores")
27+
28+
score = models.PositiveIntegerField(db_index=True)
29+
num_words_found = models.PositiveIntegerField()
30+
31+
submitted_at = models.DateTimeField(auto_now_add=True)
32+
33+
class Meta:
34+
constraints = [
35+
models.UniqueConstraint(fields=["game", "user"], name="unique_entry_per_user_per_game")
36+
]
37+
ordering = ["-score"]

0 commit comments

Comments
 (0)