CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 26s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 43s
CI / Playwright E2E tests (push) Successful in 2m2s
CI / Build and push Docker image (push) Successful in 52s
CI / Notify (push) Successful in 4s
234 lines
10 KiB
TypeScript
234 lines
10 KiB
TypeScript
import { TetrisGame, TetrisState, TetrisSettings, GarbageEvent, COUNTDOWN_SECONDS, JOIN_GRACE_SECONDS, MIN_ATTACK_GAP_MS, WATCH_FLUSH_MS, BOARD_WIDTH, BOARD_HEIGHT, PLAYER_TIMEOUT_MS, DEFAULT_SETTINGS } 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(minPlayers = 1) {
|
|
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), minPlayers, clock);
|
|
const last = () => all[all.length - 1];
|
|
const startWith = (settings: Partial<TetrisSettings>, ...logins: string[]) => {
|
|
logins.forEach(l => game.join(l));
|
|
game.configure(logins[0], settings);
|
|
game.start(logins[0]);
|
|
clock.advance(COUNTDOWN_SECONDS * 1000);
|
|
};
|
|
const startGame = (...logins: string[]) => startWith({}, ...logins);
|
|
return { game, clock, all, watched, garbage, last, startGame, startWith };
|
|
}
|
|
|
|
describe('tetris', () => {
|
|
test('společnou hru nejde spustit jen s jedním hráčem', () => {
|
|
const ctx = setup(2);
|
|
ctx.game.join('alice');
|
|
expect(() => ctx.game.start('alice')).toThrow(GameError);
|
|
expect(ctx.last()).toMatchObject({ phase: 'lobby' });
|
|
ctx.game.join('bob');
|
|
ctx.game.start('alice');
|
|
expect(ctx.last()).toMatchObject({ phase: 'countdown' });
|
|
});
|
|
|
|
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('útok jde soupeři, který dostal nejméně odpadu', () => {
|
|
const ctx = setup();
|
|
ctx.startGame('alice', 'bob', 'carol', 'dave');
|
|
ctx.game.cleared('alice', 4); // všichni 0 → první z nich (random = 0)
|
|
ctx.clock.advance(MIN_ATTACK_GAP_MS);
|
|
ctx.game.cleared('alice', 2);
|
|
ctx.clock.advance(MIN_ATTACK_GAP_MS);
|
|
ctx.game.cleared('alice', 3);
|
|
ctx.game.cleared('bob', 2); // carol 1, dave 2, alice 0 → alice
|
|
ctx.game.cleared('carol', 2); // alice 1, bob 4, dave 2 → alice
|
|
expect(ctx.garbage.map(g => g.target)).toEqual(['bob', 'carol', 'dave', 'alice', 'alice']);
|
|
});
|
|
|
|
test('odražený odpad zmenší útok, víc než tabulka poslat nejde', () => {
|
|
const ctx = setup();
|
|
ctx.startGame('alice', 'bob');
|
|
ctx.game.cleared('alice', 4, 1);
|
|
ctx.clock.advance(MIN_ATTACK_GAP_MS);
|
|
ctx.game.cleared('alice', 2, 0); // vše odraženo → žádný útok
|
|
ctx.game.cleared('alice', 2, 50);
|
|
ctx.clock.advance(MIN_ATTACK_GAP_MS);
|
|
ctx.game.cleared('alice', 3, -5);
|
|
expect(ctx.garbage.map(g => g.lines)).toEqual([1, 1]);
|
|
});
|
|
|
|
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('nastavení mění jen zakladatel v lobby, neplatné hodnoty se ignorují', () => {
|
|
const ctx = setup();
|
|
ctx.game.join('alice');
|
|
ctx.game.join('bob');
|
|
expect(ctx.last().settings).toEqual(DEFAULT_SETTINGS);
|
|
expect(() => ctx.game.configure('bob', { targeting: 'random' })).toThrow(GameError);
|
|
ctx.game.configure('alice', { targeting: 'payback', winMode: 'nesmysl', timeLimit: 7 });
|
|
expect(ctx.last().settings).toEqual({ ...DEFAULT_SETTINGS, targeting: 'payback' });
|
|
ctx.game.start('alice');
|
|
expect(() => ctx.game.configure('alice', { winMode: 'score' })).toThrow(GameError);
|
|
});
|
|
|
|
test('random míří náhodně bez ohledu na obdržený odpad', () => {
|
|
const ctx = setup();
|
|
ctx.startWith({ targeting: 'random' }, 'alice', 'bob', 'carol');
|
|
ctx.game.cleared('alice', 4);
|
|
ctx.clock.advance(MIN_ATTACK_GAP_MS);
|
|
ctx.game.cleared('alice', 4); // random = 0 → pořád první soupeř
|
|
expect(ctx.garbage.map(g => g.target)).toEqual(['bob', 'bob']);
|
|
});
|
|
|
|
test('payback vrací útok poslednímu útočníkovi, jinak míří jako even', () => {
|
|
const ctx = setup();
|
|
ctx.startWith({ targeting: 'payback' }, 'alice', 'bob', 'carol');
|
|
ctx.game.cleared('carol', 2); // carol nikdo nenapadl → even → alice
|
|
ctx.game.cleared('alice', 2); // alice napadla carol → carol
|
|
ctx.game.cleared('bob', 2); // bob nikdo nenapadl → even (alice 1, carol 1) → alice
|
|
ctx.clock.advance(MIN_ATTACK_GAP_MS);
|
|
ctx.game.cleared('alice', 2); // naposledy ji napadl bob → bob
|
|
ctx.game.died('bob');
|
|
ctx.game.cleared('bob', 2); // mrtvý neútočí
|
|
ctx.game.cleared('carol', 2); // naposledy ji napadla alice → alice
|
|
expect(ctx.garbage.map(g => `${g.from}>${g.target}`)).toEqual(['carol>alice', 'alice>carol', 'bob>alice', 'alice>bob', 'carol>alice']);
|
|
});
|
|
|
|
test('hra na skóre končí časovým limitem a pořadí je podle skóre', () => {
|
|
const ctx = setup();
|
|
ctx.startWith({ winMode: 'score', timeLimit: 120 }, 'alice', 'bob', 'carol');
|
|
expect(ctx.last().remainingMs).toBe(120_000);
|
|
ctx.game.update('bob', { board: BOARD, score: 900, lines: 8 });
|
|
ctx.game.died('bob');
|
|
ctx.game.died('alice'); // zbývá jediný hráč, ale hraje se dál
|
|
expect(ctx.last().phase).toBe('playing');
|
|
ctx.game.update('alice', { board: BOARD, score: 100, lines: 1 }); // mrtvému se skóre už nemění
|
|
ctx.game.update('carol', { board: BOARD, score: 500, lines: 4 });
|
|
ctx.clock.advance(120_000);
|
|
const state = ctx.last();
|
|
expect(state.phase).toBe('finished');
|
|
const place = (l: string) => state.players.find(p => p.login === l)?.place;
|
|
expect([place('bob'), place('carol'), place('alice')]).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
test('hra na skóre skončí dřív, když vypadnou všichni', () => {
|
|
const ctx = setup();
|
|
ctx.startWith({ winMode: 'score' }, 'alice', 'bob');
|
|
ctx.game.died('alice');
|
|
expect(ctx.last().phase).toBe('playing');
|
|
ctx.game.died('bob');
|
|
expect(ctx.last().phase).toBe('finished');
|
|
});
|
|
|
|
test('do běžící hry se nelze připojit', () => {
|
|
const ctx = setup();
|
|
ctx.startGame('alice');
|
|
expect(() => ctx.game.join('bob')).toThrow(GameError);
|
|
});
|
|
|
|
test('během odpočtu se lze ještě připojit', () => {
|
|
const ctx = setup();
|
|
ctx.game.join('alice');
|
|
ctx.game.start('alice');
|
|
ctx.clock.advance(1000);
|
|
ctx.game.join('bob');
|
|
expect(ctx.last()).toMatchObject({ phase: 'countdown', countdown: COUNTDOWN_SECONDS - 1 });
|
|
expect(ctx.last().players.map(p => p.login)).toEqual(['alice', 'bob']);
|
|
ctx.clock.advance((COUNTDOWN_SECONDS - 1) * 1000);
|
|
expect(ctx.last().phase).toBe('playing');
|
|
});
|
|
|
|
test('připojení na poslední chvíli prodlouží odpočet', () => {
|
|
const ctx = setup();
|
|
ctx.game.join('alice');
|
|
ctx.game.start('alice');
|
|
ctx.clock.advance((COUNTDOWN_SECONDS - 1) * 1000);
|
|
ctx.game.join('bob');
|
|
expect(ctx.last()).toMatchObject({ phase: 'countdown', countdown: JOIN_GRACE_SECONDS });
|
|
ctx.clock.advance((JOIN_GRACE_SECONDS - 1) * 1000);
|
|
expect(ctx.last().phase).toBe('countdown');
|
|
ctx.clock.advance(1000);
|
|
expect(ctx.last().phase).toBe('playing');
|
|
});
|
|
});
|