feat: skryté multiplayerové minihry přes websocket
CI / Generate TypeScript types (push) Canceled after 7s
CI / Server unit tests (push) Canceled after 0s
CI / Build server (push) Canceled after 0s
CI / Build client (push) Canceled after 0s
CI / Playwright E2E tests (push) Canceled after 0s
CI / Build and push Docker image (push) Canceled after 0s
CI / Notify (push) Canceled after 0s

Minihry se otevírají trojklikem na logo a napsáním kódu hry
("help" vypíše seznam do konzole prohlížeče):
- reakce: reakční hra, 3 kola, pořadí podle součtu časů
- snake: had pro více hráčů, simulaci drží server
- tetris: battle Tetris s odpadními řádky a lokálním tréninkem
- pong: Pong 1v1, ovládání myší i klávesami, bot se 4 obtížnostmi
- kresleni: kreslení a hádání jídel z dnešních menu

Ikony her se řídí zvoleným vzhledem. Stav her je v paměti serveru,
předpokládá se jedna replika.
This commit is contained in:
batmanisko committed 2026-10-12 14:25:45 +02:00
1 parent abeee19259
commit 6cb1214f08
28 files changed
+5050 -2

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
import { DrawingGame, DrawState, Stroke, toWord, isCorrectGuess, ROUND_MS, REVEAL_MS, GUESS_MAX_POINTS, DRAWER_POINTS_PER_GUESS, FALLBACK_WORDS } from '../drawingGame';
import { GameError } from '../minigames';
import { fakeClock } from './helpers/fakeClock';
describe('kreslení — slova z menu', () => {
test.each([
['Plzeňský hovězí guláš s kynutým knedlíkem (1, 3, 7)', 'Plzeňský hovězí guláš', ['gulas']],
['Tortilla wrap s trhaným vepřovým masem, slaninou a čedarem', 'Tortilla wrap', ['tortilla', 'wrap']],
['Čevabčiči, brambory, cibule, hořčice (1, 3, 7)', 'Čevabčiči', ['cevabcici']],
['Sýrová pizza, čerstvá bazalka (1, 7)', 'Sýrová pizza', ['pizza']],
['Medailonky z panenky se slaninovým kroupotem', 'Medailonky z panenky', ['medailonky', 'panenky']],
])('%s', (dish, display, keywords) => {
expect(toWord(dish)).toEqual({ display, keywords });
});
test('hádání toleruje diakritiku, velikost písmen a koncovky', () => {
const gulas = toWord('Plzeňský hovězí guláš');
expect(isCorrectGuess('gulas', gulas)).toBe(true);
expect(isCorrectGuess(' GULÁŠ ', gulas)).toBe(true);
expect(isCorrectGuess('je to guláš?', gulas)).toBe(true);
expect(isCorrectGuess('hovězí', gulas)).toBe(false);
expect(isCorrectGuess('gul', gulas)).toBe(false);
expect(isCorrectGuess('knedliky', toWord('Knedlík'))).toBe(true);
expect(isCorrectGuess('knedliky', toWord('Bramborový knedlík'))).toBe(true);
expect(isCorrectGuess('pizzu', toWord('Sýrová pizza'))).toBe(true);
});
});
function setup(dishes: string[] = ['Plzeňský hovězí guláš', 'Sýrová pizza', 'Smažený řízek']) {
const clock = fakeClock();
const states: DrawState[] = [];
const words: { login: string, word: string }[] = [];
const strokes: Stroke[] = [];
let clears = 0;
const game = new DrawingGame(
s => states.push(s),
(login, word) => words.push({ login, word }),
s => strokes.push(s),
() => { clears++; },
async () => dishes,
2,
clock,
);
const last = () => states[states.length - 1];
const player = (login: string) => last().players.find(p => p.login === login)!;
const startGame = async (...logins: string[]) => {
logins.forEach(l => game.join(l));
await game.start(logins[0]);
};
return { game, clock, states, words, strokes, last, player, startGame, clears: () => clears };
}
describe('kreslení — průběh hry', () => {
test('slovo dostane jen kreslíř, ostatní vidí masku', async () => {
const ctx = setup();
await ctx.startGame('alice', 'bob');
const state = ctx.last();
expect(state).toMatchObject({ phase: 'drawing', drawer: 'alice', round: 1, totalRounds: 2 });
expect(ctx.words).toHaveLength(1);
expect(ctx.words[0].login).toBe('alice');
expect(JSON.stringify(ctx.states)).not.toContain(ctx.words[0].word);
expect(state.hint).toMatch(/^[_ ]+$/);
expect(ctx.game.wordFor('bob')).toBeUndefined();
});
test('správný tip dá body hádajícímu i kreslíři a ukončí kolo, když uhodli všichni', async () => {
const ctx = setup(['Plzeňský hovězí guláš', 'Plzeňský hovězí guláš']);
await ctx.startGame('alice', 'bob');
ctx.game.guess('bob', 'svíčková');
expect(ctx.last().messages.at(-1)).toEqual({ login: 'bob', text: 'svíčková' });
ctx.game.guess('bob', 'guláš');
expect(ctx.player('bob').score).toBe(GUESS_MAX_POINTS);
expect(ctx.player('alice').score).toBe(DRAWER_POINTS_PER_GUESS);
expect(ctx.last()).toMatchObject({ phase: 'reveal', answer: 'Plzeňský hovězí guláš' });
ctx.clock.advance(REVEAL_MS);
expect(ctx.last()).toMatchObject({ phase: 'drawing', drawer: 'bob', round: 2 });
});
test('kreslíř a ti, kdo už uhodli, nemohou psát do chatu', async () => {
const ctx = setup(['Plzeňský hovězí guláš', 'Plzeňský hovězí guláš']);
await ctx.startGame('alice', 'bob', 'carol');
const before = ctx.last().messages.length;
ctx.game.guess('alice', 'je to guláš');
ctx.game.guess('bob', 'guláš');
ctx.game.guess('bob', 'guláš s knedlíkem!');
const texts = ctx.last().messages.slice(before).map(m => m.text);
expect(texts).toEqual(['bob uhodl(a)! 🎉']);
});
test('pozdější tip má méně bodů a nápověda postupně odkrývá písmena', async () => {
const ctx = setup(['Plzeňský hovězí guláš', 'Plzeňský hovězí guláš']);
await ctx.startGame('alice', 'bob', 'carol');
ctx.clock.advance(ROUND_MS * 0.75);
expect(ctx.last().hint!.replace(/[_ ]/g, '')).toHaveLength(2);
ctx.game.guess('bob', 'gulas');
expect(ctx.player('bob').score).toBe(25);
});
test('po vypršení času kolo skončí a po všech kolech hra', async () => {
const ctx = setup();
await ctx.startGame('alice', 'bob');
ctx.clock.advance(ROUND_MS);
expect(ctx.last().phase).toBe('reveal');
ctx.clock.advance(REVEAL_MS + ROUND_MS + REVEAL_MS);
expect(ctx.last().phase).toBe('finished');
expect(ctx.last().players.every(p => p.place !== undefined)).toBe(true);
});
test('kreslit smí jen kreslíř a neplatné tahy se zahodí', async () => {
const ctx = setup();
await ctx.startGame('alice', 'bob');
const stroke = { color: '#ff0000', size: 4, points: [[10, 10], [20, 20]], start: true };
ctx.game.stroke('bob', stroke);
ctx.game.stroke('alice', { ...stroke, color: 'red' });
ctx.game.stroke('alice', { ...stroke, points: [[5000, 1]] });
expect(ctx.strokes).toHaveLength(0);
ctx.game.stroke('alice', stroke);
expect(ctx.strokes).toHaveLength(1);
expect(ctx.game.getStrokes()).toHaveLength(1);
ctx.game.clear('alice');
expect(ctx.game.getStrokes()).toHaveLength(0);
});
test('bez menu se hraje se záložními slovy', async () => {
const ctx = setup([]);
await ctx.startGame('alice', 'bob');
expect(FALLBACK_WORDS.map(w => toWord(w).display)).toContain(ctx.words[0].word);
});
test('hru spustí jen zakladatel s dostatkem hráčů', async () => {
const ctx = setup();
ctx.game.join('alice');
await expect(ctx.game.start('alice')).rejects.toThrow(GameError);
ctx.game.join('bob');
await expect(ctx.game.start('bob')).rejects.toThrow(GameError);
});
});
+27
View File
@@ -0,0 +1,27 @@
/** Ruční hodiny — časovače se spouští jen přes advance(). */
export function fakeClock(random: () => number = () => 0) {
let now = 1_000_000;
let timers: { at: number, fn: () => void, id: number }[] = [];
let nextId = 1;
return {
now: () => now,
random,
setTimeout: (fn: () => void, ms: number) => {
const id = nextId++;
timers.push({ at: now + ms, fn, id });
return id;
},
clearTimeout: (id: unknown) => { timers = timers.filter(t => t.id !== id); },
advance(ms: number) {
const target = now + ms;
for (;;) {
const due = timers.filter(t => t.at <= target).sort((a, b) => a.at - b.at)[0];
if (!due) break;
timers = timers.filter(t => t !== due);
now = due.at;
due.fn();
}
now = target;
},
};
}
+124
View File
@@ -0,0 +1,124 @@
import { PongGame, PongState, TICK_MS, COUNTDOWN_SECONDS, FIELD_HEIGHT, PADDLE_MARGIN, PADDLE_WIDTH, PADDLE_HEIGHT, WIN_SCORE, SERVE_PAUSE_MS, botLogin, BotLevel } from '../pongGame';
import { GameError } from '../minigames';
import { fakeClock } from './helpers/fakeClock';
/** Deterministická pseudonáhoda, ať se boti nechovají pokaždé stejně jako s konstantou. */
function lcg(seed = 1) {
let x = seed;
return () => (x = (x * 1103515245 + 12345) % 2147483648) / 2147483648;
}
function setup(random?: () => number) {
const clock = fakeClock(random);
const states: PongState[] = [];
const game = new PongGame(s => states.push(s), s => states.push(s), clock);
const last = () => states[states.length - 1];
const player = (login: string) => last().players.find(p => p.login === login)!;
const startGame = () => {
game.join('alice');
game.join('bob');
game.start('alice');
clock.advance(COUNTDOWN_SECONDS * 1000);
};
const ticks = (n: number) => clock.advance(n * TICK_MS);
return { game, clock, last, player, startGame, ticks };
}
describe('pong', () => {
test('je jen pro dva hráče a hru spouští zakladatel', () => {
const ctx = setup();
ctx.game.join('alice');
expect(() => ctx.game.start('alice')).toThrow(GameError);
ctx.game.join('bob');
expect(() => ctx.game.join('carol')).toThrow(GameError);
expect(() => ctx.game.start('bob')).toThrow(GameError);
ctx.game.start('alice');
expect(ctx.last().phase).toBe('countdown');
});
test('pálka se hýbe podle vstupu a nevyjede z hřiště', () => {
const ctx = setup();
ctx.startGame();
ctx.game.input('alice', 'up');
ctx.ticks(10);
expect(ctx.player('alice').y).toBeLessThan(FIELD_HEIGHT / 2);
ctx.ticks(100);
expect(ctx.player('alice').y).toBe(45);
ctx.game.input('alice', 'stop');
const y = ctx.player('alice').y;
ctx.ticks(5);
expect(ctx.player('alice').y).toBe(y);
});
test('míček se od pálky odrazí', () => {
const ctx = setup();
ctx.startGame();
const face = PADDLE_MARGIN + PADDLE_WIDTH;
ctx.game.setBallForTest(face + 20, FIELD_HEIGHT / 2, -400, 0);
ctx.ticks(3);
expect(ctx.last().ball.x).toBeGreaterThan(face);
expect(ctx.player('bob').score).toBe(0);
});
test('minutý míček je bod pro soupeře a hra končí na vítězné skóre', () => {
const ctx = setup();
ctx.startGame();
for (let i = 0; i < WIN_SCORE; i++) {
// Míček u horního okraje, kam pálka uprostřed nedosáhne
ctx.game.setBallForTest(60, 10, -600, 0);
ctx.ticks(10);
if (i < WIN_SCORE - 1) {
expect(ctx.player('bob').score).toBe(i + 1);
ctx.clock.advance(SERVE_PAUSE_MS);
}
}
expect(ctx.last()).toMatchObject({ phase: 'finished', winner: 'bob' });
});
test('myš: pálka rychle dojede k cílové výšce a šipka myš přebije', () => {
const ctx = setup();
ctx.startGame();
ctx.game.aim('alice', 0);
ctx.ticks(5);
expect(ctx.player('alice').y).toBe(PADDLE_HEIGHT / 2);
ctx.game.input('alice', 'down');
ctx.ticks(5);
expect(ctx.player('alice').y).toBeGreaterThan(PADDLE_HEIGHT / 2);
});
test('bota přidá jen zakladatel a lze ho odebrat', () => {
const ctx = setup();
ctx.game.join('alice');
expect(() => ctx.game.addBot('bob', 'easy')).toThrow(GameError);
expect(() => ctx.game.addBot('alice', 'superhard')).toThrow(GameError);
ctx.game.addBot('alice', 'hard');
expect(ctx.last().players.map(p => p.login)).toEqual(['alice', botLogin('hard')]);
ctx.game.removeBot('alice');
expect(ctx.last().players).toHaveLength(1);
});
/** Odehraje zápas člověka, který myší dokonale sleduje míček, proti botovi. */
function playAgainstBot(level: BotLevel, maxSeconds: number) {
const ctx = setup(lcg(7));
ctx.game.join('alice');
ctx.game.addBot('alice', level);
ctx.game.start('alice');
ctx.clock.advance(COUNTDOWN_SECONDS * 1000);
for (let t = 0; t < maxSeconds * 1000 && ctx.last().phase === 'playing'; t += TICK_MS) {
ctx.game.aim('alice', ctx.last().ball.y);
ctx.ticks(1);
}
return ctx;
}
test('lehký bot proti dokonalému hráči prohraje', () => {
const ctx = playAgainstBot('easy', 300);
expect(ctx.last()).toMatchObject({ phase: 'finished', winner: 'alice' });
});
test('nemožný bot neztratí ani bod', () => {
const ctx = playAgainstBot('impossible', 120);
expect(ctx.player('alice').score).toBe(0);
expect(ctx.player(botLogin('impossible')).score).toBe(0);
});
});
+101
View File
@@ -0,0 +1,101 @@
import { ReactionDuel, DuelState, PENALTY_MS, CLICK_WINDOW_MS, ROUND_PAUSE_MS, MIN_DELAY_MS, LOBBY_TIMEOUT_MS } from '../reactionDuel';
import { GameError } from '../minigames';
import { fakeClock } from './helpers/fakeClock';
function setup(minPlayers = 2) {
const clock = fakeClock();
const states: DuelState[] = [];
const duel = new ReactionDuel(s => states.push(s), minPlayers, clock);
return { duel, clock, last: () => states[states.length - 1] };
}
/** Dohraje jedno kolo: počká na vyhlášení a nechá hráče kliknout s danými časy. */
function playRound(ctx: ReturnType<typeof setup>, reactions: Record<string, number>) {
ctx.clock.advance(MIN_DELAY_MS);
expect(ctx.last().phase).toBe('go');
const sorted = Object.entries(reactions).sort((a, b) => a[1] - b[1]);
let elapsed = 0;
for (const [login, ms] of sorted) {
ctx.clock.advance(ms - elapsed);
elapsed = ms;
ctx.duel.click(login);
}
}
describe('reakční hra', () => {
test('první hráč zakládá lobby a je hostitel', () => {
const ctx = setup();
ctx.duel.join('alice');
ctx.duel.join('bob');
expect(ctx.last()).toMatchObject({ phase: 'lobby', host: 'alice' });
expect(ctx.last().players.map(p => p.login)).toEqual(['alice', 'bob']);
});
test('hru může spustit jen hostitel a s dostatkem hráčů', () => {
const ctx = setup();
ctx.duel.join('alice');
expect(() => ctx.duel.start('alice')).toThrow(GameError);
ctx.duel.join('bob');
expect(() => ctx.duel.start('bob')).toThrow(GameError);
ctx.duel.start('alice');
expect(ctx.last()).toMatchObject({ phase: 'waiting', round: 1 });
});
test('do běžící hry se nelze připojit', () => {
const ctx = setup();
ctx.duel.join('alice');
ctx.duel.join('bob');
ctx.duel.start('alice');
expect(() => ctx.duel.join('carol')).toThrow(GameError);
});
test('vyhrává nejnižší součet časů, nejpomalejší je poslední', () => {
const ctx = setup();
ctx.duel.join('alice');
ctx.duel.join('bob');
ctx.duel.start('alice');
playRound(ctx, { alice: 200, bob: 300 });
expect(ctx.last()).toMatchObject({ phase: 'roundResult', round: 1 });
ctx.clock.advance(ROUND_PAUSE_MS);
playRound(ctx, { alice: 250, bob: 220 });
ctx.clock.advance(ROUND_PAUSE_MS);
playRound(ctx, { alice: 210, bob: 260 });
const state = ctx.last();
expect(state.phase).toBe('finished');
expect(state.players.find(p => p.login === 'alice')?.total).toBe(660);
expect(state.players.find(p => p.login === 'bob')?.total).toBe(780);
expect(state.winner).toBe('alice');
expect(state.loser).toBe('bob');
});
test('předčasný start a nekliknutí znamenají trestný čas', () => {
const ctx = setup();
ctx.duel.join('alice');
ctx.duel.join('bob');
ctx.duel.start('alice');
ctx.duel.click('alice'); // před vyhlášením
ctx.clock.advance(MIN_DELAY_MS);
ctx.clock.advance(CLICK_WINDOW_MS); // bob neklikne
const state = ctx.last();
expect(state.phase).toBe('roundResult');
expect(state.players.find(p => p.login === 'alice')?.rounds[0]).toEqual({ ms: null, falseStart: true });
expect(state.players.find(p => p.login === 'bob')?.rounds[0]).toEqual({ ms: null });
expect(state.players.every(p => p.total === PENALTY_MS)).toBe(true);
});
test('odchodem hostitele se lobby ruší', () => {
const ctx = setup();
ctx.duel.join('alice');
ctx.duel.join('bob');
ctx.duel.leave('alice');
expect(ctx.last()).toMatchObject({ phase: 'idle', players: [] });
});
test('neodstartované lobby po čase vyprší', () => {
const ctx = setup();
ctx.duel.join('alice');
ctx.clock.advance(LOBBY_TIMEOUT_MS);
expect(ctx.last().phase).toBe('idle');
});
});
+102
View File
@@ -0,0 +1,102 @@
import { SnakeGame, SnakeState, TICK_MS, COUNTDOWN_SECONDS, WIDTH, HEIGHT } from '../snakeGame';
import { GameError } from '../minigames';
import { fakeClock } from './helpers/fakeClock';
/** Náhoda vracející zadané hodnoty a pak nuly (jídlo pak padá do rohu [0,0]). */
const sequence = (...values: number[]) => () => values.length ? values.shift()! : 0;
/** Hodnoty náhody, které umístí jídlo na danou buňku. */
const foodAt = (x: number, y: number) => [x / WIDTH, y / HEIGHT];
function setup(random?: () => number) {
const clock = fakeClock(random);
const states: SnakeState[] = [];
const game = new SnakeGame(s => states.push(s), s => states.push(s), 1, clock);
const last = () => states[states.length - 1];
const player = (login: string) => last().players.find(p => p.login === login)!;
const startGame = (...logins: string[]) => {
logins.forEach(l => game.join(l));
game.start(logins[0]);
clock.advance(COUNTDOWN_SECONDS * 1000);
};
const ticks = (n: number) => clock.advance(n * TICK_MS);
return { game, clock, last, player, startGame, ticks };
}
describe('had', () => {
test('po odpočtu začne hra a had se posouvá o políčko za tick', () => {
const ctx = setup();
ctx.startGame('alice');
expect(ctx.last().phase).toBe('playing');
expect(ctx.player('alice').body).toEqual([[5, 10], [4, 10], [3, 10]]);
ctx.ticks(2);
expect(ctx.player('alice').body).toEqual([[7, 10], [6, 10], [5, 10]]);
});
test('zatáčí podle směru a obrat o 180° ignoruje', () => {
const ctx = setup();
ctx.startGame('alice');
ctx.game.turn('alice', 'left'); // obrat — ignoruje se
ctx.ticks(1);
expect(ctx.player('alice').body[0]).toEqual([6, 10]);
ctx.game.turn('alice', 'up');
ctx.ticks(1);
expect(ctx.player('alice').body[0]).toEqual([6, 9]);
});
test('nesmyslný směr se ignoruje', () => {
const ctx = setup();
ctx.startGame('alice');
ctx.game.turn('alice', 'diagonal');
ctx.ticks(1);
expect(ctx.player('alice').body[0]).toEqual([6, 10]);
});
test('sněděné jídlo prodlouží hada a přidá bod', () => {
const ctx = setup(sequence(...foodAt(7, 10)));
ctx.startGame('alice');
expect(ctx.last().food).toEqual([[7, 10]]);
ctx.ticks(2);
expect(ctx.player('alice').score).toBe(1);
expect(ctx.player('alice').body).toHaveLength(4);
expect(ctx.last().food).not.toContainEqual([7, 10]);
});
test('náraz do zdi ukončí sólo hru', () => {
const ctx = setup();
ctx.startGame('alice');
ctx.ticks(WIDTH);
expect(ctx.last().phase).toBe('finished');
expect(ctx.player('alice')).toMatchObject({ alive: false, place: 1 });
});
test('ve hře více hráčů vyhrává poslední přeživší', () => {
const ctx = setup();
ctx.startGame('alice', 'bob');
// alice startuje v řádku 6, nahoru do zdi dorazí v 7. ticku
ctx.game.turn('alice', 'up');
ctx.ticks(7);
expect(ctx.last().phase).toBe('finished');
expect(ctx.player('bob')).toMatchObject({ alive: true, place: 1 });
expect(ctx.player('alice')).toMatchObject({ alive: false, place: 2 });
});
test('čelní srážka zabije oba hady', () => {
const ctx = setup();
ctx.startGame('alice', 'bob');
// alice (5,6)→ doprava, bob (26,13)← doleva; po 7 tickách alice zatočí dolů
// a po dalších 7 se obě hlavy potkají na (12,13)
ctx.ticks(7);
ctx.game.turn('alice', 'down');
ctx.ticks(7);
const state = ctx.last();
expect(state.phase).toBe('finished');
expect(state.players.every(p => !p.alive)).toBe(true);
});
test('do běžící hry se nelze připojit', () => {
const ctx = setup();
ctx.startGame('alice');
expect(() => ctx.game.join('bob')).toThrow(GameError);
});
});
+110
View File
@@ -0,0 +1,110 @@
import { TetrisGame, TetrisState, GarbageEvent, COUNTDOWN_SECONDS, MIN_ATTACK_GAP_MS, WATCH_FLUSH_MS, BOARD_WIDTH, BOARD_HEIGHT, PLAYER_TIMEOUT_MS } from '../tetrisGame';
import { GameError } from '../minigames';
import { fakeClock } from './helpers/fakeClock';
const BOARD = '0'.repeat(BOARD_WIDTH * (BOARD_HEIGHT - 1)) + '1'.repeat(BOARD_WIDTH);
function setup() {
const clock = fakeClock();
const all: TetrisState[] = [];
const watched: TetrisState[] = [];
const garbage: GarbageEvent[] = [];
const game = new TetrisGame(s => all.push(s), s => watched.push(s), e => garbage.push(e), 1, clock);
const last = () => all[all.length - 1];
const startGame = (...logins: string[]) => {
logins.forEach(l => game.join(l));
game.start(logins[0]);
clock.advance(COUNTDOWN_SECONDS * 1000);
};
return { game, clock, all, watched, garbage, last, startGame };
}
describe('tetris', () => {
test('po odpočtu začne hra se společným seedem', () => {
const ctx = setup();
ctx.startGame('alice', 'bob');
expect(ctx.last()).toMatchObject({ phase: 'playing' });
expect(typeof ctx.last().seed).toBe('number');
});
test('desky se sledujícím posílají sloučeně', () => {
const ctx = setup();
ctx.startGame('alice', 'bob');
ctx.game.update('alice', { board: BOARD, score: 100, lines: 1 });
ctx.game.update('bob', { board: BOARD, score: 40, lines: 0 });
expect(ctx.watched).toHaveLength(0);
ctx.clock.advance(WATCH_FLUSH_MS);
expect(ctx.watched).toHaveLength(1);
expect(ctx.watched[0].players.find(p => p.login === 'alice')).toMatchObject({ board: BOARD, score: 100, lines: 1 });
});
test('neplatná deska se zahodí', () => {
const ctx = setup();
ctx.startGame('alice');
ctx.game.update('alice', { board: 'x'.repeat(200), score: 5 });
ctx.game.update('alice', { board: '0'.repeat(10) });
ctx.clock.advance(WATCH_FLUSH_MS);
expect(ctx.watched).toHaveLength(0);
});
test('smazání více řádků pošle odpad soupeři, jeden řádek nic', () => {
const ctx = setup();
ctx.startGame('alice', 'bob');
ctx.game.cleared('alice', 1);
expect(ctx.garbage).toHaveLength(0);
ctx.game.cleared('alice', 4);
expect(ctx.garbage).toEqual([{ target: 'bob', from: 'alice', lines: 4 }]);
});
test('útoky jsou omezené frekvencí a velikostí', () => {
const ctx = setup();
ctx.startGame('alice', 'bob');
ctx.game.cleared('alice', 2);
ctx.game.cleared('alice', 2); // příliš brzy
ctx.game.cleared('alice', 99); // nesmysl
expect(ctx.garbage).toHaveLength(1);
ctx.clock.advance(MIN_ATTACK_GAP_MS);
ctx.game.cleared('alice', 3);
expect(ctx.garbage.map(g => g.lines)).toEqual([1, 2]);
});
test('poslední hráč ve hře vyhrává', () => {
const ctx = setup();
ctx.startGame('alice', 'bob', 'carol');
ctx.game.died('bob');
expect(ctx.last().phase).toBe('playing');
ctx.game.died('alice');
const state = ctx.last();
expect(state.phase).toBe('finished');
const place = (l: string) => state.players.find(p => p.login === l)?.place;
expect([place('carol'), place('alice'), place('bob')]).toEqual([1, 2, 3]);
});
test('mrtvý hráč neútočí a nedostává odpad', () => {
const ctx = setup();
ctx.startGame('alice', 'bob', 'carol');
ctx.game.died('bob');
ctx.game.cleared('bob', 4);
expect(ctx.garbage).toHaveLength(0);
ctx.game.cleared('alice', 4);
expect(ctx.garbage[0].target).toBe('carol');
});
test('hráč, který přestal posílat desku, je vyřazen', () => {
const ctx = setup();
ctx.startGame('alice', 'bob');
// alice hraje dál, bob zavřel okno
for (let t = 0; t <= PLAYER_TIMEOUT_MS + 5000; t += 2000) {
ctx.game.update('alice', { board: BOARD, score: 0, lines: 0 });
ctx.clock.advance(2000);
}
expect(ctx.last().phase).toBe('finished');
expect(ctx.last().players.find(p => p.login === 'alice')?.place).toBe(1);
});
test('do běžící hry se nelze připojit', () => {
const ctx = setup();
ctx.startGame('alice');
expect(() => ctx.game.join('bob')).toThrow(GameError);
});
});