Initial commit

This commit is contained in:
Dmitry Bikulov
2025-09-11 13:42:53 +03:00
commit d1bb3559bb
9 changed files with 640 additions and 0 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
.git
.gitignore
__pycache__
*.pyc
.pytest_cache
.mypy_cache
.venv
venv
node_modules
.DS_Store
.env
dist
build
*.log

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.pyc

25
Dockerfile Normal file
View File

@@ -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}"]

31
README.md Normal file
View File

@@ -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, ошибки сгенерированы правдоподобно (соседние произведения, небольшие смещения).

278
app/main.py Normal file
View File

@@ -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,
)

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
fastapi>=0.111,<1.0
uvicorn[standard]>=0.30,<1.0

177
static/app.js Normal file
View File

@@ -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 = `
<div class="score">Результат: <strong>${correct}</strong> из <strong>${total}</strong></div>
<div class="small">Отвечено: ${total_answered}</div>
<button class="again" id="againBtn">Сыграть ещё</button>
`;
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);
})();

32
static/index.html Normal file
View File

@@ -0,0 +1,32 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Тренажёр таблицы умножения</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<div class="app">
<h1>Тренажёр таблицы умножения</h1>
<div class="top-bar">
<div class="timer">Время: <span id="time">60</span> с</div>
<div class="progress">Вопрос: <span id="progress">0/20</span></div>
<button id="startBtn" class="start">Старт</button>
</div>
<div id="status" class="status"></div>
<div id="game" class="game hidden">
<div id="problem" class="problem">×</div>
<div id="options" class="options"></div>
</div>
<div id="result" class="result hidden"></div>
</div>
<div id="fxLayer" class="fx-layer"></div>
<script src="/static/app.js"></script>
</body>
</html>

80
static/style.css Normal file
View File

@@ -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; }
}