diff --git a/README.md b/README.md index 37c9030..f3f4033 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ --- # ГАЛЕРЕЯ +

@@ -188,13 +189,13 @@ ### Категории скинов -| Редкость | Стоимость | Особенности | -|----------|-----------|-------------| -| Обычные | от 100 монет | Базовые расцветки | -| Редкие | от 200 монет | Уникальные дизайны | -| Эпические | от 390 монет | Анимированные эффекты | -| Легендарные | от 1000 монет | Особые визуальные эффекты | -| Эксклюзивные | События | Ограниченные серии | +| Редкость | Стоимость | Особенности | +| ------------ | ------------- | ------------------------- | +| Обычные | от 100 монет | Базовые расцветки | +| Редкие | от 200 монет | Уникальные дизайны | +| Эпические | от 390 монет | Анимированные эффекты | +| Легендарные | от 1000 монет | Особые визуальные эффекты | +| Эксклюзивные | События | Ограниченные серии | --- @@ -208,11 +209,11 @@ ### Система рейтинга -| Позиция | Награда | -|---------|---------| -| Топ 1 | Золотая рамка | -| Топ 2 | Серебряная рамка | -| Топ 3 | Бронзовая рамка | +| Позиция | Награда | +| ------- | ---------------- | +| Топ 1 | Золотая рамка | +| Топ 2 | Серебряная рамка | +| Топ 3 | Бронзовая рамка | --- @@ -230,17 +231,20 @@
### Backend + - **Node.js + Express** — REST API и WebSocket сервер - **PostgreSQL** — хранение профилей, инвентаря, рекордов - **WebSocket (Socket.io)** — real-time мультиплеер - **JWT** — аутентификация ### Frontend + - **HTML5 Canvas** — рендеринг игры - **CSS3** — анимации и стилизация - **Vanilla JavaScript** — игровая логика ### DevOps + - **Git** — контроль версий - **npm** — пакетный менеджер @@ -250,7 +254,6 @@ Этот проект распространяется под лицензией MIT. Подробнее см. в файле [LICENSE](LICENSE). - ## КОНТРИБЬЮТОРЫ
@@ -293,7 +296,7 @@
- + @PANIK!
PANIK! diff --git a/Roadmap.md b/Roadmap.md index 83dfc7e..45e74f0 100644 --- a/Roadmap.md +++ b/Roadmap.md @@ -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 озвучены, остальное уже было) | — | @@ -45,7 +45,7 @@ flowchart TD subgraph phase2 [Фаза 2] A1[#1 Достижения — готово] - A2[#2 Челленджи — не начато] + A2[#2 Челленджи — готово] A3[#3 Сезонный LB — не начато] end @@ -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` --- @@ -380,7 +392,7 @@ flowchart TD ## Предлагаемый порядок спринтов (актуально на июль 2026) -Выполнено вне этого порядка: `#4`, `#12`, `#1`, `#20`, `#14`, `#19`, `#16`. Оставшиеся: +Выполнено вне этого порядка: `#4`, `#12`, `#1`, `#20`, `#14`, `#19`, `#16`, `#2`. Оставшиеся: | Спринт | Deliverable | |--------|-------------| diff --git a/auth.js b/auth.js index 315650e..a4b47d5 100644 --- a/auth.js +++ b/auth.js @@ -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; @@ -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; @@ -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); @@ -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); @@ -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); @@ -232,4 +255,5 @@ module.exports = { handleRequest, hashPassword, verifyPassword, + authLimiter, }; diff --git a/data/challenges.js b/data/challenges.js new file mode 100644 index 0000000..9408cd3 --- /dev/null +++ b/data/challenges.js @@ -0,0 +1,51 @@ +"use strict"; + +// Пулы шаблонов челленджей. Активный набор на день/неделю выбирается +// детерминированно по UTC-дате (см. lib/challenges.js: getActiveDaily/ +// getActiveWeekly/getActiveGlobalWeekly) — все игроки в один и тот же день +// видят один и тот же набор, сид зависит только от даты, а не от игрока. +// +// kind (личные daily/weekly): +// "food" — съедена хорошая еда. foodKind: null = любой вид, +// иначе конкретный (см. lib/food.js GOOD_FOOD_KINDS). +// "kill" — убийство другого игрока. +// "games" — начата новая жизнь (аналог entry.stats.games). +// "score" — очки за ОДНУ игру за период (берётся максимум). +// "no_poison_death" — жизнь завершилась смертью НЕ от яда. +// +// kind (общий, GLOBAL_WEEKLY_POOL) — то же событие, что и у личных, но +// прогресс не персональный, а один общий счётчик на весь сервер: +// "food_sum" — сумма съеденной еды ВСЕМИ игроками за неделю +// "kill_sum" — сумма убийств ВСЕМИ игроками за неделю +// "score_sum" — сумма очков ВСЕХ завершённых жизней за неделю + +const DAILY_POOL = [ + { id: "daily_eat_apple_15", kind: "food", foodKind: "apple", name: "Яблочный день", desc: "Съешь 15 яблок", icon: "🍎", target: 15, reward: 30 }, + { id: "daily_eat_any_30", kind: "food", foodKind: null, name: "Обжора", desc: "Съешь 30 фруктов (любых)", icon: "🍇", target: 30, reward: 35 }, + { id: "daily_kills_5", kind: "kill", name: "Охотник", desc: "Убей 5 игроков", icon: "⚔️", target: 5, reward: 45 }, + { id: "daily_score_150", kind: "score", name: "Рывок", desc: "Набери 150 очков за одну игру", icon: "📈", target: 150, reward: 35 }, + { id: "daily_games_3", kind: "games", name: "Разминка", desc: "Сыграй 3 игры", icon: "🎮", target: 3, reward: 25 }, + { id: "daily_no_poison_3", kind: "no_poison_death", name: "Осторожный", desc: "Заверши 3 игры, не съев яд", icon: "🧪", target: 3, reward: 30 }, +]; + +const WEEKLY_POOL = [ + { id: "weekly_kills_25", kind: "kill", name: "Гроза сервера", desc: "Убей 25 игроков за неделю", icon: "🗡️", target: 25, reward: 150 }, + { id: "weekly_eat_any_150", kind: "food", foodKind: null, name: "Фуражир", desc: "Собери 150 фруктов за неделю", icon: "🧺", target: 150, reward: 140 }, + { id: "weekly_score_400", kind: "score", name: "На пределе", desc: "Набери 400 очков за одну игру", icon: "🚀", target: 400, reward: 160 }, +]; + +const GLOBAL_WEEKLY_POOL = [ + { id: "global_score_100k", kind: "score_sum", name: "Общий рывок", desc: "Наберите 100 000 очков всем сервером за неделю", icon: "🚀", target: 100000 }, + { id: "global_apples_1000", kind: "food_sum", foodKind: "apple", name: "Яблочный урожай", desc: "Съешьте 1000 яблок всем сервером за неделю", icon: "🍎", target: 1000 }, + { id: "global_food_2500", kind: "food_sum", foodKind: null, name: "Пир на весь мир", desc: "Соберите 2500 фруктов всем сервером за неделю", icon: "🧺", target: 2500 }, + { id: "global_kills_800", kind: "kill_sum", name: "Мясорубка", desc: "800 убийств всем сервером за неделю", icon: "⚔️", target: 800 }, +]; + +const DAILY_PICK_COUNT = 1; +const WEEKLY_PICK_COUNT = 1; +const GLOBAL_WEEKLY_PICK_COUNT = 1; + +module.exports = { + DAILY_POOL, WEEKLY_POOL, GLOBAL_WEEKLY_POOL, + DAILY_PICK_COUNT, WEEKLY_PICK_COUNT, GLOBAL_WEEKLY_PICK_COUNT, +}; diff --git a/db.js b/db.js index aee286d..f9c61b1 100644 --- a/db.js +++ b/db.js @@ -153,6 +153,16 @@ async function init() { ); CREATE INDEX IF NOT EXISTS idx_food_listings_seller ON food_listings (seller_name); + + -- Общее (не по-игроку) состояние сервера — сейчас единственный + -- потребитель это общий недельный челлендж (см. lib/challenges.js: + -- globalChallenge), но таблица сделана как generic key/value, чтобы под + -- следующую такую штуку не нужна была новая миграция. + CREATE TABLE IF NOT EXISTS server_state ( + key VARCHAR(64) PRIMARY KEY, + value JSONB NOT NULL DEFAULT '{}'::jsonb, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); `); await migratePlayersTable(); @@ -496,6 +506,19 @@ async function deleteFoodListing(id) { await pool.query("DELETE FROM food_listings WHERE id = $1::uuid", [id]); } +async function getServerState(key) { + const { rows } = await pool.query("SELECT value FROM server_state WHERE key = $1", [key]); + return rows[0]?.value ?? null; +} + +async function setServerState(key, value) { + await pool.query( + `INSERT INTO server_state (key, value, updated_at) VALUES ($1, $2::jsonb, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`, + [key, JSON.stringify(value)], + ); +} + // Один жалобщик — одна жалоба на цель (ON CONFLICT игнорируем повторную). async function reportAvatar(reporterName, targetName) { await pool.query( @@ -658,6 +681,8 @@ module.exports = { loadFoodListings, upsertFoodListing, deleteFoodListing, + getServerState, + setServerState, reportAvatar, loadAvatarReports, clearAvatarReports, diff --git a/lib/challenges.js b/lib/challenges.js new file mode 100644 index 0000000..bce9c21 --- /dev/null +++ b/lib/challenges.js @@ -0,0 +1,296 @@ +"use strict"; + +// Ежедневные/недельные челленджи. Личный прогресс живёт в +// entry.stats.challenges (та же stats JSONB-колонка, что и стрик/ачивки/ +// инвентарь еды — новой миграции БД не требуется). Общий (серверный) +// недельный челлендж — отдельная штука: один счётчик на всех игроков, +// живёт в ctx.globalChallenge + таблице server_state (см. db.js), т.к. +// это не персональные данные и в stats профиля ему не место. +// +// Личные функции (get/apply/ensureFreshState/claimChallenge) намеренно не +// знают про ctx/сеть — принимают и мутируют только entry, вызывающий код +// (stats-rewards.js, profiles.js, message-handlers.js) сам решает, когда +// персистить профиль. Общие applyGlobal*-функции по необходимости берут ctx +// (общий счётчик больше негде хранить) и сами дёргают ctx.persistGlobalChallenge/ +// ctx.broadcast, если они подключены. + +const { + DAILY_POOL, WEEKLY_POOL, GLOBAL_WEEKLY_POOL, + DAILY_PICK_COUNT, WEEKLY_PICK_COUNT, GLOBAL_WEEKLY_PICK_COUNT, +} = require("../data/challenges"); + +// ============================================================ +// ДЕТЕРМИНИРОВАННЫЙ ВЫБОР АКТИВНОГО НАБОРА ПО ДАТЕ +// ============================================================ + +// djb2 — не нужна крипто-стойкость, только стабильность и разброс для seed'а. +function hashString(str) { + let h = 5381; + for (let i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) | 0; + return h >>> 0; +} + +// mulberry32 — маленький детерминированный PRNG без внешних зависимостей. +function mulberry32(seed) { + let a = seed; + return () => { + a |= 0; a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function pickN(pool, n, seedStr) { + const rng = mulberry32(hashString(seedStr)); + const arr = pool.slice(); + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr.slice(0, Math.min(n, arr.length)); +} + +function dailyPeriodKey(now) { + return now.toISOString().slice(0, 10); // YYYY-MM-DD UTC +} + +// Понедельник (UTC) текущей ISO-недели, как YYYY-MM-DD. +function weeklyPeriodKey(now) { + const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const day = d.getUTCDay() || 7; // Sun=0 -> 7 + if (day !== 1) d.setUTCDate(d.getUTCDate() - (day - 1)); + return d.toISOString().slice(0, 10); +} + +function getActiveDaily(now = new Date()) { + return pickN(DAILY_POOL, DAILY_PICK_COUNT, `daily:${dailyPeriodKey(now)}`); +} + +function getActiveWeekly(now = new Date()) { + return pickN(WEEKLY_POOL, WEEKLY_PICK_COUNT, `weekly:${weeklyPeriodKey(now)}`); +} + +function getActiveGlobalWeekly(now = new Date()) { + return pickN(GLOBAL_WEEKLY_POOL, GLOBAL_WEEKLY_PICK_COUNT, `globalweekly:${weeklyPeriodKey(now)}`)[0]; +} + +// ============================================================ +// СОСТОЯНИЕ ИГРОКА (entry.stats.challenges) +// ============================================================ + +function blankState() { + return { dailyDate: null, weeklyDate: null, dailyProgress: {}, weeklyProgress: {}, dailyClaimed: [], weeklyClaimed: [] }; +} + +// Гарантирует, что entry.stats.challenges существует и актуален периоду now. +// Если дата дневного/недельного периода сменилась — прогресс и клеймы +// соответствующего масштаба обнуляются. Это важно: шаблон из старого набора +// мог попасть и в новый (пул небольшой), без сброса челлендж пришёл бы к +// игроку уже "выполненным" на прогрессе из прошлого периода. +function ensureFreshState(entry, now = new Date()) { + if (!entry.stats.challenges || typeof entry.stats.challenges !== "object") { + entry.stats.challenges = blankState(); + } + const state = entry.stats.challenges; + const dKey = dailyPeriodKey(now); + const wKey = weeklyPeriodKey(now); + if (state.dailyDate !== dKey) { state.dailyDate = dKey; state.dailyProgress = {}; state.dailyClaimed = []; } + if (state.weeklyDate !== wKey) { state.weeklyDate = wKey; state.weeklyProgress = {}; state.weeklyClaimed = []; } + if (!state.dailyProgress || typeof state.dailyProgress !== "object") state.dailyProgress = {}; + if (!state.weeklyProgress || typeof state.weeklyProgress !== "object") state.weeklyProgress = {}; + if (!Array.isArray(state.dailyClaimed)) state.dailyClaimed = []; + if (!Array.isArray(state.weeklyClaimed)) state.weeklyClaimed = []; + return state; +} + +// ============================================================ +// НАЧИСЛЕНИЕ ПРОГРЕССА +// ============================================================ + +function bumpProgress(state, scope, template, amount, mode) { + const bag = scope === "daily" ? state.dailyProgress : state.weeklyProgress; + const claimed = scope === "daily" ? state.dailyClaimed : state.weeklyClaimed; + if (claimed.includes(template.id)) return; // забрано — прогресс больше не двигаем + const current = bag[template.id] || 0; + bag[template.id] = mode === "max" ? Math.max(current, amount) : current + amount; +} + +function forEachActiveTemplate(now, matchKind, fn) { + for (const t of getActiveDaily(now)) if (t.kind === matchKind) fn("daily", t); + for (const t of getActiveWeekly(now)) if (t.kind === matchKind) fn("weekly", t); +} + +// lifeFoodCount копится за жизнь на самом player (см. game-loop.js/room.js, +// поле player.lifeFoodCount) и прилетает сюда одним вызовом при смерти или +// дисконнекте — та же экономия на трафике/записях в БД, что уже применяется +// к player.inventory (см. комментарий про foodInventory в lib/game-loop.js). +function applyFoodProgress(entry, lifeFoodCount, now = new Date()) { + if (!lifeFoodCount) return; + const state = ensureFreshState(entry, now); + forEachActiveTemplate(now, "food", (scope, t) => { + const amount = t.foodKind + ? (lifeFoodCount[t.foodKind] || 0) + : Object.values(lifeFoodCount).reduce((s, v) => s + (v || 0), 0); + if (amount > 0) bumpProgress(state, scope, t, amount, "sum"); + }); +} + +function applyKillProgress(entry, now = new Date()) { + const state = ensureFreshState(entry, now); + forEachActiveTemplate(now, "kill", (scope, t) => bumpProgress(state, scope, t, 1, "sum")); +} + +function applyGamePlayedProgress(entry, now = new Date()) { + const state = ensureFreshState(entry, now); + forEachActiveTemplate(now, "games", (scope, t) => bumpProgress(state, scope, t, 1, "sum")); +} + +// score — максимум за ОДНУ игру в периоде (mode="max"); no_poison_death — +// счётчик жизней, закончившихся не от яда (смерть от игрока/стены/босса тоже +// засчитывается — важно "не от яда", а не "выжил"). +function applyGameFinishedProgress(entry, { score, diedFromPoison }, now = new Date()) { + const state = ensureFreshState(entry, now); + forEachActiveTemplate(now, "score", (scope, t) => bumpProgress(state, scope, t, score || 0, "max")); + if (!diedFromPoison) { + forEachActiveTemplate(now, "no_poison_death", (scope, t) => bumpProgress(state, scope, t, 1, "sum")); + } +} + +// ============================================================ +// ПОЛЕЗНАЯ НАГРУЗКА ДЛЯ КЛИЕНТА / КЛЕЙМ НАГРАДЫ +// ============================================================ + +function toPayloadItem(state, scope, template) { + const bag = scope === "daily" ? state.dailyProgress : state.weeklyProgress; + const claimed = scope === "daily" ? state.dailyClaimed : state.weeklyClaimed; + const progress = Math.min(bag[template.id] || 0, template.target); + return { + id: template.id, name: template.name, desc: template.desc, icon: template.icon, + target: template.target, reward: template.reward, progress, + completed: progress >= template.target, claimed: claimed.includes(template.id), + }; +} + +function nextDailyResetIso(now) { + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1)).toISOString(); +} + +function nextWeeklyResetIso(now) { + const monday = new Date(`${weeklyPeriodKey(now)}T00:00:00.000Z`); + monday.setUTCDate(monday.getUTCDate() + 7); + return monday.toISOString(); +} + +function getChallengesPayload(entry, now = new Date()) { + const state = ensureFreshState(entry, now); + return { + daily: getActiveDaily(now).map((t) => toPayloadItem(state, "daily", t)), + weekly: getActiveWeekly(now).map((t) => toPayloadItem(state, "weekly", t)), + dailyResetAt: nextDailyResetIso(now), + weeklyResetAt: nextWeeklyResetIso(now), + }; +} + +// Забирает награду за выполненный челлендж ровно один раз — claimed-массив +// в state это гарантирует (повторный вызов вернёт ok:false). +function claimChallenge(entry, challengeId, now = new Date()) { + const state = ensureFreshState(entry, now); + const daily = getActiveDaily(now).find((t) => t.id === challengeId); + const weekly = !daily && getActiveWeekly(now).find((t) => t.id === challengeId); + const template = daily || weekly; + if (!template) return { ok: false, text: "Такого челленджа сейчас нет в наборе." }; + const scope = daily ? "daily" : "weekly"; + const bag = scope === "daily" ? state.dailyProgress : state.weeklyProgress; + const claimed = scope === "daily" ? state.dailyClaimed : state.weeklyClaimed; + if (claimed.includes(template.id)) return { ok: false, text: "Награда уже получена." }; + if ((bag[template.id] || 0) < template.target) return { ok: false, text: "Челлендж ещё не выполнен." }; + claimed.push(template.id); + return { ok: true, reward: template.reward, template }; +} + +// ============================================================ +// ОБЩИЙ (СЕРВЕРНЫЙ) НЕДЕЛЬНЫЙ ЧЕЛЛЕНДЖ +// ============================================================ +// В отличие от личных, тут нет "прогресса игрока" — один счётчик, в который +// пишут вклад ВСЕ игроки сразу. Живёт в ctx.globalChallenge (см. server.js — +// грузится из server_state при старте, персистится раз в N секунд через +// ctx.persistGlobalChallenge, транслируется всем сокетам через ctx.broadcast). + +function blankGlobalState() { + return { weekKey: null, progress: 0 }; +} + +// raw — то, что лежит в ctx.globalChallenge (может быть null при первом +// запуске сервера или null из ещё пустой server_state). +function ensureFreshGlobalState(raw, now = new Date()) { + const state = raw && typeof raw === "object" ? { ...blankGlobalState(), ...raw } : blankGlobalState(); + const key = weeklyPeriodKey(now); + if (state.weekKey !== key) { state.weekKey = key; state.progress = 0; } + if (typeof state.progress !== "number" || !Number.isFinite(state.progress)) state.progress = 0; + return state; +} + +function ensureCtxGlobalState(ctx, now = new Date()) { + ctx.globalChallenge = ensureFreshGlobalState(ctx.globalChallenge, now); + return ctx.globalChallenge; +} + +function getGlobalChallengePayload(rawState, now = new Date()) { + const template = getActiveGlobalWeekly(now); + const state = ensureFreshGlobalState(rawState, now); + return { + id: template.id, name: template.name, desc: template.desc, icon: template.icon, + target: template.target, progress: Math.min(state.progress, template.target), + completed: state.progress >= template.target, + resetAt: nextWeeklyResetIso(now), + }; +} + +// После мутации ctx.globalChallenge зовём (если подключены) персист и +// бродкаст всем сокетам — обе функции опциональны, чтобы модуль оставался +// тестируемым без реального ctx (см. test/challenges.test.js). +function notifyGlobalChange(ctx, now) { + if (ctx.persistGlobalChallenge) ctx.persistGlobalChallenge(ctx.globalChallenge); + if (ctx.broadcast) ctx.broadcast({ type: "global_challenge", ...getGlobalChallengePayload(ctx.globalChallenge, now) }); +} + +// lifeFoodCount — то же самое накопление за жизнь, что уже используется для +// личного прогресса (applyFoodProgress), просто суммируется в общий счётчик, +// а не в entry конкретного игрока. +function applyGlobalFoodProgress(ctx, lifeFoodCount, now = new Date()) { + if (!lifeFoodCount) return; + const template = getActiveGlobalWeekly(now); + if (template.kind !== "food_sum") return; + const amount = template.foodKind + ? (lifeFoodCount[template.foodKind] || 0) + : Object.values(lifeFoodCount).reduce((s, v) => s + (v || 0), 0); + if (amount <= 0) return; + const state = ensureCtxGlobalState(ctx, now); + state.progress += amount; + notifyGlobalChange(ctx, now); +} + +function applyGlobalKillProgress(ctx, now = new Date()) { + const template = getActiveGlobalWeekly(now); + if (template.kind !== "kill_sum") return; + const state = ensureCtxGlobalState(ctx, now); + state.progress += 1; + notifyGlobalChange(ctx, now); +} + +function applyGlobalScoreProgress(ctx, score, now = new Date()) { + const template = getActiveGlobalWeekly(now); + if (template.kind !== "score_sum" || !(score > 0)) return; + const state = ensureCtxGlobalState(ctx, now); + state.progress += score; + notifyGlobalChange(ctx, now); +} + +module.exports = { + getActiveDaily, getActiveWeekly, ensureFreshState, getChallengesPayload, + applyFoodProgress, applyKillProgress, applyGamePlayedProgress, applyGameFinishedProgress, + claimChallenge, dailyPeriodKey, weeklyPeriodKey, + getActiveGlobalWeekly, ensureFreshGlobalState, getGlobalChallengePayload, + applyGlobalFoodProgress, applyGlobalKillProgress, applyGlobalScoreProgress, +}; diff --git a/lib/game-loop.js b/lib/game-loop.js index d7d9375..f2e9f1c 100644 --- a/lib/game-loop.js +++ b/lib/game-loop.js @@ -137,6 +137,11 @@ function createPlayer(ctx, id, name, skin) { activeBonus: null, bonusExpires: null, combo: 0, maxCombo: 0, inventory: { ...shopEntry.stats.foodInventory }, + // Счётчик съеденной хорошей еды за эту жизнь — отдельно от inventory + // (у того есть кап INVENTORY_CAP, а прогресс челленджей не должен + // на него натыкаться). Флашится в entry.stats.challenges при + // смерти/дисконнекте, см. lib/stats-rewards.js. + lifeFoodCount: Object.fromEntries(Object.keys(shopEntry.stats.foodInventory).map((k) => [k, 0])), avatar: cos.avatar, snakeHatEmoji: cos.snakeHatEmoji, snakeHatId: cos.snakeHatId, nickColor: resolveNickColorHex(shopEntry), frozenUntil: Date.now() + SPAWN_FREEZE_MS, @@ -198,16 +203,21 @@ function spawnBonuses(ctx) { function killPlayer(ctx, player, reason, opts = {}) { if (!player.alive) return; ctx.tickJournal.deaths.push(player.id); - statsRewards.trackDeathStats(ctx, player); + // Считаем reasonKey заранее (а не после trackDeathStats), чтобы + // stats-rewards.trackDeathStats мог отличить смерть от яда для прогресса + // челленджей ("Осторожный") — раньше reasonKey выставлялся только ПОСЛЕ + // trackDeathStats и был там недоступен. + const reasonKey = opts.killerPlayer ? "death.killedByPlayer" : (opts.reasonKey || null); + statsRewards.trackDeathStats(ctx, player, { reasonKey }); player.alive = false; player.deaths += 1; if (opts.killerPlayer) { player.reason = `${opts.killerPlayer.name} убил тебя`; - player.reasonKey = "death.killedByPlayer"; + player.reasonKey = reasonKey; player.reasonParams = { name: opts.killerPlayer.name }; } else { player.reason = reason; - player.reasonKey = opts.reasonKey || null; + player.reasonKey = reasonKey; player.reasonParams = opts.reasonParams || null; } player.activeBonus = null; @@ -359,6 +369,7 @@ function tick(ctx) { if (player.inventory[eaten.kind] !== undefined && player.inventory[eaten.kind] < gameConfig.INVENTORY_CAP) { player.inventory[eaten.kind] += 1; } + if (player.lifeFoodCount[eaten.kind] !== undefined) player.lifeFoodCount[eaten.kind] += 1; } else if (player.activeBonus === "shield") { player.activeBonus = null; ctx.broadcast({ type: "notice", text: `${player.name}: щит поглотил ${FOOD_TYPES[eaten.kind]?.label || "яд"}!` }); diff --git a/lib/message-handlers.js b/lib/message-handlers.js index 0470000..d79a583 100644 --- a/lib/message-handlers.js +++ b/lib/message-handlers.js @@ -5,6 +5,7 @@ const roomsSetup = require("./rooms-setup"); const gameLoop = require("./game-loop"); const shopActions = require("./shop-actions"); const market = require("./market"); +const challengesMod = require("./challenges"); const phrasesMod = require("./phrases"); const { directionFromKey, isOpposite } = require("./utils"); const { AVATAR_PRESETS } = require("../data/shop-catalog"); @@ -20,6 +21,28 @@ async function handleShopConnect(ctx, id, message) { ctx.sendShopPayload(id, resolved.name); } +// ============================================================ +// ЧЕЛЛЕНДЖИ +// ============================================================ + +function handleChallengeClaim(ctx, id, challengeId, nameHint) { + const name = profiles.resolveName(ctx, id, nameHint); + if (!name) return; + const entry = profiles.getProfile(ctx, name); + const result = challengesMod.claimChallenge(entry, challengeId); + if (!result.ok) { ctx.send(id, { type: "notice", text: result.text }); return; } + + entry.coins = (Number(entry.coins) || 0) + result.reward; + const player = ctx.players.get(id); + if (player) player.coins = entry.coins; + ctx.shopData[name] = entry; + profiles.persistProfile(ctx, name, entry); + + ctx.send(id, { type: "notice", text: `${result.template.icon} ${result.template.name}: +${result.reward} монет!` }); + ctx.sendShopPayload(id, name); + if (player) ctx.resyncPlayer(id); +} + // ============================================================ // ROOM HANDLERS // ============================================================ @@ -256,6 +279,7 @@ const prePlayerHandlers = { market_list: (ctx, id, msg) => market.marketList(ctx, id, msg.kind, msg.quantity, msg.pricePerUnit, msg.name), market_cancel: (ctx, id, msg) => market.marketCancel(ctx, id, msg.listingId, msg.name), market_buy: (ctx, id, msg) => market.marketBuy(ctx, id, msg.listingId, msg.quantity, msg.name), + challenge_claim: (ctx, id, msg) => handleChallengeClaim(ctx, id, msg.challengeId, msg.name), set_phrase_wheel: (ctx, id, msg) => shopActions.setPhraseWheel(ctx, id, msg.slots, msg.name), }; diff --git a/lib/profiles.js b/lib/profiles.js index b23c48c..e7f0f6c 100644 --- a/lib/profiles.js +++ b/lib/profiles.js @@ -1,6 +1,7 @@ "use strict"; const achievementsMod = require("./achievements"); +const challengesMod = require("./challenges"); const foodMod = require("./food"); const { SHOP_CATALOG, AVATAR_PRESETS } = require("../data/shop-catalog"); const { resolveNickColorHex } = require("../data/battle-pass"); @@ -106,6 +107,10 @@ function normalizeProfile(raw) { unlockedAchievements: Array.isArray(raw.stats?.unlockedAchievements) ? [...raw.stats.unlockedAchievements] : [], dailyChestAvailable: raw.stats?.dailyChestAvailable || false, dailyChestDate: raw.stats?.dailyChestDate || null, + // Прогресс ежедневных/недельных квестов (см. lib/challenges.js). Без + // переноса сюда normalizeProfile на каждом чтении профиля обнулял бы + // прогресс квестов, даже если он уже был честно сохранён в БД. + challenges: raw.stats?.challenges && typeof raw.stats.challenges === "object" ? raw.stats.challenges : null, // Персистентный инвентарь еды — живёт в аккаунте, не в одной жизни змейки. foodInventory: { ...Object.fromEntries(foodMod.GOOD_FOOD_KINDS.map((k) => [k, 0])), @@ -194,6 +199,7 @@ function startNewLife(ctx, name) { entry.stats.games = (entry.stats.games || 0) + 1; entry.stats.sessionStart = Date.now(); touchDailyStreak(entry); + challengesMod.applyGamePlayedProgress(entry); ctx.shopData[name] = entry; profileIndexSet(ctx, name); persistProfile(ctx, name, entry); diff --git a/lib/rate-limiter.js b/lib/rate-limiter.js index ead4e85..a03566b 100644 --- a/lib/rate-limiter.js +++ b/lib/rate-limiter.js @@ -31,10 +31,20 @@ class TokenBucket { // Лимиты по типу WS-сообщения. Всё, что не перечислено явно, попадает в "default". // turn — самый частый тип (может слаться на каждое нажатие/свайп), даём запас побольше. +// +// auth_login/auth_register/auth_claim используют этот же механизм, но с ключом +// по IP вместо clientId (см. lib/utils.js getClientIp + auth.js) — там нет +// постоянного соединения, поэтому лимитим сам HTTP-запрос на логин/регистрацию. const LIMITS = { turn: { capacity: 20, refillPerSec: 20 }, ping: { capacity: 5, refillPerSec: 2 }, default: { capacity: 10, refillPerSec: 5 }, + // 5 попыток логина сразу, дальше по одной раз в минуту — защита от брутфорса пароля. + auth_login: { capacity: 5, refillPerSec: 5 / 300 }, + // Регистрация нужна редко — 3 сразу, дальше одна в 20 минут (защита от спама аккаунтов). + auth_register: { capacity: 3, refillPerSec: 3 / 3600 }, + // Claim-токен — как логин: 5 попыток, потом медленный рефилл (защита от перебора токена). + auth_claim: { capacity: 5, refillPerSec: 5 / 300 }, }; // Общий потолок на клиента поверх покаждотипных лимитов — защита от комбинированного @@ -50,6 +60,7 @@ class ClientLimiter { this.buckets = new Map(); // messageType -> TokenBucket this.global = new TokenBucket(GLOBAL_LIMIT.capacity, GLOBAL_LIMIT.refillPerSec, now); this.violations = []; // timestamps превышений в пределах VIOLATION_WINDOW_MS + this.lastSeenAt = now; } _bucketFor(type, now) { @@ -61,6 +72,7 @@ class ClientLimiter { } check(type, now = Date.now()) { + this.lastSeenAt = now; const globalOk = this.global.take(1, now); const bucketOk = this._bucketFor(type, now).take(1, now); const allowed = globalOk && bucketOk; @@ -93,6 +105,15 @@ class RateLimiterRegistry { size() { return this.clients.size; } + + // Для WS-клиентов remove() дергается на disconnect, но для реестров, + // ключами которых служит IP (auth-эндпоинты), явного "disconnect" нет — + // без этого Map будет расти пока сервер жив. Дергаем по таймеру снаружи. + cleanup(maxIdleMs, now = Date.now()) { + for (const [key, limiter] of this.clients) { + if (now - limiter.lastSeenAt > maxIdleMs) this.clients.delete(key); + } + } } module.exports = { diff --git a/lib/room.js b/lib/room.js index 1fe07a1..b1deb6c 100644 --- a/lib/room.js +++ b/lib/room.js @@ -262,6 +262,9 @@ class Room { activeBonus: null, bonusExpires: null, combo: 0, maxCombo: 0, inventory: { ...prof.stats.foodInventory }, + // См. комментарий в lib/game-loop.js createPlayer — тот же счётчик, + // отдельный от inventory (у него есть кап), для прогресса челленджей. + lifeFoodCount: Object.fromEntries(Object.keys(prof.stats.foodInventory).map((k) => [k, 0])), avatar: cos.avatar, snakeHatEmoji: cos.snakeHatEmoji, snakeHatId: cos.snakeHatId, @@ -398,16 +401,20 @@ class Room { _killPlayer(player, reason, opts = {}) { if (!player.alive) return; this.tickJournal.deaths.push(player.id); - if (this.trackDeathStats) this.trackDeathStats(player); + // См. комментарий в lib/game-loop.js killPlayer — reasonKey нужен + // trackDeathStats ДО того, как он выставлен на player, для прогресса + // челленджей ("Осторожный" — не умер от яда). + const reasonKey = opts.killerPlayer ? "death.killedByPlayer" : (opts.reasonKey || null); + if (this.trackDeathStats) this.trackDeathStats(player, { reasonKey }); player.alive = false; player.deaths += 1; if (opts.killerPlayer) { player.reason = `${opts.killerPlayer.name} убил тебя`; - player.reasonKey = "death.killedByPlayer"; + player.reasonKey = reasonKey; player.reasonParams = { name: opts.killerPlayer.name }; } else { player.reason = reason; - player.reasonKey = opts.reasonKey || null; + player.reasonKey = reasonKey; player.reasonParams = opts.reasonParams || null; } player.activeBonus = null; @@ -578,6 +585,7 @@ class Room { if (player.inventory[eaten.kind] !== undefined && player.inventory[eaten.kind] < gameConfig.INVENTORY_CAP) { player.inventory[eaten.kind] += 1; } + if (player.lifeFoodCount[eaten.kind] !== undefined) player.lifeFoodCount[eaten.kind] += 1; } else if (player.activeBonus === "shield") { player.activeBonus = null; } else { diff --git a/lib/rooms-setup.js b/lib/rooms-setup.js index de8cf09..69b74d8 100644 --- a/lib/rooms-setup.js +++ b/lib/rooms-setup.js @@ -22,7 +22,7 @@ function wireRoomDeps(ctx, room) { room.recordScore = (player) => leaderboard.recordScore(ctx, player); room.awardSessionCoins = statsRewards.awardSessionCoins; room.awardKillCoins = (killer, victim) => statsRewards.awardKillCoins(ctx, killer, victim); - room.trackDeathStats = (player) => statsRewards.trackDeathStats(ctx, player); + room.trackDeathStats = (player, meta) => statsRewards.trackDeathStats(ctx, player, meta); room.trackDisconnectStats = (player) => statsRewards.trackDisconnectStats(ctx, player); room.savePlayerCoins = (player) => profiles.savePlayerCoins(ctx, player); room.getOnlineCount = () => ctx.sockets.size; diff --git a/lib/shop-actions.js b/lib/shop-actions.js index da04a7a..8fadf03 100644 --- a/lib/shop-actions.js +++ b/lib/shop-actions.js @@ -1,6 +1,7 @@ "use strict"; const profiles = require("./profiles"); +const challengesMod = require("./challenges"); const { SHOP_CATALOG, SHOP_SKINS, AVATAR_PRESETS } = require("../data/shop-catalog"); const { getBattlePassConfig } = require("../data/battle-pass"); const { getPhrase } = require("../data/phrases"); @@ -9,7 +10,11 @@ function sendShopPayload(ctx, clientId, name) { const entry = profiles.getProfile(ctx, name); profiles.syncProfileCoins(ctx, name, entry); ctx.shopData[name] = entry; - ctx.send(clientId, { type: "shop_update", shopData: entry, skins: SHOP_SKINS, catalog: SHOP_CATALOG, avatars: AVATAR_PRESETS, battlePass: getBattlePassConfig() }); + ctx.send(clientId, { + type: "shop_update", shopData: entry, skins: SHOP_SKINS, catalog: SHOP_CATALOG, avatars: AVATAR_PRESETS, + battlePass: getBattlePassConfig(), challenges: challengesMod.getChallengesPayload(entry), + globalChallenge: challengesMod.getGlobalChallengePayload(ctx.globalChallenge), + }); } function buyItem(ctx, clientId, itemId, nameHint) { diff --git a/lib/stats-rewards.js b/lib/stats-rewards.js index 4399a3e..dba2703 100644 --- a/lib/stats-rewards.js +++ b/lib/stats-rewards.js @@ -2,6 +2,7 @@ const gameConfig = require("../config/game"); const profiles = require("./profiles"); +const challengesMod = require("./challenges"); const { BATTLE_PASS_MAX_TIER, BATTLE_PASS_SCORE_STEP, getBattlePassTierDef } = require("../data/battle-pass"); const { KILL_REWARD_COINS } = gameConfig; @@ -10,7 +11,7 @@ const { KILL_REWARD_COINS } = gameConfig; // СТАТИСТИКА ПРИ СМЕРТИ / ДИСКОННЕКТЕ // ============================================================ -function trackDeathStats(ctx, player) { +function trackDeathStats(ctx, player, meta = {}) { const entry = profiles.getProfile(ctx, player.name); entry.stats.deaths = (entry.stats.deaths ?? entry.stats.losses ?? 0) + 1; const prevBest = entry.stats.best || 0; @@ -25,6 +26,10 @@ function trackDeathStats(ctx, player) { player.sessionMvp = player.score > 0 && player.score >= sessionTop; entry.stats.battlePassScore = (entry.stats.battlePassScore || 0) + (player.score || 0); if (player.inventory) entry.stats.foodInventory = { ...player.inventory }; + if (player.lifeFoodCount) challengesMod.applyFoodProgress(entry, player.lifeFoodCount); + challengesMod.applyGameFinishedProgress(entry, { score: player.score, diedFromPoison: meta.reasonKey === "death.ateBadFood" }); + if (player.lifeFoodCount) challengesMod.applyGlobalFoodProgress(ctx, player.lifeFoodCount); + challengesMod.applyGlobalScoreProgress(ctx, player.score); ctx.shopData[player.name] = entry; profiles.persistProfile(ctx, player.name, entry); processBattlePassRewards(ctx, player.name, entry); @@ -40,8 +45,11 @@ function trackDisconnectStats(ctx, player) { if (player.alive && player.score > 0) { entry.stats.battlePassScore = (entry.stats.battlePassScore || 0) + player.score; processBattlePassRewards(ctx, player.name, entry); + challengesMod.applyGlobalScoreProgress(ctx, player.score); } if (player.inventory) entry.stats.foodInventory = { ...player.inventory }; + if (player.lifeFoodCount) challengesMod.applyFoodProgress(entry, player.lifeFoodCount); + if (player.lifeFoodCount) challengesMod.applyGlobalFoodProgress(ctx, player.lifeFoodCount); ctx.shopData[player.name] = entry; profiles.persistProfile(ctx, player.name, entry); } @@ -70,6 +78,8 @@ function awardKillCoins(ctx, killer, victim) { killer.coins = (killer.coins || 0) + KILL_REWARD_COINS; const entry = profiles.getProfile(ctx, killer.name); entry.stats.kills = (entry.stats.kills || 0) + 1; + challengesMod.applyKillProgress(entry); + challengesMod.applyGlobalKillProgress(ctx); entry.coins = killer.coins; ctx.shopData[killer.name] = entry; profiles.persistProfile(ctx, killer.name, entry); diff --git a/lib/utils.js b/lib/utils.js index afab35f..3d34ef3 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -19,6 +19,16 @@ function getLanAddresses() { return Object.values(os.networkInterfaces()).flat().filter((i) => i && i.family === "IPv4" && !i.internal).map((i) => i.address); } +// Тот же trust-модель, что и у getRequestOrigin: за nginx доверяем +// x-forwarded-for (первый адрес в списке — исходный клиент), иначе берём +// адрес сокета напрямую. Используется для лимитов по IP на /auth/* (см. +// LIMITS.auth_login и т.д. в lib/rate-limiter.js). +function getClientIp(req) { + const fwd = req.headers["x-forwarded-for"]; + if (fwd) return fwd.split(",")[0].trim(); + return req.socket?.remoteAddress || "unknown"; +} + function getRequestOrigin(req, port) { const host = (req.headers["x-forwarded-host"] || req.headers.host || `localhost:${port}`).split(",")[0].trim(); const proto = (req.headers["x-forwarded-proto"] || "http").split(",")[0].trim(); @@ -53,5 +63,5 @@ function readBodyLimited(req, maxBytes) { module.exports = { directionFromKey, isOpposite, cleanName, getLanAddresses, - getRequestOrigin, corsHeaders, sendJson, readBodyLimited, + getRequestOrigin, getClientIp, corsHeaders, sendJson, readBodyLimited, }; diff --git a/public/css/lobby.css b/public/css/lobby.css index 5c5487b..e91cd0f 100644 --- a/public/css/lobby.css +++ b/public/css/lobby.css @@ -550,6 +550,164 @@ .chestArrow { font-size: 1.2rem; color: #ffd166; flex-shrink: 0; } +/* ============================================================ + Колонка слева: друзья + личные челленджи стопкой друг под другом, + сам грид .menuLayout остаётся 3-колоночным как был. + ============================================================ */ +.sideColumn { + display: flex; + flex-direction: column; + gap: 20px; +} + +.personalChallengeCard { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 0; + border-bottom: 1px solid var(--panel-border); +} + +.personalChallengeCard:last-child { border-bottom: none; padding-bottom: 0; } +.personalChallengeCard:first-child { padding-top: 0; } + +.personalChallengeIcon { font-size: 1.15rem; flex-shrink: 0; line-height: 1.3; } + +.personalChallengeBody { flex: 1; min-width: 0; } + +.personalChallengeTop { + display: flex; + align-items: baseline; + gap: 6px; + flex-wrap: wrap; +} + +.personalChallengeName { font-size: .82rem; font-weight: 700; } + +.personalChallengeScope { + font-size: .58rem; + color: var(--muted); + border: 1px solid var(--panel-border); + border-radius: 6px; + padding: 1px 4px; + text-transform: uppercase; +} + +.personalChallengeDesc { + font-size: .72rem; + color: var(--muted); + margin-top: 2px; +} + +.personalChallengeBar { + height: 4px; + border-radius: 999px; + background: rgba(255,255,255,.08); + overflow: hidden; + margin-top: 6px; +} + +.personalChallengeBarFill { + height: 100%; + background: var(--green); + border-radius: 999px; + transition: width .3s; +} + +.personalChallengeFoot { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 4px; + font-size: .68rem; + color: var(--muted); +} + +.personalChallengeClaim { + border: none; + border-radius: 8px; + padding: 4px 9px; + font-size: .68rem; + font-weight: 800; + background: rgba(255,255,255,.08); + color: var(--muted); + cursor: default; +} + +.personalChallengeCard.completed .personalChallengeClaim { + background: #ffd166; + color: #1a1300; + cursor: pointer; + animation: chestGlow 2.4s ease infinite; +} + +.personalChallengeCard.completed .personalChallengeBarFill { background: #ffd166; } +.personalChallengeCard.claimed { opacity: .5; } + +/* На мобильном .sidePanel целиком прячется (см. @media 1180px ниже), но + личные челленджи — не второстепенный виджет вроде друзей/топа, а часть + основного игрового цикла, поэтому явно возвращаем видимость. */ +@media (max-width: 1180px) { + .personalChallengesPanel { + display: flex; + max-width: 480px; + width: 100%; + margin: 0 auto 20px; + } +} + +/* ============================================================ + Общий (серверный) недельный челлендж — одна полоса на всех, без кнопки + клейма, сидит прямо над кнопкой "Играть". + ============================================================ */ +.globalChallengeBar { + width: 100%; + max-width: 580px; + padding: 14px 18px; + margin-bottom: 16px; + border-radius: 14px; +} + +.globalChallengeHead { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + +.globalChallengeIcon { font-size: 1.3rem; flex-shrink: 0; } + +.globalChallengeText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.globalChallengeText strong { font-size: .82rem; } +.globalChallengeText span { font-size: .7rem; color: var(--muted); } + +.globalChallengeFraction { + flex-shrink: 0; + font-size: .72rem; + font-weight: 700; + color: var(--muted); +} + +.globalChallengeTrack { + height: 8px; + border-radius: 999px; + background: rgba(255,255,255,.08); + overflow: hidden; +} + +.globalChallengeFill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, var(--green), #7CFC9A); + transition: width .4s; +} + .menuGrid { display: grid; grid-template-columns: repeat(4, minmax(110px, 145px)); diff --git a/public/index.html b/public/index.html index 5b1b77a..d8cfe51 100644 --- a/public/index.html +++ b/public/index.html @@ -33,20 +33,32 @@
+ + + + + + `; + }).join(""); + I18N.apply(body); +} + +document.querySelector("#personalChallengesBody")?.addEventListener("click", (event) => { + const btn = event.target.closest(".personalChallengeClaim"); + if (!btn || btn.disabled || !socket || socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify({ + type: "challenge_claim", + challengeId: btn.dataset.challengeId, + name: sessionUser?.name || SnakeStore.getName(), + })); +}); + +// Общий (серверный) недельный челлендж — один счётчик на всех игроков, +// без клейма, просто полоса прогресса над кнопкой "Играть". Обновляется и +// через hello/shop_update, и вживую через отдельный broadcast global_challenge +// (см. lib/challenges.js: notifyGlobalChange). +function renderGlobalChallengeBar(data) { + const bar = document.querySelector("#globalChallengeBar"); + if (!bar || !data) return; + bar.classList.remove("hidden"); + document.querySelector("#globalChallengeIcon").textContent = data.icon; + document.querySelector("#globalChallengeName").textContent = data.name; + document.querySelector("#globalChallengeDesc").textContent = data.desc; + document.querySelector("#globalChallengeFraction").textContent = + `${data.progress.toLocaleString("ru-RU")}/${data.target.toLocaleString("ru-RU")}`; + const pct = Math.min(100, Math.round((data.progress / data.target) * 100)); + document.querySelector("#globalChallengeFill").style.width = `${pct}%`; +} + function connect() { socket = new WebSocket(getWebSocketUrl()); socket.addEventListener("open", () => { @@ -356,6 +425,8 @@ function connect() { shopData = msg.shopData; if (msg.catalog) catalog = msg.catalog; updateUserBar(shopData, sessionUser?.name || SnakeStore.getName()); + renderPersonalChallenges(msg.challenges); + renderGlobalChallengeBar(msg.globalChallenge); } if (msg.type === "presence") { lastPresence = { players: msg.players, alive: msg.alive }; @@ -378,7 +449,9 @@ function connect() { document.querySelector("#onlineCount").textContent = text; const modalCount = document.querySelector("#playOnlineCount"); if (modalCount) modalCount.textContent = text; + renderGlobalChallengeBar(msg.globalChallenge); } + if (msg.type === "global_challenge") renderGlobalChallengeBar(msg); if (msg.type === "room_invite") showInviteToast(msg.from, msg.code); if (msg.type === "achievement_unlocked") { showAchievementToast(msg.achievement); SnakeAudio.play("achievement"); } }); diff --git a/public/js/profile.js b/public/js/profile.js index 05dd1d8..07cfa16 100644 --- a/public/js/profile.js +++ b/public/js/profile.js @@ -85,7 +85,7 @@ function renderAchievements() { } } -// Приоритет: своя загруженная фотка > эмодзи-пресет. + function applyAvatarVisuals({ customAvatarUrl, avatar }) { const customImg = document.querySelector("#accountCustomAvatar"); const emojiEl = document.querySelector("#accountAvatarEmoji"); diff --git a/server.js b/server.js index b09c447..6ca029b 100644 --- a/server.js +++ b/server.js @@ -17,6 +17,7 @@ const net = require("./lib/net"); const profiles = require("./lib/profiles"); const leaderboardMod = require("./lib/leaderboard"); const statsRewards = require("./lib/stats-rewards"); +const challengesMod = require("./lib/challenges"); const shopActions = require("./lib/shop-actions"); const gameLoop = require("./lib/game-loop"); const roomsSetup = require("./lib/rooms-setup"); @@ -110,6 +111,14 @@ ctx.removeAvatarFile = (url) => avatarRoutes.removeAvatarFile(ctx, url); // .getProfile(name); у Room он уже инжектится в rooms-setup.js. ctx.getProfile = (name) => profiles.getProfile(ctx, name); +// Общий недельный челлендж мутируется на каждое убийство/еду/смерть у любого +// игрока — писать в БД на каждое такое событие было бы слишком часто. +// persistGlobalChallenge только помечает состояние "грязным"; реальная запись +// раз в 10с через setInterval ниже (bootstrap) — теряем максимум ~10с +// прогресса при падении процесса, не критично для некритичного счётчика. +let globalChallengeDirty = false; +ctx.persistGlobalChallenge = () => { globalChallengeDirty = true; }; + // ---- игровой цикл / синк ---- ctx.broadcastGameSync = () => gameLoop.broadcastGameSync(ctx); ctx.broadcastPresence = () => gameLoop.broadcastPresence(ctx); @@ -210,6 +219,7 @@ server.on("upgrade", (req, socket) => { feed: ctx.feedLog.slice(0, 8), presence: gameSync.buildPresence(gameLoop.buildSyncCtx(ctx)), battlePass: getBattlePassConfig(), + globalChallenge: challengesMod.getGlobalChallengePayload(ctx.globalChallenge), }); }); @@ -222,6 +232,7 @@ async function bootstrap() { await db.init(); ctx.shopData = await db.loadAllPlayers(); ctx.leaderboard = await db.loadLeaderboard(leaderboardMod.MAX_LEADERS); + ctx.globalChallenge = await db.getServerState("global_weekly_challenge"); const listingRows = await db.loadFoodListings(); for (const row of listingRows) { ctx.marketListings.set(row.id, { @@ -249,6 +260,13 @@ async function bootstrap() { setInterval(() => net.pingClients(ctx), 25000); setInterval(() => db.cleanupAuthSessions().catch(() => { }), 60 * 60 * 1000); setInterval(() => db.cleanupClaimTokens().catch(() => { }), 60 * 60 * 1000); + setInterval(() => auth.authLimiter.cleanup(60 * 60 * 1000), 60 * 60 * 1000); + setInterval(() => { + if (!globalChallengeDirty) return; + globalChallengeDirty = false; + db.setServerState("global_weekly_challenge", ctx.globalChallenge) + .catch((err) => console.error("Общий челлендж (persist):", err.message)); + }, 10_000); console.log("Авторизация: локальный логин/пароль (без внешних провайдеров)"); console.log(`Snake Attack → http://localhost:${PORT}`); diff --git a/test/challenges.test.js b/test/challenges.test.js new file mode 100644 index 0000000..cab46ce --- /dev/null +++ b/test/challenges.test.js @@ -0,0 +1,307 @@ +"use strict"; + +const { test } = require("node:test"); +const assert = require("node:assert/strict"); + +const challenges = require("../lib/challenges"); +const profiles = require("../lib/profiles"); +const { DAILY_POOL, DAILY_PICK_COUNT, WEEKLY_PICK_COUNT } = require("../data/challenges"); + +function freshEntry() { + return profiles.normalizeProfile({}); +} + +// ============================================================ +// РОТАЦИЯ НАБОРА +// ============================================================ + +test("getActiveDaily: детерминирован — одна и та же дата всегда даёт один и тот же набор", () => { + const day = new Date("2026-07-27T12:00:00Z"); + const a = challenges.getActiveDaily(day).map((t) => t.id); + const b = challenges.getActiveDaily(new Date("2026-07-27T23:59:59Z")).map((t) => t.id); + assert.deepEqual(a, b, "разное время суток той же UTC-даты не должно менять набор"); + assert.equal(a.length, DAILY_PICK_COUNT); + assert.equal(new Set(a).size, DAILY_PICK_COUNT, "не должно быть дублей в наборе"); +}); + +test("getActiveDaily: разные даты обычно дают разные наборы", () => { + const a = challenges.getActiveDaily(new Date("2026-07-27T00:00:00Z")).map((t) => t.id).sort(); + const b = challenges.getActiveDaily(new Date("2026-07-28T00:00:00Z")).map((t) => t.id).sort(); + assert.notDeepEqual(a, b, "сосед по дате не должен давать идентичный набор (пул достаточно большой)"); +}); + +test("getActiveWeekly: любой день одной ISO-недели (UTC) даёт одинаковый набор", () => { + // 2026-07-27 — понедельник; 2026-08-02 — воскресенье той же недели. + const monday = challenges.getActiveWeekly(new Date("2026-07-27T00:00:01Z")).map((t) => t.id); + const sunday = challenges.getActiveWeekly(new Date("2026-08-02T23:00:00Z")).map((t) => t.id); + assert.deepEqual(monday, sunday); + assert.equal(monday.length, WEEKLY_PICK_COUNT); +}); + +test("getActiveWeekly: следующий понедельник — уже другая (или как минимум пересчитанная) неделя", () => { + const key1 = challenges.weeklyPeriodKey(new Date("2026-07-27T00:00:00Z")); + const key2 = challenges.weeklyPeriodKey(new Date("2026-08-03T00:00:00Z")); + assert.notEqual(key1, key2, "ключ недели должен смениться через 7 дней"); +}); + +// ============================================================ +// ПРОГРЕСС: ЕДА +// ============================================================ + +test("applyFoodProgress: конкретный foodKind считает только свой вид", () => { + const entry = freshEntry(); + const now = new Date("2026-07-27T10:00:00Z"); + const template = DAILY_POOL.find((t) => t.foodKind === "apple"); + assert.ok(template, "в пуле должен быть челлендж на конкретный вид еды для этого теста"); + + challenges.applyFoodProgress(entry, { apple: 4, cherry: 100 }, now); + const state = entry.stats.challenges; + if (challenges.getActiveDaily(now).some((t) => t.id === template.id)) { + assert.equal(state.dailyProgress[template.id], 4, "должны посчитаться только яблоки, не вишни"); + } +}); + +test("applyFoodProgress: foodKind=null суммирует все виды еды", () => { + const entry = freshEntry(); + const now = new Date("2026-07-27T10:00:00Z"); + const anyTemplate = [...challenges.getActiveDaily(now), ...challenges.getActiveWeekly(now)].find((t) => t.kind === "food" && !t.foodKind); + if (!anyTemplate) return; // в наборе этого дня такого шаблона может не быть — тест не применим + + challenges.applyFoodProgress(entry, { apple: 3, cherry: 2, grape: 1 }, now); + const scope = challenges.getActiveDaily(now).includes(anyTemplate) ? "dailyProgress" : "weeklyProgress"; + assert.equal(entry.stats.challenges[scope][anyTemplate.id], 6); +}); + +test("applyFoodProgress: накопление суммируется через несколько вызовов (несколько жизней за день)", () => { + const entry = freshEntry(); + const now = new Date("2026-07-27T10:00:00Z"); + const template = DAILY_POOL.find((t) => t.foodKind === "apple"); + challenges.applyFoodProgress(entry, { apple: 4 }, now); + challenges.applyFoodProgress(entry, { apple: 5 }, now); + if (challenges.getActiveDaily(now).some((t) => t.id === template.id)) { + assert.equal(entry.stats.challenges.dailyProgress[template.id], 9); + } +}); + +// ============================================================ +// ПРОГРЕСС: УБИЙСТВА / ИГРЫ / СЧЁТ +// ============================================================ + +test("applyKillProgress: увеличивает прогресс всех активных kill-челленджей на 1", () => { + const entry = freshEntry(); + const now = new Date("2026-07-27T10:00:00Z"); + challenges.applyKillProgress(entry, now); + challenges.applyKillProgress(entry, now); + challenges.applyKillProgress(entry, now); + const killTemplates = [...challenges.getActiveDaily(now), ...challenges.getActiveWeekly(now)].filter((t) => t.kind === "kill"); + for (const t of killTemplates) { + const scope = challenges.getActiveDaily(now).includes(t) ? "dailyProgress" : "weeklyProgress"; + assert.equal(entry.stats.challenges[scope][t.id], 3); + } +}); + +test("applyGameFinishedProgress: score-челлендж хранит МАКСИМУМ за период, а не сумму", () => { + const entry = freshEntry(); + const now = new Date("2026-07-27T10:00:00Z"); + const scoreTemplates = [...challenges.getActiveDaily(now), ...challenges.getActiveWeekly(now)].filter((t) => t.kind === "score"); + if (!scoreTemplates.length) return; + + challenges.applyGameFinishedProgress(entry, { score: 80, diedFromPoison: false }, now); + challenges.applyGameFinishedProgress(entry, { score: 220, diedFromPoison: false }, now); + challenges.applyGameFinishedProgress(entry, { score: 150, diedFromPoison: false }, now); + + for (const t of scoreTemplates) { + const scope = challenges.getActiveDaily(now).includes(t) ? "dailyProgress" : "weeklyProgress"; + assert.equal(entry.stats.challenges[scope][t.id], 220, "должен остаться лучший результат из трёх игр, не сумма"); + } +}); + +test("applyGameFinishedProgress: смерть от яда НЕ засчитывается в no_poison_death, обычная смерть — засчитывается", () => { + const entry = freshEntry(); + const now = new Date("2026-07-27T10:00:00Z"); + const t = DAILY_POOL.find((x) => x.kind === "no_poison_death"); + const isActive = challenges.getActiveDaily(now).some((x) => x.id === t.id); + if (!isActive) return; + + challenges.applyGameFinishedProgress(entry, { score: 10, diedFromPoison: true }, now); + assert.equal(entry.stats.challenges.dailyProgress[t.id] || 0, 0, "смерть от яда не должна двигать прогресс"); + + challenges.applyGameFinishedProgress(entry, { score: 10, diedFromPoison: false }, now); + assert.equal(entry.stats.challenges.dailyProgress[t.id], 1, "смерть не от яда должна засчитаться"); +}); + +// ============================================================ +// РОЛЛОВЕР ПЕРИОДА +// ============================================================ + +test("ensureFreshState: смена дня обнуляет только dailyProgress/dailyClaimed, недельный прогресс не трогает", () => { + const entry = freshEntry(); + const day1 = new Date("2026-07-27T23:00:00Z"); // понедельник + const day2 = new Date("2026-07-28T01:00:00Z"); // вторник — новый daily-период, та же неделя + + challenges.applyKillProgress(entry, day1); + const weeklyBefore = { ...entry.stats.challenges.weeklyProgress }; + + challenges.ensureFreshState(entry, day2); + + assert.deepEqual(entry.stats.challenges.dailyProgress, {}, "дневной прогресс должен обнулиться на новый день"); + assert.deepEqual(entry.stats.challenges.weeklyProgress, weeklyBefore, "недельный прогресс в пределах той же недели не должен обнуляться"); +}); + +test("ensureFreshState: смена недели обнуляет weeklyProgress/weeklyClaimed", () => { + const entry = freshEntry(); + const week1 = new Date("2026-07-27T10:00:00Z"); + const week2 = new Date("2026-08-03T10:00:00Z"); // следующий понедельник + + challenges.applyKillProgress(entry, week1); + challenges.ensureFreshState(entry, week2); + + assert.deepEqual(entry.stats.challenges.weeklyProgress, {}, "недельный прогресс должен обнулиться на новую неделю"); +}); + +// ============================================================ +// ВЫДАЧА НАГРАДЫ (claimChallenge) +// ============================================================ + +test("claimChallenge: нельзя забрать награду, пока цель не достигнута", () => { + const entry = freshEntry(); + const now = new Date("2026-07-27T10:00:00Z"); + const t = challenges.getActiveDaily(now)[0]; + challenges.applyKillProgress(entry, now); // могло случайно попасть, но точно меньше target для kill/food/score с большим target + + const result = challenges.claimChallenge(entry, t.id, now); + if ((entry.stats.challenges.dailyProgress[t.id] || 0) < t.target) { + assert.equal(result.ok, false); + } +}); + +test("claimChallenge: успешный клейм один раз, повторный — отклоняется", () => { + const entry = freshEntry(); + const now = new Date("2026-07-27T10:00:00Z"); + const t = challenges.getActiveDaily(now).find((x) => x.kind === "kill") || challenges.getActiveDaily(now)[0]; + + // Догоняем прогресс до target напрямую, чтобы не зависеть от того, какой именно kind выпал. + challenges.ensureFreshState(entry, now).dailyProgress[t.id] = t.target; + + const first = challenges.claimChallenge(entry, t.id, now); + assert.equal(first.ok, true); + assert.equal(first.reward, t.reward); + + const second = challenges.claimChallenge(entry, t.id, now); + assert.equal(second.ok, false, "повторный клейм того же челленджа должен быть отклонён"); +}); + +test("claimChallenge: неизвестный/неактивный id отклоняется", () => { + const entry = freshEntry(); + const now = new Date("2026-07-27T10:00:00Z"); + const result = challenges.claimChallenge(entry, "not_a_real_challenge_id", now); + assert.equal(result.ok, false); +}); + +// ============================================================ +// ПОЛЕЗНАЯ НАГРУЗКА ДЛЯ КЛИЕНТА +// ============================================================ + +test("getChallengesPayload: прогресс никогда не превышает target в отдаваемом payload", () => { + const entry = freshEntry(); + const now = new Date("2026-07-27T10:00:00Z"); + for (let i = 0; i < 999; i++) challenges.applyKillProgress(entry, now); + + const payload = challenges.getChallengesPayload(entry, now); + for (const item of [...payload.daily, ...payload.weekly]) { + assert.ok(item.progress <= item.target, `${item.id}: progress не должен превышать target`); + } +}); + +test("getChallengesPayload: completed=true только когда progress >= target", () => { + const entry = freshEntry(); + const now = new Date("2026-07-27T10:00:00Z"); + const payload = challenges.getChallengesPayload(entry, now); + for (const item of [...payload.daily, ...payload.weekly]) { + assert.equal(item.completed, item.progress >= item.target); + assert.equal(item.progress, 0, "свежий профиль не должен иметь прогресса"); + } +}); + +// ============================================================ +// ОБЩИЙ (СЕРВЕРНЫЙ) НЕДЕЛЬНЫЙ ЧЕЛЛЕНДЖ +// ============================================================ + +function makeFakeCtx() { + const broadcasts = []; + const persisted = []; + return { + globalChallenge: null, + broadcast: (payload) => broadcasts.push(payload), + persistGlobalChallenge: () => persisted.push(true), + _broadcasts: broadcasts, + _persisted: persisted, + }; +} + +test("getActiveGlobalWeekly: детерминирован по неделе и не зависит от личной недельной ротации", () => { + const now = new Date("2026-07-27T10:00:00Z"); + const a = challenges.getActiveGlobalWeekly(now); + const b = challenges.getActiveGlobalWeekly(new Date("2026-07-30T10:00:00Z")); + assert.equal(a.id, b.id, "та же неделя — тот же общий челлендж"); + assert.ok(a.kind.endsWith("_sum"), "у общего челленджа всегда суммирующий kind"); +}); + +test("ensureFreshGlobalState: сбрасывает progress при смене недели, сохраняет в пределах недели", () => { + const week1 = new Date("2026-07-27T10:00:00Z"); + const week2 = new Date("2026-08-03T10:00:00Z"); + let state = challenges.ensureFreshGlobalState(null, week1); + state.progress = 500; + const sameWeek = challenges.ensureFreshGlobalState(state, week1); + assert.equal(sameWeek.progress, 500, "в пределах той же недели прогресс не трогаем"); + const nextWeek = challenges.ensureFreshGlobalState(state, week2); + assert.equal(nextWeek.progress, 0, "на новой неделе прогресс должен обнулиться"); +}); + +test("applyGlobalKillProgress: копит на ctx.globalChallenge и не трогает kind, если активен другой тип", () => { + const ctx = makeFakeCtx(); + const now = new Date("2026-07-27T10:00:00Z"); + const template = challenges.getActiveGlobalWeekly(now); + + challenges.applyGlobalKillProgress(ctx, now); + if (template.kind === "kill_sum") { + assert.equal(ctx.globalChallenge.progress, 1); + assert.equal(ctx._broadcasts.length, 1, "изменение должно транслироваться всем сокетам"); + assert.equal(ctx._persisted.length, 1, "изменение должно помечаться на персист"); + } else { + assert.equal(ctx.globalChallenge, null, "убийство не должно двигать активный челлендж другого типа"); + assert.equal(ctx._broadcasts.length, 0); + } +}); + +test("applyGlobalScoreProgress: суммирует очки нескольких игр, а не берёт максимум (в отличие от личного score)", () => { + const ctx = makeFakeCtx(); + const now = new Date("2026-07-27T10:00:00Z"); + const template = challenges.getActiveGlobalWeekly(now); + if (template.kind !== "score_sum") return; // применимо только когда выпал именно этот тип + + challenges.applyGlobalScoreProgress(ctx, 100, now); + challenges.applyGlobalScoreProgress(ctx, 250, now); + assert.equal(ctx.globalChallenge.progress, 350, "общий счёт должен суммироваться, а не браться как максимум"); +}); + +test("applyGlobalFoodProgress: игнорирует нулевой/пустой вклад (не шлёт лишний broadcast)", () => { + const ctx = makeFakeCtx(); + const now = new Date("2026-07-27T10:00:00Z"); + const template = challenges.getActiveGlobalWeekly(now); + if (template.kind !== "food_sum") return; + + challenges.applyGlobalFoodProgress(ctx, { apple: 0, cherry: 0 }, now); + assert.equal(ctx._broadcasts.length, 0, "нулевой вклад не должен триггерить рассылку"); +}); + +test("getGlobalChallengePayload: работает и с null-состоянием (первый запуск сервера), прогресс не превышает target", () => { + const now = new Date("2026-07-27T10:00:00Z"); + const payload = challenges.getGlobalChallengePayload(null, now); + assert.equal(payload.progress, 0); + assert.ok(payload.target > 0); + assert.equal(payload.completed, false); + + const overflowing = challenges.getGlobalChallengePayload({ weekKey: challenges.weeklyPeriodKey(now), progress: payload.target * 5 }, now); + assert.equal(overflowing.progress, overflowing.target, "прогресс в payload не должен превышать target даже если реальное число больше"); +}); diff --git a/test/market.test.js b/test/market.test.js new file mode 100644 index 0000000..8cbb435 --- /dev/null +++ b/test/market.test.js @@ -0,0 +1,189 @@ +"use strict"; + +const { test } = require("node:test"); +const assert = require("node:assert/strict"); + +const market = require("../lib/market"); +const profiles = require("../lib/profiles"); + +// Лёгкий фейковый ctx: реальные profiles.js/market.js работают поверх него, +// в отличие от room.test.js мы НЕ мокаем market.js — это как раз тот модуль, +// который двигает деньги/инвентарь игроков, поэтому его логика должна +// исполняться по-настоящему, мокается только "внешний мир" (БД, сеть). +function makeCtx() { + const sent = []; + const broadcasts = []; + const ctx = { + shopData: {}, + profileIndex: new Map(), + marketListings: new Map(), + players: new Map(), + shopClients: new Map(), + socketSessions: new Map(), + db: { + upsertPlayer: async () => null, + upsertFoodListing: async () => {}, + deleteFoodListing: async () => {}, + }, + send: (id, payload) => sent.push({ id, payload }), + broadcast: (payload) => broadcasts.push(payload), + sendShopPayload: () => {}, + }; + return { ctx, sent, broadcasts }; +} + +// nameHint — резолвится напрямую в marketList/marketCancel/marketBuy через +// profiles.resolveName, когда для clientId нет активной сессии/сокета — +// удобно для тестов: не нужно эмулировать WS-подключение целиком. +function seedPlayer(ctx, name, { coins = 0, foodInventory = {} } = {}) { + const entry = profiles.normalizeProfile({ coins, stats: { foodInventory } }); + ctx.shopData[name] = entry; + return entry; +} + +function listingsFor(ctx, sellerName) { + return [...ctx.marketListings.values()].filter((l) => l.sellerName === sellerName); +} + +test("marketList: выставленное количество списывается с инвентаря и появляется лотом", () => { + const { ctx } = makeCtx(); + seedPlayer(ctx, "Kirill", { foodInventory: { apple: 5 } }); + + market.marketList(ctx, "c1", "apple", 3, 10, "Kirill"); + + const entry = profiles.getProfile(ctx, "Kirill"); + assert.equal(entry.stats.foodInventory.apple, 2, "3 из 5 яблок должны уйти в лот"); + const listings = listingsFor(ctx, "Kirill"); + assert.equal(listings.length, 1); + assert.equal(listings[0].quantity, 3); + assert.equal(listings[0].pricePerUnit, 10); +}); + +test("marketList: нельзя выставить больше еды, чем есть в инвентаре", () => { + const { ctx, sent } = makeCtx(); + seedPlayer(ctx, "Kirill", { foodInventory: { apple: 2 } }); + + market.marketList(ctx, "c1", "apple", 5, 10, "Kirill"); + + assert.equal(listingsFor(ctx, "Kirill").length, 0, "лот не должен создаться"); + assert.equal(profiles.getProfile(ctx, "Kirill").stats.foodInventory.apple, 2, "инвентарь не должен измениться"); + assert.ok(sent.some((s) => s.payload.type === "notice"), "игрок должен получить уведомление об ошибке"); +}); + +test("marketList: цена выше потолка (500/шт) отклоняется", () => { + const { ctx } = makeCtx(); + seedPlayer(ctx, "Kirill", { foodInventory: { apple: 10 } }); + + market.marketList(ctx, "c1", "apple", 1, 501, "Kirill"); + assert.equal(listingsFor(ctx, "Kirill").length, 0, "цена 501 должна быть отклонена"); + + market.marketList(ctx, "c1", "apple", 1, 500, "Kirill"); + assert.equal(listingsFor(ctx, "Kirill").length, 1, "цена ровно 500 должна пройти"); +}); + +test("marketList: у одного игрока не может быть больше 10 активных лотов", () => { + const { ctx } = makeCtx(); + seedPlayer(ctx, "Kirill", { foodInventory: { apple: 20 } }); + + for (let i = 0; i < 10; i++) market.marketList(ctx, "c1", "apple", 1, 5, "Kirill"); + assert.equal(listingsFor(ctx, "Kirill").length, 10); + + market.marketList(ctx, "c1", "apple", 1, 5, "Kirill"); // 11-й лот + assert.equal(listingsFor(ctx, "Kirill").length, 10, "11-й лот не должен создаться"); +}); + +test("marketBuy: монеты и еда переходят между покупателем и продавцом", () => { + const { ctx } = makeCtx(); + seedPlayer(ctx, "Seller", { foodInventory: { apple: 5 } }); + seedPlayer(ctx, "Buyer", { coins: 100 }); + market.marketList(ctx, "sellerSocket", "apple", 5, 10, "Seller"); // лот: 5 яблок по 10 монет + + const [listing] = listingsFor(ctx, "Seller"); + market.marketBuy(ctx, "buyerSocket", listing.id, 2, "Buyer"); + + const buyer = profiles.getProfile(ctx, "Buyer"); + const seller = profiles.getProfile(ctx, "Seller"); + assert.equal(buyer.coins, 80, "у покупателя должно списаться 2×10=20 монет"); + assert.equal(buyer.stats.foodInventory.apple, 2, "покупатель должен получить 2 яблока"); + assert.equal(seller.coins, 20, "продавец должен получить 20 монет"); +}); + +test("marketBuy: частичная покупка уменьшает лот, но не удаляет его", () => { + const { ctx } = makeCtx(); + seedPlayer(ctx, "Seller", { foodInventory: { apple: 5 } }); + seedPlayer(ctx, "Buyer", { coins: 100 }); + market.marketList(ctx, "sellerSocket", "apple", 5, 10, "Seller"); + + const [listing] = listingsFor(ctx, "Seller"); + market.marketBuy(ctx, "buyerSocket", listing.id, 2, "Buyer"); + + const remaining = ctx.marketListings.get(listing.id); + assert.ok(remaining, "лот должен остаться, пока не раскуплен полностью"); + assert.equal(remaining.quantity, 3); +}); + +test("marketBuy: покупка последней единицы удаляет лот целиком", () => { + const { ctx } = makeCtx(); + seedPlayer(ctx, "Seller", { foodInventory: { apple: 2 } }); + seedPlayer(ctx, "Buyer", { coins: 100 }); + market.marketList(ctx, "sellerSocket", "apple", 2, 10, "Seller"); + + const [listing] = listingsFor(ctx, "Seller"); + market.marketBuy(ctx, "buyerSocket", listing.id, 2, "Buyer"); + + assert.equal(ctx.marketListings.has(listing.id), false, "лот должен быть удалён после полной раскупки"); +}); + +test("marketBuy: нельзя купить свой же лот", () => { + const { ctx } = makeCtx(); + seedPlayer(ctx, "Kirill", { coins: 100, foodInventory: { apple: 5 } }); + market.marketList(ctx, "c1", "apple", 5, 10, "Kirill"); + const [listing] = listingsFor(ctx, "Kirill"); + + market.marketBuy(ctx, "c1", listing.id, 1, "Kirill"); + + const entry = profiles.getProfile(ctx, "Kirill"); + assert.equal(entry.coins, 100, "монеты не должны были измениться"); + assert.equal(ctx.marketListings.get(listing.id).quantity, 5, "лот не должен уменьшиться"); +}); + +test("marketBuy: недостаточно монет — покупка отклоняется, состояние не меняется", () => { + const { ctx } = makeCtx(); + seedPlayer(ctx, "Seller", { foodInventory: { apple: 5 } }); + seedPlayer(ctx, "Buyer", { coins: 5 }); // хватит только на меньше 1 штуки по цене 10 + market.marketList(ctx, "sellerSocket", "apple", 5, 10, "Seller"); + const [listing] = listingsFor(ctx, "Seller"); + + market.marketBuy(ctx, "buyerSocket", listing.id, 1, "Buyer"); + + const buyer = profiles.getProfile(ctx, "Buyer"); + assert.equal(buyer.coins, 5, "монеты покупателя не должны были списаться"); + assert.equal(buyer.stats.foodInventory.apple ?? 0, 0, "еда не должна была зачислиться"); + assert.equal(ctx.marketListings.get(listing.id).quantity, 5, "лот должен остаться нетронутым"); +}); + +test("marketCancel: возвращает еду в инвентарь и удаляет лот", () => { + const { ctx } = makeCtx(); + seedPlayer(ctx, "Kirill", { foodInventory: { apple: 5 } }); + market.marketList(ctx, "c1", "apple", 3, 10, "Kirill"); + const [listing] = listingsFor(ctx, "Kirill"); + + market.marketCancel(ctx, "c1", listing.id, "Kirill"); + + const entry = profiles.getProfile(ctx, "Kirill"); + assert.equal(entry.stats.foodInventory.apple, 5, "все 3 яблока должны вернуться в инвентарь"); + assert.equal(ctx.marketListings.has(listing.id), false); +}); + +test("marketCancel: нельзя отменить чужой лот", () => { + const { ctx } = makeCtx(); + seedPlayer(ctx, "Seller", { foodInventory: { apple: 5 } }); + seedPlayer(ctx, "Someone", {}); + market.marketList(ctx, "sellerSocket", "apple", 3, 10, "Seller"); + const [listing] = listingsFor(ctx, "Seller"); + + market.marketCancel(ctx, "otherSocket", listing.id, "Someone"); + + assert.equal(ctx.marketListings.has(listing.id), true, "чужой лот не должен быть удалён"); + assert.equal(profiles.getProfile(ctx, "Seller").stats.foodInventory.apple, 2, "инвентарь продавца не должен измениться"); +}); diff --git a/test/rate-limiter.test.js b/test/rate-limiter.test.js index b9d54d6..c268d16 100644 --- a/test/rate-limiter.test.js +++ b/test/rate-limiter.test.js @@ -97,6 +97,43 @@ test("RateLimiterRegistry.remove: очищает состояние клиент assert.equal(registry.size(), 0); }); +test("RateLimiterRegistry: auth_login режет брутфорс по IP после 5 попыток", () => { + const registry = new RateLimiterRegistry(); + const t0 = 1000; + for (let i = 0; i < 5; i++) { + assert.equal(registry.check("1.2.3.4", "auth_login", t0).allowed, true, `попытка ${i + 1} должна пройти`); + } + assert.equal(registry.check("1.2.3.4", "auth_login", t0).allowed, false, "6-я попытка подряд должна быть отклонена"); +}); + +test("RateLimiterRegistry: auth_login не блокирует другой IP", () => { + const registry = new RateLimiterRegistry(); + const t0 = 1000; + for (let i = 0; i < 5; i++) registry.check("1.2.3.4", "auth_login", t0); + assert.equal(registry.check("1.2.3.4", "auth_login", t0).allowed, false); + assert.equal(registry.check("5.6.7.8", "auth_login", t0).allowed, true, "лимит по одному IP не должен затрагивать другой"); +}); + +test("RateLimiterRegistry: auth_register сильнее ограничен, чем auth_login (защита от спама аккаунтов)", () => { + const registry = new RateLimiterRegistry(); + const t0 = 1000; + for (let i = 0; i < 3; i++) { + assert.equal(registry.check("9.9.9.9", "auth_register", t0).allowed, true); + } + assert.equal(registry.check("9.9.9.9", "auth_register", t0).allowed, false, "4-я регистрация подряд должна быть отклонена"); +}); + +test("RateLimiterRegistry.cleanup: удаляет только записи, не тронутые дольше maxIdleMs", () => { + const registry = new RateLimiterRegistry(); + registry.check("stale-ip", "auth_login", 1000); + registry.check("fresh-ip", "auth_login", 50_000); + + registry.cleanup(10_000, 60_000); // "сейчас" = 60000, порог простоя = 10с + assert.equal(registry.size(), 1, "должна остаться только недавно активная запись"); + const stillFresh = registry.check("fresh-ip", "auth_login", 60_000); + assert.equal(stillFresh.allowed, true, "запись fresh-ip не должна была быть удалена"); +}); + test("RateLimiterRegistry: глобальный лимит режет комбинированный флуд по разным типам сообщений", () => { const registry = new RateLimiterRegistry(); const t0 = 1000;