Реалтайм-бекенд для гри "Бункер". NestJS + Socket.IO, без бази даних — весь стан кімнат/гравців зберігається in-memory, а властивості/бункери/ катаклізми — у статичних конфіг-файлах.
- NestJS (TypeScript) — модульна DI-архітектура, звична для Angular-розробників (той самий стиль декораторів/сервісів), з нативною підтримкою WebSocket-гейтвеїв.
- Socket.IO — двосторонній realtime-канал для стану кімнати (як просив автор ТЗ).
- Без БД —
RoomsService/GameServiceтримають усе уMapв пам'яті процесу. Якщо згодом знадобиться горизонтальне масштабування — це єдине місце, яке довелося б винести в Redis (adapter для Socket.IO + спільне сховище кімнат). - Фронтенд: Angular підійде без проблем — API суто REST+WS, жодних бекенд-специфічних вимог до клієнта немає.
npm install
npm run start:dev # http://localhost:3000, WS namespace /game- Кімната: 4–16 гравців, власник — той, хто створив кімнату.
- Лобі: гравці задають ім'я, тиснуть "готовий"; власник опційно обирає бункер/катаклізм (інакше обираються випадково при старті).
- Після старту кожен гравець отримує 9 випадкових властивостей (стать/вік, статура, характер, професія+стаж, здоров'я, хобі+рівень, фобія, багаж, факт) — приховані від усіх, крім власника картки.
- Гра йде 5 раундів. У раунді: коло відкриття однієї властивості на
гравця (у раунді 1 — примусово картка "Професія"), потім голосування
(кількість "сесій" голосування в раунді залежить від кількості гравців
на старті гри — див.
ELIMINATION_SCHEDULEвsrc/game/constants/game.constants.ts). - Вигнаний гравець розкриває всі властивості одразу і більше не ходить/не голосує. Наступний раунд починається з гравця, який ходив другим у попередньому раунді.
- Після 5-го раунду гра завершується, властивості всіх гравців стають видимими всім.
На старті кожен гравець отримує 2 різні картки особливих умов
(SPECIAL_CARDS_PER_PLAYER; колода — у
src/config/special-cards.data.ts).
Перша картка завжди має timing ANY, тож ніхто не залишиться лише з
картками для голосування. Картку можна зіграти будь-коли в грі, не
обов'язково у свій хід. Вигнані гравці карток не грають.
| Тип | Timing | Ціль | Ефект |
|---|---|---|---|
SWAP_TRAIT |
ANY | інший | обмін характеристикою (професія / здоров'я / багаж / фобія) |
REROLL_OWN |
ANY | — | своя характеристика замінюється випадковою |
REROLL_TARGET |
ANY | інший | характеристика цілі замінюється випадковою |
HEAL |
ANY | будь-хто | здоров'я → «Ідеально здоровий» |
PEEK |
ANY | інший | граючий приватно бачить одну приховану характеристику цілі |
FORCE_REVEAL |
ANY | інший | ціль публічно відкриває випадкову приховану характеристику |
IMMUNITY |
VOTING | — | за гравця не голосують у поточній сесії (наявні голоси знімаються) |
DOUBLE_VOTE |
VOTING | — | голос гравця рахується двічі в поточній сесії |
REVOTE |
VOTING | — | усі голоси сесії скасовуються, таймер — заново |
Якщо FORCE_REVEAL уже відкрив професію гравця до його ходу в 1-му
раунді, він відкриває будь-яку іншу картку. Кожна зіграна картка
пишеться в журнал подій room.log.
POST /rooms— створити кімнату. Body:{ ownerName }. Відповідь:{ playerId, room }POST /rooms/:code/join— приєднатися по коду. Body:{ name }. Відповідь:{ playerId, room }GET /rooms/:code— поточний стан кімнати (без маскування — сервісний перегляд)POST /rooms/:code/leave— вийти з кімнати, поки вона в лобі. Body:{ playerId }GET /config/bunkers— список доступних бункерівGET /config/cataclysms— список доступних катаклізмів
Код кімнати — 6-значний числовий код (легко продиктувати голосом).
Підключення: io('http://host/game', { query: { code, playerId } }) —
code і playerId отримуються через REST (create/join) заздалегідь.
При невалідному code/playerId сокет отримує room:error і розривається.
player:rename—{ name }. Будь-який гравець, лише в лобі.player:ready—{ ready }. Не-власник, лише в лобі.owner:selectBunker—{ bunkerId }. Власник, лише в лобі.owner:selectCataclysm—{ cataclysmId }. Власник, лише в лобі.owner:kickPlayer—{ playerId }. Власник, лише в лобі. Вигнаний гравець отримуєroom:kicked, і його сокет відключається.owner:startGame— без payload. Власник, коли гравці готові (4–16).owner:startRound— без payload. Власник.player:revealTrait—{ category }. Активний гравець (у раунді 1 категорія ігнорується — завжди PROFESSION).owner:startVoting— без payload. Власник, коли є що голосувати.player:castVote—{ targetPlayerId }. Будь-який невигнаний гравець.owner:endVoting— без payload. Власник, коли всі проголосували. Коли 60-секундний таймер голосування спливає, сервер сам віддає випадковий голос за кожного, хто не проголосував.owner:restartVoting— без payload. Власник, лише після нічиєї.player:playSpecialCard—{ cardId, targetPlayerId? }. Будь-який невигнаний гравець під час гри;targetPlayerIdобов'язковий для карток з ціллю.
category — одне з: GENDER_AGE, BODY, CHARACTER, PROFESSION,
HEALTH, HOBBY, PHOBIA, BAGGAGE, FACT.
room:state— повний знімок кімнати, персоналізований під конкретного глядача: властивості інших гравців приховані (revealed: false, card: null), поки власник картки їх не відкрив; властивості вигнаних гравців і, після завершення гри, взагалі всіх гравців — видно повністю. Кожен гравець уplayers[]додатково несеpubliclyRevealedCategories— список категорій, які він розкрив публічно, незалежно від того, хто дивиться. Це окремо відtraits[].revealed(яке для власних карток гравця завждиtrue) і потрібне клієнту, щоб відрізнити "бачу лише я" від "уже бачать усі". Полеvoting(коли є активна сесія) міститьsessionIndex(зростає при кожному новому/перезапущеному голосуванні — сигнал для клієнтських таймерів) таlastResult/tiedPlayerIdsдля показу нічиєї, а такожimmunePlayerIds/doubleVoterIds;voteCountsуже враховують подвійні голоси.players[].specialCards— власні картки глядача завжди, чужі — лише зіграні (після завершення гри — всі);specialCardsLeftпублічний. Характеристики, підглянуті черезPEEK, приходять глядачу якrevealed: true, але не потрапляють уpubliclyRevealedCategories.log— останні 30 подій (раунди, зіграні картки, вигнання).room:error—{ message }при відхиленій дії.
Ліміт 60 секунд на хід і 1 хвилина на обговорення при голосуванні — навмисно не реалізовані на бекенді (немає server-side таймаутів чи примусових дій). Легше й надійніше рахувати їх на фронтенді (як і пропонував автор ТЗ) — бекенд лишається єдиним джерелом істини щодо ігрового стану, а UI сам вирішує, коли підсвітити "час вийшов".
src/
common/ enum'и та типи (Room, Player, TraitCategory, RoomPhase)
config/ статичні пули властивостей, бункерів, катаклізмів + REST-контролер
rooms/ RoomsService (лобі: створення/приєднання/готовність/вибір бункера)
game/ GameService (раунди/голосування/елімінація), GameGateway (WS),
SpecialCardsService (ефекти особливих умов),
room-view.mapper (маскування властивостей під глядача)