diff --git a/app.py b/app.py index c6bf00c..e7cf6b9 100644 --- a/app.py +++ b/app.py @@ -11,11 +11,13 @@ from handlers.main import MainHandler from handlers.game import GameHandler from handlers.howtoplay import HowToPlayHandler from handlers.about import AboutHandler +from handlers.scoreboard import ScoreboardPageHandler from handlers.api import ( CategoriesHandler, NewGameHandler, GameStateHandler, GuessHandler, + ScoreboardAPIHandler, ) BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -38,7 +40,9 @@ def make_app(session_factory=None, debug=False): (r"/game", GameHandler), (r"/howtoplay", HowToPlayHandler), (r"/about", AboutHandler), + (r"/scoreboard", ScoreboardPageHandler), (r"/api/categories", CategoriesHandler), + (r"/api/scoreboard", ScoreboardAPIHandler), (r"/api/game/new", NewGameHandler), (r"/api/game/([^/]+)", GameStateHandler), (r"/api/game/([^/]+)/guess", GuessHandler), diff --git a/data/words.json b/data/words.json new file mode 100644 index 0000000..8274158 --- /dev/null +++ b/data/words.json @@ -0,0 +1,184 @@ +{ + "animals": [ + "TIGER", "DOLPHIN", "EAGLE", "WOLF", "BEAR", "LION", "HAWK", "DEER", + "FROG", "SHARK", "SNAKE", "WHALE", "ZEBRA", "PANDA", "OTTER", "FALCON", + "BISON", "CRANE", "MOOSE", "RAVEN", "JAGUAR", "PARROT", "TURTLE", + "SALMON", "FERRET", "COYOTE", "OSPREY", "LIZARD", "BADGER", "PELICAN", + "MONKEY", "RABBIT", "IGUANA", "CONDOR", "JACKAL", "GORILLA", "PENGUIN", + "GAZELLE", "HAMSTER", "LOBSTER", "PANTHER", "SPARROW", "BUFFALO", + "CHEETAH", "VULTURE", "GIRAFFE", "LEOPARD", "OCTOPUS", "ROOSTER", + "CRICKET", "FLAMINGO", "MUSTANG", "PIRANHA", "STARLING", "MACKEREL", + "MEERKAT", "WARTHOG", "WALLABY", "WOODCOCK", "BULLFROG", "CARDINAL", + "ELEPHANT", "CHIPMUNK", "SCORPION", "GOLDFISH", "BLUEBIRD", "HEDGEHOG", + "ANTELOPE", "MANATEE", "PHEASANT", "CHIMP", "STORK", "FINCH", "GOOSE", + "HERON", "HYENA", "KOALA", "LEMUR", "LLAMA", "LYNX", "VIPER", "TOAD", + "NEWT", "MULE", "MOTH", "WASP", "DOVE", "CROW", "WREN", "IBIS" + ], + "food": [ + "PIZZA", "SUSHI", "MANGO", "PASTA", "TACO", "BREAD", "STEAK", "GRAPE", + "MELON", "PEACH", "OLIVE", "LEMON", "BACON", "WAFFLE", "CREPE", "DONUT", + "SALAD", "CURRY", "BAGEL", "FUDGE", "SHRIMP", "CHEESE", "BUTTER", + "CHERRY", "PEPPER", "GINGER", "PRETZEL", "BRISKET", "NOODLE", "OYSTER", + "TURNIP", "RADISH", "CELERY", "COOKIE", "BISQUE", "ALMOND", "CASHEW", + "WALNUT", "PAPAYA", "BROWNIE", "BURRITO", "CHOWDER", "COBBLER", + "CRACKER", "CUSTARD", "DUMPLING", "GRANOLA", "LASAGNA", "MUFFIN", + "PANCAKE", "POPCORN", "PUDDING", "RAISIN", "RISOTTO", "SAUSAGE", + "SCALLOP", "SPINACH", "TRUFFLE", "BISCUIT", "AVOCADO", "APRICOT", + "CABBAGE", "CARROT", "CLAM", "CORN", "CRAB", "DATE", "DUCK", "FISH", + "GUAVA", "HERB", "KALE", "LAMB", "LIME", "MINT", "OKRA", "PEAR", + "PLUM", "PORK", "RICE", "SAGE", "TOFU", "TUNA", "YAMS", "BASIL", + "BERRY", "COCOA", "CUMIN", "GOUDA", "HONEY", "LEEK", "ONION", "THYME" + ], + "colors": [ + "CRIMSON", "VIOLET", "AMBER", "SCARLET", "INDIGO", "MAROON", "CORAL", + "IVORY", "SILVER", "GOLDEN", "BRONZE", "TEAL", "OLIVE", "PLUM", "RUST", + "SAGE", "NAVY", "JADE", "CYAN", "MAUVE", "COBALT", "COPPER", "EBONY", + "PEACH", "CREAM", "LEMON", "ORCHID", "SALMON", "SIENNA", "KHAKI", + "LILAC", "PEARL", "CHARCOAL", "MAGENTA", "EMERALD", "AZURE", "BEIGE", + "CHERRY", "FAWN", "FLAX", "GARNET", "HAZEL", "LAVENDER", "MAHOGANY", + "MUSTARD", "OPAL", "PERIWINKLE", "PEWTER", "RUBY", "SAFFRON", + "SAPPHIRE", "SEPIA", "TANGERINE", "TOPAZ", "TURQUOISE", "UMBER", + "VERMILION", "WINE", "BURGUNDY", "FUCHSIA", "THISTLE", "CERISE", + "DENIM", "IVORY", "PUMPKIN", "SLATE", "SMOKE", "TAUPE" + ], + "sports": [ + "SOCCER", "TENNIS", "BOXING", "RUGBY", "GOLF", "DIVING", "ROWING", + "HOCKEY", "FENCING", "SPRINT", "DISCUS", "SQUASH", "KARATE", "JUDO", + "RELAY", "SLALOM", "VAULT", "VOLLEY", "TACKLE", "PADDLE", "JAVELIN", + "HURDLE", "ARCHERY", "SURFING", "SKIING", "CYCLING", "SAILING", + "BOWLING", "CRICKET", "LACROSSE", "MARATHON", "WRESTLING", "SWIMMING", + "CLIMBING", "CURLING", "DRIBBLE", "PENALTY", "FIELDER", "PITCHER", + "BATTER", "GOALIE", "RACKET", "STROKE", "TROPHY", "LEAGUE", "MATCH", + "SCORE", "SERVE", "COACH", "ARENA", "CATCH", "CHASE", "DRILL", "FINAL", + "GUARD", "PITCH", "ROUND", "TRACK", "TRIAL", "BLOCK", "CRAWL", "DASH", + "LUNGE", "PUNCH", "THROW", "MEDAL", "RIDER", "SKATE", "SWING" + ], + "space": [ + "PLANET", "GALAXY", "NEBULA", "COMET", "ORBIT", "QUASAR", "PULSAR", + "LUNAR", "SOLAR", "METEOR", "AURORA", "ECLIPSE", "ZENITH", "CRATER", + "COSMOS", "PHOTON", "PLASMA", "ROCKET", "SATURN", "VENUS", "MARS", + "PLUTO", "TITAN", "VORTEX", "FLARE", "ASTEROID", "BLACKHOLE", + "CONSTELLATION", "GRAVITY", "HORIZON", "JUPITER", "MERCURY", "NEPTUNE", + "NEUTRON", "NUCLEUS", "PARSEC", "SHUTTLE", "STELLAR", "SUNSPOT", + "SUPERNOVA", "URANUS", "VOYAGER", "WORMHOLE", "CAPSULE", "DOCKING", + "EQUINOX", "FUSION", "MISSION", "MODULE", "SOLSTICE", "STARLIGHT", + "TELESCOPE", "APOGEE", "PERIGEE", "RADIANT", "SATELLITE", "SPECTRUM", + "NOVA", "STAR", "MOON", "BEAM", "DUST", "VOID", "DAWN", "DUSK", + "RING", "AXIS", "POLE" + ], + "nature": [ + "RIVER", "OCEAN", "CANYON", "FOREST", "DESERT", "ISLAND", "GLACIER", + "MEADOW", "VALLEY", "SUMMIT", "LAGOON", "TUNDRA", "MARSH", "CLIFF", + "DELTA", "FJORD", "GROVE", "RIDGE", "BROOK", "DUNE", "GEYSER", + "CAVERN", "RAPIDS", "SAVANNA", "BAMBOO", "BOULDER", "CASCADE", + "CYPRESS", "EROSION", "ESTUARY", "FOLIAGE", "GRANITE", "HABITAT", + "PLATEAU", "PRAIRIE", "RAVINE", "SEQUOIA", "TERRACE", "VOLCANO", + "WETLAND", "WILLOW", "BLOSSOM", "CANOPY", "JUNGLE", "LAGOON", + "MANGROVE", "OASIS", "PEBBLE", "SPRING", "STREAM", "THICKET", + "TIMBER", "BASIN", "BLUFF", "CEDAR", "CORAL", "CREST", "FALLS", + "FIELD", "FROST", "HEATH", "MAPLE", "MOSS", "PEAK", "PINE", "POND", + "REEF", "SAND", "SEED", "SOIL", "TIDE", "VINE", "WEED", "BIRCH", + "FERN" + ], + "music": [ + "GUITAR", "PIANO", "DRUMS", "VIOLIN", "FLUTE", "CELLO", "BANJO", + "ORGAN", "HARP", "TRUMPET", "RHYTHM", "MELODY", "CHORD", "TEMPO", + "FORTE", "TREBLE", "OCTAVE", "SONATA", "BALLAD", "OPERA", "HYMN", + "LYRIC", "CHORUS", "BRIDGE", "DUET", "ANTHEM", "CADENCE", "CLARINET", + "CONCERTO", "CYMBAL", "ENSEMBLE", "HARMONY", "MANDOLIN", "OVERTURE", + "PRELUDE", "QUARTET", "SERENADE", "SOPRANO", "SYMPHONY", "UKULELE", + "VIBRATO", "BASSOON", "BARITONE", "FALSETTO", "HARMONICA", "INTERLUDE", + "STACCATO", "ACOUSTIC", "ALTO", "ARIA", "BASS", "BEAT", "CLEF", "CODA", + "FLAT", "FUGUE", "GONG", "JAZZ", "LUTE", "NOTE", "OBOE", "RIFF", + "REED", "REST", "SOLO", "SONG", "TONE", "TUBA", "TUNE", "WALTZ" + ], + "science": [ + "ATOM", "CELL", "GENE", "ACID", "BASE", "BOND", "FORCE", "MASS", + "WAVE", "LENS", "VOLT", "WATT", "JOULE", "QUARK", "PRISM", "ALLOY", + "ANODE", "HELIX", "DIODE", "LASER", "OPTIC", "OXIDE", "PHASE", + "RADAR", "RATIO", "SOLID", "VAPOR", "BIOME", "CLONE", "DECAY", + "FIELD", "FLUID", "GAMMA", "INERT", "IONIC", "MOLAR", "ORBIT", + "PROBE", "RESIN", "STEEL", "TRAIT", "CARBON", "ENZYME", "FUSION", + "GENOME", "KELVIN", "MAGNET", "NEURON", "PHOTON", "PROTON", "REFLEX", + "STRAIN", "TENSOR", "THEORY", "VACUUM", "VECTOR", "VOLTAGE", "CALCIUM", + "CRYSTAL", "DENSITY", "ELEMENT", "FISSION", "FORMULA", "GRAVITY", + "ISOTOPE", "KINETIC", "LITHIUM", "MINERAL", "NUCLEUS", "OSMOSIS", + "POLYMER", "REACTOR", "SILICON", "THERMAL", "URANIUM", "CATALYST", + "ELECTRON", "HYDROGEN", "MOLECULE", "NITROGEN", "SPECTRUM" + ], + "countries": [ + "FRANCE", "BRAZIL", "JAPAN", "KENYA", "EGYPT", "CHILE", "CHINA", + "INDIA", "SPAIN", "ITALY", "GREECE", "PERU", "CUBA", "NEPAL", + "QATAR", "TONGA", "FIJI", "IRAN", "IRAQ", "LAOS", "MALI", "OMAN", + "CHAD", "GHANA", "NIGER", "BENIN", "GABON", "LIBYA", "SUDAN", "SYRIA", + "YEMEN", "KOREA", "SAMOA", "NAURU", "BELIZE", "PANAMA", "MEXICO", + "CANADA", "NORWAY", "SWEDEN", "POLAND", "TURKEY", "JORDAN", "ISRAEL", + "BHUTAN", "ANGOLA", "MALAWI", "ZAMBIA", "UGANDA", "RWANDA", "SERBIA", + "LATVIA", "MONACO", "TUVALU", "BOLIVIA", "CROATIA", "DENMARK", + "ECUADOR", "FINLAND", "GERMANY", "HUNGARY", "ICELAND", "IRELAND", + "JAMAICA", "LEBANON", "MOROCCO", "NIGERIA", "ROMANIA", "SENEGAL", + "TUNISIA", "UKRAINE", "URUGUAY", "VIETNAM", "AUSTRIA", "ALBANIA", + "BAHRAIN", "GEORGIA" + ], + "occupations": [ + "PILOT", "NURSE", "BAKER", "JUDGE", "CHEF", "TUTOR", "GUARD", + "MINER", "DIVER", "CLERK", "GUIDE", "USHER", "MASON", "SMITH", + "SCOUT", "CADET", "ACTOR", "AGENT", "COACH", "ENVOY", "MAYOR", + "FARMER", "LAWYER", "DOCTOR", "DANCER", "SINGER", "WELDER", "BARBER", + "BUTLER", "CASHIER", "DENTIST", "EDITOR", "ANGLER", "ARCHER", + "ARTIST", "AUTHOR", "BANKER", "BREWER", "BROKER", "DRIVER", "DRAFTER", + "FLORIST", "JEWELER", "PLUMBER", "PORTER", "RANGER", "SAILOR", + "SCHOLAR", "SOLDIER", "SURGEON", "TAILOR", "TELLER", "TRADER", + "USHER", "WAITER", "WARDEN", "CHEMIST", "CURATOR", "MARSHAL", + "OFFICER", "PAINTER", "JANITOR", "TRAINER", "MANAGER", "AUDITOR", + "ANALYST", "ADVISOR", "BUILDER", "COURIER" + ], + "ocean": [ + "WHALE", "CORAL", "SHARK", "SQUID", "KELP", "REEF", "TIDE", "WAVE", + "ABYSS", "COAST", "SHOAL", "SHELL", "SHRIMP", "MARLIN", "URCHIN", + "SPONGE", "ANCHOR", "HARBOR", "VESSEL", "VOYAGE", "LAGOON", "TRENCH", + "SEABED", "PLANKTON", "BARNACLE", "CURRENT", "DOLPHIN", "ESTUARY", + "HARPOON", "ICEBERG", "LOBSTER", "NARWHAL", "OCTOPUS", "PELICAN", + "SCALLOP", "SEAFOOD", "SUNFISH", "TSUNAMI", "ABALONE", "BARRACUDA", + "FLOUNDER", "MARITIME", "NAUTICAL", "SANDBAR", "SEAHORSE", "SEAGULL", + "STARFISH", "STINGRAY", "SURFING", "SWORDFISH", "TIDEPOOL", "TROPICAL", + "CLAM", "CRAB", "DOCK", "FISH", "GULL", "HULL", "KEEL", "MAST", + "PIER", "PORT", "SAIL", "SEAL", "SURF", "DECK", "BUOY" + ], + "weather": [ + "STORM", "CLOUD", "FROST", "SLEET", "FLOOD", "BREEZE", "CIRRUS", + "CYCONE", "GALE", "GUST", "HAIL", "HEAT", "MIST", "RAIN", "SNOW", + "WIND", "HUMID", "MUGGY", "BALMY", "SUNNY", "FOGGY", "MISTY", + "WINDY", "DUSTY", "DRAFT", "THAW", "FRONT", "SQUALL", "FUNNEL", + "BLIZZARD", "CLIMATE", "CYCLONE", "DRIZZLE", "DROUGHT", "MONSOON", + "RAINBOW", "SHELTER", "THUNDER", "TORNADO", "TROPICS", "TYPHOON", + "WARMING", "CELSIUS", "CUMULUS", "DEWDROP", "EROSION", "FLURRY", + "HEATWAVE", "HUMIDITY", "OVERCAST", "PRESSURE", "SNOWFALL", "TEMPEST", + "WINDMILL" + ], + "mythology": [ + "ZEUS", "HERA", "ARES", "ATLAS", "TITAN", "HYDRA", "MEDUSA", + "PHOENIX", "DRAGON", "ORACLE", "SPHINX", "SIREN", "MINOTAUR", + "CENTAUR", "CYCLOPS", "GRIFFIN", "KRAKEN", "NYMPH", "SATYR", "TRITON", + "POSEIDON", "ARTEMIS", "HERMES", "ATHENA", "APOLLO", "ODIN", "THOR", + "FREYA", "FENRIR", "VALKYRIE", "VALHALLA", "CERBERUS", "CHIMERA", + "GORGON", "HARPY", "PEGASUS", "UNICORN", "GOLEM", "DJINN", "BANSHEE", + "BASILISK", "GARGOYLE", "WEREWOLF", "LEVIATHAN", "ACHILLES", + "HERCULES", "ICARUS", "ODYSSEUS", "ORPHEUS", "PANDORA", "MERCURY", + "NEPTUNE", "MINERVA", "OLYMPUS", "ELYSIUM", "ASGARD" + ], + "technology": [ + "PIXEL", "MODEM", "CACHE", "CLOUD", "DEBUG", "DRONE", "FIBER", + "FLASH", "MACRO", "PROXY", "QUERY", "STACK", "TOKEN", "VIRUS", + "WIDGET", "BINARY", "BITMAP", "BRANCH", "BUFFER", "CIPHER", "CLIENT", + "CURSOR", "ENGINE", "FILTER", "FOLDER", "KERNEL", "LAPTOP", "MEMORY", + "MODULE", "NEURAL", "OUTPUT", "PARSER", "PORTAL", "RENDER", "ROUTER", + "SCRIPT", "SENSOR", "SERVER", "SOCKET", "SOURCE", "SWITCH", "SYNTAX", + "THREAD", "BACKUP", "CURSOR", "DOMAIN", "DRIVER", "ENCODE", "EXPORT", + "FORMAT", "HANDLE", "IMPORT", "MATRIX", "NEURAL", "OBJECT", "OFFSET", + "PACKET", "PLUGIN", "REBOOT", "SCROLL", "STATIC", "STREAM", "TOGGLE", + "UPLOAD", "VECTOR", "BYTE", "CHIP", "CODE", "DATA", "DISK", "FILE", + "GRID", "HASH", "HOST", "ICON", "LINK", "NODE", "PORT", "SCAN", + "SYNC", "TASK", "WIFI" + ] +} diff --git a/handlers/api.py b/handlers/api.py index dd0dcf2..e6270fa 100644 --- a/handlers/api.py +++ b/handlers/api.py @@ -2,6 +2,7 @@ import collections import json +import re import time from datetime import datetime, timezone @@ -33,6 +34,24 @@ def _check_rate(ip, bucket="default"): return True +_PLAYER_NAME_RE = re.compile(r"^[a-zA-Z0-9 ]+$") +MAX_PLAYER_NAME_LEN = 20 + + +def _sanitize_player_name(raw): + """Validate and sanitize a player name. Returns name or None (invalid).""" + if not isinstance(raw, str): + return "Anonymous" + name = raw.strip() + if not name: + return "Anonymous" + if len(name) > MAX_PLAYER_NAME_LEN: + name = name[:MAX_PLAYER_NAME_LEN] + if not _PLAYER_NAME_RE.match(name): + return None + return name + + class BaseAPIHandler(tornado.web.RequestHandler): """Base handler for JSON API endpoints.""" @@ -112,6 +131,19 @@ class NewGameHandler(BaseAPIHandler): return word_count = max(3, min(30, word_count)) + player_name = _sanitize_player_name(data.get("player_name", "")) + if player_name is None: + self.set_status(400) + self.write( + json.dumps( + { + "error": "Player name may only contain" + " letters, numbers, and spaces" + } + ) + ) + return + category = session.query(Category).filter_by(id=category_id).first() if not category: self.set_status(404) @@ -130,6 +162,7 @@ class NewGameHandler(BaseAPIHandler): self.write(json.dumps({"error": "Failed to generate board with words"})) return + game.player_name = player_name session.add(game) for w in words: session.add(w) @@ -146,6 +179,7 @@ class NewGameHandler(BaseAPIHandler): return { "id": game.id, "board_size": game.board_size, + "player_name": game.player_name, "category": game.category.name, "grid": game.grid, "words": [{"text": w.text, "found": w.found} for w in game.words], @@ -171,6 +205,7 @@ class GameStateHandler(BaseAPIHandler): { "id": game.id, "board_size": game.board_size, + "player_name": game.player_name, "category": game.category.name, "grid": game.grid, "words": [ @@ -296,6 +331,7 @@ class GuessHandler(BaseAPIHandler): "cells": cells, "words": [{"text": w.text, "found": w.found} for w in game.words], "status": game.status, + "player_name": game.player_name, "completed_at": ( game.completed_at.isoformat() + "Z" if game.completed_at else None ), @@ -308,5 +344,77 @@ class GuessHandler(BaseAPIHandler): "cells": [], "words": [{"text": w.text, "found": w.found} for w in game.words], "status": game.status, + "player_name": game.player_name, "completed_at": None, } + + +class ScoreboardAPIHandler(BaseAPIHandler): + """``GET /api/scoreboard`` -- leaderboards and popular categories.""" + + def get(self): + session = self.get_session() + try: + categories = session.query(Category).all() + + leaderboards = {} + for cat in categories: + completed = ( + session.query(Game) + .filter( + Game.category_id == cat.id, + Game.status == "completed", + Game.completed_at.isnot(None), + ) + .all() + ) + completed.sort( + key=lambda g: (g.completed_at - g.started_at).total_seconds() + ) + top = completed[:10] + if top: + leaderboards[cat.name] = [ + { + "player_name": g.player_name, + "time_seconds": ( + g.completed_at - g.started_at + ).total_seconds(), + "board_size": g.board_size, + "word_count": len(g.words), + "completed_at": g.completed_at.isoformat() + "Z", + } + for g in top + ] + + popular = [] + for cat in categories: + count = ( + session.query(Game) + .filter( + Game.category_id == cat.id, + Game.status == "completed", + ) + .count() + ) + popular.append( + { + "id": cat.id, + "name": cat.name, + "completed_games": count, + } + ) + popular.sort(key=lambda x: x["completed_games"], reverse=True) + + self.write( + json.dumps( + { + "leaderboards": leaderboards, + "popular_categories": popular, + } + ) + ) + except Exception: + session.rollback() + raise + finally: + session.close() diff --git a/handlers/scoreboard.py b/handlers/scoreboard.py new file mode 100644 index 0000000..e462efc --- /dev/null +++ b/handlers/scoreboard.py @@ -0,0 +1,10 @@ +"""Scoreboard page handler.""" + +import tornado.web + + +class ScoreboardPageHandler(tornado.web.RequestHandler): + """Serves the scoreboard page.""" + + def get(self): + self.render("scoreboard.html") diff --git a/models/game.py b/models/game.py index 089a6c0..c0568d2 100644 --- a/models/game.py +++ b/models/game.py @@ -28,6 +28,9 @@ class Game(Base): id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) category_id = Column(Integer, ForeignKey("categories.id"), nullable=False) board_size = Column(Integer, nullable=False, default=10) + player_name = Column( + String(20), nullable=False, default="Anonymous", server_default="Anonymous" + ) grid = Column(JSON, nullable=False) status = Column(String(20), nullable=False, default="in_progress") started_at = Column( diff --git a/seed.py b/seed.py index 67b161c..5472096 100644 --- a/seed.py +++ b/seed.py @@ -1,227 +1,17 @@ +"""Seed the database with word categories from data/words.json.""" + +import json +import os + from db import get_engine, get_session_factory, init_db from models.category import Category, CategoryWord -SEED_DATA = { - "animals": [ - "TIGER", - "DOLPHIN", - "EAGLE", - "WOLF", - "BEAR", - "LION", - "HAWK", - "DEER", - "FROG", - "SHARK", - "SNAKE", - "WHALE", - "ZEBRA", - "PANDA", - "OTTER", - "FALCON", - "BISON", - "CRANE", - "MOOSE", - "RAVEN", - "JAGUAR", - "PARROT", - "TURTLE", - "SALMON", - "FERRET", - "COYOTE", - "OSPREY", - "LIZARD", - "BADGER", - "PELICAN", - "MONKEY", - "RABBIT", - "IGUANA", - "CONDOR", - "JACKAL", - ], - "colors": [ - "CRIMSON", - "VIOLET", - "AMBER", - "SCARLET", - "INDIGO", - "MAROON", - "CORAL", - "IVORY", - "SILVER", - "GOLDEN", - "BRONZE", - "TEAL", - "OLIVE", - "PLUM", - "RUST", - "SAGE", - "NAVY", - "JADE", - "CYAN", - "MAUVE", - "COBALT", - "COPPER", - "EBONY", - "PEACH", - "CREAM", - "LEMON", - "ORCHID", - "SALMON", - "SIENNA", - "KHAKI", - "LILAC", - "PEARL", - "CHARCOAL", - "MAGENTA", - "EMERALD", - ], - "food": [ - "PIZZA", - "SUSHI", - "MANGO", - "PASTA", - "TACO", - "BREAD", - "STEAK", - "GRAPE", - "MELON", - "PEACH", - "OLIVE", - "LEMON", - "BACON", - "WAFFLE", - "CREPE", - "DONUT", - "SALAD", - "CURRY", - "BAGEL", - "FUDGE", - "SHRIMP", - "CHEESE", - "BUTTER", - "CHERRY", - "PEPPER", - "GINGER", - "PRETZEL", - "BRISKET", - "NOODLE", - "OYSTER", - "TURNIP", - "RADISH", - "CELERY", - "COOKIE", - "BISQUE", - ], - "sports": [ - "SOCCER", - "TENNIS", - "BOXING", - "RUGBY", - "GOLF", - "DIVING", - "ROWING", - "HOCKEY", - "FENCING", - "ARCHER", - "SPRINT", - "DISCUS", - "SQUASH", - "KARATE", - "JUDO", - "RELAY", - "SLALOM", - "CRAWL", - "VAULT", - "SERVE", - "VOLLEY", - "TACKLE", - "PADDLE", - "JAVELIN", - "HURDLE", - ], - "space": [ - "PLANET", - "GALAXY", - "NEBULA", - "COMET", - "ORBIT", - "QUASAR", - "PULSAR", - "LUNAR", - "SOLAR", - "METEOR", - "AURORA", - "ECLIPSE", - "ZENITH", - "CRATER", - "COSMOS", - "PHOTON", - "PLASMA", - "ROCKET", - "SATURN", - "VENUS", - "MARS", - "PLUTO", - "TITAN", - "VORTEX", - "FLARE", - ], - "nature": [ - "RIVER", - "OCEAN", - "CANYON", - "FOREST", - "DESERT", - "ISLAND", - "GLACIER", - "MEADOW", - "VALLEY", - "SUMMIT", - "LAGOON", - "TUNDRA", - "MARSH", - "CLIFF", - "DELTA", - "FJORD", - "GROVE", - "RIDGE", - "CORAL", - "BROOK", - "DUNE", - "GEYSER", - "CAVERN", - "RAPIDS", - "SAVANNA", - ], - "music": [ - "GUITAR", - "PIANO", - "DRUMS", - "VIOLIN", - "FLUTE", - "CELLO", - "BANJO", - "ORGAN", - "HARP", - "TRUMPET", - "RHYTHM", - "MELODY", - "CHORD", - "TEMPO", - "FORTE", - "TREBLE", - "OCTAVE", - "SONATA", - "BALLAD", - "OPERA", - "HYMN", - "LYRIC", - "CHORUS", - "BRIDGE", - "DUET", - ], -} +DATA_PATH = os.path.join(os.path.dirname(__file__), "data", "words.json") + + +def load_words(): + with open(DATA_PATH) as f: + return json.load(f) def seed(): @@ -230,7 +20,9 @@ def seed(): Session = get_session_factory(engine) session = Session() - for cat_name, words in SEED_DATA.items(): + word_data = load_words() + + for cat_name, words in word_data.items(): existing = session.query(Category).filter_by(name=cat_name).first() if existing: cat = existing diff --git a/static/css/theme.css b/static/css/theme.css index 9a4da8c..6e4916b 100644 --- a/static/css/theme.css +++ b/static/css/theme.css @@ -1,12 +1,82 @@ -:root { +/* Theme variables — Indigo (default) */ +:root, +[data-theme="indigo"] { --indigo-darkest: #1a1a2e; --indigo-dark: #16213e; --indigo-mid: #0f3460; --coral: #e94560; --coral-light: #ff6b81; + --coral-bg: rgba(233, 69, 96, 0.3); --gold: #f5c518; --text-primary: #e0e0e0; --text-muted: #a0aec0; + --nav-hover-bg: rgba(15, 52, 96, 0.5); + --heading-color: #ffffff; + --tip-bg: rgba(15, 52, 96, 0.4); +} + +/* Theme variables — Dark */ +[data-theme="dark"] { + --indigo-darkest: #121212; + --indigo-dark: #1e1e1e; + --indigo-mid: #333333; + --coral: #e94560; + --coral-light: #ff6b81; + --coral-bg: rgba(233, 69, 96, 0.3); + --gold: #f5c518; + --text-primary: #e0e0e0; + --text-muted: #9e9e9e; + --nav-hover-bg: rgba(255, 255, 255, 0.08); + --heading-color: #ffffff; + --tip-bg: rgba(51, 51, 51, 0.6); +} + +/* Theme variables — Light */ +[data-theme="light"] { + --indigo-darkest: #f5f5f5; + --indigo-dark: #ffffff; + --indigo-mid: #dee2e6; + --coral: #d63851; + --coral-light: #e94560; + --coral-bg: rgba(214, 56, 81, 0.2); + --gold: #d4a017; + --text-primary: #212529; + --text-muted: #6c757d; + --nav-hover-bg: rgba(0, 0, 0, 0.05); + --heading-color: #212529; + --tip-bg: rgba(0, 0, 0, 0.03); +} + +/* Theme variables — Ocean */ +[data-theme="ocean"] { + --indigo-darkest: #e8f4f8; + --indigo-dark: #ffffff; + --indigo-mid: #b8d8e7; + --coral: #0077b6; + --coral-light: #0096c7; + --coral-bg: rgba(0, 119, 182, 0.15); + --gold: #e07a2f; + --text-primary: #1b3a4b; + --text-muted: #4a7c8f; + --nav-hover-bg: rgba(0, 119, 182, 0.08); + --heading-color: #023e58; + --tip-bg: rgba(0, 119, 182, 0.06); +} + +/* Theme variables — Forest */ +[data-theme="forest"] { + --indigo-darkest: #1a2e1a; + --indigo-dark: #1e3a1e; + --indigo-mid: #2d5a2d; + --coral: #e6a838; + --coral-light: #f0c060; + --coral-bg: rgba(230, 168, 56, 0.25); + --gold: #e6a838; + --text-primary: #d4e5d4; + --text-muted: #8aab8a; + --nav-hover-bg: rgba(45, 90, 45, 0.5); + --heading-color: #f0f5f0; + --tip-bg: rgba(45, 90, 45, 0.4); } body { @@ -42,12 +112,12 @@ body { } .navbar-nav .nav-link:hover { - color: #ffffff; - background-color: rgba(15, 52, 96, 0.5); + color: var(--heading-color); + background-color: var(--nav-hover-bg); } .navbar-nav .nav-link.active { - color: #ffffff; + color: var(--heading-color); background-color: var(--indigo-mid); } @@ -63,7 +133,7 @@ body { } .hero h1 { - color: #ffffff; + color: var(--heading-color); font-weight: 800; } @@ -129,7 +199,7 @@ body { } .tip-card { - background-color: rgba(15, 52, 96, 0.4); + background-color: var(--tip-bg); border-left: 3px solid var(--gold); border-radius: 4px; padding: 1rem 1.25rem; @@ -170,7 +240,7 @@ body { } .game-grid td.cell-selected { - background-color: rgba(233, 69, 96, 0.3); + background-color: var(--coral-bg); outline: 2px solid var(--coral); outline-offset: -2px; } @@ -195,11 +265,11 @@ body { .card-header { background-color: var(--indigo-mid); border-color: var(--indigo-mid); - color: #ffffff; + color: var(--heading-color); font-weight: 600; } -/* Form controls in dark theme */ +/* Form controls */ .form-select, .form-control { background-color: var(--indigo-dark); @@ -212,7 +282,7 @@ body { background-color: var(--indigo-dark); border-color: var(--coral); color: var(--text-primary); - box-shadow: 0 0 0 0.2rem rgba(233, 69, 96, 0.25); + box-shadow: 0 0 0 0.2rem var(--coral-bg); } .form-range::-webkit-slider-thumb { @@ -222,3 +292,14 @@ body { .form-range::-moz-range-thumb { background: var(--coral); } + +/* Theme toggle */ +#theme-toggle { + border-color: var(--indigo-mid); + color: var(--text-muted); +} + +#theme-toggle:hover { + color: var(--coral); + border-color: var(--coral); +} diff --git a/static/js/game.js b/static/js/game.js index d9c84db..85db005 100644 --- a/static/js/game.js +++ b/static/js/game.js @@ -6,6 +6,10 @@ let selectedCells = []; let feedbackTimeout = null; + function titleCase(word) { + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + } + // DOM refs const setupDiv = document.getElementById("game-setup"); const boardDiv = document.getElementById("game-board"); @@ -26,6 +30,7 @@ const finalTime = document.getElementById("final-time"); const playAgainBtn = document.getElementById("play-again-btn"); const inputArea = document.getElementById("input-area"); + const playerNameInput = document.getElementById("player-name"); // Load categories async function loadCategories() { @@ -59,10 +64,16 @@ const categoryId = parseInt(categorySelect.value); const boardSize = parseInt(boardSizeInput.value); const wordCount = parseInt(wordCountInput.value); + const playerName = playerNameInput.value.trim(); const resp = await fetch("/api/game/new", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ category_id: categoryId, board_size: boardSize, word_count: wordCount }), + body: JSON.stringify({ + category_id: categoryId, + board_size: boardSize, + word_count: wordCount, + player_name: playerName, + }), }); if (!resp.ok) { const errData = await resp.json().catch(function () { return {}; }); @@ -110,7 +121,7 @@ gameState.words.forEach(function (w) { const li = document.createElement("li"); li.className = "list-group-item"; - li.textContent = w.text; + li.textContent = titleCase(w.text); li.dataset.word = w.text; if (w.found) { li.classList.add("word-found"); @@ -255,12 +266,14 @@ if (result.correct) { highlightFoundCells(result.cells); renderWordList(); - showFeedback("Found: " + result.word + "!", "text-success"); + showFeedback("Found: " + titleCase(result.word) + "!", "text-success"); } else { showFeedback("Not a match.", "text-danger"); } if (result.status === "completed") { + gameState.completed_at = result.completed_at; + gameState.player_name = result.player_name; onGameComplete(result.completed_at); } } catch (err) { @@ -294,6 +307,30 @@ finalTime.textContent = timerEl.textContent; playAgainBtn.classList.remove("d-none"); inputArea.classList.add("d-none"); + + var startedAt = new Date(gameState.started_at); + var endedAt = new Date(completedAt); + var seconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000); + saveRecentGame({ + game_id: gameState.id, + player_name: gameState.player_name || "Anonymous", + category: gameState.category, + time_display: timerEl.textContent, + time_seconds: seconds, + board_size: gameState.board_size, + word_count: gameState.words.length, + completed_at: completedAt, + }); + } + + function saveRecentGame(entry) { + var KEY = "ws-recent-games"; + try { + var games = JSON.parse(localStorage.getItem(KEY) || "[]"); + games.unshift(entry); + if (games.length > 20) games = games.slice(0, 20); + localStorage.setItem(KEY, JSON.stringify(games)); + } catch (e) { /* localStorage unavailable */ } } playAgainBtn.addEventListener("click", function () { diff --git a/static/js/scoreboard.js b/static/js/scoreboard.js new file mode 100644 index 0000000..f3b8d5b --- /dev/null +++ b/static/js/scoreboard.js @@ -0,0 +1,158 @@ +(function () { + "use strict"; + + var loadingDiv = document.getElementById("scoreboard-loading"); + var recentSection = document.getElementById("recent-games-section"); + var recentTbody = document.querySelector("#recent-games-table tbody"); + var popularSection = document.getElementById("popular-section"); + var popularRow = document.getElementById("popular-row"); + var leaderboardsSection = document.getElementById("leaderboards-section"); + var leaderboardsContainer = document.getElementById("leaderboards-container"); + var emptyDiv = document.getElementById("scoreboard-empty"); + + function formatTime(seconds) { + var mins = Math.floor(seconds / 60); + var secs = Math.floor(seconds % 60); + return mins + ":" + (secs < 10 ? "0" : "") + secs; + } + + function formatDate(iso) { + return new Date(iso).toLocaleDateString(); + } + + function titleCase(str) { + return str.charAt(0).toUpperCase() + str.slice(1); + } + + function escapeHtml(str) { + var div = document.createElement("div"); + div.textContent = str; + return div.innerHTML; + } + + // Recent games from localStorage + function getRecentGames() { + try { + return JSON.parse(localStorage.getItem("ws-recent-games") || "[]"); + } catch (e) { + return []; + } + } + + function renderRecentGames(games) { + recentTbody.innerHTML = ""; + games.forEach(function (g) { + var tr = document.createElement("tr"); + tr.innerHTML = + "
' + c.completed_games + ' games completed
' + + ' Play' + + '| # | Player | Time | ' + + 'Board | Words | Date | ' + + '
|---|
Failed to load scoreboard data.
'; + } + } + + loadScoreboard(); +})(); diff --git a/static/js/theme.js b/static/js/theme.js new file mode 100644 index 0000000..8c3e129 --- /dev/null +++ b/static/js/theme.js @@ -0,0 +1,34 @@ +(function () { + "use strict"; + + var themes = ["indigo", "dark", "light", "ocean", "forest"]; + var icons = { + indigo: "bi-moon-stars", + dark: "bi-circle-half", + light: "bi-sun", + ocean: "bi-water", + forest: "bi-tree" + }; + var lightThemes = { light: true, ocean: true }; + var btn = document.getElementById("theme-toggle"); + var current = localStorage.getItem("ws-theme") || "indigo"; + + function apply(theme) { + document.documentElement.setAttribute("data-theme", theme); + document.documentElement.setAttribute( + "data-bs-theme", + lightThemes[theme] ? "light" : "dark" + ); + btn.querySelector("i").className = "bi " + icons[theme]; + btn.title = theme.charAt(0).toUpperCase() + theme.slice(1) + " theme"; + localStorage.setItem("ws-theme", theme); + current = theme; + } + + apply(current); + + btn.addEventListener("click", function () { + var idx = (themes.indexOf(current) + 1) % themes.length; + apply(themes[idx]); + }); +})(); diff --git a/templates/base.html b/templates/base.html index ad3a0d6..779ad34 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,5 +1,5 @@ - + @@ -11,6 +11,13 @@ + {% block active_page %}{% end %} @@ -44,6 +51,12 @@ How to Play +| Category | +Player | +Time | +Board | +Words | +Date | +
|---|
No completed games yet. Be the first to finish a puzzle!
+ Start a Game +