Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 17 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
---

# ГАЛЕРЕЯ

<br><br>

<table align="center" border="0" cellpadding="20">
Expand Down Expand Up @@ -188,13 +189,13 @@

### Категории скинов

| Редкость | Стоимость | Особенности |
|----------|-----------|-------------|
| Обычные | от 100 монет | Базовые расцветки |
| Редкие | от 200 монет | Уникальные дизайны |
| Эпические | от 390 монет | Анимированные эффекты |
| Легендарные | от 1000 монет | Особые визуальные эффекты |
| Эксклюзивные | События | Ограниченные серии |
| Редкость | Стоимость | Особенности |
| ------------ | ------------- | ------------------------- |
| Обычные | от 100 монет | Базовые расцветки |
| Редкие | от 200 монет | Уникальные дизайны |
| Эпические | от 390 монет | Анимированные эффекты |
| Легендарные | от 1000 монет | Особые визуальные эффекты |
| Эксклюзивные | События | Ограниченные серии |

---

Expand All @@ -208,11 +209,11 @@

### Система рейтинга

| Позиция | Награда |
|---------|---------|
| Топ 1 | Золотая рамка |
| Топ 2 | Серебряная рамка |
| Топ 3 | Бронзовая рамка |
| Позиция | Награда |
| ------- | ---------------- |
| Топ 1 | Золотая рамка |
| Топ 2 | Серебряная рамка |
| Топ 3 | Бронзовая рамка |

---

Expand All @@ -230,17 +231,20 @@
<br>

### Backend

- **Node.js + Express** — REST API и WebSocket сервер
- **PostgreSQL** — хранение профилей, инвентаря, рекордов
- **WebSocket (Socket.io)** — real-time мультиплеер
- **JWT** — аутентификация

### Frontend

- **HTML5 Canvas** — рендеринг игры
- **CSS3** — анимации и стилизация
- **Vanilla JavaScript** — игровая логика

### DevOps

- **Git** — контроль версий
- **npm** — пакетный менеджер

Expand All @@ -250,7 +254,6 @@

Этот проект распространяется под лицензией MIT. Подробнее см. в файле [LICENSE](LICENSE).


## КОНТРИБЬЮТОРЫ

<table align="center">
Expand Down Expand Up @@ -293,7 +296,7 @@
<table align="center">
<tr>
<td align="center">
<a href="https://github.com/KaVoshnik">
<a href="https://t.me/panik_tg">
<img src="screenshots/PanikIcon.png" width="70" height="70" style="border-radius: 50%;" alt="@PANIK!" />
<br />
<strong style="color: #00ff88;">PANIK!</strong>
Expand Down
50 changes: 31 additions & 19 deletions Roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
|------|--------|------|--------|----------|
| **0** | Фундамент и стабильность | ~~#5~~, #19, #20 | #19 и #20 сделаны полностью, #5 снят с повестки | — |
| **1** | Социальное ядро | #4, #12, #11 | #4 и #12 сделаны, #11 не начато | 1 неделя (осталось) |
| **2** | Удержание и прогресс | #1, #2, #3 | #1 сделано (16/~30), #2 и #3 не начаты | 2–3 недели (осталось) |
| **2** | Удержание и прогресс | #1, #2, #3 | #1 и #2 сделаны, #3 не начато | 2–3 недели (осталось) |
| **3** | Мета-сезон и косметика | #13, #14, #15 | #14 сделано, #13 не начато, #15 частично (нет отдельной категории trail) | 3–4 недели (осталось) |
| **4** | Полировка | #16 | сделано (achievement/bonus озвучены, остальное уже было) | — |

Expand All @@ -45,7 +45,7 @@ flowchart TD

subgraph phase2 [Фаза 2]
A1[#1 Достижения — готово]
A2[#2 Челленджи — не начато]
A2[#2 Челленджи — готово]
A3[#3 Сезонный LB — не начато]
end

Expand Down Expand Up @@ -187,28 +187,40 @@ flowchart TD

---

### #2 · Ежедневные и недельные челленджи — не начато
### #2 · Ежедневные и недельные челленджи — ✅ ГОТОВО (v3)

**Примечание:** отдельно от этого пункта уже реализована система daily streak (ежедневный вход + mini-chest награды) — она закрывает часть retention-цели, но это не то же самое, что цели-челленджи («съешь 30 вишен» и т.п.). Ниже — исходный план, актуален как есть.
**Статус:** три ревизии по ходу фидбека, ниже — итоговая версия.
- v1: 3 daily + 1 weekly, виджет на `/profile.html`
- v2: перенос на главную (лобби) — оказалось, что спрятанное в профиле никто не находил; заодно вскрылся баг с непереведёнными `data-i18n`-ключами (текст ключа рендерился как есть, т.к. сами строки забыли добавить в словарь `i18n.js`)
- **v3 (текущая):** личные челленджи сокращены до **1 daily + 1 weekly**, добавлен третий, принципиально другой тип — **общий (серверный) недельный челлендж**: один счётчик прогресса на ВСЕХ игроков сразу (например "наберите 100 000 очков всем сервером"), без личной награды, просто общая цель

**Цель:** причина заходить каждый день; мягкая выдача монет.
**Состояние:** личный прогресс — без новых таблиц БД, живёт в `entry.stats.challenges` (та же `stats` JSONB-колонка, что у стрика/ачивок/инвентаря еды). Общий прогресс — **новая generic key/value таблица `server_state`** (`db.js`) — единственный случай в проекте, где понадобилась реальная миграция, потому что общий счётчик не принадлежит ни одному игроку и потому не может лежать в чьём-то `stats`. Шаблоны обоих видов — в `data/challenges.js` (6 daily + 3 weekly личных пулов, 4 в общем пуле). Набор на день/неделю выбирается **детерминированно по UTC-дате** (`lib/challenges.js`, `mulberry32` + `djb2`-хэш даты как seed, у общего челленджа — свой namespace сида, чтобы не коррелировать с личным недельным) — все игроки видят один и тот же набор, ничего лишнего хранить/раздавать не нужно.

| Область | Задачи |
|---------|--------|
| **DB** | `challenge_templates`; `player_challenges` (player, challenge_id, period_start, progress, claimed) |
| **Ротация** | 3 daily + 1 weekly; seed от UTC-date |
| **Примеры** | «Съешь 30 вишен», «3 игры без смерти от яда», «Набери 200 очков за сессию» |
| **UI** | Виджет в лобби + профиль; кнопка «Забрать награду» |
| **Связь** | Переиспользовать счётчики из stats + hooks достижений (`#1`, готово) |
**Реализованные типы (личные):** `food`, `kill`, `games`, `score` (максимум за игру периода), `no_poison_death`.
**Реализованные типы (общий):** `food_sum`, `kill_sum`, `score_sum` — те же события, что у личных, но суммируются в один общий счётчик вместо `entry`.

**Критерии готовности:**
- [ ] Сброс daily в 00:00 UTC
- [ ] Weekly с понедельника
- [ ] Claim один раз; награда в `coins`
**Точки начисления прогресса** (переиспользуют уже существующие хуки, ни одного нового вызова в hot path тика): еда — `player.lifeFoodCount`, флашится при смерти/дисконнекте; убийства — `stats-rewards.js: awardKillCoins()`; игры — `profiles.js: startNewLife()`; счёт/причина смерти — `stats-rewards.js: trackDeathStats()`/`trackDisconnectStats()`. Общий прогресс триггерится из тех же самых точек параллельно личному.

**Общий челлендж — доставка и запись:** `ctx.globalChallenge` грузится из `server_state` при старте, шлётся всем в `hello` при коннекте, транслируется всем сокетам через `ctx.broadcast` при любом изменении (`notifyGlobalChange` в `lib/challenges.js`). Запись в БД **throttled раз в 10с через dirty-флаг** (`server.js`), а не на каждое событие — иначе один активный сервер бомбардировал бы Postgres на каждое убийство/укус.

**Побочный фикс по пути:** в `killPlayer`/`_killPlayer` (`game-loop.js`, `room.js`) `trackDeathStats` вызывался **до** того, как на `player` выставлялся `reasonKey` — из самого `trackDeathStats` было невозможно узнать причину смерти. Причина теперь считается в начале функции и передаётся явным параметром `{ reasonKey }`.

**Клейм награды (только личные):** `challenge_claim { challengeId, name }` — WS-тип в `prePlayerHandlers` (`message-handlers.js`). Награда — монеты, выдаётся ровно один раз. Общий челлендж без клейма — чистая витрина прогресса.

**UI (v3):**
- Личные (1 daily + 1 weekly) — компактные карточки под панелью друзей в лобби (`#personalChallengesPanel`, новый `.sideColumn`-враппер вокруг `#friendsPanel`, чтобы не трогать 3-колоночную сетку `.menuLayout`). На мобильном (`<1180px`, где `.sidePanel` целиком прячется) этот блок явно возвращается видимым — в отличие от друзей/топа это часть основного цикла, а не второстепенный виджет
- Общий — полоса прогресса `#globalChallengeBar` прямо над кнопкой "Играть" (там, где раньше сидел общий стрип v1/v2)
- Заодно расширили топ игроков в лобби с 6 до 10 (сервер уже отдавал до 20 записей, ограничение было только в клиентском `.slice(0, 6)`)
- Тексты — теперь реальные ключи в словаре `i18n.js` (`index.challengesTitle`, `index.challengesDayTag`, `index.challengesWeekTag` на ru/en), а не забытые `data-i18n`-атрибуты без перевода

**Тесты:** `test/challenges.test.js`, 23 теста — детерминизм ротации (личной и общей, независимо друг от друга), суммирование/максимум по типам, сброс прогресса при смене дня БЕЗ затрагивания недели (и наоборот), запрет повторного клейма, потолок `progress ≤ target` в payload, накопление общего счётчика несколькими игроками, игнор нулевого вклада (не шлём лишний broadcast).

**Зависимости:** `#1` готово — hooks уже есть, можно переиспользовать.
**Не сделано (низкий приоритет, кандидаты на следующую итерацию):**
- [ ] Награда/эффект по достижении общей цели (сейчас просто заполняется полоса и всё, дальше ничего не происходит — например, можно давать всем участникам небольшой бонус или разовое украшение)
- [ ] `no_poison_death` засчитывает любую смерть кроме яда как "успех" — если нужна более узкая формулировка ("выжил 3 игры подряд"), потребуется отдельная стрик-логика
- [ ] Явный лог/подтверждение того, кто и сколько внёс в общий челлендж (сейчас это просто сумма без разбивки по игрокам)

**Файлы:** `db.js`, `server.js`, `public/js/lobby.js`, `public/js/profile.js`
**Файлы:** `data/challenges.js`, `lib/challenges.js`, `db.js`, `server.js`, `lib/stats-rewards.js`, `lib/profiles.js`, `lib/game-loop.js`, `lib/room.js`, `lib/rooms-setup.js`, `lib/shop-actions.js`, `lib/message-handlers.js`, `public/index.html`, `public/css/lobby.css`, `public/js/lobby.js`, `public/js/i18n.js`, `test/challenges.test.js`

---

Expand Down Expand Up @@ -380,7 +392,7 @@ flowchart TD

## Предлагаемый порядок спринтов (актуально на июль 2026)

Выполнено вне этого порядка: `#4`, `#12`, `#1`, `#20`, `#14`, `#19`, `#16`. Оставшиеся:
Выполнено вне этого порядка: `#4`, `#12`, `#1`, `#20`, `#14`, `#19`, `#16`, `#2`. Оставшиеся:

| Спринт | Deliverable |
|--------|-------------|
Expand Down
24 changes: 24 additions & 0 deletions auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
// (Google/Яндекс/VK и т.п.) — всё живёт на нашем сервере, никуда не уходит.

const crypto = require("crypto");
const { RateLimiterRegistry } = require("./lib/rate-limiter");
const { getClientIp } = require("./lib/utils");

// Отдельный от WS реестр: здесь ключ — IP, а не clientId, и нет "disconnect",
// по которому можно было бы чистить запись, поэтому периодически подчищаем
// протухшие записи вручную (см. вызов authLimiter.cleanup() в server.js).
const authLimiter = new RateLimiterRegistry();

const SESSION_COOKIE = "snake_session";
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
Expand Down Expand Up @@ -102,6 +109,10 @@ function validateCredentials(name, password) {
return null;
}

function rateLimited(res, sendJson) {
sendJson(res, { ok: false, error: "Слишком много попыток. Попробуй чуть позже." });
}

async function handleRequest(req, res, url, ctx) {
const { db, sendJson } = ctx;

Expand All @@ -128,6 +139,10 @@ async function handleRequest(req, res, url, ctx) {
}

if (url.pathname === "/auth/register" && req.method === "POST") {
if (!authLimiter.check(getClientIp(req), "auth_register").allowed) {
rateLimited(res, sendJson);
return true;
}
let payload;
try {
payload = await readJsonBody(req);
Expand Down Expand Up @@ -159,6 +174,10 @@ async function handleRequest(req, res, url, ctx) {
}

if (url.pathname === "/auth/login" && req.method === "POST") {
if (!authLimiter.check(getClientIp(req), "auth_login").allowed) {
rateLimited(res, sendJson);
return true;
}
let payload;
try {
payload = await readJsonBody(req);
Expand All @@ -180,6 +199,10 @@ async function handleRequest(req, res, url, ctx) {
}

if (url.pathname === "/auth/claim" && req.method === "POST") {
if (!authLimiter.check(getClientIp(req), "auth_claim").allowed) {
rateLimited(res, sendJson);
return true;
}
let payload;
try {
payload = await readJsonBody(req);
Expand Down Expand Up @@ -232,4 +255,5 @@ module.exports = {
handleRequest,
hashPassword,
verifyPassword,
authLimiter,
};
Loading
Loading