fix. férovější výběr cíle u Tetrisu, odrážení odpadu

This commit is contained in:
mates committed 2026-10-19 16:37:27 +02:00
1 parent 8a7dbd94c8
commit ed64fc60e4
5 files changed
+79 -17

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
[
"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á"
]
+25
View File
@@ -68,6 +68,31 @@ describe('tetris', () => {
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');
+28 -9
View File
@@ -6,7 +6,7 @@ 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 náhodnému soupeři) a vyřazování.
* ú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.
*
* Klientovi se věří (skóre, smazané řádky) — jde o kancelářskou hru; útoky jsou ale
@@ -58,7 +58,13 @@ export type TetrisState = {
export type GarbageEvent = { target: string, from: string, lines: number };
type InternalPlayer = TetrisPlayer & { lastAttack: number, lastSeen: number, deathOrder?: number };
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. */
garbageReceived: number,
};
const EMPTY_BOARD = '0'.repeat(BOARD_WIDTH * BOARD_HEIGHT);
const BOARD_PATTERN = new RegExp(`^[0-8]{${BOARD_WIDTH * BOARD_HEIGHT}}$`);
@@ -113,7 +119,7 @@ export class TetrisGame {
if (this.players.size >= MAX_PLAYERS) {
throw new GameError('Lobby je plné.');
}
this.players.set(login, { login, board: EMPTY_BOARD, score: 0, lines: 0, alive: true, lastAttack: 0, lastSeen: 0 });
this.players.set(login, { login, board: EMPTY_BOARD, score: 0, lines: 0, alive: true, lastAttack: 0, lastSeen: 0, garbageReceived: 0 });
}
this.emitAll();
}
@@ -160,17 +166,27 @@ export class TetrisGame {
this.emitWatchers();
}
/** Hráč smazal řádky — případný útok jde náhodnému živému soupeři. */
cleared(login: string, count: unknown) {
/**
* 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ě.
*
* @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
*/
cleared(login: string, count: unknown, sent?: unknown) {
const p = this.players.get(login);
if (!p || !p.alive || this.phase !== 'playing' || !Number.isInteger(count)) return;
const garbage = GARBAGE_FOR_LINES[count as number] ?? 0;
const attack = GARBAGE_FOR_LINES[count as number] ?? 0;
const garbage = Number.isInteger(sent) ? Math.min(Math.max(sent as number, 0), attack) : attack;
const now = this.clock.now();
if (garbage === 0 || now - p.lastAttack < MIN_ATTACK_GAP_MS) return;
p.lastAttack = now;
const targets = [...this.players.values()].filter(t => t.alive && t.login !== login);
if (targets.length === 0) return;
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)];
target.garbageReceived += garbage;
this.sendGarbage({ target: target.login, from: login, lines: garbage });
}
@@ -288,7 +304,10 @@ export function registerTetrisGame(io: Server) {
socket.on('tetris:leave', handle(login => game.leave(login)));
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) => game.cleared(login, (payload as { count?: unknown })?.count)));
socket.on('tetris:cleared', handle((login, payload) => {
const { count, sent } = (payload ?? {}) as { count?: unknown, sent?: unknown };
game.cleared(login, count, sent);
}));
socket.on('tetris:died', handle(login => game.died(login)));
});
}