├── common ├── __init__.py ├── typing.py ├── consts.py ├── logger.py ├── error.py ├── config.py ├── schemas.py ├── session.py └── tables.py ├── mock ├── __init__.py └── mock_db.py ├── tests ├── __init__.py └── test_secret_logic.py ├── scripts ├── __init__.py ├── get_user.py ├── semantle.py ├── add_subscription.py ├── get_day_stats.py ├── fix_missing_cache.py └── set_secret.py ├── runtime.txt ├── logic ├── __init__.py ├── auth_logic.py ├── game_logic.py └── user_logic.py ├── alembic ├── README ├── script.py.mako ├── versions │ ├── 734535a7c997_secret_word_table.py │ ├── 8798f64b5f19_hot_clue_table.py │ ├── 8240e5d61928_user_subscription_table.py │ ├── 23e3fa471b40_add_user_clue_count_table.py │ ├── edef17c40466_add_indices.py │ ├── 6777d9f1937f_fine_tune_indexes.py │ ├── 342c9a594f33_add_closest_1000_table.py │ └── c4ee2484db71_user_user_history_tables.py └── env.py ├── static ├── favicon.ico ├── styles.css └── semantle.js ├── Procfile ├── .gitignore ├── .pre-commit-config.yaml ├── download_model.py ├── routers ├── __init__.py ├── legal_routes.py ├── user_routes.py ├── subscription_routes.py ├── base.py ├── auth_routes.py ├── game_routes.py ├── admin_routes.py └── pages_routes.py ├── .github └── workflows │ └── quality.yml ├── config.format.yaml ├── docker-compose.yml ├── __main__.py ├── Dockerfile ├── pyproject.toml ├── model.py ├── README.md ├── templates ├── template.html.template ├── privacy.html ├── videos.html ├── closest1000.html ├── all_secrets.html ├── error.html ├── statistics.html ├── set_secret.html ├── menu.html ├── faq.html └── index.html ├── alembic.ini └── app.py /common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mock/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.11.4 2 | -------------------------------------------------------------------------------- /logic/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | -------------------------------------------------------------------------------- /alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ishefi/semantle-he/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: newrelic-admin run-program uvicorn app:app --host=0.0.0.0 --port=${PORT:-5000} 2 | -------------------------------------------------------------------------------- /common/typing.py: -------------------------------------------------------------------------------- 1 | import numpy.typing 2 | 3 | np_float_arr = numpy.typing.NDArray[numpy.float32] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | .idea 3 | venv 4 | __pycache__ 5 | semantle.cfg 6 | config.json 7 | config.yaml 8 | model.mdl* 9 | -------------------------------------------------------------------------------- /common/consts.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | VEC_SIZE = "100f" 4 | FIRST_DATE = datetime(2022, 2, 21).date() 5 | -------------------------------------------------------------------------------- /common/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def setup_logger() -> logging.Logger: 5 | log = logging.getLogger("web app") 6 | log.setLevel(logging.DEBUG) 7 | return log 8 | 9 | 10 | logger = setup_logger() 11 | -------------------------------------------------------------------------------- /common/error.py: -------------------------------------------------------------------------------- 1 | class HSError(Exception): 2 | def __init__(self, message: str, code: int): 3 | super().__init__(message) 4 | self.message = message 5 | self.code = code 6 | 7 | def __str__(self) -> str: 8 | return f"Error: {self.message} ({self.code})" 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: ruff 5 | name: ruff 6 | entry: uv run ruff check --fix 7 | language: system 8 | types: [python] 9 | - id: ruff-format 10 | name: ruff format 11 | entry: uv run ruff format 12 | language: system 13 | types: [python] 14 | - id: mypy 15 | name: MyPy 16 | entry: uv run mypy 17 | language: system 18 | types: [python] 19 | -------------------------------------------------------------------------------- /download_model.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | 3 | import dropbox 4 | 5 | from common import config 6 | 7 | if __name__ == "__main__": 8 | client = dropbox.Dropbox( 9 | app_key=config.dropbox["key"], 10 | app_secret=config.dropbox["secret"], 11 | oauth2_refresh_token=config.dropbox["refresh_token"], 12 | ) 13 | destination = "model.zip" 14 | _, response = client.files_download("/model.zip") 15 | with open(destination, "wb") as f: 16 | f.write(response.content) 17 | shutil.unpack_archive(destination) 18 | -------------------------------------------------------------------------------- /routers/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from routers.admin_routes import admin_router 3 | from routers.auth_routes import auth_router 4 | from routers.game_routes import game_router 5 | from routers.legal_routes import legal_router 6 | from routers.pages_routes import pages_router 7 | from routers.subscription_routes import subscription_router 8 | from routers.user_routes import user_router 9 | 10 | routers = [ 11 | admin_router, 12 | auth_router, 13 | game_router, 14 | pages_router, 15 | legal_router, 16 | subscription_router, 17 | user_router, 18 | ] 19 | -------------------------------------------------------------------------------- /routers/legal_routes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import annotations 3 | 4 | from fastapi import APIRouter 5 | from fastapi import Request 6 | from fastapi.responses import HTMLResponse 7 | from fastapi.responses import Response 8 | 9 | from common import config 10 | from routers.base import render 11 | 12 | legal_router = APIRouter(prefix="/legal") 13 | 14 | 15 | @legal_router.get("/privacy", response_class=HTMLResponse, include_in_schema=False) 16 | async def privacy_policy(request: Request) -> Response: 17 | return render( 18 | "privacy.html", request=request, privacy_sections=config.privacy_policy 19 | ) 20 | -------------------------------------------------------------------------------- /.github/workflows/quality.yml: -------------------------------------------------------------------------------- 1 | name: Hooks 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | pre-commit-hooks: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: '3.11' 17 | - name: Install uv 18 | uses: astral-sh/setup-uv@v5 19 | with: 20 | version: "0.6.17" 21 | - name: Install Dependancies 22 | run: | 23 | export UV_PROJECT_ENVIRONMENT="${pythonLocation}" 24 | uv sync --locked 25 | - uses: pre-commit/action@v3.0.0 26 | 27 | -------------------------------------------------------------------------------- /config.format.yaml: -------------------------------------------------------------------------------- 1 | # change to config.yaml, and fill the missing attributes accordingly 2 | { 3 | "easter_eggs":{ 4 | "SECRET1":"EGG", 5 | "SECRET2":"EGG2" 6 | }, 7 | "quotes":[ 8 | [ 9 | "QUOTE1", 10 | "QUOTEE1", 11 | "SOURCE1", 12 | "URL1" 13 | ], 14 | [ 15 | "QUOTE2", 16 | "QUOTEE2", 17 | "SOURCE2", 18 | "URL2" 19 | ] 20 | ], 21 | "videos":[ 22 | "YOUTUBE_ID1", 23 | "YOUTUBE_ID2" 24 | ], 25 | "limit":500, #number of requests allowed in 26 | "period":400, # period (seconds) 27 | "port":"PORTNUM", 28 | "reload":false, 29 | "model_zip_id": "gdrive_file_id" 30 | } 31 | -------------------------------------------------------------------------------- /common/config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import sys 5 | from pathlib import Path 6 | from typing import TYPE_CHECKING 7 | 8 | from omegaconf import OmegaConf 9 | 10 | if TYPE_CHECKING: 11 | from typing import Any 12 | 13 | thismodule = sys.modules[__name__] 14 | 15 | conf = OmegaConf.create() 16 | if (config_path := Path(__file__).parent.parent.resolve() / "config.yaml").exists(): 17 | conf.merge_with(OmegaConf.load(config_path)) 18 | if yaml_str := os.environ.get("YAML_CONFIG_STR"): 19 | conf.merge_with(OmegaConf.create(yaml_str)) 20 | 21 | 22 | def __getattr__(name: str) -> Any: 23 | if name in conf: 24 | return conf[name] 25 | raise AttributeError(f"module {__name__} has no attribute {name}") 26 | -------------------------------------------------------------------------------- /alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | import sqlmodel 13 | ${imports if imports else ""} 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = ${repr(up_revision)} 17 | down_revision: Union[str, None] = ${repr(down_revision)} 18 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 19 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 20 | 21 | 22 | def upgrade() -> None: 23 | ${upgrades if upgrades else "pass"} 24 | 25 | 26 | def downgrade() -> None: 27 | ${downgrades if downgrades else "pass"} 28 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | mongodb: 5 | image: mongo:latest 6 | container_name: mongodb 7 | environment: 8 | MONGO_INITDB_ROOT_USERNAME: admin 9 | MONGO_INITDB_ROOT_PASSWORD: admin 10 | MONGO_INITDB_DATABASE: Semantle 11 | ports: 12 | - "27017-27019:27017-27019" 13 | volumes: 14 | - ./mongo-volume:/data/db 15 | redis: 16 | container_name: redis 17 | command: bash -c "redis-server --appendonly yes" 18 | image: redis 19 | ports: 20 | - "6379:6379" 21 | volumes: 22 | - ./redis-volume:/data 23 | 24 | web_app: 25 | container_name: web_app 26 | build: 27 | dockerfile: Dockerfile 28 | context: . 29 | image: semantle/fastapi 30 | depends_on: 31 | - mongodb 32 | - redis 33 | ports: 34 | - "5000:5000" -------------------------------------------------------------------------------- /__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from argparse import ArgumentParser 3 | from itertools import pairwise 4 | 5 | import uvicorn 6 | 7 | 8 | def main() -> None: 9 | parser = ArgumentParser() 10 | parser.add_argument("--port", type=int, default=8000) 11 | parser.add_argument("--host", default="localhost") 12 | parser.add_argument("--no-reload", action="store_false") 13 | args, unknown_args = parser.parse_known_args() 14 | uvicorn_kwargs = { 15 | "host": args.host, 16 | "reload": args.no_reload, 17 | "port": args.port, 18 | } 19 | for arg1, arg2 in pairwise(unknown_args): 20 | if not arg1.startswith("--"): 21 | parser.error(f"Unknow arg: {arg1}") 22 | else: 23 | uvicorn_kwargs[arg1[2:]] = arg2 24 | 25 | uvicorn.run("app:app", **uvicorn_kwargs) 26 | 27 | 28 | if __name__ == "__main__": 29 | main() 30 | -------------------------------------------------------------------------------- /common/schemas.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import datetime 3 | 4 | from pydantic import BaseModel 5 | from pydantic import validator 6 | 7 | 8 | class DistanceResponse(BaseModel): 9 | guess: str 10 | similarity: float | None 11 | distance: int 12 | egg: str | None = None 13 | solver_count: int | None = None 14 | guess_number: int = 0 15 | 16 | 17 | class UserStatistics(BaseModel): 18 | game_streak: int 19 | highest_rank: int | None 20 | total_games_played: int 21 | total_games_won: int 22 | average_guesses: float 23 | 24 | @validator("average_guesses") # TODO: use some other parsing method 25 | def result_check(cls, v: float) -> float: 26 | ... 27 | return round(v, 2) 28 | 29 | 30 | class Subscription(BaseModel): 31 | verification_token: str 32 | message_id: str 33 | timestamp: datetime.datetime 34 | email: str 35 | amount: int 36 | tier_name: str | None = None 37 | -------------------------------------------------------------------------------- /scripts/get_user.py: -------------------------------------------------------------------------------- 1 | #!/user/bin/env python 2 | import argparse 3 | import os 4 | import sys 5 | 6 | base = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) 7 | sys.path.extend([base]) 8 | 9 | from common.session import get_session 10 | from logic.user_logic import UserLogic 11 | 12 | 13 | def parse_args() -> argparse.Namespace: 14 | parser = argparse.ArgumentParser(description="Get user information") 15 | parser.add_argument("email", help="Email of the user") 16 | return parser.parse_args() 17 | 18 | 19 | async def main() -> None: 20 | args = parse_args() 21 | print(f"Getting user information for {args.email}") 22 | user_logic = UserLogic(get_session()) 23 | user = await user_logic.get_user(args.email) 24 | if user is None: 25 | print(f"User with email {args.email} not found") 26 | else: 27 | print(user) 28 | print(f"User subsciption expiry: {user_logic.get_subscription_expiry(user)}") 29 | 30 | 31 | if __name__ == "__main__": 32 | import asyncio 33 | 34 | asyncio.run(main()) 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 2 | FROM --platform=linux/amd64 python:3.12 3 | # 4 | ARG YAML_CONFIG_STR 5 | ENV YAML_CONFIG_STR=${YAML_CONFIG_STR} 6 | ENV UV_PROJECT_ENVIRONMENT="/usr/local/" 7 | 8 | # 9 | RUN curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg 10 | RUN curl https://packages.microsoft.com/config/debian/12/prod.list | tee /etc/apt/sources.list.d/mssql-release.list 11 | RUN apt-get update 12 | RUN ACCEPT_EULA=Y apt-get install -y msodbcsql18 13 | 14 | # 15 | WORKDIR /code 16 | 17 | # 18 | COPY uv.lock pyproject.toml /code/ 19 | 20 | # 21 | RUN pip install --no-cache-dir --upgrade uv==0.6.17 22 | RUN uv sync --no-install-project --locked 23 | RUN pip install setuptools 24 | # 25 | 26 | COPY ./common /code/common 27 | COPY ./static /code/static 28 | COPY ./templates /code/templates 29 | COPY ./logic/ /code/logic 30 | COPY ./routers/ /code/routers 31 | COPY ./*.py /code 32 | 33 | RUN python /code/download_model.py 34 | RUN rm /code/model.zip 35 | 36 | EXPOSE 5000 37 | 38 | CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000"] 39 | 40 | -------------------------------------------------------------------------------- /common/session.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from contextlib import contextmanager 4 | from typing import TYPE_CHECKING 5 | 6 | import gensim.models.keyedvectors as word2vec 7 | from sqlmodel import Session 8 | from sqlmodel import create_engine 9 | 10 | from common import config 11 | from model import GensimModel 12 | 13 | if TYPE_CHECKING: 14 | from typing import Iterator 15 | 16 | 17 | def get_model() -> GensimModel: 18 | return GensimModel(word2vec.KeyedVectors.load("model.mdl").wv) 19 | 20 | 21 | def get_session() -> Session: 22 | engine = create_engine(config.db_url) 23 | return Session(engine) 24 | 25 | 26 | @contextmanager 27 | def hs_transaction( 28 | session: Session, expire_on_commit: bool = True 29 | ) -> Iterator[Session]: 30 | try: 31 | if not expire_on_commit: 32 | session.expire_on_commit = False 33 | session.begin() 34 | yield session 35 | session.commit() 36 | except Exception: 37 | session.rollback() 38 | raise 39 | finally: 40 | session.expire_on_commit = True 41 | session.close() 42 | -------------------------------------------------------------------------------- /alembic/versions/734535a7c997_secret_word_table.py: -------------------------------------------------------------------------------- 1 | """Secret word table 2 | 3 | Revision ID: 734535a7c997 4 | Revises: 23e3fa471b40 5 | Create Date: 2024-01-14 18:09:54.747781 6 | 7 | """ 8 | 9 | from typing import Sequence 10 | from typing import Union 11 | 12 | import sqlalchemy as sa 13 | 14 | from alembic import op 15 | 16 | # revision identifiers, used by Alembic. 17 | revision: str = "734535a7c997" 18 | down_revision: Union[str, None] = "23e3fa471b40" 19 | branch_labels: Union[str, Sequence[str], None] = None 20 | depends_on: Union[str, Sequence[str], None] = None 21 | 22 | 23 | def upgrade() -> None: 24 | op.create_table( 25 | "secretword", 26 | sa.Column("id", sa.Integer(), nullable=False, autoincrement=True), 27 | sa.Column( 28 | "word", 29 | sa.String( 30 | 32, 31 | # collation="Hebrew_100_CI_AI_SC_UTF8" 32 | ), 33 | nullable=False, 34 | ), 35 | sa.Column("game_date", sa.Date(), nullable=False), 36 | sa.Column("solver_count", sa.Integer(), nullable=False, default=0), 37 | sa.PrimaryKeyConstraint("id"), 38 | sa.UniqueConstraint("game_date"), 39 | sa.UniqueConstraint("word"), 40 | ) 41 | 42 | 43 | def downgrade() -> None: 44 | op.drop_table("secretword") 45 | -------------------------------------------------------------------------------- /alembic/versions/8798f64b5f19_hot_clue_table.py: -------------------------------------------------------------------------------- 1 | """hot clue table 2 | 3 | Revision ID: 8798f64b5f19 4 | Revises: 8240e5d61928 5 | Create Date: 2024-01-17 21:31:45.610168 6 | 7 | """ 8 | 9 | from typing import Sequence 10 | from typing import Union 11 | 12 | import sqlalchemy as sa 13 | 14 | from alembic import op 15 | 16 | # revision identifiers, used by Alembic. 17 | revision: str = "8798f64b5f19" 18 | down_revision: Union[str, None] = "8240e5d61928" 19 | branch_labels: Union[str, Sequence[str], None] = None 20 | depends_on: Union[str, Sequence[str], None] = None 21 | 22 | 23 | def upgrade() -> None: 24 | op.create_table( 25 | "hotclue", 26 | sa.Column("id", sa.Integer(), nullable=False, autoincrement=True), 27 | sa.Column("secret_word_id", sa.Integer(), nullable=False), 28 | sa.Column( 29 | "clue", 30 | sa.String( 31 | 32, 32 | # collation="Hebrew_100_CI_AI_SC_UTF8", 33 | ), 34 | nullable=False, 35 | ), 36 | sa.ForeignKeyConstraint( 37 | ["secret_word_id"], 38 | ["secretword.id"], 39 | ), 40 | sa.PrimaryKeyConstraint("id"), 41 | sa.UniqueConstraint("secret_word_id", "clue"), 42 | ) 43 | 44 | 45 | def downgrade() -> None: 46 | op.drop_table("hotclue") 47 | -------------------------------------------------------------------------------- /alembic/versions/8240e5d61928_user_subscription_table.py: -------------------------------------------------------------------------------- 1 | """User subscription table 2 | 3 | Revision ID: 8240e5d61928 4 | Revises: 734535a7c997 5 | Create Date: 2024-01-15 16:15:44.306258 6 | 7 | """ 8 | 9 | from typing import Sequence 10 | from typing import Union 11 | 12 | import sqlalchemy as sa 13 | 14 | from alembic import op 15 | 16 | # revision identifiers, used by Alembic. 17 | revision: str = "8240e5d61928" 18 | down_revision: Union[str, None] = "734535a7c997" 19 | branch_labels: Union[str, Sequence[str], None] = None 20 | depends_on: Union[str, Sequence[str], None] = None 21 | 22 | 23 | def upgrade() -> None: 24 | op.create_table( 25 | "usersubscription", 26 | sa.Column("id", sa.Integer(), nullable=False, autoincrement=True), 27 | sa.Column("user_id", sa.Integer(), nullable=False), 28 | sa.Column("amount", sa.Float(), nullable=False), 29 | sa.Column("tier_name", sa.String(), nullable=True), 30 | sa.Column("uuid", sa.String(36), nullable=False), 31 | sa.Column("timestamp", sa.DateTime(), nullable=False), 32 | sa.ForeignKeyConstraint( 33 | ["user_id"], 34 | ["user.id"], 35 | ), 36 | sa.PrimaryKeyConstraint("id"), 37 | sa.UniqueConstraint("uuid"), 38 | ) 39 | 40 | 41 | def downgrade() -> None: 42 | op.drop_table("usersubscription") 43 | -------------------------------------------------------------------------------- /alembic/versions/23e3fa471b40_add_user_clue_count_table.py: -------------------------------------------------------------------------------- 1 | """Add user clue count table 2 | 3 | Revision ID: 23e3fa471b40 4 | Revises: c4ee2484db71 5 | Create Date: 2024-01-09 17:44:57.981253 6 | 7 | """ 8 | 9 | from typing import Sequence 10 | from typing import Union 11 | 12 | import sqlalchemy as sa 13 | 14 | from alembic import op 15 | 16 | # revision identifiers, used by Alembic. 17 | revision: str = "23e3fa471b40" 18 | down_revision: Union[str, None] = "c4ee2484db71" 19 | branch_labels: Union[str, Sequence[str], None] = None 20 | depends_on: Union[str, Sequence[str], None] = None 21 | 22 | 23 | def upgrade() -> None: 24 | # ### commands auto generated by Alembic - please adjust! ### 25 | op.create_table( 26 | "usercluecount", 27 | sa.Column("id", sa.Integer(), nullable=False), 28 | sa.Column("user_id", sa.Integer(), nullable=False), 29 | sa.Column("clue_count", sa.Integer(), nullable=False), 30 | sa.Column("game_date", sa.Date(), nullable=False), 31 | sa.ForeignKeyConstraint( 32 | ["user_id"], 33 | ["user.id"], 34 | ), 35 | sa.PrimaryKeyConstraint("id"), 36 | sa.UniqueConstraint("user_id", "game_date"), 37 | ) 38 | # ### end Alembic commands ### 39 | 40 | 41 | def downgrade() -> None: 42 | # ### commands auto generated by Alembic - please adjust! ### 43 | op.drop_table("usercluecount") 44 | # ### end Alembic commands ### 45 | -------------------------------------------------------------------------------- /routers/user_routes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import annotations 3 | 4 | import datetime 5 | import hashlib 6 | 7 | from fastapi import APIRouter 8 | from fastapi import HTTPException 9 | from fastapi import status 10 | from fastapi.requests import Request 11 | 12 | from common import config 13 | from logic.user_logic import UserLogic 14 | from logic.user_logic import UserStatisticsLogic 15 | 16 | user_router = APIRouter(prefix="/api/user") 17 | 18 | 19 | @user_router.get("/info") 20 | async def get_user_info( 21 | request: Request, 22 | ) -> dict[str, str | datetime.datetime | int | None]: 23 | user = request.state.user 24 | if not user: 25 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) 26 | else: 27 | user_logic = UserLogic(request.app.state.session) 28 | stats_logic = UserStatisticsLogic(request.app.state.session, user) 29 | hasher = hashlib.sha3_256() 30 | # TODO: make this consistent 31 | hasher.update(user.email.encode() + config.secret_key.encode()) 32 | return { 33 | "id": hasher.hexdigest()[:8], 34 | "email": user.email, 35 | "picture": user.picture, 36 | "name": f"{user.given_name} {user.family_name}", 37 | "subscription_expiry": user_logic.get_subscription_expiry(user), 38 | # TODO: consider adding more statistics in the future 39 | "game_streak": (await stats_logic.get_statistics()).game_streak, 40 | } 41 | -------------------------------------------------------------------------------- /routers/subscription_routes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import datetime 3 | import hmac 4 | import json 5 | from typing import Annotated 6 | 7 | import requests 8 | from fastapi import APIRouter 9 | from fastapi import Form 10 | from fastapi import HTTPException 11 | from fastapi import status 12 | from fastapi.requests import Request 13 | 14 | from common import config 15 | from common import schemas 16 | from logic.user_logic import UserLogic 17 | 18 | subscription_router = APIRouter(prefix="/api/subscribe") 19 | 20 | 21 | @subscription_router.post("/ko-fi") 22 | async def subscribe(request: Request, data: Annotated[str, Form()]) -> dict[str, str]: 23 | subscription = schemas.Subscription(**json.loads(data)) 24 | is_valid_token = hmac.compare_digest( 25 | subscription.verification_token, config.kofi_verification_token 26 | ) 27 | message_dt = subscription.timestamp 28 | is_new_message = message_dt > datetime.datetime.now( 29 | datetime.UTC 30 | ) - datetime.timedelta(minutes=5) 31 | if not is_valid_token or not is_new_message: 32 | raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) 33 | logic = UserLogic(session=request.app.state.session) 34 | success = await logic.subscribe(subscription) 35 | success_message = "Success :smile:" if success else "Failed :rage:" 36 | requests.post( 37 | config.alerts_webhook, 38 | json={ 39 | "text": f"New Ko-fi subscription: {subscription.email} ({subscription.amount}$) - {success_message}", 40 | }, 41 | ) 42 | return {"status": "ok"} 43 | -------------------------------------------------------------------------------- /scripts/semantle.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import datetime 3 | 4 | from common.error import HSError 5 | from common.session import get_model 6 | from common.session import get_session 7 | from logic.game_logic import CacheSecretLogic 8 | from logic.game_logic import VectorLogic 9 | 10 | 11 | def _get_todays_date() -> datetime.date: 12 | return datetime.datetime.now(datetime.UTC).date() 13 | 14 | 15 | async def main() -> None: 16 | print("Welcome! Take a guess:") 17 | inp = None 18 | model = get_model() 19 | session = get_session() 20 | date = _get_todays_date() 21 | logic = VectorLogic(session, dt=date, model=model) 22 | secret = await logic.secret_logic.get_secret() 23 | cache_logic = CacheSecretLogic(session, secret=secret, dt=date, model=model) 24 | while inp != "exit": 25 | if _get_todays_date() != date: 26 | date = _get_todays_date() 27 | logic = VectorLogic(session, dt=date, model=model) 28 | secret = await logic.secret_logic.get_secret() 29 | cache_logic = CacheSecretLogic(session, secret=secret, dt=date, model=model) 30 | inp = input(">") 31 | print(inp[::-1]) 32 | try: 33 | similarity = await logic.get_similarity(inp) 34 | except HSError: 35 | print("I don't know this word!") 36 | else: 37 | cache_score = await cache_logic.get_cache_score(inp) 38 | if cache_score < 0: 39 | cache_data = "(cold)" 40 | else: 41 | cache_data = f"{cache_score}/1000" 42 | print(f"Distance: {similarity} | {cache_data}") 43 | 44 | 45 | if __name__ == "__main__": 46 | asyncio.run(main()) 47 | -------------------------------------------------------------------------------- /alembic/versions/edef17c40466_add_indices.py: -------------------------------------------------------------------------------- 1 | """add indices 2 | 3 | Revision ID: edef17c40466 4 | Revises: 8798f64b5f19 5 | Create Date: 2024-03-03 14:19:35.170844 6 | 7 | """ 8 | 9 | from typing import Sequence 10 | from typing import Union 11 | 12 | from alembic import op 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = "edef17c40466" 16 | down_revision: Union[str, None] = "8798f64b5f19" 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.create_index( 24 | op.f("ix_hotclue_secret_word_id"), "hotclue", ["secret_word_id"], unique=False 25 | ) 26 | op.create_index( 27 | op.f("ix_secretword_game_date"), "secretword", ["game_date"], unique=False 28 | ) 29 | op.create_index(op.f("ix_user_email"), "user", ["email"], unique=True) 30 | op.create_index( 31 | op.f("ix_userhistory_user_id"), "userhistory", ["user_id"], unique=False 32 | ) 33 | op.create_index( 34 | "ux_user_id__game_date", "userhistory", ["user_id", "game_date"], unique=False 35 | ) 36 | # ### end Alembic commands ### 37 | 38 | 39 | def downgrade() -> None: 40 | # ### commands auto generated by Alembic - please adjust! ### 41 | op.drop_index("ux_user_id__game_date", table_name="userhistory") 42 | op.drop_index(op.f("ix_userhistory_user_id"), table_name="userhistory") 43 | op.drop_index(op.f("ix_user_email"), table_name="user") 44 | op.drop_index(op.f("ix_secretword_game_date"), table_name="secretword") 45 | op.drop_index(op.f("ix_hotclue_secret_word_id"), table_name="hotclue") 46 | # ### end Alembic commands ### 47 | -------------------------------------------------------------------------------- /alembic/versions/6777d9f1937f_fine_tune_indexes.py: -------------------------------------------------------------------------------- 1 | """fine-tune indexes 2 | 3 | Revision ID: 6777d9f1937f 4 | Revises: edef17c40466 5 | Create Date: 2024-03-05 17:11:50.533412 6 | 7 | """ 8 | 9 | from typing import Sequence 10 | from typing import Union 11 | 12 | from alembic import op 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = "6777d9f1937f" 16 | down_revision: Union[str, None] = "edef17c40466" 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.drop_index("ix_secretword_game_date", table_name="secretword") 24 | op.drop_index("ux_user_id__game_date", table_name="userhistory") 25 | op.drop_index("ix_user_email", table_name="user") 26 | op.create_index( 27 | "nci_user_id__game_date", 28 | "userhistory", 29 | ["user_id", "game_date"], 30 | unique=False, 31 | mssql_include=["distance", "egg", "guess", "similarity", "solver_count"], 32 | ) 33 | # ### end Alembic commands ### 34 | 35 | 36 | def downgrade() -> None: 37 | # ### commands auto generated by Alembic - please adjust! ### 38 | op.drop_index( 39 | "nci_user_id__game_date", 40 | table_name="userhistory", 41 | mssql_include=["distance", "egg", "guess", "similarity", "solver_count"], 42 | ) 43 | op.create_index( 44 | "ux_user_id__game_date", "userhistory", ["user_id", "game_date"], unique=False 45 | ) 46 | op.create_index("ix_user_email", "user", columns=["email"], unique=True) 47 | op.create_index( 48 | "ix_secretword_game_date", "secretword", ["game_date"], unique=False 49 | ) 50 | # ### end Alembic commands ### 51 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "semantle-he" 3 | version = "0.1.0" 4 | description = "A Hebrew version of Semantle." 5 | authors = [{ name = "Itamar Shefi", email = "itamarshefi@gmail.com" }] 6 | requires-python = ">=3.12,<3.13" 7 | readme = "README.md" 8 | license = "MIT" 9 | dependencies = [ 10 | "fastapi==0.110.0", 11 | "jinja2>=3.1.2,<4", 12 | "numpy>=1.26.2,<2", 13 | "omegaconf>=2.3.0,<3", 14 | "uvicorn[standard]>=0.25.0,<0.26", 15 | "pydantic>=2.5.3,<3", 16 | "gensim>=4.3.2,<5", 17 | "google-auth>=2.25.2,<3", 18 | "python-dateutil>=2.8.2,<3", 19 | "python-multipart>=0.0.6,<0.0.7", 20 | "sqlmodel>=0.0.14,<0.0.15", 21 | "sqlalchemy-libsql>=0.1.0,<0.2", 22 | "dropbox>=11.36.2,<12", 23 | "pyodbc>=5.1.0,<6", 24 | "milon>=0.0.1,<0.0.2", 25 | "sqlalchemy[postgress]>=2.0.29", 26 | "psycopg2-binary>=2.9.10", 27 | "pyjwt>=2.10.1", 28 | ] 29 | 30 | [dependency-groups] 31 | dev = [ 32 | "pre-commit>=3.6.0,<4", 33 | "mypy>=1.8.0,<2", 34 | "types-python-dateutil>=2.8.19.14,<3", 35 | "types-requests>=2.31.0.10,<3", 36 | "ruff==0.3.4", 37 | "alembic>=1.13.1,<2", 38 | "pytest>=8.1.1,<9", 39 | "pytest-sugar>=1.0.0,<2", 40 | "tqdm>=4.67.1,<5", 41 | "types-tqdm>=4.67.0.20250809", 42 | ] 43 | 44 | [build-system] 45 | requires = ["hatchling"] 46 | build-backend = "hatchling.build" 47 | 48 | [tool.hatch.build.targets.wheel] 49 | packages = ["."] 50 | 51 | [tool.ruff] 52 | fix = true 53 | 54 | [tool.ruff.lint] 55 | select = ["F", "I"] 56 | 57 | [tool.ruff.lint.isort] 58 | force-single-line = true 59 | 60 | [tool.mypy] 61 | strict = true 62 | 63 | [[tool.mypy.overrides]] 64 | module = [ 65 | "dropbox", 66 | "gensim.*", 67 | "google.oauth2", 68 | "google.auth.transport", 69 | ] 70 | ignore_missing_imports = true 71 | -------------------------------------------------------------------------------- /scripts/add_subscription.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import argparse 3 | import asyncio 4 | import datetime 5 | import os 6 | import sys 7 | import uuid 8 | 9 | base = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) 10 | sys.path.extend([base]) 11 | from common import schemas 12 | from common.session import get_session 13 | from logic.user_logic import UserLogic 14 | 15 | 16 | def parse_args() -> argparse.Namespace: 17 | parser = argparse.ArgumentParser(description="Add subscription") 18 | parser.add_argument("--email", type=str, help="User email", required=True) 19 | parser.add_argument("--amount", type=int, help="Amount of subscription", default=3) 20 | parser.add_argument("--message_id", type=str, help="Message ID") 21 | 22 | return parser.parse_args() 23 | 24 | 25 | async def main() -> None: 26 | args = parse_args() 27 | 28 | user_logic = UserLogic(get_session()) 29 | 30 | subscription = schemas.Subscription( 31 | verification_token="", 32 | message_id=args.message_id or uuid.uuid4().hex, 33 | timestamp=datetime.datetime.now(datetime.UTC), 34 | email=args.email, 35 | amount=args.amount, 36 | tier_name=None, 37 | ) 38 | success = await user_logic.subscribe(subscription) 39 | if not success: 40 | print("Failed to add subscription") 41 | return 42 | else: 43 | user = await user_logic.get_user(args.email) 44 | if user is None: 45 | print("No such user") 46 | return 47 | expiry = user_logic.get_subscription_expiry(user) 48 | print( 49 | f"Subscription of {args.amount} added successfully to user {args.email}, " 50 | f"expires on {expiry}" 51 | ) 52 | 53 | 54 | if __name__ == "__main__": 55 | asyncio.run(main()) 56 | -------------------------------------------------------------------------------- /alembic/versions/342c9a594f33_add_closest_1000_table.py: -------------------------------------------------------------------------------- 1 | """add closest 1000 table 2 | 3 | Revision ID: 342c9a594f33 4 | Revises: 6777d9f1937f 5 | Create Date: 2025-06-09 16:36:45.379902 6 | 7 | """ 8 | 9 | from typing import Sequence 10 | from typing import Union 11 | 12 | import sqlalchemy as sa 13 | 14 | from alembic import op 15 | 16 | # revision identifiers, used by Alembic. 17 | revision: str = "342c9a594f33" 18 | down_revision: Union[str, None] = "6777d9f1937f" 19 | branch_labels: Union[str, Sequence[str], None] = None 20 | depends_on: Union[str, Sequence[str], None] = None 21 | 22 | 23 | def upgrade() -> None: 24 | # ### commands auto generated by Alembic - please adjust! ### 25 | op.create_table( 26 | "closest1000", 27 | sa.Column("id", sa.Integer(), nullable=False), 28 | sa.Column("word", sa.String(), nullable=False), 29 | sa.Column("secret_word_id", sa.Integer(), nullable=False), 30 | sa.Column("out_of_1000", sa.Integer(), nullable=False), 31 | sa.ForeignKeyConstraint( 32 | ["secret_word_id"], 33 | ["secretword.id"], 34 | ), 35 | sa.PrimaryKeyConstraint("id"), 36 | ) 37 | op.create_index( 38 | op.f("ix_closest1000_secret_word_id"), 39 | "closest1000", 40 | ["secret_word_id"], 41 | unique=False, 42 | ) 43 | op.create_index(op.f("ix_closest1000_word"), "closest1000", ["word"], unique=False) 44 | # ### end Alembic commands ### 45 | 46 | 47 | def downgrade() -> None: 48 | # ### commands auto generated by Alembic - please adjust! ### 49 | op.drop_index(op.f("ix_closest1000_word"), table_name="closest1000") 50 | op.drop_index(op.f("ix_closest1000_secret_word_id"), table_name="closest1000") 51 | op.drop_table("closest1000") 52 | # ### end Alembic commands ### 53 | -------------------------------------------------------------------------------- /logic/auth_logic.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import annotations 3 | 4 | import datetime 5 | from typing import TYPE_CHECKING 6 | 7 | import jwt 8 | from google.auth.transport import requests 9 | from google.oauth2 import id_token 10 | 11 | from common import config 12 | from logic.user_logic import UserLogic 13 | 14 | if TYPE_CHECKING: 15 | from typing import Any 16 | 17 | from sqlmodel import Session 18 | 19 | ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 30 # 30 days 20 | 21 | 22 | class AuthLogic: 23 | def __init__(self, session: Session, auth_client_id: str) -> None: 24 | self.user_logic = UserLogic(session) 25 | self.auth_client_id = auth_client_id 26 | 27 | async def jwt_from_credential(self, credential: str) -> str: 28 | user_info = self._verify_credential(credential) 29 | email = user_info["email"] 30 | user = await self.user_logic.get_user(email) 31 | if user is None: 32 | user = await self.user_logic.create_user(user_info) 33 | expire = datetime.datetime.now(datetime.UTC) + datetime.timedelta( 34 | minutes=ACCESS_TOKEN_EXPIRE_MINUTES 35 | ) 36 | return jwt.encode( 37 | {"sub": user.email, "exp": expire}, 38 | key=config.jwt_key, 39 | algorithm=config.jwt_algorithm, 40 | ) 41 | 42 | def _verify_credential(self, credential: str) -> dict[str, Any]: 43 | try: 44 | id_info: dict[str, Any] = id_token.verify_oauth2_token( # type: ignore[no-untyped-call] 45 | credential, 46 | requests.Request(), # type: ignore[no-untyped-call] 47 | self.auth_client_id, 48 | ) 49 | return id_info 50 | except ValueError: 51 | raise ValueError("Invalid credential", 401324) 52 | -------------------------------------------------------------------------------- /model.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import numpy as np 6 | from gensim.models import KeyedVectors 7 | 8 | if TYPE_CHECKING: 9 | from typing import AsyncIterator 10 | 11 | from common.typing import np_float_arr 12 | 13 | 14 | class GensimModel: 15 | def __init__(self, model: KeyedVectors): 16 | self.model = model 17 | 18 | async def get_vector(self, word: str) -> np_float_arr | None: 19 | if not all(ord("א") <= ord(c) <= ord("ת") for c in word): 20 | return None 21 | if len(word) == 1: 22 | return None 23 | if word not in self.model: 24 | return None 25 | vector: np_float_arr = self.model[word].tolist() 26 | return vector 27 | 28 | async def get_similarities( 29 | self, words: list[str], vector: np_float_arr 30 | ) -> np_float_arr: 31 | similarities: np_float_arr = np.round( 32 | self.model.cosine_similarities( 33 | vector, np.asarray([self.model[w] for w in words]) 34 | ) 35 | * 100, 36 | 2, 37 | ) 38 | return similarities 39 | 40 | async def iterate_all(self) -> AsyncIterator[tuple[str, np_float_arr]]: 41 | for word in self.model.key_to_index.keys(): 42 | if isinstance(word, str): 43 | vector = await self.get_vector(word) 44 | else: 45 | continue 46 | if vector is None: 47 | continue 48 | yield word, self.model[word] 49 | 50 | async def calc_similarity(self, vec1: np_float_arr, vec2: np_float_arr) -> float: 51 | similarities: np_float_arr = self.model.cosine_similarities( 52 | vec1, np.expand_dims(vec2, axis=0) 53 | ) 54 | return round(float(similarities[0]) * 100, 2) 55 | -------------------------------------------------------------------------------- /scripts/get_day_stats.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | base = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) 6 | sys.path.extend([base]) 7 | 8 | import argparse 9 | import datetime 10 | 11 | from common.session import get_model 12 | from common.session import get_session 13 | from logic.game_logic import CacheSecretLogic 14 | from logic.game_logic import VectorLogic 15 | from logic.user_logic import UserClueLogic 16 | 17 | 18 | def valid_date(date_str: str) -> datetime.date: 19 | try: 20 | return datetime.datetime.strptime(date_str, "%Y-%m-%d").date() 21 | except ValueError: 22 | raise argparse.ArgumentTypeError("Bad date: should be of the format YYYY-mm-dd") 23 | 24 | 25 | def parse_args() -> argparse.Namespace: 26 | parser = argparse.ArgumentParser() 27 | parser.add_argument( 28 | "-d", 29 | "--date", 30 | metavar="DATE", 31 | type=valid_date, 32 | required=True, 33 | help="Date of secret. If not provided, first date w/o secret is used", 34 | ) 35 | return parser.parse_args() 36 | 37 | 38 | async def main() -> None: 39 | args = parse_args() 40 | session = get_session() 41 | model = get_model() 42 | logic = VectorLogic(session, dt=args.date, model=model) 43 | secret = await logic.secret_logic.get_secret() 44 | 45 | cache_logic = CacheSecretLogic( 46 | session=session, 47 | secret=secret, 48 | dt=args.date, 49 | model=model, 50 | ) 51 | 52 | cache_len = len(await cache_logic.get_cache()) 53 | 54 | clue_logic = UserClueLogic( 55 | session=session, 56 | user=None, # type: ignore[arg-type] 57 | secret=secret, 58 | date=args.date, 59 | ) 60 | clues = clue_logic.clues 61 | 62 | print(f"Word for {args.date}: {secret}") 63 | print(f"{cache_len} cached words and {len(clues)} clues") 64 | 65 | 66 | if __name__ == "__main__": 67 | import asyncio 68 | 69 | asyncio.run(main()) 70 | -------------------------------------------------------------------------------- /routers/base.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import annotations 3 | 4 | import datetime 5 | from typing import TYPE_CHECKING 6 | 7 | from fastapi import FastAPI 8 | from fastapi import HTTPException 9 | from fastapi import Request 10 | from fastapi import status 11 | from fastapi.responses import HTMLResponse 12 | from fastapi.templating import Jinja2Templates 13 | 14 | from logic.game_logic import CacheSecretLogic 15 | from logic.game_logic import VectorLogic 16 | from logic.user_logic import UserLogic 17 | 18 | templates = Jinja2Templates(directory="templates") 19 | 20 | if TYPE_CHECKING: 21 | from typing import Any 22 | 23 | 24 | def get_date(delta: datetime.timedelta) -> datetime.date: 25 | return datetime.datetime.now(datetime.UTC).date() - delta 26 | 27 | 28 | # TODO: replace this with a dependency 29 | async def get_logics( 30 | app: FastAPI, delta: datetime.timedelta = datetime.timedelta() 31 | ) -> tuple[VectorLogic, CacheSecretLogic]: 32 | delta += app.state.days_delta 33 | date = get_date(delta) 34 | logic = VectorLogic(app.state.session, dt=date, model=app.state.model) 35 | secret = await logic.secret_logic.get_secret() 36 | cache_logic = CacheSecretLogic( 37 | app.state.session, 38 | secret=secret, 39 | dt=date, 40 | model=app.state.model, 41 | ) 42 | return logic, cache_logic 43 | 44 | 45 | def super_admin(request: Request) -> None: 46 | user = request.state.user 47 | if not user or not UserLogic.has_permissions(user, UserLogic.SUPER_ADMIN): 48 | raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) 49 | 50 | 51 | def render(name: str, request: Request, **kwargs: Any) -> HTMLResponse: 52 | kwargs["js_version"] = request.app.state.js_version 53 | kwargs["css_version"] = request.app.state.css_version 54 | kwargs["enumerate"] = enumerate 55 | kwargs["google_auth_client_id"] = request.app.state.google_app["client_id"] 56 | return templates.TemplateResponse(request=request, name=name, context=kwargs) 57 | -------------------------------------------------------------------------------- /mock/mock_db.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sqlite3 4 | from typing import TYPE_CHECKING 5 | 6 | from sqlalchemy import Engine 7 | from sqlalchemy import event 8 | from sqlmodel import Session 9 | from sqlmodel import SQLModel 10 | from sqlmodel import StaticPool 11 | from sqlmodel import create_engine 12 | 13 | if TYPE_CHECKING: 14 | from typing import Any 15 | from typing import TypeVar 16 | 17 | T = TypeVar("T", bound=SQLModel) 18 | 19 | 20 | def collation(string1: str, string2: str) -> int: 21 | if string1 == string2: 22 | return 0 23 | elif string1 > string2: 24 | return 1 25 | else: 26 | return -1 27 | 28 | 29 | @event.listens_for(Engine, "connect") 30 | def set_sqlite_pragma( 31 | dbapi_connection: sqlite3.Connection, dummy_connection_record: Any 32 | ) -> None: 33 | dbapi_connection.create_collation("Hebrew_100_CI_AI_SC_UTF8", collation) 34 | dbapi_connection.create_collation("Hebrew_CI_AI", collation) 35 | cursor = dbapi_connection.cursor() 36 | cursor.execute("PRAGMA foreign_keys=ON") 37 | cursor.close() 38 | 39 | 40 | class MockDb: 41 | def __init__(self) -> None: 42 | self.db_uri = "sqlite:///:memory:?cache=shared" 43 | self.engine = create_engine( 44 | self.db_uri, 45 | connect_args={"check_same_thread": False}, 46 | poolclass=StaticPool, 47 | ) 48 | self.session = Session( 49 | bind=self.engine, 50 | expire_on_commit=False, 51 | autoflush=True, 52 | ) 53 | SQLModel.metadata.create_all(self.engine) 54 | 55 | def add(self, entity: T) -> T: 56 | self.session.begin() 57 | self.session.add(entity) 58 | self.session.commit() 59 | return entity 60 | 61 | def add_many(self, entities: list[T]) -> None: 62 | self.session.begin() 63 | for entity in entities: 64 | self.session.add(entity) 65 | self.session.commit() 66 | for entity in entities: 67 | self.session.refresh(entity) 68 | -------------------------------------------------------------------------------- /tests/test_secret_logic.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | 4 | import pytest 5 | from sqlmodel import Session 6 | 7 | from common import tables 8 | from common.error import HSError 9 | from logic.game_logic import SecretLogic 10 | from mock.mock_db import MockDb 11 | 12 | 13 | class TestGameLogic(unittest.IsolatedAsyncioTestCase): 14 | async def asyncSetUp(self) -> None: 15 | self.db = MockDb() 16 | self.date = datetime.date(2021, 1, 1) 17 | self.testee = SecretLogic(session=self.db.session, dt=self.date) 18 | 19 | async def test_no_secret(self) -> None: 20 | # act & assert 21 | with pytest.raises(HSError): 22 | await self.testee.get_secret() 23 | 24 | async def test_get_secret(self) -> None: 25 | # arrange 26 | db_secret = tables.SecretWord(word="test", game_date=self.date) 27 | self.db.add(db_secret) 28 | 29 | # act 30 | secret = await self.testee.get_secret() 31 | 32 | # assert 33 | self.assertEqual(db_secret.word, secret) 34 | 35 | async def test_get_secret__cache(self) -> None: 36 | # arrange 37 | cached = self.db.add(tables.SecretWord(word="cached", game_date=self.date)) 38 | await self.testee.get_secret() 39 | with Session(self.db.engine) as session: 40 | db_secret = session.get(tables.SecretWord, cached.id) 41 | assert db_secret is not None 42 | db_secret.word = "not_cached" 43 | session.add(db_secret) 44 | session.commit() 45 | 46 | # act 47 | secret = await self.testee.get_secret() 48 | 49 | # assert 50 | self.assertEqual("cached", secret) 51 | 52 | async def test_get_secret__dont_cache_if_no_secret(self) -> None: 53 | # arrange 54 | try: 55 | await self.testee.get_secret() 56 | except HSError: 57 | pass 58 | self.db.add(tables.SecretWord(word="not_cached", game_date=self.date)) 59 | 60 | # act 61 | secret = await self.testee.get_secret() 62 | 63 | # assert 64 | self.assertEqual("not_cached", secret) 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hebrew Semantle 2 | A Hebrew version of [Semantle](https://semantle.com/). 3 | 4 | [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) 5 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 6 | [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/) 7 | [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) 8 | 9 | 10 | ## Installation 11 | 12 | Extract word2vec model in repository. 13 | you can download one by following the instructions [here](https://github.com/Iddoyadlin/hebrew-w2v). 14 | 15 | ### Console 16 | 17 | ```commandline 18 | pip install poetry 19 | poetry install 20 | ``` 21 | 22 | Install some sql server. 23 | 24 | ### Docker Compose 25 | install Docker Compose from [here](https://docs.docker.com/compose/install/) 26 | 27 | build the game with: 28 | ```commandline 29 | docker build compose 30 | ``` 31 | 32 | ## Configuring databases 33 | populate mongodb with vectors from word2vec model by running `populate.py` (make sure mongo db is running). 34 | select secret word by running `set_secret.py` (make sure redis and mongo are running). 35 | 36 | ## Running the game 37 | 38 | configurations should be set by creating a config.yaml file with the relevant settings (see config.format.yaml). 39 | when running with docker compose, every change to configuration requires rebuilding. 40 | 41 | ### Console 42 | 43 | You can run the game with: 44 | ```commandline 45 | python app.py 46 | ``` 47 | 48 | you should run and configure mongo and redis server (see "Configuring Databases" section). 49 | Word2Vec model was trained as described [here](https://github.com/Iddoyadlin/hebrew-w2v) 50 | 51 | ### Docker Compose 52 | 53 | run the game with: 54 | ```commandline 55 | docker build up 56 | ``` 57 | 58 | ## Scripts 59 | 60 | There are some useful scripts in the `scripts/` folder: 61 | 62 | - `populate.py`: Given a Word2Vec model, will populate mongo collection used by the game. 63 | - `set_secret.py`: Well... 64 | - `semantle.py`: A CLI version of the game. 65 | 66 | ## Tests 67 | 68 | Only for some of the logic right now. Sorry. 69 | 70 | -------------------------------------------------------------------------------- /templates/template.html.template: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 11 | 12 | 13 | 14 | 15 | {{title}} 16 | 17 | 18 | 19 | 20 | 21 | 22 | 29 | 30 | 31 | 32 | 33 | 35 | 36 |
37 |

{{header}}

38 |
39 |
40 | חזרה 41 | 42 | {{ content }} 43 | 44 | 45 | 46 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /routers/auth_routes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import urllib.parse 3 | from typing import Annotated 4 | 5 | from fastapi import APIRouter 6 | from fastapi import Form 7 | from fastapi import HTTPException 8 | from fastapi import Request 9 | from fastapi import status 10 | from fastapi.responses import RedirectResponse 11 | from starlette.responses import HTMLResponse 12 | 13 | from logic.auth_logic import AuthLogic 14 | 15 | auth_router = APIRouter() 16 | 17 | 18 | @auth_router.post("/login") 19 | async def login( 20 | request: Request, 21 | credential: Annotated[str, Form()], 22 | state: Annotated[str, Form()] = "", 23 | ) -> HTMLResponse: 24 | try: 25 | parsed_state = urllib.parse.parse_qs(state) 26 | auth_logic = AuthLogic( 27 | request.app.state.session, 28 | request.app.state.google_app["client_id"], 29 | ) 30 | encoded_jwt = await auth_logic.jwt_from_credential(credential) 31 | if state is None or "next" not in parsed_state: 32 | next_uri = "/" 33 | else: 34 | next_uri = parsed_state["next"][0] 35 | 36 | response = HTMLResponse( 37 | content=f""" 38 | 39 | 40 | 43 | 44 | 45 | Login successful, redirecting... 46 | 47 | 48 | """ 49 | ) 50 | 51 | response.set_cookie( 52 | key="access_token", 53 | value=encoded_jwt, 54 | secure=True, 55 | httponly=True, 56 | samesite="strict", 57 | ) 58 | return response 59 | except ValueError: 60 | raise HTTPException( 61 | status_code=status.HTTP_403_FORBIDDEN, 62 | detail="Could not validate credentials", 63 | ) 64 | 65 | 66 | @auth_router.get("/logout") 67 | async def logout( 68 | request: Request, 69 | ) -> RedirectResponse: 70 | redirect = urllib.parse.urlparse(request.headers.get("referer")) 71 | if isinstance(redirect.path, bytes): 72 | path = redirect.path.decode() 73 | else: 74 | path = redirect.path 75 | response = RedirectResponse(path, status_code=status.HTTP_302_FOUND) 76 | response.delete_cookie(key="access_token") 77 | return response 78 | -------------------------------------------------------------------------------- /alembic/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from sqlalchemy import engine_from_config 4 | from sqlalchemy import pool 5 | 6 | from alembic import context 7 | 8 | # this is the Alembic Config object, which provides 9 | # access to the values within the .ini file in use. 10 | config = context.config 11 | from common import config as sh_config 12 | 13 | db_url = sh_config.db_url 14 | 15 | 16 | # Interpret the config file for Python logging. 17 | # This line sets up loggers basically. 18 | if config.config_file_name is not None: 19 | fileConfig(config.config_file_name) 20 | 21 | # add your model's MetaData object here 22 | # for 'autogenerate' support 23 | from common.tables import SQLModel # type: ignore 24 | 25 | target_metadata = SQLModel.metadata 26 | 27 | # other values from the config, defined by the needs of env.py, 28 | # can be acquired: 29 | # my_important_option = config.get_main_option("my_important_option") 30 | # ... etc. 31 | 32 | 33 | def run_migrations_offline() -> None: 34 | """Run migrations in 'offline' mode. 35 | 36 | This configures the context with just a URL 37 | and not an Engine, though an Engine is acceptable 38 | here as well. By skipping the Engine creation 39 | we don't even need a DBAPI to be available. 40 | 41 | Calls to context.execute() here emit the given string to the 42 | script output. 43 | 44 | """ 45 | url = config.get_main_option("sqlalchemy.url") 46 | context.configure( 47 | url=url, 48 | target_metadata=target_metadata, 49 | literal_binds=True, 50 | dialect_opts={"paramstyle": "named"}, 51 | ) 52 | 53 | with context.begin_transaction(): 54 | context.run_migrations() 55 | 56 | 57 | def run_migrations_online() -> None: 58 | """Run migrations in 'online' mode. 59 | 60 | In this scenario we need to create an Engine 61 | and associate a connection with the context. 62 | 63 | """ 64 | connectable = engine_from_config( 65 | config.get_section(config.config_ini_section, {}), 66 | prefix="sqlalchemy.", 67 | poolclass=pool.NullPool, 68 | url=db_url, 69 | ) 70 | 71 | with connectable.connect() as connection: 72 | context.configure(connection=connection, target_metadata=target_metadata) 73 | 74 | with context.begin_transaction(): 75 | context.run_migrations() 76 | 77 | 78 | if context.is_offline_mode(): 79 | run_migrations_offline() 80 | else: 81 | run_migrations_online() 82 | -------------------------------------------------------------------------------- /templates/privacy.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | תנאי פרטיות 19 | 20 | 21 | 22 | 29 | {% if not request.state.has_active_subscription %} 30 | 31 | {% endif %} 32 | 33 | 34 | 35 | 36 | 38 | 39 |
40 |

תנאי פרטיות

41 |
42 |
43 | חזרה 44 |
45 |

Privacy Policy

46 | {% for heading, section in privacy_sections.items() %} 47 |

{{ heading }}

48 |

{{ section }}

49 | {% endfor %} 50 |
51 | חזרה 52 | 53 | 57 | -------------------------------------------------------------------------------- /templates/videos.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | סמנטעל: כולנו סמנטעל 18 | 19 | 20 | 21 | 28 | {% if not request.state.has_active_subscription %} 29 | 30 | {% endif %} 31 | 32 | 33 | 34 | 36 | 37 |
38 |

כולנו סמנטעל

39 |
40 |
41 | חזרה 42 |

43 |
44 | {% for video in videos %} 45 |
46 | 47 |
48 |
49 | {% endfor %} 50 |
51 |
52 | חזרה 53 | 57 | 58 | -------------------------------------------------------------------------------- /alembic/versions/c4ee2484db71_user_user_history_tables.py: -------------------------------------------------------------------------------- 1 | """user + user history tables 2 | 3 | Revision ID: c4ee2484db71 4 | Revises: 5 | Create Date: 2024-01-09 17:19:56.323607 6 | 7 | """ 8 | 9 | from typing import Sequence 10 | from typing import Union 11 | 12 | import sqlalchemy as sa 13 | import sqlmodel 14 | 15 | from alembic import op 16 | 17 | # revision identifiers, used by Alembic. 18 | revision: str = "c4ee2484db71" 19 | down_revision: Union[str, None] = None 20 | branch_labels: Union[str, Sequence[str], None] = None 21 | depends_on: Union[str, Sequence[str], None] = None 22 | 23 | 24 | def upgrade() -> None: 25 | # ### commands auto generated by Alembic - please adjust! ### 26 | op.create_table( 27 | "user", 28 | sa.Column("id", sa.Integer(), nullable=False), 29 | sa.Column("email", sa.String(128), nullable=False), 30 | sa.Column("user_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False), 31 | sa.Column("active", sa.Boolean(), nullable=False), 32 | sa.Column("picture", sqlmodel.sql.sqltypes.AutoString(), nullable=False), 33 | sa.Column("given_name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), 34 | sa.Column("family_name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), 35 | sa.Column("first_login", sa.DateTime(), nullable=False), 36 | sa.Column("subscription_expiry", sa.DateTime(), nullable=True), 37 | sa.PrimaryKeyConstraint("id"), 38 | sa.UniqueConstraint("email"), 39 | ) 40 | op.create_table( 41 | "userhistory", 42 | sa.Column("id", sa.Integer(), nullable=False), 43 | sa.Column("user_id", sa.Integer(), nullable=False), 44 | sa.Column( 45 | "guess", 46 | sa.String( 47 | 32, 48 | # collation="Hebrew_100_CI_AI_SC_UTF8", 49 | ), 50 | nullable=False, 51 | ), 52 | sa.Column("similarity", sa.Float(), nullable=True), 53 | sa.Column("distance", sa.Integer(), nullable=False), 54 | sa.Column("egg", sqlmodel.sql.sqltypes.AutoString(), nullable=True), 55 | sa.Column("game_date", sa.Date(), nullable=False), 56 | sa.Column("solver_count", sa.Integer(), nullable=True), 57 | sa.ForeignKeyConstraint( 58 | ["user_id"], 59 | ["user.id"], 60 | ), 61 | sa.PrimaryKeyConstraint("id"), 62 | sa.UniqueConstraint("user_id", "game_date", "guess"), 63 | ) 64 | # ### end Alembic commands ### 65 | 66 | 67 | def downgrade() -> None: 68 | # ### commands auto generated by Alembic - please adjust! ### 69 | op.drop_table("userhistory") 70 | op.drop_table("user") 71 | # ### end Alembic commands ### 72 | -------------------------------------------------------------------------------- /templates/closest1000.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | סמנטעל: 1000 המילים הכי קרובות 18 | 19 | 20 | 21 | 28 | {% if not request.state.has_active_subscription %} 29 | 30 | {% endif %} 31 | 32 | 33 | 34 | 36 | 37 |
38 |

{{yesterday[0][0]}}

39 |
40 |
41 |

1000 המילים הכי קרובות

42 | חזרה 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | {% for i, ws in enumerate(yesterday[1:], start=1) %} 52 | 53 | 56 | 59 | 62 | 63 | {% endfor %} 64 | 65 |
מספר (גבוה = קרוב)מילהציון קרבה
54 | {{ 1000 - i }} 55 | 57 | {{ ws[0] }} 58 | 60 | {{ ws[1] }} 61 |
66 | חזרה 67 | 71 | 72 | -------------------------------------------------------------------------------- /templates/all_secrets.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | סודות 19 | 20 | 21 | 22 | 29 | {% if not request.state.has_active_subscription %} 30 | 31 | {% endif %} 32 | 33 | 34 | 35 | 36 | 38 | 39 |
40 |

סודות של פעם

41 |
42 |
43 | חזרה 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | {% for secret, dt in secrets %} 52 | 53 | 56 | 59 | 62 | 63 | {% endfor %} 64 | 65 |
#סודתאריך
54 | {{ loop.revindex }} 55 | 57 | {{ secret }} 58 | 60 | {{ dt }} 61 |
66 | חזרה 67 | 68 | 72 | -------------------------------------------------------------------------------- /routers/game_routes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import annotations 3 | 4 | from fastapi import APIRouter 5 | from fastapi import HTTPException 6 | from fastapi import Query 7 | from fastapi import Request 8 | from fastapi import status 9 | 10 | from common import schemas 11 | from common.error import HSError 12 | from logic.game_logic import EasterEggLogic 13 | from logic.user_logic import UserClueLogic 14 | from logic.user_logic import UserHistoryLogic 15 | from routers.base import get_date 16 | from routers.base import get_logics 17 | 18 | game_router = APIRouter() 19 | 20 | 21 | @game_router.get("/api/distance") 22 | async def distance( 23 | request: Request, 24 | word: str = Query(default=..., min_length=2, max_length=24, regex=r"^[א-ת ']+$"), 25 | ) -> list[schemas.DistanceResponse]: 26 | word = word.replace("'", "") 27 | if egg := EasterEggLogic.get_easter_egg(word): 28 | response = schemas.DistanceResponse( 29 | guess=word, similarity=99.99, distance=-1, egg=egg 30 | ) 31 | else: 32 | logic, cache_logic = await get_logics(app=request.app) 33 | sim = await logic.get_similarity(word) 34 | cache_score = await cache_logic.get_cache_score(word) 35 | if cache_score == 1000: 36 | solver_count = await logic.get_and_update_solver_count() 37 | else: 38 | solver_count = None 39 | response = schemas.DistanceResponse( 40 | guess=word, 41 | similarity=sim, 42 | distance=cache_score, 43 | solver_count=solver_count, 44 | ) 45 | if request.state.user: 46 | history_logic = UserHistoryLogic( 47 | request.app.state.session, 48 | request.state.user, 49 | get_date(request.app.state.days_delta), 50 | ) 51 | return await history_logic.update_and_get_history(response) 52 | else: 53 | return [response] 54 | 55 | 56 | @game_router.get("/api/clue") 57 | async def get_clue(request: Request) -> dict[str, str]: 58 | if not request.state.user: 59 | raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) 60 | else: 61 | logic, _ = await get_logics(app=request.app) 62 | try: 63 | secret = await logic.secret_logic.get_secret() 64 | except HSError: 65 | raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE) 66 | user_logic = UserClueLogic( 67 | session=request.app.state.session, 68 | user=request.state.user, 69 | secret=secret, 70 | date=get_date(request.app.state.days_delta), 71 | ) 72 | try: 73 | clue = await user_logic.get_clue() 74 | except ValueError: 75 | raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED) 76 | if clue is None: 77 | raise HTTPException(status_code=status.HTTP_204_NO_CONTENT) 78 | else: 79 | return {"clue": clue} 80 | -------------------------------------------------------------------------------- /scripts/fix_missing_cache.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | import tqdm 7 | 8 | base = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) 9 | sys.path.extend([base]) 10 | 11 | import argparse 12 | import datetime 13 | 14 | from sqlmodel import select 15 | 16 | from common import tables 17 | from common.session import get_model 18 | from common.session import get_session 19 | from common.session import hs_transaction 20 | from logic.game_logic import CacheSecretLogic 21 | from logic.game_logic import VectorLogic 22 | 23 | 24 | async def main() -> None: 25 | parser = argparse.ArgumentParser() 26 | parser.add_argument( 27 | "-d", 28 | "--date", 29 | metavar="DATE", 30 | type=lambda s: datetime.datetime.strptime(s, "%Y-%m-%d").date(), 31 | required=True, 32 | ) 33 | 34 | args = parser.parse_args() 35 | date = args.date 36 | 37 | session = get_session() 38 | model = get_model() 39 | 40 | vector_logic = VectorLogic(session=session, model=model, dt=date) 41 | secret = await vector_logic.secret_logic.get_secret() 42 | 43 | cache_logic = CacheSecretLogic( 44 | session=session, dt=date, model=get_model(), secret=secret 45 | ) 46 | 47 | cache = await cache_logic.get_cache() 48 | print(f"found cache of {len(cache)} for {date} with secret {secret}") 49 | 50 | with hs_transaction(session) as session: 51 | query = select(tables.UserHistory) 52 | query = query.where(tables.UserHistory.game_date == date) 53 | query = query.where(tables.UserHistory.guess.in_(cache)) # type: ignore[attr-defined] 54 | query = query.where(tables.UserHistory.distance == -1) 55 | histories = session.exec(query).all() 56 | hist_to_guess = {hist.id: hist.guess for hist in histories} 57 | 58 | print(f"Found {len(hist_to_guess)} histories to update") 59 | print(hist_to_guess) 60 | 61 | hist_id_to_cache_and_solver_count = {} 62 | 63 | for hist_id, guess in tqdm.tqdm(hist_to_guess.items()): 64 | distance = cache.index(guess) + 1 65 | solver_count = await vector_logic.get_and_update_solver_count() 66 | hist_id_to_cache_and_solver_count[hist_id] = (distance, solver_count) 67 | 68 | print(hist_id_to_cache_and_solver_count) 69 | 70 | with hs_transaction(session) as session: 71 | query = select(tables.UserHistory) 72 | query = query.where( 73 | tables.UserHistory.id.in_(hist_id_to_cache_and_solver_count.keys()) # type: ignore[attr-defined] 74 | ) 75 | histories = session.exec(query).all() 76 | 77 | for history in tqdm.tqdm(histories): 78 | distance, solver_count = hist_id_to_cache_and_solver_count[history.id] 79 | history.distance = distance 80 | history.solver_count = solver_count 81 | session.add(history) 82 | 83 | 84 | if __name__ == "__main__": 85 | import asyncio 86 | 87 | asyncio.run(main()) 88 | -------------------------------------------------------------------------------- /templates/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 11 | 12 | 13 | 14 | 15 | אופס! משהו השתבש 16 | 17 | 18 | 19 | 20 | 21 | 22 | 29 | {% if not request.state.has_active_subscription %} 30 | 31 | {% endif %} 32 | 33 | 34 | 35 | 37 | 38 |
39 |

סמנטעל

40 |
41 |
42 | 43 |
44 |
😕
45 | 46 |

{{ error_heading | default('אופס! משהו השתבש') }}

47 | 48 |
49 |

50 | {{ error_message | default('אירעה שגיאה לא צפויה. אנא נסה שוב מאוחר יותר.') }} 51 |

52 |
53 |
54 | 55 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /common/tables.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from sqlalchemy import Index 4 | from sqlalchemy import String 5 | from sqlalchemy import UniqueConstraint 6 | from sqlmodel import Field 7 | from sqlmodel import Relationship 8 | from sqlmodel import SQLModel 9 | 10 | 11 | class User(SQLModel, table=True): 12 | id: int = Field(default=None, primary_key=True) 13 | email: str = Field(String(128), unique=True) 14 | user_type: str = "User" # TODO: enum 15 | active: bool = True 16 | picture: str 17 | given_name: str 18 | family_name: str 19 | first_login: datetime.datetime = Field( 20 | default_factory=lambda: datetime.datetime.now(datetime.UTC) 21 | ) 22 | # subscription_expiry: datetime.datetime | None = None 23 | subscriptions: "UserSubscription" = Relationship() 24 | 25 | 26 | class UserHistory(SQLModel, table=True): 27 | __table_args__ = ( 28 | UniqueConstraint("user_id", "game_date", "guess"), 29 | Index( 30 | "nci_user_id__game_date", 31 | "user_id", 32 | "game_date", 33 | mssql_include=["distance", "egg", "guess", "similarity", "solver_count"], 34 | ), 35 | ) 36 | 37 | id: int = Field(default=None, primary_key=True) 38 | user_id: int = Field(foreign_key="user.id", index=True) 39 | guess: str = Field(String(32)) 40 | similarity: float 41 | distance: int 42 | egg: str | None = None 43 | game_date: datetime.date 44 | solver_count: int | None = None 45 | 46 | 47 | class UserClueCount(SQLModel, table=True): 48 | __table_args__ = (UniqueConstraint("user_id", "game_date"),) 49 | 50 | id: int = Field(default=None, primary_key=True) 51 | user_id: int = Field(foreign_key="user.id") 52 | clue_count: int 53 | game_date: datetime.date 54 | 55 | 56 | class SecretWord(SQLModel, table=True): 57 | __table_args__ = (UniqueConstraint("game_date"), UniqueConstraint("word")) 58 | 59 | id: int = Field(default=None, primary_key=True) 60 | word: str = Field(String(32), unique=True) 61 | game_date: datetime.date 62 | solver_count: int = 0 63 | 64 | closest1000: list["Closest1000"] = Relationship() 65 | 66 | 67 | class UserSubscription(SQLModel, table=True): 68 | id: int = Field(default=None, primary_key=True) 69 | user_id: int = Field(foreign_key="user.id") 70 | amount: float 71 | tier_name: str | None 72 | uuid: str = Field(String(36), unique=True) 73 | timestamp: datetime.datetime 74 | 75 | 76 | class HotClue(SQLModel, table=True): 77 | __table_args__ = (UniqueConstraint("secret_word_id", "clue"),) 78 | 79 | id: int = Field(default=None, primary_key=True) 80 | secret_word_id: int = Field(foreign_key="secretword.id", index=True) 81 | clue: str = Field(String(32)) 82 | 83 | secret: SecretWord = Relationship() 84 | 85 | 86 | class Closest1000(SQLModel, table=True): 87 | __tableargs__ = (UniqueConstraint("secret_word_id", "out_of_1000"),) 88 | 89 | id: int = Field(default=None, primary_key=True) 90 | 91 | word: str = Field(String(32), index=True) 92 | secret_word_id: int = Field(foreign_key="secretword.id", index=True) 93 | out_of_1000: int = Field(ge=1, le=1000) 94 | -------------------------------------------------------------------------------- /routers/admin_routes.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import random 3 | 4 | from fastapi import APIRouter 5 | from fastapi import Depends 6 | from fastapi import HTTPException 7 | from fastapi.requests import Request 8 | from fastapi.responses import HTMLResponse 9 | from pydantic import BaseModel 10 | from sqlmodel import Session 11 | from sqlmodel import select 12 | 13 | from common import tables 14 | from common.consts import FIRST_DATE 15 | from common.session import hs_transaction 16 | from logic.game_logic import CacheSecretLogic 17 | from logic.game_logic import SecretLogic 18 | from model import GensimModel 19 | from routers.base import render 20 | from routers.base import super_admin 21 | 22 | TOP_SAMPLE = 10000 23 | 24 | admin_router = APIRouter(prefix="/admin", dependencies=[Depends(super_admin)]) 25 | 26 | 27 | @admin_router.get("/set-secret", response_class=HTMLResponse, include_in_schema=False) 28 | async def index(request: Request) -> HTMLResponse: 29 | model = request.app.state.model 30 | secret_logic = SecretLogic(request.app.state.session) 31 | all_secrets = [ 32 | secret[0] for secret in await secret_logic.get_all_secrets(with_future=True) 33 | ] 34 | potential_secrets: list[str] = [] 35 | while len(potential_secrets) < 45: 36 | secret = await get_random_word(model) # todo: in batches 37 | if secret not in all_secrets: 38 | potential_secrets.append(secret) 39 | 40 | return render( 41 | name="set_secret.html", request=request, potential_secrets=potential_secrets 42 | ) 43 | 44 | 45 | @admin_router.get("/model", include_in_schema=False) 46 | async def get_word_data( 47 | request: Request, word: str 48 | ) -> dict[str, list[str] | datetime.date | int]: 49 | session = request.app.state.session 50 | model = request.app.state.model 51 | logic = CacheSecretLogic( 52 | session=session, 53 | secret=word, 54 | dt=await get_date(session), 55 | model=model, 56 | ) 57 | try: 58 | await logic.simulate_set_secret(force=False) 59 | except ValueError as ex: 60 | raise HTTPException(status_code=400, detail=str(ex)) 61 | cache = await logic.get_cache() 62 | return { 63 | "date": logic.date_, 64 | "game_number": (logic.date_ - FIRST_DATE).days + 1, 65 | "data": cache[::-1], 66 | } 67 | 68 | 69 | class SetSecretRequest(BaseModel): 70 | secret: str 71 | clues: list[str] 72 | 73 | 74 | @admin_router.post("/set-secret", include_in_schema=False) 75 | async def set_new_secret(request: Request, set_secret: SetSecretRequest) -> str: 76 | session = request.app.state.session 77 | model = request.app.state.model 78 | logic = CacheSecretLogic( 79 | session=session, 80 | secret=set_secret.secret, 81 | dt=await get_date(session), 82 | model=model, 83 | ) 84 | await logic.simulate_set_secret(force=False) 85 | await logic.do_populate(set_secret.clues) 86 | return f"Set '{set_secret.secret}' with clues '{set_secret.clues}' on {logic.date_}" 87 | 88 | 89 | # TODO: everything below here should be in a separate file, and set_secret script should be updated to use it 90 | async def get_random_word(model: GensimModel) -> str: 91 | rand_index = random.randint(0, TOP_SAMPLE) 92 | word: str = model.model.index_to_key[rand_index] 93 | return word 94 | 95 | 96 | async def get_date(session: Session) -> datetime.date: 97 | query = select(tables.SecretWord.game_date) # type: ignore 98 | query = query.order_by(tables.SecretWord.game_date.desc()) # type: ignore 99 | with hs_transaction(session) as s: 100 | latest: datetime.date = s.exec(query).first() 101 | 102 | dt = latest + datetime.timedelta(days=1) 103 | return dt 104 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file 10 | # for all available tokens 11 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 12 | 13 | # sys.path path, will be prepended to sys.path if present. 14 | # defaults to the current working directory. 15 | prepend_sys_path = . 16 | 17 | # timezone to use when rendering the date within the migration file 18 | # as well as the filename. 19 | # If specified, requires the python>=3.9 or backports.zoneinfo library. 20 | # Any required deps can installed by adding `alembic[tz]` to the pip requirements 21 | # string value is passed to ZoneInfo() 22 | # leave blank for localtime 23 | # timezone = 24 | 25 | # max length of characters to apply to the 26 | # "slug" field 27 | # truncate_slug_length = 40 28 | 29 | # set to 'true' to run the environment during 30 | # the 'revision' command, regardless of autogenerate 31 | # revision_environment = false 32 | 33 | # set to 'true' to allow .pyc and .pyo files without 34 | # a source .py file to be detected as revisions in the 35 | # versions/ directory 36 | # sourceless = false 37 | 38 | # version location specification; This defaults 39 | # to alembic/versions. When using multiple version 40 | # directories, initial revisions must be specified with --version-path. 41 | # The path separator used here should be the separator specified by "version_path_separator" below. 42 | # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions 43 | 44 | # version path separator; As mentioned above, this is the character used to split 45 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 46 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 47 | # Valid values for version_path_separator are: 48 | # 49 | # version_path_separator = : 50 | # version_path_separator = ; 51 | # version_path_separator = space 52 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 53 | 54 | # set to 'true' to search source files recursively 55 | # in each "version_locations" directory 56 | # new in Alembic version 1.10 57 | # recursive_version_locations = false 58 | 59 | # the output encoding used when revision files 60 | # are written from script.py.mako 61 | # output_encoding = utf-8 62 | 63 | sqlalchemy.url = driver://user:pass@localhost/dbname 64 | 65 | 66 | [post_write_hooks] 67 | # post_write_hooks defines scripts or Python functions that are run 68 | # on newly generated revision scripts. See the documentation for further 69 | # detail and examples 70 | 71 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 72 | # hooks = black 73 | # black.type = console_scripts 74 | # black.entrypoint = black 75 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 76 | 77 | # lint with attempts to fix using "ruff" - use the exec runner, execute a binary 78 | # hooks = ruff 79 | # ruff.type = exec 80 | # ruff.executable = %(here)s/.venv/bin/ruff 81 | # ruff.options = --fix REVISION_SCRIPT_FILENAME 82 | 83 | # Logging configuration 84 | [loggers] 85 | keys = root,sqlalchemy,alembic 86 | 87 | [handlers] 88 | keys = console 89 | 90 | [formatters] 91 | keys = generic 92 | 93 | [logger_root] 94 | level = WARN 95 | handlers = console 96 | qualname = 97 | 98 | [logger_sqlalchemy] 99 | level = WARN 100 | handlers = 101 | qualname = sqlalchemy.engine 102 | 103 | [logger_alembic] 104 | level = INFO 105 | handlers = 106 | qualname = alembic 107 | 108 | [handler_console] 109 | class = StreamHandler 110 | args = (sys.stderr,) 111 | level = NOTSET 112 | formatter = generic 113 | 114 | [formatter_generic] 115 | format = %(levelname)-5.5s [%(name)s] %(message)s 116 | datefmt = %H:%M:%S 117 | -------------------------------------------------------------------------------- /templates/statistics.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 11 | 12 | 13 | 14 | 15 | סטטיסטיקות ונתונים 16 | 17 | 18 | 19 | 20 | 21 | 22 | 29 | {% if not request.state.has_active_subscription %} 30 | 31 | {% endif %} 32 | 33 | 34 | 35 | 37 | 38 |
39 |

סטטיסטיקות ונתונים

40 |
41 |
42 | חזרה 43 | {% if statistics %} 44 | 45 | 46 | 50 | 51 | 52 | 53 | 57 | 64 | 65 | 66 | 70 | 71 | 72 | 73 | 77 | 78 | 79 | 80 | 84 | 85 | 86 |
47 | animation 48 | רצף משחקים 49 | {{ statistics.game_streak }}
54 | social_leaderboard 55 | מקום הכי גבוה 56 | 58 | {% if statistics.highest_rank %} 59 | {{ statistics.highest_rank}} 60 | {% else %} 61 | – 62 | {% endif %} 63 |
67 | stadia_controller 68 | סך כל המשחקים ששיחקת 69 | {{ statistics.total_games_played }}
74 | trophy 75 | נצחונות 76 | {{ statistics.total_games_won }}
81 | monitoring 82 | ממוצע ניחושים לפתרון 83 | {{ statistics.average_guesses }}
87 | {% else %} 88 |

שלום! בשביל להציג נתונים לגבי המשחקים שלך בסמנטעל, צריך להתחבר:

89 |
90 | 91 |
92 | 99 | 107 |
108 |
109 | {% endif %} 110 | 111 | 112 | 116 | 117 | 118 | -------------------------------------------------------------------------------- /scripts/set_secret.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import annotations 3 | 4 | import asyncio 5 | import datetime 6 | import os 7 | import random 8 | import sys 9 | from argparse import ArgumentParser 10 | from argparse import ArgumentTypeError 11 | from typing import TYPE_CHECKING 12 | 13 | from sqlmodel import select 14 | 15 | base = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) 16 | sys.path.extend([base]) 17 | 18 | from common import tables # noqa: E402 19 | from common.session import get_model # noqa: E402 20 | from common.session import get_session # noqa: E402 21 | from common.session import hs_transaction # noqa: E402 22 | from logic.game_logic import CacheSecretLogic # noqa: E402 23 | 24 | if TYPE_CHECKING: 25 | from sqlmodel import Session 26 | 27 | from model import GensimModel 28 | 29 | 30 | def valid_date(date_str: str) -> datetime.date: 31 | try: 32 | return datetime.datetime.strptime(date_str, "%Y-%m-%d").date() 33 | except ValueError: 34 | raise ArgumentTypeError("Bad date: should be of the format YYYY-mm-dd") 35 | 36 | 37 | async def main() -> None: 38 | parser = ArgumentParser("Set SematleHe secret for any date") 39 | parser.add_argument( 40 | "-s", 41 | "--secret", 42 | metavar="SECRET", 43 | help="Secret to set. If not provided, chooses a random word from Wikipedia.", 44 | ) 45 | parser.add_argument( 46 | "-d", 47 | "--date", 48 | metavar="DATE", 49 | type=valid_date, 50 | help="Date of secret. If not provided, first date w/o secret is used", 51 | ) 52 | parser.add_argument( 53 | "--force", 54 | action="store_true", 55 | help="Allow rewriting dates or reusing secrets. Use with caution!", 56 | ) 57 | parser.add_argument( 58 | "-i", 59 | "--iterative", 60 | action="store_true", 61 | help="If provided, run in an iterative mode, starting the given date", 62 | ) 63 | parser.add_argument( 64 | "--top-sample", 65 | default=10000, 66 | type=int, 67 | help="Top n words to choose from when choosing a random word. If not provided, will use all words in the model.", 68 | ) 69 | 70 | args = parser.parse_args() 71 | 72 | session = get_session() 73 | model = get_model() 74 | 75 | if args.date: 76 | date = args.date 77 | else: 78 | date = await get_date(session) 79 | if args.secret: 80 | secret = args.secret 81 | else: 82 | secret = await get_random_word(model, args.top_sample) 83 | while True: 84 | if await do_populate(session, secret, date, model, args.force, args.top_sample): 85 | if not args.iterative: 86 | break 87 | date += datetime.timedelta(days=1) 88 | print(f"Now doing {date}") 89 | secret = await get_random_word(model, args.top_sample) 90 | 91 | 92 | async def get_date(session: Session) -> datetime.date: 93 | query = select(tables.SecretWord.game_date) # type: ignore 94 | query = query.order_by(tables.SecretWord.game_date.desc()) # type: ignore 95 | with hs_transaction(session) as s: 96 | latest: datetime.date = s.exec(query).first() 97 | 98 | dt = latest + datetime.timedelta(days=1) 99 | print(f"Now doing {dt}") 100 | return dt 101 | 102 | 103 | async def do_populate( 104 | session: Session, 105 | secret: str, 106 | date: datetime.date, 107 | model: GensimModel, 108 | force: bool, 109 | top_sample: int | None, 110 | ) -> bool: 111 | logic = CacheSecretLogic(session, secret, dt=date, model=model) 112 | try: 113 | await logic.simulate_set_secret(force=force) 114 | except ValueError as err: 115 | print(err) 116 | return False 117 | cache = [w[::-1] for w in (await logic.get_cache())[::-1]] 118 | print(" ,".join(cache)) 119 | print(cache[0]) 120 | for rng in (range(i, i + 10) for i in [1, 50, 100, 300, 550, 750]): 121 | for i in rng: 122 | w = cache[i] 123 | print(f"{i}: {w}") 124 | pop = input("Populate?\n") 125 | if pop in ("y", "Y"): 126 | hot_clues = input("Clues? Space separated\n") 127 | clues = [] 128 | for hot_clue in hot_clues.split(" "): 129 | if input(f"Add clue?\n{hot_clue[::-1]}\n[Yn] > ").lower() == "n": 130 | continue 131 | else: 132 | clues.append(hot_clue) 133 | await logic.do_populate(clues) 134 | print("Done!") 135 | return True 136 | else: 137 | secret = await get_random_word(model, top_sample) # TODO: use model 138 | return await do_populate(session, secret, date, model, force, top_sample) 139 | 140 | 141 | async def get_random_word(model: GensimModel, top_sample: int | None) -> str: 142 | while True: 143 | if top_sample is None: 144 | top_sample = len(model.model.index_to_key) 145 | rand_index = random.randint(0, top_sample) 146 | if best_secret := get_best_secret(model.model.index_to_key[rand_index]): 147 | return best_secret 148 | 149 | 150 | def get_best_secret(secret: str) -> str: 151 | inp = input(f"I chose {secret[::-1]}. Ok? [Ny] > ") 152 | if inp in "nN": 153 | return "" 154 | if inp in ("y", "Y"): 155 | return secret 156 | else: 157 | return get_best_secret(inp) 158 | 159 | 160 | if __name__ == "__main__": 161 | asyncio.run(main()) 162 | -------------------------------------------------------------------------------- /routers/pages_routes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import annotations 3 | 4 | import json 5 | import random 6 | import urllib.parse 7 | from datetime import timedelta 8 | 9 | from fastapi import APIRouter 10 | from fastapi import HTTPException 11 | from fastapi import Request 12 | from fastapi import Response 13 | from fastapi import status 14 | from fastapi.responses import HTMLResponse 15 | 16 | from common.consts import FIRST_DATE 17 | from common.error import HSError 18 | from logic.game_logic import VectorLogic 19 | from logic.user_logic import UserClueLogic 20 | from logic.user_logic import UserHistoryLogic 21 | from logic.user_logic import UserStatisticsLogic 22 | from routers.base import get_date 23 | from routers.base import get_logics 24 | from routers.base import render 25 | from routers.base import super_admin 26 | 27 | pages_router = APIRouter() 28 | 29 | 30 | @pages_router.get("/", response_class=HTMLResponse, include_in_schema=False) 31 | async def index(request: Request) -> Response: 32 | try: 33 | logic, cache_logic = await get_logics(app=request.app) 34 | except HSError: 35 | return render( 36 | name="error.html", 37 | request=request, 38 | error_heading="אוי לא!", 39 | error_message="אופס, נראה ששכחתי לבחור מילה יומית. נסו שנית מאוחר יותר", 40 | ) 41 | cache = await cache_logic.get_cache() 42 | closest1 = await logic.get_similarity(cache[-2]) 43 | closest10 = await logic.get_similarity(cache[-12]) 44 | closest1000 = await logic.get_similarity(cache[0]) 45 | 46 | date = get_date(delta=request.app.state.days_delta) 47 | number = (date - FIRST_DATE).days + 1 48 | 49 | yestersecret = await VectorLogic( 50 | session=request.app.state.session, 51 | model=request.app.state.model, 52 | dt=date - timedelta(days=1), 53 | ).secret_logic.get_secret() # TODO: raise a user friendly exception 54 | 55 | if request.state.user: 56 | history_logic = UserHistoryLogic( 57 | request.app.state.session, 58 | request.state.user, 59 | get_date(request.app.state.days_delta), 60 | ) 61 | history = json.dumps( 62 | [historia.model_dump() for historia in await history_logic.get_history()] 63 | ) 64 | else: 65 | history = "" 66 | 67 | quotes = request.app.state.quotes 68 | quote = random.choices( 69 | quotes, weights=[0.5] + [0.5 / (len(quotes) - 1)] * (len(quotes) - 1) 70 | )[0] 71 | 72 | if request.state.user: 73 | try: 74 | secret = await logic.secret_logic.get_secret() 75 | except HSError: 76 | raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE) 77 | clue_logic = UserClueLogic( 78 | session=request.app.state.session, 79 | user=request.state.user, 80 | secret=secret, 81 | date=date, 82 | ) 83 | used_clues = await clue_logic.get_all_clues_used() 84 | else: 85 | used_clues = [] 86 | 87 | return render( 88 | name="index.html", 89 | request=request, 90 | number=number, 91 | closest1=closest1, 92 | closest10=closest10, 93 | closest1000=closest1000, 94 | yesterdays_secret=yestersecret, 95 | quote=quote, 96 | guesses=history, 97 | notification=request.app.state.notification, 98 | clues=used_clues, 99 | ) 100 | 101 | 102 | @pages_router.get( 103 | "/yesterday-top-1000", response_class=HTMLResponse, include_in_schema=False 104 | ) 105 | async def yesterday_top(request: Request) -> Response: 106 | return render( 107 | name="closest1000.html", 108 | request=request, 109 | yesterday=(["נראה שהמילים הקרובות מעצבנות יותר מדי אנשים, אז העמוד הוסר", 0],), 110 | ) 111 | 112 | 113 | @pages_router.get("/secrets", response_class=HTMLResponse) 114 | async def secrets(request: Request, with_future: bool = False) -> Response: 115 | if with_future: 116 | super_admin(request) 117 | 118 | logic, _ = await get_logics(app=request.app) 119 | all_secrets = await logic.secret_logic.get_all_secrets(with_future=with_future) 120 | 121 | return render( 122 | name="all_secrets.html", 123 | request=request, 124 | secrets=sorted(all_secrets, key=lambda ws: ws[1], reverse=True), 125 | ) 126 | 127 | 128 | @pages_router.get("/faq", response_class=HTMLResponse, include_in_schema=False) 129 | async def faq(request: Request) -> Response: 130 | return render( 131 | name="faq.html", 132 | request=request, 133 | yesterday=[], 134 | ) 135 | 136 | 137 | @pages_router.get("/videos", response_class=HTMLResponse, include_in_schema=False) 138 | async def videos(request: Request) -> Response: 139 | return render(name="videos.html", request=request, videos=request.app.state.videos) 140 | 141 | 142 | @pages_router.get("/api/menu", response_class=HTMLResponse, include_in_schema=False) 143 | async def menu(request: Request) -> Response: 144 | return render( 145 | name="menu.html", 146 | request=request, 147 | next_page=urllib.parse.urlparse(request.headers.get("referer")).path, 148 | ) 149 | 150 | 151 | @pages_router.get("/statistics", response_class=HTMLResponse, include_in_schema=False) 152 | async def get_statistics(request: Request) -> Response: 153 | if request.state.user is None: 154 | statistics = None 155 | else: 156 | logic = UserStatisticsLogic(request.app.state.session, request.state.user) 157 | statistics = await logic.get_statistics() 158 | return render(name="statistics.html", request=request, statistics=statistics) 159 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import hashlib 5 | import os 6 | import time 7 | from collections import defaultdict 8 | from typing import TYPE_CHECKING 9 | 10 | import jwt 11 | import uvicorn 12 | from fastapi import FastAPI 13 | from fastapi import HTTPException 14 | from fastapi import Request 15 | from fastapi import Response 16 | from fastapi import status 17 | from fastapi.responses import JSONResponse 18 | from fastapi.staticfiles import StaticFiles 19 | 20 | from common import config 21 | from common.error import HSError 22 | from common.session import get_model 23 | from common.session import get_session 24 | from logic.user_logic import UserLogic 25 | from routers import routers 26 | from routers.base import get_logics 27 | 28 | if TYPE_CHECKING: 29 | from typing import Awaitable 30 | from typing import Callable 31 | 32 | STATIC_FOLDER = "static" 33 | js_hasher = hashlib.sha3_256() 34 | with open(STATIC_FOLDER + "/semantle.js", "rb") as f: 35 | js_hasher.update(f.read()) 36 | JS_VERSION = js_hasher.hexdigest()[:6] 37 | 38 | css_hasher = hashlib.sha3_256() 39 | with open(STATIC_FOLDER + "/styles.css", "rb") as f: 40 | css_hasher.update(f.read()) 41 | CSS_VERSION = css_hasher.hexdigest()[:6] 42 | 43 | app = FastAPI() 44 | app.state.limit = int(os.environ.get("LIMIT", getattr(config, "limit", 10))) 45 | app.state.period = int(os.environ.get("PERIOD", getattr(config, "period", 20))) 46 | app.state.videos = config.videos 47 | app.state.current_timeframe = 0 48 | app.state.usage = defaultdict(int) 49 | app.state.quotes = config.quotes 50 | app.state.notification = config.notification 51 | app.state.js_version = JS_VERSION 52 | app.state.css_version = CSS_VERSION 53 | app.state.model = get_model() 54 | app.state.google_app = config.google_app 55 | app.state.session = get_session() 56 | 57 | 58 | try: 59 | date = datetime.datetime.strptime( 60 | os.environ.get("GAME_DATE", ""), "%Y-%m-%d" 61 | ).date() 62 | delta = (datetime.datetime.now(datetime.UTC).date() - date).days 63 | except ValueError: 64 | delta = 0 65 | app.state.days_delta = datetime.timedelta(days=delta) 66 | app.mount(f"/{STATIC_FOLDER}", StaticFiles(directory=STATIC_FOLDER), name=STATIC_FOLDER) 67 | for router in routers: 68 | app.include_router(router) 69 | 70 | 71 | def request_is_limited(key: str) -> bool: 72 | now = int(time.time()) 73 | current = now - now % app.state.period 74 | if app.state.current_timeframe != current: 75 | app.state.current_timeframe = current 76 | for ip, usage in app.state.usage.items(): 77 | if usage > app.state.limit * 0.75: 78 | app.state.usage[ip] = usage // 2 79 | else: 80 | app.state.usage[ip] = 0 81 | app.state.usage = defaultdict( 82 | int, {ip: usage for ip, usage in app.state.usage.items() if usage > 0} 83 | ) 84 | app.state.usage[key] += 1 85 | if app.state.usage[key] > app.state.limit: 86 | return True 87 | else: 88 | return False 89 | 90 | 91 | def get_idenitifier(request: Request) -> str: 92 | forwarded = request.headers.get("X-Forwarded-For") 93 | if forwarded: 94 | return forwarded.split(",")[0].strip() 95 | if request.client: 96 | return request.client.host 97 | else: 98 | return "unknown" 99 | 100 | 101 | @app.middleware("http") 102 | async def is_limited( 103 | request: Request, call_next: Callable[[Request], Awaitable[Response]] 104 | ) -> Response: 105 | identifier = get_idenitifier(request) 106 | if request_is_limited(key=identifier): 107 | return JSONResponse(content="", status_code=status.HTTP_429_TOO_MANY_REQUESTS) 108 | response = await call_next(request) 109 | return response 110 | 111 | 112 | @app.middleware("http") 113 | async def get_user( 114 | request: Request, 115 | call_next: Callable[[Request], Awaitable[Response]], 116 | ) -> Response: 117 | access_token = request.cookies.get("access_token") 118 | if access_token is not None: 119 | try: 120 | payload = jwt.decode( 121 | access_token, config.jwt_key, algorithms=[config.jwt_algorithm] 122 | ) 123 | user_logic = UserLogic(request.app.state.session) 124 | user = await user_logic.get_user(payload["sub"]) 125 | if user is not None: 126 | request.state.user = user 127 | if expiry := user_logic.get_subscription_expiry(request.state.user): 128 | is_active = expiry > datetime.datetime.now(datetime.UTC) 129 | request.state.has_active_subscription = is_active 130 | request.state.expires_at = str(expiry.date()) 131 | except jwt.exceptions.ExpiredSignatureError: 132 | request.state.user = None 133 | else: 134 | request.state.user = None 135 | return await call_next(request) 136 | 137 | 138 | @app.middleware("http") 139 | async def catch_known_errors( 140 | request: Request, call_next: Callable[[Request], Awaitable[Response]] 141 | ) -> Response: 142 | try: 143 | return await call_next(request) 144 | except HSError as e: 145 | return JSONResponse(content=str(e), status_code=status.HTTP_400_BAD_REQUEST) 146 | 147 | 148 | @app.get("/health") 149 | async def health() -> JSONResponse: 150 | try: 151 | await get_logics(app=app) 152 | except (ValueError, HSError) as ex: 153 | raise HTTPException( 154 | status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(ex) 155 | ) 156 | return JSONResponse(content="OK", status_code=status.HTTP_200_OK) 157 | 158 | 159 | if __name__ == "__main__": 160 | uvicorn.run("app:app", port=5001, reload=getattr(config, "reload", False)) 161 | -------------------------------------------------------------------------------- /templates/set_secret.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | בחר חידה 19 | 20 | 21 | 22 | 29 | 30 | 31 | 32 |
33 | 54 |
55 |
56 |

Word data will appear here

57 | 59 |
60 | 61 | 151 | -------------------------------------------------------------------------------- /templates/menu.html: -------------------------------------------------------------------------------- 1 | 2 | 75 |
76 | 112 |
113 |
114 | 123 |
124 |
125 | -------------------------------------------------------------------------------- /templates/faq.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | סמנטעל: שאלות נפוצות 18 | 19 | 20 | 21 | 28 | {% if not request.state.has_active_subscription %} 29 | 30 | {% endif %} 31 | 32 | 33 | 34 | 36 | 37 |
38 |

שאלות נפוצות

39 |
40 |
41 | חזרה 42 |
43 | 44 |

45 |

46 | הסיבה העיקרית שאני יכול לחשוב עליה היא 47 | שלניחוש שלך או למילה הסודית יש כמה משמעויות, והמשמעות שעלתה בדעתך פחות נפוצה מהאחרות. 48 | למשל, כשבדקתי את המשחק ניסיתי אותו עם המילה ״שלום״, 49 | ורוב ה1000 המילים הקרובות היו שמות משפחה - בגלל ש״שלום״ נפוץ בעיקר כשם משפחה 50 | בויקיפדיה העברית. חוץ מזה, הדאטה לא מושלם. בכל זאת, ויקיפדיה. 51 |

52 | 53 |

ככה.

54 | 55 |

56 | אני בוחר את המילים, בדרך כלל שבוע־שבועיים מראש. ניסיתי להגריל 57 | מילים אבל יש עם זה שתי בעיו(1) המון מילים שנמצאות בDB הן מילים לא אמיתיות 58 | או מילים עם הטיה (״שולחנותיו״) ואני מעדיף לבחור מילים לא כאלה מסיבות ברורות 59 | (2) הרבה פעמים הקשרים שונים ממה שאינטואטיבי לנו, כמו הדוגמה של ״שלום״ למעלה. 60 | אני רוצה לבדוק שבאמת יש ב־1000 מילים הקרובות מילים שיעזרו להגיע לפתרון של החידה. 61 |

62 | 63 |

0:00 64 | 65 | UTC, 66 | כלומר 2:00 בישראל (או 3:00 בשעון קיץ). הסיבה שזה לא ב0:00 בשעון ישראל כי אני לא 67 | רוצה שהחידה תתחלף באמצע למי שעדיין רוצה לשחק ב0:00. 68 |

69 | 70 | 71 |

72 | ביצירת המודל הנוכחי של סמנטעל השתמשנו ב 73 | HebPipe 74 | בשביל לנקות את הדאטה לפני האימון של Word2Vec - מה שאומר שמילים כמו ״והשוקו״ 75 | הגיעו למודל כשהן מפורקות ל״ו+ה+שוקו״. בגלל זה יותר כיף לשחק עכשיו ממה שהיה בימים 76 | הראשונים של המשחק. 77 |

78 | 79 | 80 |

81 | ברור שלא. למשל, החבילה שטוענת את הדאטה 82 | מויקיפדיה מנקה סימנים שאנחנו לא רוצים שהיא תנקה כמו למשל ', מה שאומר שמבחינת 83 | המודל מילים כמו ״גל״, ״ג'ל״ הן אותה מילה. אנחנו עובדים על זה. 84 |

85 | 86 | 87 |

88 | איך שבא לך :) 89 |

90 | 91 | 92 |

הנה מאמר על Word2Vec.

93 | 94 |

ויקיפדיה.

95 | {% if yesterday %} 96 | 97 |

{{yesterday[-1]}}

98 | 99 |

{{', '.join(yesterday[:-1][::-1])}}. עוד?

100 | {% endif %} 101 | 102 |

בטח.

103 | 104 |

בוודאי.

105 | 106 |

אפשר לפתוח issue בקישור שבתשובה הקודמת. אם אני אוהב את הרעיון אפשר לממש אותו 107 | בעצמך או לחכות שמישהי אחרת תעשה את זה! 108 |

109 | 110 | 111 |

כן, אבל אני כנראה אתעלם ממך. זאת המדיניות של דיויד טרנר שיצר את המשחק המקורי ואני זורם איתו.

112 | 113 |

כן, עשיתי את דעגעל.

114 | 115 | 116 | חזרה 117 | 118 | 122 | 123 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 11 | 12 | 13 | 14 | 15 | סמנטעל 16 | 17 | 18 | 19 | 20 | 21 | 22 | 29 | {% if not request.state.has_active_subscription %} 30 | 31 | {% endif %} 32 | 33 | 34 | 35 | 37 | 38 |
39 |

סמנטעל

40 |
41 |
42 | 53 |
54 |
55 | 56 |
57 |
58 |

{{quote[0]}}

59 |
60 |
61 | - {{quote[1]}}, 62 | {{quote[2]}} 63 |
64 |
65 | 66 | {% if not request.state.user %} 67 |
68 | כדאי להתחבר בשביל רמזים, סטטיסטיקות ועוד! 69 | 70 |
71 | 78 | 86 |
87 |
88 | {% else %} 89 |
90 | 91 |
92 | 93 |
94 | {% endif %} 95 | 96 |

97 | {% if yesterdays_secret %} 98 | המילה של אתמול הייתה 99 | 100 | {{yesterdays_secret}}. 101 | {% endif %} 102 | היום, חידה מספר {{number}}, 103 | ציון הקרבה של המילה הכי קרובה (999/1000) למילה הסודית היום הוא 104 | {{closest1}}, 105 | ציון הקרבה של המילה העשירית הכי קרובה (990/1000) הוא 106 | {{closest10}} 107 | וציון הקרבה של המילה האלף הכי קרובה (1/1000) הוא 108 | {{closest1000}}. 109 |

110 |
111 |
112 | 113 | משחק חדש! נסו את ויקינג🪓, מסע דרך ויקיפדיה! 114 | 115 |
116 |
117 |
118 | 119 |
120 |
122 |
123 |
124 | 127 | 128 |
129 |
130 |
131 | {% if request.state.has_active_subscription %} 132 | 133 | תודה על התרומה! רמזים חופשי עד {{ request.state.expires_at }} 134 | 135 | {% endif %} 136 |
137 | 138 |
139 |

140 |
141 | 142 |
143 | 148 | 149 |
150 | 151 |
152 |
153 | 154 |
❤️ את סמנטעל? 155 | אפשר לקנות לי ☕! 156 |
157 | או ב־₿ 158 |
159 |
160 | (אפשר גם לשחק בויקינג🪓, 161 | דעגעל🚩 162 | או ב־MiMaMu🖼️, 163 | או לעקוב אחרי כמה סופגניות אכלתי🍩) 164 |

165 | Made by Itamar Shefi 2022 166 |
167 | Language model by Iddo Yadlin 168 |
169 | Word2Vec data from Wikipedia. 170 | Based on David Turner's Semantle 171 |

172 |
173 | 174 | 179 | 180 | 181 | 182 | 190 | 191 | 192 | 193 | 194 | -------------------------------------------------------------------------------- /logic/game_logic.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import heapq 5 | from functools import lru_cache 6 | from typing import TYPE_CHECKING 7 | from typing import no_type_check 8 | 9 | from sqlmodel import select 10 | from sqlmodel import update 11 | 12 | from common import config 13 | from common import tables 14 | from common.error import HSError 15 | from common.session import hs_transaction 16 | from common.typing import np_float_arr 17 | 18 | if TYPE_CHECKING: 19 | from typing import AsyncIterator 20 | 21 | from sqlmodel import Session 22 | 23 | from model import GensimModel 24 | 25 | 26 | class SecretLogic: 27 | def __init__(self, session: Session, dt: datetime.date | None = None): 28 | if dt is None: 29 | dt = datetime.datetime.now(datetime.UTC).date() 30 | self.date = dt 31 | self.session = session 32 | 33 | async def get_secret(self) -> str: 34 | return self._get_cached_secret(session=self.session, date=self.date) 35 | 36 | @staticmethod 37 | @lru_cache(maxsize=2048) 38 | def _get_cached_secret(session: Session, date: datetime.date) -> str: 39 | # TODO: this function is accessing db but is NOT ASYNC, which might be 40 | # problematic if we choose to do async stuff with sql in the future. 41 | # the reason for that is `@lru_cache` does not support async. 42 | query = select(tables.SecretWord) 43 | query = query.where(tables.SecretWord.game_date == date) 44 | 45 | with hs_transaction(session) as session: 46 | secret_word = session.exec(query).one_or_none() 47 | if secret_word is not None: 48 | return secret_word.word 49 | else: 50 | raise HSError("No secret found!", code=250722) 51 | 52 | async def set_secret(self, secret: str, clues: list[str]) -> None: 53 | with hs_transaction(self.session, expire_on_commit=False) as session: 54 | db_secret = tables.SecretWord(word=secret, game_date=self.date) 55 | session.add(db_secret) 56 | with hs_transaction(self.session) as session: 57 | for clue in clues: 58 | if clue: 59 | session.add(tables.HotClue(secret_word_id=db_secret.id, clue=clue)) 60 | 61 | async def get_all_secrets( 62 | self, with_future: bool 63 | ) -> list[tuple[str, str]]: # TODO: better return type 64 | query = select(tables.SecretWord) 65 | if not with_future: 66 | query = query.where(tables.SecretWord.game_date < self.date) 67 | with hs_transaction(self.session) as session: 68 | secrets = session.exec(query) 69 | return [(secret.word, str(secret.game_date)) for secret in secrets] 70 | 71 | @no_type_check 72 | async def get_and_update_solver_count(self) -> int: 73 | # UPDATE RETURNING is not fully supported by sqlmodel typing yet 74 | query = update(tables.SecretWord) 75 | query = query.where(tables.SecretWord.game_date == self.date) 76 | query = query.values(solver_count=tables.SecretWord.solver_count + 1) 77 | query = query.returning(tables.SecretWord.solver_count) 78 | 79 | with hs_transaction(self.session) as session: 80 | solver_count = session.exec(query).scalar_one() 81 | return solver_count 82 | 83 | 84 | class VectorLogic: 85 | _secret_cache: dict[str, np_float_arr] = {} 86 | 87 | def __init__(self, session: Session, model: GensimModel, dt: datetime.date): 88 | self.model = model 89 | self.session = session 90 | self.date = str(dt) 91 | self.secret_logic = SecretLogic(self.session, dt=dt) 92 | 93 | async def get_vector(self, word: str) -> np_float_arr | None: 94 | return await self.model.get_vector(word) 95 | 96 | async def get_similarities(self, words: list[str]) -> np_float_arr: 97 | secret_vector = await self.get_secret_vector() 98 | return await self.model.get_similarities(words, secret_vector) 99 | 100 | async def get_secret_vector(self) -> np_float_arr: 101 | if self._secret_cache.get(self.date) is None: 102 | secret = await self.secret_logic.get_secret() 103 | vector = await self.get_vector(secret) 104 | if vector is None: 105 | raise ValueError("No secret found!") # TODO: better exception 106 | self._secret_cache[self.date] = vector 107 | return self._secret_cache[self.date] 108 | 109 | async def get_similarity(self, word: str) -> float: 110 | word_vector = await self.get_vector(word) 111 | if word_vector is None: 112 | raise HSError("Word not found", code=100796) 113 | secret_vector = await self.get_secret_vector() 114 | return await self.calc_similarity(secret_vector, word_vector) 115 | 116 | async def calc_similarity(self, vec1: np_float_arr, vec2: np_float_arr) -> float: 117 | return await self.model.calc_similarity(vec1, vec2) 118 | 119 | async def get_and_update_solver_count(self) -> int: 120 | solver_count: int = await self.secret_logic.get_and_update_solver_count() 121 | return solver_count 122 | 123 | def iterate_all(self) -> AsyncIterator[tuple[str, np_float_arr]]: 124 | return self.model.iterate_all() 125 | 126 | 127 | class CacheSecretLogic: 128 | _secret_cache_key_fmt = "hs:{}:{}" 129 | _cache_dict: dict[str, list[str]] = {} 130 | MAX_CACHE = 50 131 | 132 | def __init__( 133 | self, 134 | session: Session, 135 | secret: str, 136 | dt: datetime.date, 137 | model: GensimModel, 138 | ): 139 | self.session = session 140 | if dt is None: 141 | dt = datetime.datetime.now(datetime.UTC).date() 142 | self.date_ = dt 143 | self.date = str(dt) 144 | self.vector_logic = VectorLogic(self.session, model=model, dt=dt) 145 | self.secret = secret 146 | self._secret_cache_key: str | None = None 147 | self.model = model.model 148 | self.words = self.model.key_to_index.keys() 149 | self.session = session 150 | 151 | def _iterate_all_wv(self) -> AsyncIterator[tuple[str, np_float_arr]]: 152 | return self.vector_logic.iterate_all() 153 | 154 | async def simulate_set_secret(self, force: bool = False) -> None: 155 | """Simulates setting a secret, but does not actually do it. 156 | In order to actually set the secret, call do_populate() 157 | """ 158 | if not force: 159 | try: 160 | await self.vector_logic.secret_logic.get_secret() 161 | raise ValueError("There is already a secret for this date") 162 | except HSError: 163 | pass 164 | 165 | query = select(tables.SecretWord.game_date) # type: ignore 166 | query = query.where(tables.SecretWord.word == self.secret) 167 | with hs_transaction(self.session) as session: 168 | date = session.exec(query).one_or_none() 169 | if date is not None: 170 | raise ValueError(f"This word was a secret on {date}") 171 | if self.secret not in self.words: 172 | raise ValueError("This word is not in the model") 173 | 174 | secret_vec = self.model[self.secret] 175 | 176 | nearest: list[tuple[float, str]] = [] 177 | async for word, vec in self._iterate_all_wv(): 178 | s = await self.vector_logic.calc_similarity(vec, secret_vec) 179 | heapq.heappush(nearest, (s, word)) 180 | if len(nearest) > 1000: 181 | heapq.heappop(nearest) 182 | nearest.sort() 183 | self._cache_dict[self.date] = [w[1] for w in nearest] 184 | 185 | async def do_populate(self, clues: list[str]) -> None: 186 | # expiration = ( # TODO: implement this for SQL 187 | # self.date_ 188 | # - datetime.datetime.now(datetime.UTC).date() 189 | # + datetime.timedelta(days=4) 190 | # ) 191 | await self.vector_logic.secret_logic.set_secret(self.secret, clues) 192 | closest1000 = [] 193 | for out_of, word in enumerate(self._cache_dict[self.date], start=1): 194 | closest1000.append(tables.Closest1000(word=word, out_of_1000=out_of)) 195 | with hs_transaction(self.session) as session: 196 | secret_word = select(tables.SecretWord).where( 197 | tables.SecretWord.game_date == self.date, 198 | tables.SecretWord.word == self.secret, 199 | ) 200 | secret_word = session.execute(secret_word).scalar_one() 201 | secret_word.closest1000 = closest1000 # type: ignore 202 | session.add(secret_word) 203 | 204 | async def get_cache(self) -> list[str]: 205 | cache = self._cache_dict.get(self.date) 206 | if cache is None or len(cache) < 1000: 207 | if len(self._cache_dict) > self.MAX_CACHE: 208 | self._cache_dict.clear() 209 | with hs_transaction(self.session) as session: 210 | query = select(tables.SecretWord).where( 211 | tables.SecretWord.game_date == self.date, 212 | tables.SecretWord.word == self.secret, 213 | ) 214 | secret_word = session.exec(query).one() 215 | if secret_word is None: 216 | raise HSError("Secret not found", code=100796) 217 | cached = [ 218 | closest.word 219 | for closest in sorted( 220 | secret_word.closest1000, key=lambda c: c.out_of_1000 221 | ) 222 | ] 223 | self._cache_dict[self.date] = cached 224 | return self._cache_dict[self.date] 225 | 226 | async def get_cache_score(self, word: str) -> int: 227 | try: 228 | return (await self.get_cache()).index(word) + 1 229 | except ValueError: 230 | return -1 231 | 232 | 233 | class EasterEggLogic: 234 | EASTER_EGGS: dict[str, str] = config.easter_eggs 235 | 236 | @staticmethod 237 | def get_easter_egg(phrase: str) -> str | None: 238 | return EasterEggLogic.EASTER_EGGS.get(phrase) 239 | -------------------------------------------------------------------------------- /static/styles.css: -------------------------------------------------------------------------------- 1 | /* Base styles */ 2 | body { 3 | font-size: 16px; 4 | line-height: 1.6; 5 | font-family: 'Inter', -apple-system-font, BlinkMacSystemFont, 'Helvetica Neue', sans-serif; 6 | font-weight: 400; 7 | color: #2d3436; 8 | } 9 | 10 | body { 11 | margin: 0 auto; 12 | max-width: 800px; 13 | padding: 2rem; 14 | background-color: #f9fafb; 15 | } 16 | 17 | /* Modern input styling */ 18 | #guess-wrapper { 19 | display: flex; 20 | margin: 1.5rem 0; 21 | align-items: center; 22 | gap: 1rem; 23 | width: 120%; 24 | } 25 | 26 | input { 27 | height: 50px; 28 | font-size: 1.2rem; 29 | border: 2px solid #e2e8f0; 30 | border-radius: 10px; 31 | padding: 0 1rem; 32 | transition: all 0.2s ease; 33 | } 34 | 35 | input:focus { 36 | border-color: #4299e1; 37 | box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.15); 38 | outline: none; 39 | } 40 | 41 | #guess { 42 | width: 75%; 43 | } 44 | 45 | #guesses { 46 | margin-bottom:40px; 47 | } 48 | 49 | #give-up-btn { 50 | height: 44px; 51 | } 52 | 53 | input[type="checkbox"] { 54 | margin-bottom: 20px; 55 | } 56 | 57 | body { 58 | padding-left:20px; 59 | padding-right:20px; 60 | padding-top:18px; 61 | } 62 | 63 | 64 | th { 65 | padding-left: 3em; 66 | text-align: right; 67 | } 68 | 69 | th#chronoOrder, th#alphaOrder, th#similarityOrder, input#give-up-btn, input#guess-btn, input#lower{ 70 | cursor: pointer; 71 | } 72 | 73 | .gaveup { 74 | border: 1px solid black; 75 | padding-top: 0.7ex; 76 | padding-bottom: 0.7ex; 77 | padding-left: 0.7em; 78 | background-color: #eeeeff; 79 | } 80 | 81 | .close { 82 | text-align: right; 83 | } 84 | 85 | /*"Unusual word found! This word is not in the list of \"normal\" words that we use for the top-1000 list, but it is still similar!";*/ 86 | 87 | .weirdWord { 88 | position: relative; 89 | display: inline-block; 90 | border-bottom: 1px dotted black; 91 | } 92 | 93 | .weirdWord .tooltiptext { 94 | visibility: hidden; 95 | width: 20em; 96 | background-color: black; 97 | color: #fff; 98 | text-align: center; 99 | padding: 0.3ex 0; 100 | border-radius: 1ex; 101 | 102 | position: absolute; 103 | z-index: 1; 104 | } 105 | 106 | 107 | .weirdWord:hover .tooltiptext { 108 | visibility: visible; 109 | } 110 | 111 | footer { 112 | margin-top: 200px; 113 | } 114 | 115 | .review { 116 | margin: 0; 117 | } 118 | 119 | .review__quote { 120 | font-size: medium; 121 | } 122 | 123 | .review__quote::before { 124 | content: '\201C'; 125 | } 126 | 127 | .review__quote::after { 128 | content: '\201D'; 129 | } 130 | 131 | .review__reviewer { 132 | margin-right: 20px; 133 | font-size: small; 134 | } 135 | 136 | body.rules-open { 137 | overflow: hidden; 138 | } 139 | 140 | body.rules-open #rules-underlay { 141 | display: block; 142 | float: top; 143 | } 144 | 145 | body.notification-open { 146 | overflow: hidden; 147 | } 148 | 149 | body.notification-open #notification-underlay { 150 | display: block; 151 | float: top; 152 | } 153 | 154 | body.settings-open #settings-underlay { 155 | display: block; 156 | } 157 | 158 | .dialog-underlay { 159 | background: rgba(0,0,0,.5); 160 | bottom: 0; 161 | display: none; 162 | left: 0; 163 | position: fixed; 164 | right: 0; 165 | top: 0; 166 | z-index: 1000; 167 | } 168 | 169 | body.dialog-open { 170 | overflow: hidden; 171 | } 172 | 173 | /* Dialog styling */ 174 | .dialog { 175 | background: white; 176 | border-radius: 15px; 177 | box-shadow: 0 10px 25px rgba(0,0,0,0.1); 178 | overflow: hidden; 179 | } 180 | 181 | .dialog-close { 182 | color: black; 183 | font-size: 1.5em; 184 | height: 1em; 185 | line-height: 1em; 186 | left: .5em; 187 | position: absolute; 188 | top: .5em; 189 | width: 1em; 190 | z-index: 2; 191 | } 192 | 193 | .dialog-content { 194 | padding: 2.5rem; 195 | } 196 | 197 | .dialog-content > *:first-child { 198 | margin-top: 0; 199 | } 200 | 201 | .dialog-content > *:last-child { 202 | margin-bottom: 0; 203 | } 204 | 205 | #rules-heading { 206 | margin-top: 0; 207 | } 208 | 209 | header { 210 | align-items: center; 211 | display: flex; 212 | justify-content: space-between; 213 | } 214 | 215 | #menu { 216 | display: flex; 217 | column-gap: .5em; 218 | align-items: center; 219 | line-height: 1rem; 220 | z-index: 999; /* under modal */ 221 | } 222 | 223 | #menu button, 224 | .dialog-close { 225 | background: transparent; 226 | border: none; 227 | padding: 0; 228 | opacity: 0.6; 229 | } 230 | 231 | #menu button:hover { 232 | opacity: 1; 233 | } 234 | 235 | #menu button svg { 236 | height: 1.5em; 237 | width: 1.5em; 238 | } 239 | 240 | .percentile, 241 | .progress-container, 242 | .progress-bar { 243 | display: inline-block; 244 | background-color:#008000; 245 | } 246 | 247 | .percentile { 248 | text-align: right; 249 | width: 5em; 250 | } 251 | 252 | .progress-container { 253 | background-color: #eeeeee; 254 | width: 10em; 255 | text-align: left; 256 | } 257 | 258 | a { 259 | text-decoration: none; 260 | } 261 | 262 | .implicita { 263 | color: #000; 264 | } 265 | 266 | 267 | @media only screen and (max-width: 640px) { 268 | body, input { 269 | font-size: 14px; 270 | } 271 | 272 | #guess-wrapper { 273 | flex-grow: 1; 274 | } 275 | 276 | table { 277 | width: 100%; 278 | } 279 | 280 | th { 281 | padding-left: .5em; 282 | } 283 | 284 | .accordion { 285 | font-size: 14px; 286 | } 287 | } 288 | 289 | .embed-container { 290 | z-index: 0; 291 | position: relative; 292 | padding-bottom: 56.25%; 293 | height: 0; 294 | overflow: hidden; 295 | max-width: 100%; 296 | } 297 | 298 | .embed-container iframe, .embed-container object, .embed-container embed { 299 | position: absolute; 300 | top: 0; 301 | left: 0; 302 | width: 100%; 303 | height: 100%; 304 | } 305 | 306 | /* Dark mode */ 307 | body.dark { 308 | background: #111; 309 | color: #fafafa; 310 | color-scheme: dark; 311 | } 312 | 313 | body.dark a { 314 | color: #9af; 315 | } 316 | 317 | body.dark #menu button { 318 | color: #fafafa; 319 | } 320 | 321 | body.dark .dialog { 322 | background: #333; 323 | } 324 | 325 | body.dark .dialog-close { 326 | color: #fafafa; 327 | } 328 | 329 | body.dark .progress-container { 330 | background: #333; 331 | } 332 | 333 | body.dark .implicita { 334 | color: #FFF; 335 | } 336 | 337 | body.dark .panel { 338 | background: #334; 339 | } 340 | 341 | body.dark #menu .burger-menu .bar { 342 | background-color: #fafafa; 343 | } 344 | 345 | /* 346 | Accordion styling taken from 347 | https://www.w3schools.com/howto/howto_js_accordion.asp 348 | */ 349 | .accordion { 350 | background-color: #eee; 351 | color: #444; 352 | cursor: pointer; 353 | padding: 18px; 354 | width: 100%; 355 | text-align: right; 356 | border: none; 357 | outline: none; 358 | font-size: 17px; 359 | transition: 0.4s; 360 | margin-top: 3px; 361 | } 362 | 363 | 364 | body.dark .accordion { 365 | background-color: #666; 366 | color: #eee; 367 | cursor: pointer; 368 | padding: 18px; 369 | width: 100%; 370 | text-align: right; 371 | border: none; 372 | outline: none; 373 | font-size: 17px; 374 | transition: 0.4s; 375 | margin-top: 3px; 376 | } 377 | 378 | .active, .accordion:hover { 379 | background-color: #ccc; 380 | } 381 | 382 | .panel { 383 | padding: 0 18px; 384 | background-color: #eeeeff; 385 | display: none; 386 | overflow: hidden; 387 | } 388 | 389 | .accordion:after { 390 | content: '\02795'; /* Unicode character for "plus" sign (+) */ 391 | font-size: 17px; 392 | color: #777; 393 | float: left; 394 | margin-right: 5px; 395 | } 396 | 397 | .accordion.active:after { 398 | content: "\2796"; /* Unicode character for "minus" sign (-) */ 399 | font-size: 17px; 400 | } 401 | 402 | 403 | body.dark .accordion:after { 404 | content: '\02795'; /* Unicode character for "plus" sign (+) */ 405 | color: #FFF; 406 | margin-right: 5px; 407 | } 408 | 409 | body.dark .accordion.active:after { 410 | content: "\2796"; /* Unicode character for "minus" sign (-) */ 411 | font-size: 17px; 412 | } 413 | 414 | img.emoji { 415 | height: 1rem; 416 | vertical-align: text-top; 417 | } 418 | 419 | #tooltip-menu { 420 | position: absolute; 421 | background-color: #fff; 422 | box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.3); 423 | border-radius: 5px; 424 | padding: 5px; 425 | display: none; 426 | transform: translateX(50%); /* Move left by 100% of its own width */ 427 | } 428 | 429 | 430 | .burger-menu { 431 | display: none; /* Hide the hamburger menu button by default */ 432 | background-color: transparent; 433 | border: none; 434 | cursor: pointer; 435 | padding: 10px; 436 | } 437 | 438 | .burger-menu .bar { 439 | width: 20px; 440 | height: 3px; 441 | background-color: #000; /* Adjust the color as needed */ 442 | margin: 5px auto; 443 | transition: 0.4s; 444 | border-radius: 10px; 445 | } 446 | 447 | /* Add animation for the bars when menu is toggled */ 448 | .burger-menu.active .bar:nth-child(1) { 449 | transform: rotate(-45deg) translate(-5px, 6px); 450 | } 451 | 452 | .burger-menu.active .bar:nth-child(2) { 453 | opacity: 0; 454 | } 455 | 456 | .burger-menu.active .bar:nth-child(3) { 457 | transform: rotate(45deg) translate(-5px, -6px); 458 | } 459 | 460 | /* Media query for mobile devices */ 461 | @media screen and (max-width: 768px) { 462 | /* Show the hamburger icon and hide the navigation buttons */ 463 | .burger-menu { 464 | display: block !important; 465 | } 466 | 467 | #menu button { 468 | display: none; 469 | } 470 | 471 | /* Style the hamburger icon as needed */ 472 | .burger-menu { 473 | /* Add styles for the hamburger icon, e.g., background, size, etc. */ 474 | } 475 | #menu button { 476 | display: none; 477 | opacity: 1; 478 | 479 | } 480 | 481 | #menu { 482 | top: 2rem; 483 | left: 1rem; 484 | position: fixed; 485 | display: flex; 486 | flex-direction: column-reverse; 487 | background-color: #f3f3f38a; 488 | padding: 4px; 489 | border-radius: 2px; 490 | row-gap: .4em; 491 | } 492 | 493 | #menu.active { 494 | height: auto; 495 | } 496 | 497 | body.dark #menu { 498 | background-color: #7570708a; 499 | } 500 | } 501 | 502 | .show { 503 | display: block !important; 504 | } 505 | 506 | .material-symbols-rounded { 507 | vertical-align: bottom; 508 | } 509 | 510 | #statistics-table td { 511 | padding-left: 2em; 512 | padding-top: 0.2em; 513 | } 514 | 515 | .shimmer { 516 | -webkit-mask:linear-gradient(-60deg,#000 30%,#0005,#000 70%) right/300% 100%; 517 | background-repeat: no-repeat; 518 | animation: shimmer 2s infinite; 519 | } 520 | 521 | @keyframes shimmer { 522 | 20% {-webkit-mask-position:left} 523 | } 524 | 525 | @keyframes shimmer { 526 | 100% {-webkit-mask-position:left} 527 | } 528 | 529 | /* The snackbar - position it at the bottom and in the middle of the screen */ 530 | #snackbar { 531 | visibility: hidden; /* Hidden by default. Visible on click */ 532 | min-width: 250px; /* Set a default minimum width */ 533 | margin-left: -125px; /* Divide value of min-width by 2 */ 534 | text-align: center; /* Centered text */ 535 | border-radius: 5px; /* Rounded borders */ 536 | padding: 16px; /* Padding */ 537 | position: fixed; /* Sit on top of the screen */ 538 | z-index: 9999; /* Add a z-index if needed */ 539 | left: 50%; /* Center the snackbar */ 540 | top: 30px; /* 30px from the bottom */ 541 | justify-content: center; 542 | color: #000; /* Text color should always be black, even in dark mode */ 543 | } 544 | 545 | /* Show the snackbar when clicking on a button (class added with JavaScript) */ 546 | #snackbar.show { 547 | visibility: visible; /* Show the snackbar */ 548 | /* Add animation: Take 0.5 seconds to fade in and out the snackbar. 549 | However, delay the fade out process for 3.5 seconds */ 550 | -webkit-animation: fadein 0.5s, fadeout 0.5s 3.5s; 551 | animation: fadein 0.5s, fadeout 0.5s 3.5s; 552 | } 553 | 554 | /* Animations to fade the snackbar in and out */ 555 | @-webkit-keyframes fadein { 556 | from {top: 0; opacity: 0;} 557 | to {top: 30px; opacity: 1;} 558 | } 559 | 560 | @keyframes fadein { 561 | from {top: 0; opacity: 0;} 562 | to {top: 30px; opacity: 1;} 563 | } 564 | 565 | @-webkit-keyframes fadeout { 566 | from {top: 30px; opacity: 1;} 567 | to {top: 0; opacity: 0;} 568 | } 569 | 570 | @keyframes fadeout { 571 | from {top: 30px; opacity: 1;} 572 | to {top: 0; opacity: 0;} 573 | } -------------------------------------------------------------------------------- /logic/user_logic.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import annotations 3 | 4 | import datetime 5 | import hashlib 6 | from functools import lru_cache 7 | from typing import TYPE_CHECKING 8 | 9 | from dateutil.relativedelta import relativedelta 10 | from sqlalchemy import func 11 | from sqlalchemy.exc import NoResultFound 12 | from sqlmodel import asc 13 | from sqlmodel import col 14 | from sqlmodel import select 15 | 16 | from common import config 17 | from common import schemas 18 | from common import tables 19 | from common.session import hs_transaction 20 | 21 | if TYPE_CHECKING: 22 | from typing import Awaitable 23 | from typing import Callable 24 | 25 | from sqlmodel import Session 26 | from sqlmodel.sql.expression import SelectOfScalar 27 | 28 | 29 | class UserLogic: 30 | USER = "User" 31 | SUPER_ADMIN = config.super_admin 32 | 33 | PERMISSIONS = ( # TODO: enum 34 | USER, 35 | SUPER_ADMIN, 36 | ) 37 | 38 | def __init__(self, session: Session) -> None: 39 | self.session = session 40 | 41 | async def create_user(self, user_info: dict[str, str]) -> tables.User: 42 | user = { 43 | "email": user_info["email"], 44 | "user_type": self.USER, 45 | "active": True, 46 | "picture": user_info.get( 47 | "picture", "https://www.ishefi.com/images/favicon.ico" 48 | ), 49 | "given_name": user_info["given_name"], 50 | "family_name": user_info.get("family_name", ""), 51 | "first_login": datetime.datetime.now(tz=datetime.UTC), 52 | } 53 | with hs_transaction(self.session, expire_on_commit=False) as session: 54 | db_user = tables.User(**user) 55 | session.add(db_user) 56 | return db_user 57 | 58 | async def get_user(self, email: str) -> tables.User | None: 59 | try: 60 | return self._get_cached_user(self.session, email) 61 | except NoResultFound: 62 | return None 63 | 64 | @staticmethod 65 | @lru_cache(maxsize=2048) 66 | def _get_cached_user(session: Session, email: str) -> tables.User: 67 | with hs_transaction(session, expire_on_commit=False) as session: 68 | query = select(tables.User).where(tables.User.email == email) 69 | return session.exec(query).one() 70 | 71 | @staticmethod 72 | def has_permissions(user: tables.User, permission: str) -> bool: 73 | return UserLogic.PERMISSIONS.index( 74 | user.user_type 75 | ) >= UserLogic.PERMISSIONS.index(permission) 76 | 77 | async def subscribe(self, subscription: schemas.Subscription) -> bool: 78 | user = await self.get_user(subscription.email) 79 | if user is None: 80 | return False 81 | 82 | with hs_transaction(self.session) as session: 83 | query = select(tables.UserSubscription) 84 | query = query.where(tables.UserSubscription.uuid == subscription.message_id) 85 | if session.exec(query).one_or_none() is None: 86 | session.add( 87 | tables.UserSubscription( 88 | user_id=user.id, 89 | amount=subscription.amount, 90 | tier_name=subscription.tier_name, 91 | uuid=subscription.message_id, 92 | timestamp=subscription.timestamp, 93 | ) 94 | ) 95 | return True 96 | else: 97 | return False 98 | 99 | def get_subscription_expiry(self, user: tables.User) -> datetime.datetime | None: 100 | with hs_transaction(self.session, expire_on_commit=False) as session: 101 | query = select(tables.UserSubscription) 102 | query = query.where(tables.UserSubscription.user_id == user.id) 103 | query = query.order_by(asc(tables.UserSubscription.timestamp)) 104 | subscriptions = session.exec(query).all() 105 | 106 | expiry = None 107 | now = datetime.datetime.now(datetime.UTC) 108 | for subscription in subscriptions: 109 | if expiry is None: 110 | expiry = subscription.timestamp.replace(tzinfo=datetime.UTC) 111 | expiry += self._get_subscription_duration(subscription) 112 | if expiry < now: 113 | expiry = None 114 | return expiry 115 | 116 | @staticmethod 117 | def _get_subscription_duration( 118 | subscription: tables.UserSubscription, 119 | ) -> relativedelta: 120 | round_amount = round(subscription.amount) 121 | return relativedelta( 122 | months=round_amount // 3, # one month per 3$ 123 | days=10 * (round_amount % 3), # 10 days per 1$ reminder 124 | ) 125 | 126 | 127 | class UserHistoryLogic: 128 | def __init__( 129 | self, 130 | session: Session, 131 | user: tables.User, 132 | date: datetime.date, 133 | ): 134 | self.session = session 135 | self.user = user 136 | self.date = date # TODO: use this 137 | 138 | async def update_and_get_history( 139 | self, guess: schemas.DistanceResponse 140 | ) -> list[schemas.DistanceResponse]: 141 | if guess.similarity is not None: 142 | with hs_transaction(self.session) as session: 143 | history = await self._begun_get_history(session) 144 | if guess.guess not in [h.guess for h in history]: 145 | session.add( 146 | tables.UserHistory( 147 | user_id=self.user.id, 148 | guess=guess.guess, 149 | similarity=guess.similarity, 150 | distance=guess.distance, 151 | egg=guess.egg, 152 | game_date=self.date, 153 | solver_count=guess.solver_count, 154 | ) 155 | ) 156 | history.append(guess) 157 | return history 158 | else: 159 | return [guess] + await self.get_history() 160 | 161 | async def get_history(self) -> list[schemas.DistanceResponse]: 162 | with hs_transaction(self.session, expire_on_commit=False) as session: 163 | return await self._begun_get_history(session=session) 164 | 165 | async def _begun_get_history( 166 | self, session: Session 167 | ) -> list[schemas.DistanceResponse]: 168 | history_query = select(tables.UserHistory) 169 | history_query = history_query.where(tables.UserHistory.user_id == self.user.id) 170 | history_query = history_query.where(tables.UserHistory.game_date == self.date) 171 | history_query = history_query.order_by(col(tables.UserHistory.id)) 172 | history = session.exec(history_query).all() 173 | return [ 174 | schemas.DistanceResponse( 175 | guess=historia.guess, 176 | similarity=historia.similarity, 177 | distance=historia.distance, 178 | egg=historia.egg, 179 | solver_count=historia.solver_count, 180 | guess_number=i, 181 | ) 182 | for i, historia in enumerate(history, start=1) 183 | ] 184 | 185 | 186 | class UserStatisticsLogic: 187 | def __init__(self, session: Session, user: tables.User): 188 | self.session = session 189 | self.user = user 190 | 191 | async def get_statistics(self) -> schemas.UserStatistics: 192 | stats_subquery = select( 193 | tables.UserHistory.similarity, 194 | tables.UserHistory.solver_count, 195 | func.row_number() 196 | .over( 197 | partition_by=[col(tables.UserHistory.game_date)], 198 | order_by=col(tables.UserHistory.id), 199 | ) 200 | .label("guess_number"), 201 | ) 202 | stats_subquery = stats_subquery.select_from(tables.UserHistory) 203 | stats_subquery = stats_subquery.where( 204 | tables.UserHistory.user_id == self.user.id 205 | ) 206 | stats_sub = stats_subquery.subquery() 207 | stats_query = select( 208 | func.count(), 209 | func.min(stats_sub.c.solver_count), 210 | func.avg(stats_sub.c.guess_number), 211 | ) 212 | stats_query = stats_query.select_from(stats_sub) 213 | stats_query = stats_query.where(stats_sub.c.similarity == 100) 214 | 215 | with hs_transaction(self.session, expire_on_commit=False) as session: 216 | stats = session.exec(stats_query).one_or_none() 217 | 218 | if stats is None: 219 | total_games_won, highest_rank, avg_guesses = 0, None, 0 220 | else: 221 | total_games_won, highest_rank, avg_guesses = stats 222 | 223 | game_streak, total_games_played = self._get_game_streak_and_total() 224 | 225 | return schemas.UserStatistics( 226 | game_streak=game_streak, 227 | highest_rank=highest_rank, 228 | total_games_played=total_games_played, 229 | total_games_won=total_games_won or 0, 230 | average_guesses=avg_guesses or 0, 231 | ) 232 | 233 | def _get_game_streak_and_total(self) -> tuple[int, int]: 234 | dates_query = select(col(tables.UserHistory.game_date)) 235 | dates_query = dates_query.where(tables.UserHistory.user_id == self.user.id) 236 | dates_query = dates_query.group_by(col(tables.UserHistory.game_date)) 237 | dates_query = dates_query.order_by(col(tables.UserHistory.game_date).desc()) 238 | with hs_transaction(session=self.session, expire_on_commit=False) as session: 239 | game_dates = session.exec(dates_query).all() 240 | 241 | date = datetime.datetime.now(datetime.UTC).date() 242 | game_streak = 0 243 | for game_date in game_dates: 244 | if date == game_date: 245 | game_streak += 1 246 | date -= datetime.timedelta(days=1) 247 | else: 248 | break 249 | return game_streak, len(game_dates) 250 | 251 | 252 | class UserClueLogic: 253 | CLUE_CHAR_FORMAT = 'המילה הסודית מכילה את האות "{clue_char}"' 254 | CLUE_LEN_FORMAT = "המילה הסודית מכילה {clue_len} אותיות" 255 | HOT_CLUE_FORMAT = "המילה '{hot_clue}' קרובה למילה הסודית" 256 | NO_MORE_CLUES_STR = "אין יותר רמזים" 257 | CLUE_COOLDOWN_FOR_UNSUBSCRIBED = datetime.timedelta(days=7) 258 | MAX_CLUES_DURING_COOLDOWN = 5 259 | HOT_CLUES_CACHE: dict[str, list[str]] = {} 260 | 261 | def __init__( 262 | self, 263 | session: Session, 264 | user: tables.User, 265 | secret: str, 266 | date: datetime.date, 267 | ): 268 | self.session = session 269 | self.user = user 270 | self.secret = secret 271 | self.date = date 272 | 273 | @property 274 | def clues(self) -> list[Callable[[], Awaitable[str]]]: 275 | return [ 276 | self._get_clue_char, 277 | self._get_secret_len, 278 | *self._get_hot_clue_funcs(), 279 | ] 280 | 281 | @property 282 | def clues_used(self) -> int: 283 | with hs_transaction(self.session) as session: 284 | query = select(tables.UserClueCount.clue_count) 285 | query = query.where(tables.UserClueCount.user_id == self.user.id) 286 | query = query.where(tables.UserClueCount.game_date == self.date) 287 | return session.exec(query).one_or_none() or 0 288 | 289 | async def get_clue(self) -> str | None: 290 | user_logic = UserLogic(self.session) 291 | expiry = user_logic.get_subscription_expiry(self.user) 292 | if expiry is None or expiry < datetime.datetime.now(datetime.UTC): 293 | has_active_subscription = False 294 | else: 295 | has_active_subscription = True 296 | if self.clues_used < len(self.clues): 297 | if ( 298 | not has_active_subscription 299 | and await self._used_max_clues_for_inactive() 300 | ): 301 | raise ValueError() # TODO: custom exception 302 | clue = await self.clues[self.clues_used]() 303 | await self._update_clue_usage() 304 | return clue 305 | else: 306 | return None 307 | 308 | async def get_all_clues_used(self) -> list[str]: 309 | clues = [] 310 | for i in range(self.clues_used): 311 | clues.append(await self.clues[i]()) 312 | return clues 313 | 314 | async def _used_max_clues_for_inactive(self) -> bool: 315 | # TODO: verify this logic is correct 316 | with hs_transaction(self.session) as session: 317 | query: SelectOfScalar[int] = select( 318 | func.sum(tables.UserClueCount.clue_count) 319 | ) 320 | query = query.where(tables.UserClueCount.user_id == self.user.id) 321 | query = query.where( 322 | tables.UserClueCount.game_date 323 | > self.date - self.CLUE_COOLDOWN_FOR_UNSUBSCRIBED 324 | ) 325 | used_clues = session.exec(query).one() or 0 326 | return used_clues >= self.MAX_CLUES_DURING_COOLDOWN 327 | 328 | async def _update_clue_usage(self) -> None: 329 | with hs_transaction(self.session) as session: 330 | clue_count_query = select(tables.UserClueCount).where( 331 | tables.UserClueCount.user_id == self.user.id, 332 | tables.UserClueCount.game_date == self.date, 333 | ) 334 | clue_count = session.exec(clue_count_query).first() 335 | if clue_count is None: 336 | clue_count = tables.UserClueCount( 337 | user_id=self.user.id, 338 | game_date=self.date, 339 | clue_count=1, 340 | ) 341 | else: 342 | clue_count.clue_count += 1 343 | session.add(clue_count) 344 | 345 | async def _get_clue_char(self) -> str: 346 | digest = hashlib.md5(self.secret.encode()).hexdigest() 347 | clue_index = int(digest, 16) % len(self.secret) 348 | # TODO: deal with final letters? 349 | return self.CLUE_CHAR_FORMAT.format(clue_char=self.secret[clue_index]) 350 | 351 | async def _get_secret_len(self) -> str: 352 | return self.CLUE_LEN_FORMAT.format(clue_len=len(self.secret)) 353 | 354 | def _get_hot_clue_funcs(self) -> list[Callable[[], Awaitable[str]]]: 355 | def hot_clue_func_generator(hot_clue: str) -> Callable[[], Awaitable[str]]: 356 | async def get_hot_clue() -> str: 357 | return self.HOT_CLUE_FORMAT.format(hot_clue=hot_clue) 358 | 359 | return get_hot_clue 360 | 361 | hot_clues = self._get_hot_clues() 362 | return [hot_clue_func_generator(clue) for clue in hot_clues] 363 | 364 | def _get_hot_clues(self) -> list[str]: 365 | if self.secret not in self.HOT_CLUES_CACHE: 366 | with hs_transaction(self.session) as session: 367 | query = ( 368 | select(tables.HotClue) 369 | .join(tables.SecretWord) 370 | .where(tables.SecretWord.game_date == self.date) 371 | ) 372 | hot_clues = session.exec(query).all() 373 | self.HOT_CLUES_CACHE[self.secret] = [ 374 | hot_clue.clue for hot_clue in hot_clues 375 | ] 376 | return self.HOT_CLUES_CACHE[self.secret] 377 | -------------------------------------------------------------------------------- /static/semantle.js: -------------------------------------------------------------------------------- 1 | let cache = {}; 2 | let darkModeMql = window.matchMedia('(prefers-color-scheme: dark)'); 3 | let darkMode = false; 4 | 5 | let storyToShare = ""; 6 | 7 | const RED = "#f44336"; 8 | const GREEN = "#4CAF50"; 9 | const YELLOW = "#ffeb3b"; 10 | 11 | 12 | function snackbarAlert(alertText, alertColor) { 13 | let x = document.getElementById("snackbar"); 14 | x.innerText = alertText; 15 | x.style.backgroundColor = alertColor; 16 | x.className = "show"; 17 | let duration = 60 * alertText.length; 18 | setTimeout(function(){ x.className = x.className.replace("show", ""); }, duration); 19 | } 20 | 21 | 22 | function getSolverCountStory(beforePronoun) { 23 | let solverCountStory = ""; 24 | if (localStorage.getItem("solverCount")) { 25 | const solversBefore = parseInt(localStorage.getItem("solverCount")) - 1; 26 | if (solversBefore === 0) { 27 | solverCountStory = `🤩 מקום ראשון! 🤩`; 28 | if (Math.random() >= 0.5) { 29 | solverCountStory += ` אף אחת לא פתרה`; 30 | } else { 31 | solverCountStory += ` אף אחד לא פתר`; 32 | } 33 | } else if (solversBefore === 1) { 34 | if (Math.random() >= 0.5) { 35 | solverCountStory = `רק אחת פתרה`; 36 | } else { 37 | solverCountStory = `רק אחד פתר`; 38 | } 39 | } else { 40 | solverCountStory = `רק ${solversBefore} פתרו`; 41 | } 42 | solverCountStory += ` היום את סמנטעל ${beforePronoun}!`; 43 | } 44 | return solverCountStory; 45 | } 46 | 47 | 48 | function solveStory(guesses, puzzleNumber) { 49 | const totalClues = $("#clueList > li").length; 50 | txt = `פתרתי את סמנטעל #${puzzleNumber} ב`; 51 | if (guesses.length == 1) { 52 | txt += "ניחוש אחד, "; 53 | } 54 | else { 55 | txt += `־${guesses.length} ניחושים, `; 56 | } 57 | if (totalClues === 0) { 58 | txt += 'ובלי רמזים בכלל!\n'; 59 | } 60 | else if (totalClues === 1) { 61 | txt += 'עם רמז אחד!\n'; 62 | } 63 | else { 64 | txt += `עם ${totalClues} רמזים!\n`; 65 | } 66 | txt += getSolverCountStory('לפני'); 67 | txt += '\nhttps://semantle.ishefi.com\n'; 68 | let shareGuesses = guesses.slice(); 69 | shareGuesses.sort(function(a, b){return b.similarity-a.similarity}); 70 | shareGuesses = shareGuesses.slice(0, 6); 71 | let greens = 0; 72 | let whites = 0; 73 | let squares = 5; 74 | shareGuesses.forEach(entry => { 75 | let {similarity, guess, guess_number, distance, egg} = entry; 76 | greens = Math.max(Math.floor(squares * distance / 1000), 0); 77 | whites = squares - greens; 78 | if (egg) { 79 | txt += '✨'.repeat(squares); 80 | } 81 | else { 82 | txt += '🟩'.repeat(greens) + '⬜'.repeat(whites) + ' '; 83 | } 84 | txt += ' ' + guess_number; 85 | if (greens != 0) { 86 | txt += ' (' + distance + '/1000)'; 87 | } 88 | txt += '\n' 89 | } 90 | ); 91 | 92 | return txt; 93 | } 94 | 95 | function share() { 96 | const copied = ClipboardJS.copy(storyToShare); 97 | 98 | if (copied) { 99 | snackbarAlert("העתקת, אפשר להדביק ברשתות החברתיות!", GREEN); 100 | } 101 | else { 102 | snackbarAlert("Failed to copy to clipboard", RED); 103 | } 104 | } 105 | 106 | function shareBtc() { 107 | const BTCAddress = "bc1qe3hpdddft34lmm7g6s6u6pef6k6mz4apykrla3jewapxeup4hpwsydhgx0"; 108 | const copied = ClipboardJS.copy(BTCAddress); 109 | if (copied) { 110 | snackbarAlert("copied BTC wallet address :)", GREEN); 111 | } 112 | else { 113 | snackbarAlert("Failed to copy to clipboard", RED); 114 | } 115 | } 116 | 117 | let Semantle = (function() { 118 | 'use strict'; 119 | 120 | let guessed = new Set(); 121 | let firstGuess = true; 122 | let guesses = []; 123 | let guessCount = 1; 124 | let gameOver = false; 125 | const handleStats = false; 126 | const storage = window.localStorage; 127 | let notificationId = null; 128 | let guessesToWin = 5000; 129 | 130 | // TODO: use value sent from BE ? 131 | const day_ms = 86400000; 132 | const now = Date.now(); 133 | const today = Math.floor(now / day_ms); 134 | const initialDay = 19044; 135 | const puzzleNumber = today + 1 - initialDay; 136 | const tomorrow = new Date(); 137 | tomorrow.setUTCHours(24, 0, 0, 0); 138 | 139 | function includeHTML() { 140 | var z, i, elmnt, file, xhttp; 141 | /* Loop through a collection of all HTML elements: */ 142 | z = document.getElementsByTagName("*"); 143 | for (i = 0; i < z.length; i++) { 144 | elmnt = z[i]; 145 | /*search for elements with a certain atrribute:*/ 146 | file = elmnt.getAttribute("w3-include-html"); 147 | if (file) { 148 | /* Make an HTTP request using the attribute value as the file name: */ 149 | xhttp = new XMLHttpRequest(); 150 | xhttp.onreadystatechange = function() { 151 | if (this.readyState == 4) { 152 | if (this.status == 200) {elmnt.innerHTML = this.responseText;} 153 | if (this.status == 404) {elmnt.innerHTML = "Page not found.";} 154 | /* Remove the attribute, and call this function once more: */ 155 | elmnt.removeAttribute("w3-include-html"); 156 | includeHTML(); 157 | } 158 | } 159 | xhttp.open("GET", file, true); 160 | xhttp.send(); 161 | /* Exit the function: */ 162 | return; 163 | } 164 | } 165 | } 166 | 167 | includeHTML(); 168 | 169 | async function updateStats() { 170 | const totalStreak = document.getElementById("total-streak"); 171 | if (!totalStreak) return; 172 | 173 | const url = "/api/user/info"; 174 | fetch(url, {headers: new Headers({'X-SH-Version': "2023-09-10"})}) 175 | .then( 176 | response => { 177 | if (!response.ok) { 178 | return; 179 | } 180 | return response.json(); 181 | }).then( 182 | data => { 183 | const gameStreak = data.game_streak; 184 | const totalStreak = document.getElementById("total-streak"); 185 | totalStreak.innerText = "רצף משחקים: " + gameStreak; 186 | if (gameStreak >= 5) { 187 | totalStreak.innerText += " 🔥"; 188 | } 189 | if (gameStreak == 0) { 190 | totalStreak.innerHTML += "
מספיק לנחש ניחוש אחד בשביל לשמר את הרצף שלך!"; 191 | } 192 | const fullStars = Math.min(gameStreak, 5); 193 | const starStreak = document.getElementById("star-streak"); 194 | let starText = ""; 195 | for (let i = 0; i < 5 - fullStars; i++) { 196 | starText += "☆"; 197 | } 198 | for (let i = 0; i < fullStars; i++) { 199 | starText += "⭐"; 200 | } 201 | starStreak.innerText = starText; 202 | if (fullStars == 5) { 203 | starStreak.className = "shimmer"; 204 | } 205 | twemoji.parse(totalStreak); 206 | twemoji.parse(starStreak); 207 | } 208 | ) 209 | } 210 | 211 | async function getSim(word) { 212 | if (cache.hasOwnProperty(word)) { 213 | let cached = cache[word]; 214 | cached.guess = word; 215 | return [cached]; 216 | } 217 | const url = "/api/distance" + '?word=' + word; 218 | const response = await fetch(url, {headers: new Headers({'X-SH-Version': "2023-09-10"})}); 219 | try { 220 | if (response.status === 200) { 221 | return await response.json(); 222 | } 223 | } catch (e) { 224 | 225 | } 226 | } 227 | 228 | function guessRow(similarity, oldGuess, percentile, guessNumber, guess, egg) { // TODO: simplify method's signature 229 | let percentileText = "(רחוק)"; 230 | let progress = ""; 231 | let cls = ""; 232 | if (egg) { 233 | percentileText = egg; 234 | } 235 | if (percentile > 0) { 236 | if (percentile == 1000) { 237 | percentileText = "מצאת!"; 238 | } else { 239 | cls = "close"; 240 | percentileText = `${percentile}/1000 `; 241 | progress = ` 242 |   243 | `; 244 | } 245 | } 246 | let color; 247 | if (oldGuess === guess) { 248 | color = '#c0c'; 249 | } else if (darkMode) { 250 | color = '#fafafa'; 251 | } else { 252 | color = '#000'; 253 | } 254 | if (similarity == null) return ''; 255 | return `${guessNumber} 256 | ${oldGuess} 257 | ${similarity.toFixed(2)} 258 | ${percentileText}${progress} 259 | `; 260 | 261 | } 262 | 263 | function checkMedia() { 264 | const storagePrefersDarkColorScheme = storage.getItem("prefersDarkColorScheme"); 265 | if (storagePrefersDarkColorScheme === 'true' || storagePrefersDarkColorScheme === 'false') { 266 | darkMode = storagePrefersDarkColorScheme === 'true'; 267 | } else { 268 | darkMode = darkModeMql.matches; 269 | darkModeMql.onchange = (e) => { 270 | darkMode = e.matches; 271 | toggleDarkMode(darkMode) 272 | updateGuesses(); 273 | } 274 | } 275 | toggleDarkMode(darkMode); 276 | } 277 | 278 | function addToCache(guessDataPoints) { 279 | guessDataPoints.forEach((guessData) => { 280 | let toCache = Object.assign({}, guessData); 281 | toCache.guess_number = 0; 282 | cache[guessData.guess] = toCache; 283 | }); 284 | storage.setItem("cache", JSON.stringify(cache)); 285 | } 286 | 287 | function clearState(withCache) { 288 | storage.removeItem("guesses"); 289 | storage.removeItem("winState"); 290 | if (withCache) { 291 | storage.removeItem("cache"); 292 | } 293 | } 294 | 295 | function saveGame(guessCount, winState) { 296 | // If we are in a tab still open from yesterday, we're done here. 297 | // Don't save anything because we may overwrite today's game! 298 | let savedPuzzleNumber = storage.getItem("puzzleNumber"); 299 | if (savedPuzzleNumber != puzzleNumber) { return } 300 | 301 | storage.setItem("winState", winState); 302 | storage.setItem("guesses", JSON.stringify(guesses)); 303 | }; 304 | 305 | function openRules() { 306 | document.body.classList.add('rules-open'); 307 | storage.setItem("readRules", true); 308 | } 309 | function openSettings() { 310 | document.body.classList.add('dialog-open', 'settings-open'); 311 | $("#settings-close")[0].focus(); 312 | } 313 | function openNotification() { 314 | document.body.classList.add("notification-open"); 315 | storage.setItem("notification-" + notificationId, true); 316 | } 317 | 318 | function updateGuesses(newGuess) { 319 | if (guesses.length < 1) { 320 | return; 321 | } 322 | let inner = ` 323 | # 324 | ניחוש 325 | קרבה 326 | מתחמם?`; 327 | /* This is dumb: first we find the most-recent word, and put 328 | it at the top. Then we do the rest. */ 329 | var i; 330 | for (i = 0; i < guesses.length; i++) { 331 | let entry = guesses[i]; 332 | if (entry.guess == newGuess) { 333 | inner += guessRow(entry.similarity, entry.guess, entry.distance, entry.guess_number, newGuess, entry.egg); 334 | break; 335 | } 336 | } 337 | inner += "
"; 338 | for (i = 0; i < guesses.length; i++) { 339 | let entry = guesses[i]; 340 | if (entry.guess != newGuess) { 341 | inner += guessRow(entry.similarity, entry.guess, entry.distance, entry.guess_number, newGuess, entry.egg); 342 | } 343 | } 344 | $('#guesses').html(inner); 345 | twemoji.parse($('#guesses')[0]); 346 | } 347 | 348 | function fetchGuesses() { 349 | let fixed = [] 350 | let storedGuesses = JSON.parse(storage.getItem("guesses")); 351 | for (let entry of storedGuesses) { 352 | if (entry instanceof Array) { 353 | entry = { 354 | similarity: entry[0], 355 | guess: entry[1], 356 | guess_number: entry[2], 357 | distance: entry[3], 358 | egg: entry[4] 359 | } 360 | } 361 | fixed.push(entry); 362 | } 363 | return fixed; 364 | } 365 | 366 | function toggleDarkMode(on) { 367 | document.body.classList[on ? 'add' : 'remove']('dark'); 368 | const darkModeCheckbox = $("#dark-mode")[0]; 369 | // this runs before the DOM is ready, so we need to check 370 | if (darkModeCheckbox) { 371 | darkModeCheckbox.checked = on; 372 | } 373 | } 374 | 375 | function getAllChildren(node, nodes) { 376 | node.childNodes.forEach(function(child) { 377 | getAllChildren(child, nodes); 378 | }); 379 | nodes.push(node); 380 | } 381 | 382 | function addEventListenerWhenElementAppears(elementId, event, eventListener, domEventListener) { 383 | const element = document.getElementById(elementId); 384 | if (element) { 385 | element.addEventListener(event, eventListener); 386 | if (domEventListener) { 387 | document.addEventListener(event, domEventListener); 388 | } 389 | } else { 390 | // The "menu" element is not yet available, so set up a MutationObserver to wait for it 391 | const observer = new MutationObserver(function (mutationsList) { 392 | for (const mutation of mutationsList) { 393 | if (mutation.type === "childList" && mutation.addedNodes) { 394 | for (const addedNode of mutation.addedNodes) { 395 | let allChildren = []; 396 | getAllChildren(addedNode, allChildren); 397 | for (const node of allChildren) { 398 | if (node.id === elementId) { 399 | // element is now available, remove the observer and add event listeners 400 | observer.disconnect(); 401 | addEventListenerWhenElementAppears(elementId, event, eventListener, domEventListener); 402 | return; 403 | } 404 | } 405 | } 406 | } 407 | } 408 | }); 409 | // Start observing changes in the DOM 410 | observer.observe(document.body, { childList: true, subtree: true }); 411 | } 412 | } 413 | 414 | addEventListenerWhenElementAppears("profile-image", "click", (function (event) { 415 | event.stopPropagation(); // Prevent the click event from propagating to the document 416 | const tooltipMenu = document.getElementById("tooltip-menu"); 417 | tooltipMenu.style.display = tooltipMenu.style.display === "block" ? "none" : "block"; 418 | }), (function(event) { 419 | const tooltipMenu = document.getElementById("tooltip-menu"); 420 | if (event.target !== tooltipMenu) { 421 | tooltipMenu.style.display = "none"; 422 | } 423 | })); 424 | addEventListenerWhenElementAppears("logout-link", "click", function(event) { 425 | event.preventDefault(); 426 | clearState(false); 427 | window.location.href = "/logout"; 428 | } 429 | ); 430 | addEventListenerWhenElementAppears("menu-toggle", "click", function(event){ 431 | this.classList.toggle("active"); 432 | this.parentNode.classList.toggle("active"); 433 | const navButtons = document.querySelectorAll('nav button'); 434 | navButtons.forEach(function (button) { 435 | button.classList.toggle("show"); 436 | }); 437 | }); 438 | 439 | async function init() { 440 | let notification = document.getElementById("notification"); 441 | let popupBlocks = ["rules"]; 442 | if (notification) { 443 | notificationId = notification.dataset.notificationid; 444 | popupBlocks.push("notification"); 445 | } 446 | if (!storage.getItem("readRules")) { 447 | openRules(); 448 | } else if (notificationId) { 449 | let seenNotification = storage.getItem("notification-" + notificationId); 450 | let expiry = Date.parse(notification.dataset.notificationexpire) / day_ms; 451 | let expired = today > expiry; 452 | if (!(seenNotification || expired)) { 453 | openNotification(); 454 | } 455 | } 456 | 457 | $("#rules-button").click(openRules); 458 | $("#settings-button").click(openSettings); 459 | 460 | popupBlocks.forEach((blockType) => { 461 | let blockId = "#" + blockType + "-"; 462 | [$(blockId + "underlay"), $(blockId + "close")].forEach((el) => { 463 | el.click(() => { 464 | document.body.classList.remove(blockType + '-open'); 465 | }); 466 | }); 467 | }); 468 | 469 | $("#rules").click((event) => { 470 | // prevents click from propagating to the underlay, which closes the rules 471 | event.stopPropagation(); 472 | }); 473 | 474 | 475 | document.querySelectorAll(".dialog-underlay, .dialog-close, #capitalized-link").forEach((el) => { 476 | el.addEventListener('click', () => { 477 | document.body.classList.remove('dialog-open', 'rules-open', 'settings-open'); 478 | }); 479 | }); 480 | 481 | document.querySelectorAll(".dialog").forEach((el) => { 482 | el.addEventListener("click", (event) => { 483 | // prevents click from propagating to the underlay, which closes the rules 484 | event.stopPropagation(); 485 | }); 486 | }); 487 | 488 | // accordion functionality taken from 489 | // https://www.w3schools.com/howto/howto_js_accordion.asp 490 | document.querySelectorAll(".accordion").forEach((el) => { 491 | el.addEventListener("click", function() { 492 | this.classList.toggle("active"); 493 | 494 | const panel = this.nextElementSibling; 495 | if (panel.style.display === "block") { 496 | panel.style.display = "none"; 497 | } else { 498 | panel.style.display = "block"; 499 | } 500 | }); 501 | }); 502 | 503 | $("#dark-mode").click(function(event) { 504 | storage.setItem("prefersDarkColorScheme", event.target.checked); 505 | darkModeMql.onchange = null; 506 | darkMode = event.target.checked; 507 | toggleDarkMode(darkMode); 508 | updateGuesses(); 509 | }); 510 | 511 | toggleDarkMode(darkMode); 512 | 513 | if (storage.getItem("prefersDarkColorScheme") === null) { 514 | $("#dark-mode")[0].checked = false; 515 | $("#dark-mode")[0].indeterminate = true; 516 | } 517 | 518 | let form = $('#form')[0]; 519 | if (form === undefined) return; 520 | 521 | function dealWithHistory(guessHistory) { 522 | if (!guessHistory) { 523 | return; 524 | } 525 | guessed = new Set(); 526 | guesses = [] 527 | for (var i = 0; i < guessHistory.length; i++) { 528 | let guess = guessHistory[i]; 529 | dealWithGuess(guess); 530 | // guessed.add(guess.guess); 531 | // guesses.push(guess); 532 | } 533 | guessCount = guessed.size + 1; 534 | } 535 | 536 | function dealWithGuess(entry) { 537 | addToCache([entry]); 538 | if (entry.solver_count != null) { 539 | storage.setItem("solverCount", JSON.stringify(entry.solver_count)); 540 | } 541 | let {similarity, guess, distance, egg} = entry; 542 | if ((!guessed.has(guess)) && (similarity != null)) { 543 | guessed.add(guess); 544 | if (!entry.guess_number) { 545 | entry.guess_number = guessCount 546 | } 547 | guesses.push(entry); 548 | guessCount += 1; 549 | if (distance == 1000){ 550 | endGame(true, true); 551 | } 552 | } 553 | guesses.sort(function(a, b){return b.similarity-a.similarity}); 554 | if (!gameOver){ 555 | saveGame(-1, -1); 556 | } 557 | updateGuesses(guess); 558 | if (firstGuess) { 559 | updateStats().then(() => {console.log("updated stats")}); 560 | } 561 | firstGuess = false; 562 | } 563 | 564 | $('#form').submit(async function(event) { 565 | event.preventDefault(); 566 | $('#guess').focus(); 567 | $('#error').text(""); 568 | let guess = $('#guess').val().trim().replace("!", "").replace("*", ""); 569 | if (!guess) { 570 | return false; 571 | } 572 | $('#guess-btn').prop('disabled', true); 573 | let allGuessData = await getSim(guess); 574 | $('#guess-btn').prop('disabled', false); 575 | let guessData = null; 576 | if (allGuessData) { 577 | guessData = allGuessData[allGuessData.length - 1]; 578 | allGuessData = allGuessData.slice(0, allGuessData.length - 1); 579 | } 580 | if (guessData == null || guessData.similarity === null) { 581 | $('#error').text(`אני לא מכיר את המילה ${guess}.`); 582 | $('#guess')[0].select(); 583 | return false; 584 | } 585 | 586 | $('#guess').val(""); 587 | if (allGuessData.length > 1) { 588 | dealWithHistory(allGuessData); 589 | } 590 | dealWithGuess(guessData); 591 | return false; 592 | }); 593 | 594 | $("#clue-btn").click(async function(event) { 595 | const url = "/api/clue"; 596 | var clue; 597 | var keepDisabled = false; 598 | try { 599 | $("#clue-btn").attr("disabled", true) 600 | const response = await fetch(url); 601 | if (response.status === 200) { 602 | clue = (await response.json()).clue; 603 | $("#clue-btn").val("עוד רמז"); 604 | $("#clueList").append(`
  • ${clue}
  • `) 605 | } 606 | else if (response.status === 204 ) { 607 | clue = "אין יותר רמזים"; 608 | keepDisabled = true; 609 | } 610 | else if (response.status === 401) { 611 | clue = "רמזים זמינים רק למשתמשים מחוברים, יש להתחבר"; 612 | } 613 | else if (response.status === 402) { 614 | clue = "רמזים זאת הדרך שלנו לומר תודה למי שתרם לסמנטעל. משתמשים שלא תרמו זכאים לחמישה רמזים בשבוע. אפשר להמשיך לשחק בלי רמזים :)"; 615 | } 616 | } catch (e) { 617 | console.log(e); 618 | clue = "בעיה בקבלת רמז. אפשר לנסות שוב מאוחר יותר."; 619 | } 620 | $("#clue-btn").attr("disabled", keepDisabled); 621 | snackbarAlert(clue, response.status === 200 ? GREEN : YELLOW); 622 | }); 623 | 624 | let oldGuessesStr = $("#old_guesses").text(); 625 | if (oldGuessesStr && oldGuessesStr.length > 1) { 626 | let oldGuesses = JSON.parse(oldGuessesStr); 627 | clearState(true); 628 | dealWithHistory(oldGuesses); 629 | addToCache(oldGuesses); 630 | saveGame(-1, -1); 631 | } 632 | 633 | let storagePuzzleNumber = storage.getItem("puzzleNumber"); 634 | if (storagePuzzleNumber != puzzleNumber) { 635 | clearState(true); 636 | storage.setItem("puzzleNumber", puzzleNumber); 637 | } 638 | 639 | const winState = storage.getItem("winState"); 640 | if (winState != null) { 641 | guesses = fetchGuesses(); 642 | cache = JSON.parse(storage.getItem("cache")) || {}; 643 | guesses.sort(function(a, b){return b.similarity-a.similarity}); 644 | for (let guess of guesses) { 645 | guessed.add(guess.guess); 646 | } 647 | guessCount = guesses.length + 1; 648 | updateGuesses(); 649 | if (winState != -1) { 650 | endGame(winState); 651 | } 652 | } 653 | 654 | var x = setInterval(function() { 655 | // Find the distance between now and the count down date 656 | var distance = tomorrow.getTime() - Date.now(); 657 | if (distance < 0 && (!document.hidden)) { 658 | window.location.replace(location.protocol + '//' + location.host + location.pathname); 659 | return; 660 | } 661 | 662 | // Time calculations for days, hours, minutes and seconds 663 | var hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); 664 | var minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60)); 665 | var seconds = Math.floor((distance % (1000 * 60)) / 1000); 666 | 667 | // Output the result in an element with id="demo" 668 | document.getElementById("timer").innerHTML = "הסמנטעל הבא בעוד " + 669 | hours + ":" + minutes.toString().padStart(2, '0') + ":" + seconds.toString().padStart(2, '0'); 670 | 671 | // If the count down is over, write some text 672 | }, 1000); 673 | if (window.location.host === 'semantle-he.herokuapp.com') { 674 | window.location.replace("https://semantle.ishefi.com?guesses=" + JSON.stringify(guesses)); 675 | } 676 | updateStats(); 677 | twemoji.parse(document.body); 678 | } // end init 679 | 680 | function endGame(won, countStats) { 681 | let stats; 682 | if (handleStats) { 683 | stats = getStats(); 684 | if (countStats) { 685 | const onStreak = (stats['lastEnd'] == puzzleNumber - 1); 686 | 687 | stats['lastEnd'] = puzzleNumber; 688 | if (won) { 689 | if (onStreak) { 690 | stats['winStreak'] += 1; 691 | } else { 692 | stats['winStreak'] = 1; 693 | } 694 | stats['wins'] += 1; 695 | } else { 696 | stats['winStreak'] = 0; 697 | stats['giveups'] += 1; 698 | } 699 | storage.setItem("stats", JSON.stringify(stats)); 700 | } 701 | } 702 | 703 | gameOver = true; 704 | document.getElementById("timer").hidden = false; 705 | let response; 706 | 707 | const solverCountStory = getSolverCountStory("לפניך"); 708 | 709 | if (won) { 710 | storyToShare = solveStory(guesses, puzzleNumber); 711 | response = `

    712 | ניצחת! 713 | מצאת את הפתרון תוך ${guesses.length} ניחושים! 714 | ${solverCountStory} 715 | אפשר להמשיך לנסות להכניס מילים ולראות את הקרבה שלהן, 716 | וגם לשתף 717 | ולחזור לשחק מחר. 718 | 719 |
    720 |

    721 |

    722 | ואם ממש בא לך, 723 |
    724 | אפשר גם לקנות לי ☕, 725 |
    726 | לשחק בויקינג🪓, 727 | בדעגעל🚩 728 |
    729 | או ב־MiMaMu 730 |
    731 | או לעקוב אחרי כמה סופגניות אכלתי 732 |
    733 | עד הסמנטעל הבא 734 |

    ` 735 | } else { 736 | // right now we do not allow giving up 737 | response = `

    You gave up! The secret word is: ${secret}. Feel free to keep entering words if you are curious about the similarity to other words. You can see the nearest words here.

    `; 738 | } 739 | 740 | if (handleStats) { 741 | const totalGames = stats['wins'] + stats['giveups'] + stats['abandons']; 742 | response += `
    743 | Stats (since we started recording, on day 23):
    744 | 745 | 746 | 747 | 748 | 749 | 750 | 751 | 752 |
    First game:${stats['firstPlay']}
    Total days played:${totalGames}
    Wins:${stats['wins']}
    Win streak:${stats['winStreak']}
    Give-ups:${stats['giveups']}
    Did not finish${stats['abandons']}
    Total guesses across all games:${stats['totalGuesses']}
    753 | `; 754 | } 755 | $('#response').html(response); 756 | twemoji.parse($('#response')[0]); 757 | if (endGame) { 758 | saveGame(guesses.length, won ? 1 : 0); 759 | } 760 | } 761 | 762 | return { 763 | init: init, 764 | checkMedia: checkMedia, 765 | }; 766 | })(); 767 | 768 | // do this when the file loads instead of waiting for DOM to be ready to avoid 769 | // a flash of unstyled content 770 | Semantle.checkMedia(); 771 | 772 | const observer = new MutationObserver((mutations, obs) => { 773 | let includees = document.querySelectorAll('[w3-include-html]'); 774 | if (includees.length == 0) { 775 | Semantle.init(); 776 | obs.disconnect(); 777 | return; 778 | } 779 | }); 780 | 781 | observer.observe(document, { 782 | childList: true, 783 | subtree: true 784 | }); 785 | --------------------------------------------------------------------------------