commit d1bb3559bb25334277c0cf43c20d6801780a7b97 Author: Dmitry Bikulov Date: Thu Sep 11 13:42:53 2025 +0300 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..aa73207 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.gitignore +__pycache__ +*.pyc +.pytest_cache +.mypy_cache +.venv +venv +node_modules +.DS_Store +.env +dist +build +*.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d20b64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4384080 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# syntax=docker/dockerfile:1 +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PORT=8000 + +WORKDIR /app + +# System deps (optional, kept minimal) +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +# Install Python deps first for better caching +COPY requirements.txt /app/requirements.txt +RUN pip install -r requirements.txt + +# Copy application code +COPY . /app + +EXPOSE 8000 + +CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..d94a997 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +Multiplication Table Trainer (FastAPI) + +Запуск локально + +- Установите зависимости: `pip install fastapi uvicorn` +- Запустите сервер: `uvicorn app.main:app --reload` +- Откройте в браузере: `http://127.0.0.1:8000/` + +Docker + +- Сборка: `docker build -t mult-trainer .` +- Запуск: `docker run --rm -p 8000:8000 mult-trainer` +- Переменная порта: `-e PORT=8000` (по умолчанию 8000) + +Описание + +- Кнопка «Старт» начинает сессию: 20 примеров, 60 секунд. +- На экране крупно показывается пример (например, «6 × 8») и 4 варианта ответа, один из которых правильный. +- Сервер проверяет ответы и ограничение по времени. По завершении показывается результат. + - В рамках одной сессии примеры не повторяются (исключены зеркальные дубли вида 6×8 и 8×6). + +API + +- POST `/api/session/start` → { session_id, total_questions, duration_seconds, remaining_seconds, question } +- POST `/api/session/{session_id}/answer` body: { answer } → { correct, finished, reason, remaining_seconds, score, question? } +- GET `/api/session/{session_id}/state` → текущее состояние (для возможного опроса) + +Замечания + +- Сессии хранятся в памяти процесса и сбрасываются при перезапуске. +- Диапазон множителей — от 2 до 9, ошибки сгенерированы правдоподобно (соседние произведения, небольшие смещения). diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..b9c740a --- /dev/null +++ b/app/main.py @@ -0,0 +1,278 @@ +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel +from typing import List, Dict, Optional +import random +import time +import uuid +import os + + +# ----------------- +# Models / Schemas +# ----------------- + +class Question(BaseModel): + index: int # 1-based question number + a: int + b: int + options: List[int] + + +class StartResponse(BaseModel): + session_id: str + total_questions: int + duration_seconds: int + remaining_seconds: int + question: Question + + +class AnswerRequest(BaseModel): + answer: int + + +class Score(BaseModel): + correct: int + total_answered: int + total: int + + +class AnswerResponse(BaseModel): + correct: bool + finished: bool + reason: Optional[str] = None # "completed" | "timeout" + remaining_seconds: int + score: Score + question: Optional[Question] = None + + +# ----------------- +# Session management +# ----------------- + +TOTAL_QUESTIONS = 20 +DURATION_SECONDS = 60 + + +class Session: + def __init__(self, total_questions: int = TOTAL_QUESTIONS, duration: int = DURATION_SECONDS): + self.id = str(uuid.uuid4()) + self.total_questions = total_questions + self.duration = duration + self.start_time = time.time() + self.questions = self._generate_questions(total_questions) + self.current_index = 0 # 0-based + self.correct_count = 0 + self.answered = 0 + self.finished = False + self.reason: Optional[str] = None + + def remaining_seconds(self) -> int: + remaining = int(self.duration - (time.time() - self.start_time)) + return max(0, remaining) + + def time_over(self) -> bool: + return self.remaining_seconds() <= 0 + + @staticmethod + def _generate_questions(n: int) -> List[Dict]: + # Build unique unordered pairs (avoid mirrored duplicates like 6×8 and 8×6) + all_pairs = [(a, b) for a in range(2, 10) for b in range(a, 10)] # a <= b + count = min(n, len(all_pairs)) + picked = random.sample(all_pairs, count) + + questions: List[Dict] = [] + for a0, b0 in picked: + # Randomize order for variety while keeping uniqueness by unordered pair + if random.random() < 0.5: + a, b = a0, b0 + else: + a, b = b0, a0 + correct = a * b + options = Session._generate_options(a, b, correct) + questions.append({ + "a": a, + "b": b, + "correct": correct, + "options": options, + }) + return questions + + @staticmethod + def _generate_options(a: int, b: int, correct: int) -> List[int]: + # Generate plausible distractors based on nearby multiplication results + candidates = set() + # Nearby products + for da, db in [(-1, 0), (1, 0), (0, -1), (0, 1)]: + na = a + da + nb = b + db + if 1 <= na <= 10 and 1 <= nb <= 10: + candidates.add(na * nb) + # Small offsets + for off in [-3, -2, -1, 1, 2, 3, 4]: + if correct + off > 0: + candidates.add(correct + off) + # Random other table values + while len(candidates) < 20: + candidates.add(random.randint(2, 10) * random.randint(2, 10)) + + # Remove the correct answer and pick three unique distractors + candidates.discard(correct) + distractors = random.sample(list(candidates), 3) + opts = distractors + [correct] + random.shuffle(opts) + return opts + + def current_question(self) -> Optional[Question]: + if self.current_index >= self.total_questions: + return None + q = self.questions[self.current_index] + return Question(index=self.current_index + 1, a=q["a"], b=q["b"], options=q["options"]) + + def submit_answer(self, answer: int) -> bool: + q = self.questions[self.current_index] + is_correct = (answer == q["correct"]) + if is_correct: + self.correct_count += 1 + self.answered += 1 + self.current_index += 1 + if self.current_index >= self.total_questions: + self.finished = True + self.reason = "completed" + return is_correct + + +SESSIONS: Dict[str, Session] = {} + + +# ------------- +# FastAPI setup +# ------------- + +app = FastAPI(title="Таблица умножения — тренажёр") + + +static_dir = os.path.join(os.path.dirname(__file__), "..", "static") +static_dir = os.path.abspath(static_dir) +if not os.path.isdir(static_dir): + os.makedirs(static_dir, exist_ok=True) + +app.mount("/static", StaticFiles(directory=static_dir), name="static") + + +@app.get("/") +def index(): + index_path = os.path.join(static_dir, "index.html") + if not os.path.exists(index_path): + raise HTTPException(status_code=404, detail="index.html not found") + return FileResponse(index_path) + + +@app.post("/api/session/start", response_model=StartResponse) +def start_session(): + session = Session() + SESSIONS[session.id] = session + if session.time_over(): + session.finished = True + session.reason = "timeout" + raise HTTPException(status_code=400, detail="Session immediately timed out") + q = session.current_question() + return StartResponse( + session_id=session.id, + total_questions=session.total_questions, + duration_seconds=session.duration, + remaining_seconds=session.remaining_seconds(), + question=q, + ) + + +def _get_session(session_id: str) -> Session: + session = SESSIONS.get(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + return session + + +@app.post("/api/session/{session_id}/answer", response_model=AnswerResponse) +def submit_answer(session_id: str, req: AnswerRequest): + session = _get_session(session_id) + if session.finished: + return AnswerResponse( + correct=False, + finished=True, + reason=session.reason or "completed", + remaining_seconds=session.remaining_seconds(), + score=Score(correct=session.correct_count, total_answered=session.answered, total=session.total_questions), + question=None, + ) + + if session.time_over(): + session.finished = True + session.reason = "timeout" + return AnswerResponse( + correct=False, + finished=True, + reason="timeout", + remaining_seconds=0, + score=Score(correct=session.correct_count, total_answered=session.answered, total=session.total_questions), + question=None, + ) + + # Must have a current question + current_q = session.current_question() + if current_q is None: + session.finished = True + session.reason = "completed" + return AnswerResponse( + correct=False, + finished=True, + reason="completed", + remaining_seconds=session.remaining_seconds(), + score=Score(correct=session.correct_count, total_answered=session.answered, total=session.total_questions), + question=None, + ) + + is_correct = session.submit_answer(req.answer) + + if session.time_over() and not session.finished: + session.finished = True + session.reason = "timeout" + + next_q = session.current_question() if not session.finished else None + + return AnswerResponse( + correct=is_correct, + finished=session.finished, + reason=session.reason, + remaining_seconds=session.remaining_seconds(), + score=Score(correct=session.correct_count, total_answered=session.answered, total=session.total_questions), + question=next_q, + ) + + +class StateResponse(BaseModel): + finished: bool + reason: Optional[str] + remaining_seconds: int + current_index: int + total_questions: int + correct: int + answered: int + + +@app.get("/api/session/{session_id}/state", response_model=StateResponse) +def get_state(session_id: str): + session = _get_session(session_id) + if session.time_over() and not session.finished: + session.finished = True + session.reason = "timeout" + return StateResponse( + finished=session.finished, + reason=session.reason, + remaining_seconds=session.remaining_seconds(), + current_index=min(session.current_index + 1, session.total_questions), + total_questions=session.total_questions, + correct=session.correct_count, + answered=session.answered, + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c511c5b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +fastapi>=0.111,<1.0 +uvicorn[standard]>=0.30,<1.0 diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..954b0e2 --- /dev/null +++ b/static/app.js @@ -0,0 +1,177 @@ +(() => { + const startBtn = document.getElementById('startBtn'); + const timeEl = document.getElementById('time'); + const progressEl = document.getElementById('progress'); + const problemEl = document.getElementById('problem'); + const optionsEl = document.getElementById('options'); + const gameEl = document.getElementById('game'); + const statusEl = document.getElementById('status'); + const resultEl = document.getElementById('result'); + const fxLayer = document.getElementById('fxLayer'); + + let sessionId = null; + let totalQuestions = 20; + let remaining = 60; + let countdown = null; + let inFlight = false; + + function setStatus(msg) { + statusEl.textContent = msg || ''; + } + + function showGame(show) { + gameEl.classList.toggle('hidden', !show); + } + + function showResult(score) { + const { correct, total_answered, total } = score; + resultEl.innerHTML = ` +
Результат: ${correct} из ${total}
+
Отвечено: ${total_answered}
+ + `; + resultEl.classList.remove('hidden'); + document.getElementById('againBtn')?.addEventListener('click', () => { + resultEl.classList.add('hidden'); + start(); + }); + } + + function renderQuestion(q) { + if (!q) return; + problemEl.textContent = `${q.a} × ${q.b}`; + optionsEl.innerHTML = ''; + q.options.forEach(value => { + const btn = document.createElement('button'); + btn.className = 'option-btn'; + btn.textContent = String(value); + btn.addEventListener('click', () => answer(value, btn)); + optionsEl.appendChild(btn); + }); + progressEl.textContent = `${q.index}/${totalQuestions}`; + } + + function stopTimer() { + if (countdown) { + clearInterval(countdown); + countdown = null; + } + } + + function startTimer(seconds) { + remaining = seconds; + timeEl.textContent = String(remaining); + stopTimer(); + countdown = setInterval(() => { + remaining -= 1; + if (remaining <= 0) { + remaining = 0; + timeEl.textContent = '0'; + stopTimer(); + } else { + timeEl.textContent = String(remaining); + } + }, 1000); + } + + async function start() { + if (inFlight) return; + inFlight = true; + setStatus(''); + showGame(false); + startBtn.disabled = true; + resultEl.classList.add('hidden'); + try { + const res = await fetch('/api/session/start', { method: 'POST' }); + if (!res.ok) throw new Error('Не удалось начать сессию'); + const data = await res.json(); + sessionId = data.session_id; + totalQuestions = data.total_questions; + startTimer(data.remaining_seconds); + renderQuestion(data.question); + showGame(true); + setStatus('Отвечайте как можно быстрее!'); + } catch (e) { + console.error(e); + setStatus('Ошибка старта. Попробуйте ещё раз.'); + } finally { + startBtn.disabled = false; + inFlight = false; + } + } + + function celebrateFromElement(el) { + if (!fxLayer) return; + const rect = el.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + const count = 40; + for (let i = 0; i < count; i++) { + const p = document.createElement('span'); + p.className = 'confetti'; + // origin + p.style.left = `${x}px`; + p.style.top = `${y}px`; + // random trajectory + const angle = Math.random() * Math.PI * 2; + const distance = 60 + Math.random() * 80; + const tx = Math.cos(angle) * distance; + const ty = Math.sin(angle) * distance + 40 + Math.random() * 60; // gravity pull + const rot = (Math.random() * 720 - 360).toFixed(0) + 'deg'; + p.style.setProperty('--tx', `${tx.toFixed(0)}px`); + p.style.setProperty('--ty', `${ty.toFixed(0)}px`); + p.style.setProperty('--rot', rot); + const hue = Math.floor(Math.random() * 360); + p.style.backgroundColor = `hsl(${hue} 90% 55%)`; + p.style.animation = `confetti ${650 + Math.random() * 400}ms ease-out forwards`; + fxLayer.appendChild(p); + setTimeout(() => p.remove(), 1200); + } + } + + async function answer(value, clickedBtn) { + if (!sessionId || inFlight) return; + inFlight = true; + // Disable all buttons to prevent double clicks + const buttons = Array.from(optionsEl.querySelectorAll('button')); + buttons.forEach(b => b.disabled = true); + try { + const res = await fetch(`/api/session/${sessionId}/answer`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ answer: value }), + }); + if (!res.ok) throw new Error('Не удалось отправить ответ'); + const data = await res.json(); + + // feedback + if (data.correct) { + clickedBtn.classList.add('correct'); + celebrateFromElement(clickedBtn); + } else { + clickedBtn.classList.add('wrong'); + } + + // Small pause to show feedback + await new Promise(r => setTimeout(r, data.correct ? 350 : 250)); + + if (data.finished) { + stopTimer(); + showGame(false); + const reason = data.reason === 'timeout' ? 'Время вышло.' : 'Завершено!'; + setStatus(reason); + showResult(data.score); + } else { + timeEl.textContent = String(data.remaining_seconds); + renderQuestion(data.question); + } + } catch (e) { + console.error(e); + setStatus('Ошибка ответа. Перезапустите сессию.'); + } finally { + inFlight = false; + } + } + + startBtn.addEventListener('click', start); +})(); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..6ec80c5 --- /dev/null +++ b/static/index.html @@ -0,0 +1,32 @@ + + + + + + Тренажёр таблицы умножения + + + +
+

Тренажёр таблицы умножения

+
+
Время: 60 с
+
Вопрос: 0/20
+ +
+ +
+ + + + +
+ +
+ + + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..f12ee9d --- /dev/null +++ b/static/style.css @@ -0,0 +1,80 @@ +:root { + --bg: #f8fafc; + --panel: #ffffff; + --text: #0f172a; + --muted: #475569; + --accent: #16a34a; + --danger: #dc2626; + --brand: #2563eb; + --border: #e2e8f0; + --shadow: 0 10px 20px rgba(2, 6, 23, 0.06), 0 2px 6px rgba(2, 6, 23, 0.05); +} + +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, + Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', Arial, sans-serif; + background: radial-gradient(1200px 600px at 10% 0%, #ffffff 0%, #f8fafc 70%); + color: var(--text); +} + +.app { max-width: 900px; margin: 0 auto; padding: 24px; } + +h1 { margin: 8px 0 20px; font-size: 28px; font-weight: 800; color: #0f172a; } + +.top-bar { display: flex; gap: 12px; align-items: center; } +.timer, .progress { background: var(--panel); padding: 10px 14px; border-radius: 10px; color: var(--muted); border: 1px solid var(--border); box-shadow: var(--shadow); } +.start { margin-left: auto; padding: 10px 16px; border-radius: 10px; border: 1px solid var(--border); background: var(--panel); color: #0f172a; cursor: pointer; box-shadow: var(--shadow); } +.start:hover { background: #f1f5f9; } +.start:disabled { opacity: 0.6; cursor: not-allowed; } + +.status { min-height: 22px; margin: 10px 0 8px; color: var(--muted); } + +.game { background: var(--panel); border: 1px solid var(--border); border-radius: 16px; padding: 28px; box-shadow: var(--shadow); } +.problem { text-align: center; font-size: 72px; font-weight: 900; letter-spacing: 1px; margin: 10px 0 24px; color: #0f172a; } +.options { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; } + +.option-btn { font-size: 28px; font-weight: 800; padding: 22px; border-radius: 14px; border: 1px solid var(--border); background: #ffffff; + color: #0f172a; cursor: pointer; transition: transform 80ms ease, background 0.2s ease, box-shadow 0.2s ease; box-shadow: var(--shadow); } +.option-btn:hover { transform: translateY(-1px); background: #f8fafc; } +.option-btn:active { transform: translateY(0); } +.option-btn:disabled { opacity: 0.8; cursor: default; transform: none; } +.option-btn.correct { outline: 2px solid var(--accent); box-shadow: 0 0 0 6px rgba(22,163,74,0.12), var(--shadow); animation: pop 280ms ease-out; } +.option-btn.wrong { outline: 2px solid var(--danger); box-shadow: 0 0 0 6px rgba(220,38,38,0.12), var(--shadow); animation: shake 300ms ease-in-out; } + +.result { margin-top: 18px; padding: 18px; background: var(--panel); border: 1px solid var(--border); border-radius: 14px; box-shadow: var(--shadow); } +.result .score { font-size: 20px; margin-bottom: 8px; } +.again { padding: 10px 16px; border-radius: 10px; border: 1px solid var(--border); background: var(--panel); color: #0f172a; cursor: pointer; box-shadow: var(--shadow); } +.again:hover { background: #f1f5f9; } + +.hidden { display: none; } + +/* Animations */ +@keyframes pop { + 0% { transform: scale(1); } + 50% { transform: scale(1.08); } + 100% { transform: scale(1); } +} + +@keyframes shake { + 0% { transform: translateX(0); } + 20% { transform: translateX(-6px); } + 40% { transform: translateX(6px); } + 60% { transform: translateX(-4px); } + 80% { transform: translateX(4px); } + 100% { transform: translateX(0); } +} + +.fx-layer { position: fixed; inset: 0; pointer-events: none; overflow: hidden; z-index: 999; } +.confetti { position: absolute; width: 8px; height: 14px; border-radius: 2px; will-change: transform, opacity; transform: translate(-50%, -50%); } +@keyframes confetti { + 0% { opacity: 1; transform: translate(0, 0) rotate(0deg); } + 100% { opacity: 0; transform: translate(var(--tx), var(--ty)) rotate(var(--rot)); } +} + +@media (max-width: 640px) { + .problem { font-size: 52px; } + .option-btn { font-size: 22px; padding: 18px; } +}