import { Server } from "socket.io"; import { Clock, defaultClock, GameError, gameHandler, loginFromPayload, minPlayersForEnv } from "./minigames"; import { getActiveRestaurants, getRestaurantMenu } from "./service"; /** * Kreslení — jeden hráč kreslí jídlo z dnešních menu, ostatní hádají v chatu. * * Každý hráč jednou kreslí. Hádá se klíčové slovo jídla (např. "guláš" u * "Plzeňský hovězí guláš"), diakritika ani drobné koncovky nevadí. Kdo uhodne dřív, * dostane víc bodů; kreslíř dostává body za každého, kdo uhodl. * * Hádané slovo dostává jen kreslíř (osobní místnost), ostatním chodí maska s nápovědou. * Stav je v paměti, předpokládá jednu repliku. */ export const ROUND_MS = 80_000; export const REVEAL_MS = 5000; export const MAX_PLAYERS = 10; export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000; export const GUESS_MAX_POINTS = 100; export const GUESS_MIN_POINTS = 20; export const DRAWER_POINTS_PER_GUESS = 25; /** Podíly času kola, kdy se v nápovědě odkryje další písmeno. */ const HINT_REVEALS = [0.4, 0.7]; const MAX_MESSAGES = 30; const MAX_MESSAGE_LENGTH = 100; /** Pojistka proti nekonečné kresbě (součet bodů všech tahů v kole). */ const MAX_POINTS_PER_ROUND = 20_000; export const WATCH_ROOM = 'draw-watchers'; export const userRoom = (login: string) => `draw-user:${login}`; /** Záložní slova, když dnešní menu nejsou k dispozici (víkend, výpadek). */ export const FALLBACK_WORDS = [ 'pizza', 'guláš', 'řízek', 'svíčková', 'burger', 'palačinky', 'knedlíky', 'špagety', 'sushi', 'kebab', 'salát', 'hranolky', 'párek', 'koláč', 'dort', 'bramboráky', 'rizoto', 'lasagne', 'tacos', 'vepřo knedlo zelo', ]; export type DrawPhase = 'idle' | 'lobby' | 'drawing' | 'reveal' | 'finished'; export type DrawPlayer = { login: string, score: number, guessed: boolean, place?: number }; export type ChatMessage = { login?: string, text: string, system?: boolean }; /** Tah štětcem; souřadnice 0–1000 nezávisle na velikosti plátna. */ export type Stroke = { color: string, size: number, points: [number, number][], start: boolean }; export type DrawState = { phase: DrawPhase, host?: string, players: DrawPlayer[], drawer?: string, round: number, totalRounds: number, /** Maska hádaného jídla, např. "g _ _ _ š". */ hint?: string, /** Řešení — jen ve fázi reveal a finished. */ answer?: string, /** Zbývající čas kola (ms) v okamžiku odeslání. */ remainingMs?: number, messages: ChatMessage[], minPlayers: number, }; type Word = { display: string, keywords: string[] }; /** Malá písmena bez diakritiky a nadbytečných mezer. */ export const normalize = (s: string) => s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/\s+/g, ' ').trim(); /** * Z názvu jídla v menu udělá hádatelné slovo: bez alergenů, jen hlavní část * (před čárkou a přílohou "s/se ..."), klíčová slova bez přídavných jmen. */ export function toWord(dish: string): Word { let display = dish.replace(/\([^)]*\)/g, '').split(',')[0].split(/\s+(?:s|se|a|na)\s+/i)[0].replace(/\s+/g, ' ').trim(); const words = display.split(' '); if (display.length > 30) display = words.slice(0, 3).join(' '); // Přídavná jména v češtině končí obvykle na -ý/-á/-é/-í ("Plzeňský hovězí guláš" → guláš) let keywords = display.split(' ').filter(w => w.length >= 4 && !/[ýáéí]$/i.test(w)).map(normalize); if (keywords.length === 0) keywords = display.split(' ').filter(w => w.length >= 3).map(normalize); if (keywords.length === 0) keywords = [normalize(display)]; return { display, keywords }; } /** Uhodl hráč? Stačí jedno klíčové slovo; tolerují se koncovky (knedlík ~ knedlíky). */ export function isCorrectGuess(guess: string, word: Word): boolean { const g = normalize(guess); if (!g) return false; if (g === normalize(word.display)) return true; return g.split(' ').some(gw => word.keywords.some(kw => { if (gw === kw) return true; const need = Math.max(4, kw.length - 2); return gw.length >= need && kw.slice(0, need) === gw.slice(0, need); })); } export class DrawingGame { private phase: DrawPhase = 'idle'; private host?: string; private players: DrawPlayer[] = []; private order: string[] = []; private round = 0; private word?: Word; private words: Word[] = []; private revealed = new Set(); private roundEndsAt = 0; private messages: ChatMessage[] = []; private strokes: Stroke[] = []; private strokePoints = 0; private starting = false; private timer: unknown; private hintTimers: unknown[] = []; constructor( private readonly broadcastAll: (state: DrawState) => void, private readonly sendWord: (login: string, word: string) => void, private readonly relayStroke: (stroke: Stroke) => void, private readonly relayClear: () => void, private readonly loadDishes: () => Promise, private readonly minPlayers = 2, private readonly clock: Clock = defaultClock, ) { } get drawer(): string | undefined { return this.phase === 'drawing' || this.phase === 'reveal' ? this.order[this.round - 1] : undefined; } getState(): DrawState { const showAnswer = this.phase === 'reveal' || this.phase === 'finished'; return { phase: this.phase, host: this.host, players: this.players.map(p => ({ ...p })), drawer: this.drawer, round: this.round, totalRounds: this.order.length, hint: this.phase === 'drawing' ? this.hint() : undefined, answer: showAnswer ? this.word?.display : undefined, remainingMs: this.phase === 'drawing' ? Math.max(0, this.roundEndsAt - this.clock.now()) : undefined, messages: [...this.messages], minPlayers: this.minPlayers, }; } getStrokes(): Stroke[] { return this.strokes; } /** Hádané slovo pro daného hráče (jen kreslíř během kola). */ wordFor(login: string): string | undefined { return this.phase === 'drawing' && login === this.drawer ? this.word?.display : undefined; } join(login: string) { if (this.phase === 'idle' || this.phase === 'finished') { this.reset(); this.phase = 'lobby'; this.host = login; this.schedule(() => this.cancel(), LOBBY_TIMEOUT_MS); } else if (this.phase !== 'lobby') { throw new GameError('Hra už běží, počkejte na další.'); } if (this.players.some(p => p.login === login)) return; if (this.players.length >= MAX_PLAYERS) { throw new GameError('Lobby je plné.'); } this.players.push({ login, score: 0, guessed: false }); this.emit(); } leave(login: string) { if (this.phase !== 'lobby' || !this.players.some(p => p.login === login)) return; if (login === this.host) { this.cancel(); return; } this.players = this.players.filter(p => p.login !== login); this.emit(); } async start(login: string) { if (this.phase !== 'lobby' || this.starting) { throw new GameError('Není otevřené žádné lobby.'); } if (login !== this.host) { throw new GameError('Hru může spustit jen ten, kdo lobby založil.'); } if (this.players.length < this.minPlayers) { throw new GameError(`Ke hře je potřeba alespoň ${this.minPlayers} hráčů.`); } this.starting = true; let dishes: string[] = []; try { dishes = await this.loadDishes(); } catch { // Menu nejsou k dispozici — hraje se se záložními slovy } finally { this.starting = false; } if (this.phase !== 'lobby') return; const unique = [...new Map(dishes.map(toWord).map(w => [normalize(w.display), w])).values()]; // Dnešní jídla mají přednost; když dojdou, doplní se záložní slova (viz nextRound) this.words = unique.length > 0 ? unique : FALLBACK_WORDS.map(toWord); this.order = this.players.map(p => p.login); this.round = 0; this.nextRound(); } stroke(login: string, payload: unknown) { if (this.phase !== 'drawing' || login !== this.drawer) return; const { color, size, points, start } = (payload ?? {}) as Partial; if (typeof color !== 'string' || !/^#[0-9a-f]{6}$/i.test(color)) return; if (typeof size !== 'number' || size < 1 || size > 40) return; if (!Array.isArray(points) || points.length === 0 || points.length > 200) return; if (!points.every(p => Array.isArray(p) && p.length === 2 && p.every(v => Number.isFinite(v) && v >= 0 && v <= 1000))) return; if (this.strokePoints + points.length > MAX_POINTS_PER_ROUND) return; const stroke: Stroke = { color, size, points: points.map(([x, y]) => [Math.round(x), Math.round(y)]), start: !!start }; this.strokes.push(stroke); this.strokePoints += points.length; this.relayStroke(stroke); } clear(login: string) { if (this.phase !== 'drawing' || login !== this.drawer) return; this.strokes = []; this.strokePoints = 0; this.relayClear(); } guess(login: string, text: unknown) { if (typeof text !== 'string') return; const message = text.trim().slice(0, MAX_MESSAGE_LENGTH); const player = this.players.find(p => p.login === login); if (!message || !player) return; if (this.phase !== 'drawing') { this.pushMessage({ login, text: message }); this.emit(); return; } // Kreslíř a ti, kdo už uhodli, by mohli prozradit řešení if (login === this.drawer || player.guessed || !this.word) return; if (isCorrectGuess(message, this.word)) { const share = Math.max(0, this.roundEndsAt - this.clock.now()) / ROUND_MS; player.guessed = true; player.score += Math.max(GUESS_MIN_POINTS, Math.round(GUESS_MAX_POINTS * share)); const drawer = this.players.find(p => p.login === this.drawer); if (drawer) drawer.score += DRAWER_POINTS_PER_GUESS; this.pushMessage({ text: `${login} uhodl(a)! 🎉`, system: true }); const guessers = this.players.filter(p => p.login !== this.drawer); if (guessers.every(p => p.guessed)) { this.endRound(); return; } } else { this.pushMessage({ login, text: message }); } this.emit(); } private nextRound() { this.round++; if (this.round > this.order.length) { this.finish(); return; } const index = Math.floor(this.clock.random() * this.words.length); this.word = this.words.splice(index, 1)[0] ?? toWord(FALLBACK_WORDS[0]); if (this.words.length === 0) this.words = FALLBACK_WORDS.map(toWord); this.revealed.clear(); this.strokes = []; this.strokePoints = 0; this.players.forEach(p => { p.guessed = false; }); this.phase = 'drawing'; this.roundEndsAt = this.clock.now() + ROUND_MS; this.pushMessage({ text: `Kreslí ${this.drawer}.`, system: true }); this.relayClear(); this.schedule(() => this.endRound(), ROUND_MS); this.hintTimers = HINT_REVEALS.map(share => this.clock.setTimeout(() => this.revealLetter(), ROUND_MS * share)); this.emit(); this.sendWord(this.drawer!, this.word.display); } private revealLetter() { if (this.phase !== 'drawing' || !this.word) return; const hidden = [...this.word.display].map((c, i) => i).filter(i => this.word!.display[i] !== ' ' && !this.revealed.has(i)); // Poslední písmeno nikdy neodkrýváme if (hidden.length <= 1) return; this.revealed.add(hidden[Math.floor(this.clock.random() * hidden.length)]); this.emit(); } private hint(): string { if (!this.word) return ''; return [...this.word.display].map((c, i) => c === ' ' ? ' ' : this.revealed.has(i) ? c : '_').join(' '); } private endRound() { this.clearHintTimers(); this.phase = 'reveal'; this.pushMessage({ text: `Bylo to: ${this.word?.display}`, system: true }); this.schedule(() => this.nextRound(), REVEAL_MS); this.emit(); } private finish() { const ranked = [...this.players].sort((a, b) => b.score - a.score); ranked.forEach((p, i) => { p.place = i + 1; }); this.phase = 'finished'; this.clearTimer(); this.emit(); } private pushMessage(message: ChatMessage) { this.messages.push(message); if (this.messages.length > MAX_MESSAGES) this.messages.shift(); } private cancel() { this.reset(); this.emit(); } private reset() { this.clearTimer(); this.clearHintTimers(); this.phase = 'idle'; this.host = undefined; this.players = []; this.order = []; this.round = 0; this.word = undefined; this.messages = []; this.strokes = []; this.strokePoints = 0; } private schedule(fn: () => void, ms: number) { this.clearTimer(); this.timer = this.clock.setTimeout(() => { this.timer = undefined; fn(); }, ms); } private clearTimer() { if (this.timer !== undefined) { this.clock.clearTimeout(this.timer); this.timer = undefined; } } private clearHintTimers() { this.hintTimers.forEach(t => this.clock.clearTimeout(t)); this.hintTimers = []; } private emit() { this.broadcastAll(this.getState()); } } /** Názvy dnešních jídel (bez polévek) ze všech aktivních podniků. */ async function loadTodaysDishes(): Promise { const menus = await Promise.all(getActiveRestaurants().map(r => getRestaurantMenu(r).catch(() => undefined))); return menus.flatMap(m => m?.food ?? []).filter(f => !f.isSoup && f.name).map(f => f.name); } /** Zaregistruje websocket handlery Kreslení. */ export function registerDrawingGame(io: Server) { const game = new DrawingGame( state => io.emit('draw:state', state), (login, word) => io.to(userRoom(login)).emit('draw:word', word), stroke => io.to(WATCH_ROOM).emit('draw:stroke', stroke), () => io.to(WATCH_ROOM).emit('draw:clear'), loadTodaysDishes, minPlayersForEnv(2), ); io.on('connection', socket => { const handle = (action: (login: string, payload: unknown) => void | Promise) => gameHandler(socket, 'draw:error', action); socket.on('draw:sync', () => socket.emit('draw:state', game.getState())); // Sledování vyžaduje přihlášení — kreslíř si tak po znovupřipojení vyzvedne své slovo socket.on('draw:watch', payload => { let login: string | undefined; try { login = loginFromPayload(payload); } catch { /* sleduje anonymně */ } socket.join(WATCH_ROOM); if (login) socket.join(userRoom(login)); socket.emit('draw:state', game.getState()); socket.emit('draw:strokes', game.getStrokes()); const word = login ? game.wordFor(login) : undefined; if (word) socket.emit('draw:word', word); }); socket.on('draw:unwatch', () => { socket.leave(WATCH_ROOM); [...socket.rooms].filter(r => r.startsWith('draw-user:')).forEach(r => socket.leave(r)); }); socket.on('draw:join', handle(login => game.join(login))); socket.on('draw:leave', handle(login => game.leave(login))); socket.on('draw:start', handle(login => game.start(login))); socket.on('draw:stroke', handle((login, payload) => game.stroke(login, payload))); socket.on('draw:clear', handle(login => game.clear(login))); socket.on('draw:guess', handle((login, payload) => game.guess(login, (payload as { text?: unknown })?.text))); }); }