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
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:
28 files changed
+5050
-2
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user