feat: přepínání režimů a systému vítězství v Tetrisu
CI / Generate TypeScript types (push) Canceled after 0s
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

This commit is contained in:
mates committed 2026-10-19 16:49:56 +02:00
1 parent ed64fc60e4
commit 297cb80662
5 files changed
+277 -39

No files matched your search

-4
View File
@@ -1,4 +0,0 @@
[
"Tetris: odpadní řádky se mezi soupeře rozdělují rovnoměrně místo náhodně",
"Tetris: smazáním řádků lze odrazit odpad, který na vás čeká"
]
+66 -3
View File
@@ -1,4 +1,4 @@
import { TetrisGame, TetrisState, GarbageEvent, COUNTDOWN_SECONDS, MIN_ATTACK_GAP_MS, WATCH_FLUSH_MS, BOARD_WIDTH, BOARD_HEIGHT, PLAYER_TIMEOUT_MS } from '../tetrisGame';
import { TetrisGame, TetrisState, TetrisSettings, GarbageEvent, COUNTDOWN_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';
@@ -11,12 +11,14 @@ function setup() {
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[]) => {
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);
};
return { game, clock, all, watched, garbage, last, startGame };
const startGame = (...logins: string[]) => startWith({}, ...logins);
return { game, clock, all, watched, garbage, last, startGame, startWith };
}
describe('tetris', () => {
@@ -127,6 +129,67 @@ describe('tetris', () => {
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');
+84 -13
View File
@@ -6,8 +6,9 @@ import { Clock, defaultClock, GameError, gameHandler } from "./minigames";
*
* Každý hráč hraje vlastní desku v prohlížeči (kvůli odezvě ovládání), server koordinuje:
* lobby a odpočet, společný seed pro pořadí kostek, přeposílání desek soupeřům,
* útoky (smazané řádky → odpadní řádky soupeři, který zatím dostal nejméně odpadu) a vyřazování.
* Vyhrává poslední hráč ve hře, sólo hra končí prohrou.
* útoky (smazané řádky → odpadní řádky soupeři podle zvoleného režimu míření) a vyřazování.
* Zakladatel lobby volí režim míření a způsob hodnocení: buď vyhrává poslední hráč ve hře
* (sólo hra končí prohrou), nebo nejvyšší skóre po uplynutí časového limitu.
*
* Klientovi se věří (skóre, smazané řádky) — jde o kancelářskou hru; útoky jsou ale
* omezené co do velikosti i frekvence. Stav je v paměti, předpokládá jednu repliku.
@@ -36,6 +37,29 @@ export const GARBAGE_FOR_LINES: Record<number, number> = { 1: 0, 2: 1, 3: 2, 4:
export type TetrisPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
/**
* Komu jde útok:
* - random — náhodnému živému soupeři,
* - even — soupeři, který zatím dostal nejméně odpadu,
* - payback — tomu, kdo útočníka naposledy napadl (jinak jako even).
*/
export type TetrisTargeting = 'random' | 'even' | 'payback';
/** survival — vyhrává poslední ve hře; score — nejvyšší skóre po časovém limitu. */
export type TetrisWinMode = 'survival' | 'score';
export type TetrisSettings = {
targeting: TetrisTargeting,
winMode: TetrisWinMode,
/** Délka hry v sekundách (jen pro winMode = score). */
timeLimit: number,
};
export const TARGETING_MODES: TetrisTargeting[] = ['random', 'even', 'payback'];
export const WIN_MODES: TetrisWinMode[] = ['survival', 'score'];
/** Povolené délky hry na skóre (s). */
export const TIME_LIMITS = [120, 180, 300];
export const DEFAULT_SETTINGS: TetrisSettings = { targeting: 'even', winMode: 'survival', timeLimit: 180 };
export type TetrisPlayer = {
login: string,
/** Deska po řádcích shora, znak 0 = prázdno, 1–8 = barva (8 = odpad). */
@@ -54,6 +78,9 @@ export type TetrisState = {
seed?: number,
countdown?: number,
minPlayers: number,
settings: TetrisSettings,
/** Zbývající čas hry na skóre (ms), jen ve fázi playing. */
remainingMs?: number,
};
export type GarbageEvent = { target: string, from: string, lines: number };
@@ -62,8 +89,10 @@ type InternalPlayer = TetrisPlayer & {
lastAttack: number,
lastSeen: number,
deathOrder?: number,
/** Kolik odpadních řádků už hráč od soupeřů dostal — podle toho se volí cíl útoku. */
/** Kolik odpadních řádků už hráč od soupeřů dostal (režim even). */
garbageReceived: number,
/** Kdo hráče naposledy napadl (režim payback). */
lastAttacker?: string,
};
const EMPTY_BOARD = '0'.repeat(BOARD_WIDTH * BOARD_HEIGHT);
@@ -76,6 +105,9 @@ export class TetrisGame {
private seed?: number;
private countdown = 0;
private deaths = 0;
private settings: TetrisSettings = { ...DEFAULT_SETTINGS };
/** Kdy skončí hra na skóre (čas z clock.now()). */
private endsAt?: number;
private timer: unknown;
/** Čeká už odeslání průběžného stavu sledujícím? */
private flushPending = false;
@@ -103,6 +135,9 @@ export class TetrisGame {
seed: this.seed,
countdown: this.phase === 'countdown' ? this.countdown : undefined,
minPlayers: this.minPlayers,
settings: { ...this.settings },
remainingMs: this.phase === 'playing' && this.endsAt !== undefined
? Math.max(0, this.endsAt - this.clock.now()) : undefined,
};
}
@@ -136,6 +171,21 @@ export class TetrisGame {
this.emitAll();
}
/** Nastavení hry — jen zakladatel a jen v lobby; neplatné hodnoty se ignorují. */
configure(login: string, payload: unknown) {
if (this.phase !== 'lobby') {
throw new GameError('Nastavení lze měnit jen v lobby.');
}
if (login !== this.host) {
throw new GameError('Nastavení může měnit jen ten, kdo lobby založil.');
}
const { targeting, winMode, timeLimit } = (payload ?? {}) as Partial<Record<keyof TetrisSettings, unknown>>;
if (TARGETING_MODES.includes(targeting as TetrisTargeting)) this.settings.targeting = targeting as TetrisTargeting;
if (WIN_MODES.includes(winMode as TetrisWinMode)) this.settings.winMode = winMode as TetrisWinMode;
if (TIME_LIMITS.includes(timeLimit as number)) this.settings.timeLimit = timeLimit as number;
this.emitAll();
}
start(login: string) {
if (this.phase !== 'lobby') {
throw new GameError('Není otevřené žádné lobby.');
@@ -167,8 +217,7 @@ export class TetrisGame {
}
/**
* Hráč smazal řádky — případný útok jde živému soupeři, který zatím dostal nejméně odpadu
* (při shodě náhodně), aby se útoky rozkládaly rovnoměrně.
* Hráč smazal řádky — případný útok jde živému soupeři podle režimu míření (viz TetrisTargeting).
*
* @param sent kolik řádků po odražení vlastního čekajícího odpadu zbylo na útok (počítá klient);
* bez něj se pošle plný útok podle tabulky, víc než tabulka ale nikdy
@@ -183,13 +232,24 @@ export class TetrisGame {
p.lastAttack = now;
const opponents = [...this.players.values()].filter(t => t.alive && t.login !== login);
if (opponents.length === 0) return;
const least = Math.min(...opponents.map(t => t.garbageReceived));
const targets = opponents.filter(t => t.garbageReceived === least);
const target = targets[Math.floor(this.clock.random() * targets.length)];
const target = this.pickTarget(p, opponents);
target.garbageReceived += garbage;
target.lastAttacker = login;
this.sendGarbage({ target: target.login, from: login, lines: garbage });
}
private pickTarget(attacker: InternalPlayer, opponents: InternalPlayer[]): InternalPlayer {
const pick = (list: InternalPlayer[]) => list[Math.floor(this.clock.random() * list.length)];
if (this.settings.targeting === 'random') return pick(opponents);
if (this.settings.targeting === 'payback') {
const revenge = opponents.find(t => t.login === attacker.lastAttacker);
if (revenge) return revenge;
}
// even (a payback, dokud útočníka nikdo nenapadl): kdo dostal nejméně, při shodě náhodně
const least = Math.min(...opponents.map(t => t.garbageReceived));
return pick(opponents.filter(t => t.garbageReceived === least));
}
/** Hráči přetekla deska. */
died(login: string) {
const p = this.players.get(login);
@@ -197,7 +257,9 @@ export class TetrisGame {
p.alive = false;
p.deathOrder = ++this.deaths;
const alive = [...this.players.values()].filter(x => x.alive).length;
if (alive === 0 || (this.players.size > 1 && alive <= 1)) {
// Ve hře na skóre se hraje do limitu, dokud je ve hře aspoň jeden hráč
const lastStanding = this.settings.winMode === 'survival' && this.players.size > 1 && alive <= 1;
if (alive === 0 || lastStanding) {
this.finish();
return;
}
@@ -214,8 +276,10 @@ export class TetrisGame {
this.phase = 'playing';
const now = this.clock.now();
this.players.forEach(p => { p.lastSeen = now; });
const duration = this.settings.winMode === 'score' ? this.settings.timeLimit * 1000 : MAX_GAME_MS;
this.endsAt = this.settings.winMode === 'score' ? now + duration : undefined;
this.emitAll();
this.schedule(() => this.finish(), MAX_GAME_MS);
this.schedule(() => this.finish(), duration);
this.clock.setTimeout(() => this.watchdog(), WATCHDOG_MS);
}
@@ -229,10 +293,15 @@ export class TetrisGame {
if (this.phase === 'playing') this.clock.setTimeout(() => this.watchdog(), WATCHDOG_MS);
}
/** Pořadí: živí první (podle skóre), pak vyřazení od posledního. */
/**
* Pořadí — survival: živí první (podle skóre), pak vyřazení od posledního;
* score: podle skóre, při shodě déle hrající.
*/
private finish() {
const ranked = [...this.players.values()].sort((a, b) =>
(b.deathOrder ?? Infinity) - (a.deathOrder ?? Infinity) || b.score - a.score);
const survival = (a: InternalPlayer, b: InternalPlayer) => (b.deathOrder ?? Infinity) - (a.deathOrder ?? Infinity);
const ranked = [...this.players.values()].sort(this.settings.winMode === 'score'
? (a, b) => b.score - a.score || survival(a, b)
: (a, b) => survival(a, b) || b.score - a.score);
ranked.forEach((p, i) => { p.place = i + 1; });
this.phase = 'finished';
this.clearTimer();
@@ -251,6 +320,7 @@ export class TetrisGame {
this.players.clear();
this.seed = undefined;
this.deaths = 0;
this.endsAt = undefined;
}
private schedule(fn: () => void, ms: number) {
@@ -302,6 +372,7 @@ export function registerTetrisGame(io: Server) {
socket.on('tetris:unwatch', () => socket.leave(WATCH_ROOM));
socket.on('tetris:join', handle(login => game.join(login)));
socket.on('tetris:leave', handle(login => game.leave(login)));
socket.on('tetris:configure', handle((login, payload) => game.configure(login, payload)));
socket.on('tetris:start', handle(login => game.start(login)));
socket.on('tetris:update', handle((login, payload) => game.update(login, payload)));
socket.on('tetris:cleared', handle((login, payload) => {